diff --git a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md index caa595d719..76b4ba1c88 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md +++ b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md @@ -6,6 +6,10 @@ 编辑器只依赖统一的语言能力接口,不直接绑定某一种语言服务器。轻量能力可以不启动外部进程;需要 LSP 时,由 Rust Core 统一管理进程、协议和结果。以后新增语言服务,应接入这条公共路径,而不是在 Swift 和平台层各写一套生命周期。 +### 通用 Runtime 与 Java 领域适配的范围 + +本笔记的通用 LSP 传输、会话、文档版本和取消边界仍然适用。下文记录的 Core `languages/jdt.rs` 与 Swift Java 专属语义只是当前实现位置;[#1495 的迁移提案](../../proposed/architecture/2026-10-26-maven-java-domain-plugin-migration.md) 将逐项替换并删除这些领域分支,不另建第二套通用 LSP Runtime,也不把当前目录位置视为长期归属要求。 + ## 问题 语言能力不应等同于"已经启动一个 LSP 进程":轻量本地能力(关键字、当前 diff --git a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md index dcfe042ffc..7ae9ab367b 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md +++ b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md @@ -188,6 +188,19 @@ Run 控件,也不在 `java-run-launch` 或平台 adapter 再做一次 `blocksR 用户点击后可以在准备状态可见的同时排队等待 Core。否则快照更新时序会产生“面板 已就绪、启动仍被准备中拦截”的自相矛盾状态。 +### 每一步使用自己的工具链环境(#1216) + +Windows 曾先填入自动发现的 JAVA_HOME,再用“仅为空时插入”应用 Maven JDK, +导致用户选的 Maven JDK 永远无法覆盖先填的 Java 值。两个宿主现在调用 Core 的 +execution.resolveEnvironment:继承环境低于当前步骤选定的工具链,用户显式 +字符串最高。Maven 资源处理步骤即使继承主程序的 Java 绑定,也选择 Maven JDK; +后续 JVM 保持项目 JDK。Windows 环境键按不区分大小写处理。 + +平台仍负责定位可执行文件与 JDK home,Core 不探测机器、不执行 Maven,也不 +复制上游构建模型。不要在 Windows 调换两次 insert 就结束:这会让后续前置 +步骤继续各自解释优先级。共享 fixture 和真实 Windows 宿主解析测试覆盖选定 +Maven JDK、显式覆盖及资源步骤;#1133 的 Maven 原生资源步骤保留,不改成手工复制。 + ## 考虑过的备选方案 - 先执行 `mvn compile`,再手工猜 `target/classes` 与依赖:被否。自定义输出目录、 diff --git a/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md b/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md index e9163c1cad..b4d022f283 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md +++ b/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md @@ -35,7 +35,7 @@ PHP Support 已经验证了独立插件包、固定 Intelephense 版本和插件 插件包不编译、不链接任何 Swift 代码,也不做 codesign,因此与架构和宿主构建无关。 -唯一的例外是自带运行时的 Java Support:它的包里有每个架构各一份的 JDK,因此与架构有关,并且只随应用发行、由应用的代码签名覆盖。它的组装步骤在插件自己的目录里,见 [Java 语言服务器是一个声明式插件](2026-10-13-java-language-server-as-declarative-plugin.md)。 +唯一的例外是自带运行时的 Java Support:它的包里有每个架构各一份的 JDK,因此与架构有关,当前仍随应用发行,内置副本由应用的代码签名覆盖;独立安装副本要求完整包的发布者签名。双架构发布输入与迁移已准备,官方索引完整上架后构建会省去内置资源;自动上架仍暂停,见[按来源校验与独立发布基础](2026-10-25-java-plugin-distribution-foundation.md)。它的组装步骤在插件自己的目录里,见 [Java 语言服务器是一个声明式插件](2026-10-13-java-language-server-as-declarative-plugin.md)。 下载和组装逻辑放在插件专属的准备脚本里(PHP 迁出前是 `prepare-php-language-server.sh`,已随迁移删除)。后续插件可以复用相同的构建阶段和校验结构,但不能把某个语言的路径、版本或下载地址硬编码到宿主应用中。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md index 657e05ee2b..a26c17dd9b 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md +++ b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md @@ -4,12 +4,16 @@ ## 先说结论 -Java 的语言服务器 JDT LS(Eclipse 的 Java 语言服务器,提供补全、诊断和跳转)不再是 Lithe.app 里写死的一部分,而是官方插件 Java Support(`dev.lithe.plugin.java-support`)。插件包拥有 JDT LS、Lombok、调试和测试扩展,以及它运行所用的 JDK;`plugin.json` 声明这些文件在包内的位置和启动方式。这个插件随应用发行、默认启用,用户可以卸载和恢复。 +Java 的语言服务器 JDT LS(Eclipse 的 Java 语言服务器,提供补全、诊断和跳转)不再是 Lithe.app 里写死的一部分,而是官方插件 Java Support(`dev.lithe.plugin.java-support`)。插件包拥有 JDT LS、Lombok、调试和测试扩展,以及它运行所用的 JDK;`plugin.json` 声明这些文件在包内的位置和启动方式。过渡期随应用发行、默认启用,用户可以卸载和恢复;完整双架构官方下载上架后默认应用省去资源,迁移与安装引导由[后续分发决策](2026-10-25-java-plugin-distribution-foundation.md)规定。 宿主不再知道 JDT LS 或 JDK 放在哪里。它只做两件事:按插件的声明启动语言服务器;为声明了 `jdt` 宿主集成(由宿主实现、插件按名字启用的一段专用逻辑)的服务器启用 Java 的工程模型、`jdt://` 虚拟文档、测试和调试支持。 对日常开发的直接影响:需要某个插件专属的资源时,在该插件的清单里声明;不要在宿主的通用代码里按语言或工具的名字加分支。 +### 当前边界与后续迁移 + +本笔记记录资源声明式插件化的已实现状态;`hostIntegration: jdt` 及宿主 Java Run/Test/Debug 是这个中间状态的事实,不是长期保留要求。[#1495 的领域迁移提案](../../proposed/architecture/2026-10-26-maven-java-domain-plugin-migration.md) 已列出生产调用、上游所有者、数据和删除条件。后续应在通用能力与全部消费者切换后删除 JDT 宿主专属实现;此前不能仅因资源已进插件就宣称领域迁移完成,也不能提前删除仍被调用的功能。 + ## 问题 迁移之前 JDT LS 和声明式插件契约有三处不一致: @@ -104,9 +108,9 @@ dev.lithe.plugin.java-support/ `LitheOfficialPluginVerifier` 的规则随之调整:Mach-O 原生代码只允许出现在清单 `packagedRuntime` 声明的顶层目录内;包内任何位置都不允许符号链接。 -### 5. 随应用发行、默认启用、可卸载 +### 5. 过渡期内置与独立安装 -- **随应用发行**:Java 是主场景,首次启动必须离线可用,所以包在 `Lithe.app/Contents/Resources/OfficialPlugins/` 下,信任方式是 `sameTeamAsHost`(由应用自身的代码签名封住)。 +- **过渡期随应用发行**:原设计为 Java 保持首次启动离线可用,过渡包在 `Lithe.app/Contents/Resources/OfficialPlugins/` 下,清单主签名要求为 `publisherPackage`,内置副本通过 `bundledSignatureRequirement: sameTeamAsHost` 依靠应用自身的代码签名;只有真实位于宿主 bundle 内且来源为 bundled 的副本能用这个例外。按来源校验与独立发布基础见[后续决策](2026-10-25-java-plugin-distribution-foundation.md)。 - **默认启用**:随应用发行的包替代的是内置功能,它的模块默认开启;下载安装的插件,模块清单默认值仍是关闭,但用户在确认窗口里确认的首次安装(插件市场、社区源、磁盘包)会把它的模块记为启用,重启后直接生效,与 IntelliJ IDEA 安装即启用一致;更新和重装不改用户已有的启用选择。否则构建插件装好后 Build 工具窗口不出现,用户会以为界面没有注入。 - **卸载**:app bundle 运行时只读,包本身删不掉。卸载记录在用户目录的 `/Lithe/Plugins/.removed-bundled-plugins.json`,宿主重启后不再注册这个插件,并在那次启动时删除它的语言服务器状态(卸载后到重启前,语言服务器仍在运行并写入该目录,所以不在点击时删除;重启前恢复则不会丢失索引)。插件管理里仍然列出它并提供“恢复”。 - **卸载时 JDT 状态的清理**:JDT LS 的工作区索引(`/Lithe/language-servers/jdtls/`)和 Equinox 配置区(`jdtls-configuration/`)在宿主缓存里,不在插件自己的 `PluginState//` 下,插件包管理器删不到。所以由 `jdt` 集成自己清理:启动时 `pluginsDidLoad` 拿到扫描结果,如果没有任何“未卸载”的已安装插件声明 `jdt` 集成,就在后台删除这两个目录(不碰同目录下其他语言服务器的缓存)。扫描本身失败(拿不到已安装列表)、或插件已安装但启动时被拒绝,都不算卸载,状态保留。这样宿主既不用写死 Java Support 的插件 ID,也不需要把缓存搬进插件状态目录。 @@ -115,7 +119,7 @@ dev.lithe.plugin.java-support/ ### 正确做法 - 语言服务器依赖特定运行时:把运行时放进插件包并声明 `packagedRuntime`。只有“用户机器上任意够新的版本都行”时才用宿主发现加 `minimumVersion`。 -- 某个服务器需要宿主代码配合:做成具名宿主集成,集成要读的文件由插件在 `resources` 里声明。 +- 现有服务器需要宿主代码配合时,按已声明的具名集成及 `resources` 解析文件;这不授权继续扩展语言专属宿主分支。新增或迁移领域能力按 #1495 的通用契约与后端边界处理。 - 插件只提供语言服务器、运行/测试/调试仍用宿主内置实现:在 `hostIntegration.capabilities` 里逐项声明。迁移一个语言到插件后,必须在打包出的应用里实际点一次工具栏运行、调试和测试面板;只跑集成测试发现不了能力目录里丢掉的标记。 - 插件专属的构建步骤:放在插件目录的 `assemble-package.sh` 里。 @@ -170,7 +174,7 @@ dev.lithe.plugin.java-support/ - Rust 引擎里仍按提供方 ID `java` 启用 JDT 适配;Java 的运行、测试和调试仍是宿主内置实现,尚未成为扩展点,由插件通过 `hostIntegration.capabilities` 声明后启用。 - Windows 仍把 JDT LS 和 JDK 放在安装目录里,没有接入这份契约。 -尚未完成、需要单独提案的部分:把 `jdt://` 虚拟文档等协议扩展改成清单里的声明式特性;Debug、Run/Test 扩展点;Java Support 改为从市场下载;Windows 接入。 +尚未完成、需要继续推进的部分:把 `jdt://` 虚拟文档等协议扩展改成清单里的声明式特性;Debug、Run/Test 扩展点;Java Support 的正式市场下载与迁移;Windows 接入。独立分发已具备来源签名校验和双架构发布基础,但自动发布暂停,计划见[独立分发提案](../../proposed/architecture/2026-10-24-java-support-independent-distribution.md)。 ## 验证 diff --git a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md index 8262826890..fa1b3ece7f 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md @@ -6,12 +6,20 @@ Lithe 现在用一套统一的构建模型描述所有项目级构建工具(Build Tool,例如 Maven、Gradle、Cargo、Go)。插件在 `plugin.json` 的 `buildSystems`(插件 API 级别 3)里用纯 JSON 声明:怎样认出工程、用哪个可执行文件、有哪些选项、任务、运行入口和测试,以及怎样读工具的输出。思路与 LSP 插件化相同:插件只提供数据,通用逻辑只在 Rust Core 实现一次。 -Rust Core 负责校验声明、识别工程、展开每一条命令行,并把输出解码成统一的形状,通过 13 个 `build.*` JSON 命令交给宿主。宿主只负责找可执行文件、启动进程、监听文件和保存用户选择,代码里不按构建系统的名字写分支。因此社区插件不改宿主代码就能加入一个新构建工具。 +Rust Core 负责校验声明、识别工程、展开每一条命令行,并把输出解码成统一的形状,通过 `build.*` JSON 命令交给宿主。宿主只负责找可执行文件、启动进程、监听文件和保存用户选择,代码里不按构建系统的名字写分支。因此社区插件不改宿主代码就能加入一个新构建工具。 映射(`map`)只从解码后的文档里挑值。挑值以外的逻辑由插件自己处理:读 JSON、YAML 清单,从工具读任务,计算父子关系,这些都交给插件自带的脚本输出规范格式(第 6 节)。声明和规范格式都表达不了的工具(Gradle Tooling API 等),由插件自带的 Provider 进程回答。Provider 就是语言服务器那种进程:共用启动字段、校验和启动计划,只多回答 `lithe/build/model`、`lithe/build/dependencies`,回复仍由 Core 解码(第 9 节)。 目前落地的是 Core、共享契约和 macOS 宿主(第 10 节)。macOS 为每个启用的构建系统各开一个工具窗口,向 Core 声明 `buildSystems: [1]` 和 `buildProviders: [1]`,并在用户点击加载模型或依赖时启动 Provider、发送 `lithe/build/*`;Windows 宿主还没有接入。API 3 的清单按能力分版本,声明式构建系统使用 `buildSystems`,两种写法的 Provider 都额外使用 `buildProviders: 1`;插件市场只在宿主声明支持对应能力时才列出 API 3 的版本,见[声明式插件按能力分版本](2026-10-17-plugin-capability-versions.md)。宿主接入和 Maven 迁移的计划见[构建系统 Provider 提案](../../proposed/architecture/2026-10-17-build-system-provider-api.md)。 +### 当前实现范围与删除条件 + +这里的统一模型、Provider 和通用工具窗口已经落地;下文记录的 `MavenService`、Run/Debug/Agent 旧消费者及 Maven 导入兼容路径仍是当前实现,不能把它们解释为 Maven 领域迁移已完成或必须长期保留。[#1495 的迁移清单](../../proposed/architecture/2026-10-26-maven-java-domain-plugin-migration.md) 按入口列出替换与删除条件:最新宿主/匹配插件接管所有消费者并完成数据迁移后,删除旧服务、旧配置读取和 fallback。Build Provider v1 的模型/依赖能力与宿主执行声明式 CLI 任务,也不等同于插件后端已拥有构建执行。 + +### Provider 任务与测试执行补充(2026-10-26) + +当前增加 `buildProviders: 2/3/4` 与 `litheBuild` 协议 2/3/4:原生 Build 任务、测试发现/执行和应用启动准备通过类型化请求进入同一个工作区 Provider,不要求网页,不使用插件私有函数。测试报告复用原有解析器,应用准备交给既有 Run 进程所有者。本文关于版本 1 仅有模型/依赖的说明仍适用于版本 1;具体版本准入、进度、取消与删除条件见[Build Provider 原生任务执行](2026-10-26-build-provider-task-execution.md)。这不代表命令模型运行配置、服务编排或 Java/Maven 业务迁移已经完成。 + ## 问题 在这次改动之前,构建工具的知识分散在三处: @@ -233,7 +241,7 @@ Gradle 的 `offline` 和 `userHome` 是用户自己选的值,不是猜测, macOS 接入规则(2026-10-18): - `BuildSystemService` 在无进程检测后,用当前模型接受的有效选项调用 `build.languageImport`。只按返回的 `kind` 转发,不按插件或构建系统 ID 判断。Maven 安装路径来自 `MacBuildToolResolver.languageImportExecutable` 的被动发现,不执行版本探测、Wrapper 或运行时探测;找不到工具仍可使用 JDT 的默认安装设置。 -- 一个 JDT 会话可同时带一个 Maven 和一个 Gradle 输入。检测到多个相同种类的实例时,先选工作区根,再选最浅的根,最后按路径和稳定 ID 排序;不合并不同 reactor 的 Profiles 或机器路径。没有对应插件输入时保留原 Maven 设置兼容路径;`MavenService` 仍是 Run/Debug/Agent 业务的现有提供者。 +- 一个 JDT 会话可同时带一个 Maven 和一个 Gradle 输入。检测到多个相同种类的实例时,先选工作区根,再选最浅的根,最后按路径和稳定 ID 排序;不合并不同 reactor 的 Profiles 或机器路径。没有对应插件输入时,当前实现仍读取原 Maven 设置兼容路径;`MavenService` 仍是 Run/Debug/Agent 业务的现有提供者。这是待迁移状态:#1495 的 M3–M8 消费者完成切换后删除此路径,不作为新能力的失败回退。 - 打开项目、编辑器同步、Run/Debug、测试发现、Java 入口点与维护操作在启动前都受同一份准备结果约束。维护入口先结束模块激活和插件注册,再等待导入;首次启动在 Java 运行时准备完成后还要重新等待当前结果,因为此前已经准备好的配置可能在挂起期间失效。最后一次等待结束后直接调用同步启动,不能在二者之间再插入异步工作。入口携带包含重开代次的 `WorkspaceIdentity`,每个异步边界后重查并检查取消;同步启动入口在配置尚未准备好时拒绝启动,不能借旧 Maven 上下文绕过等待。 - 检测、选项或插件变化使导入结果失效并重新计算。命令模型加载后可能收窄可选值,因此发布模型和生效选项后也要重新归一化;受监听文件变化影响时,立即清除旧输入并取消旧检测,再沿用 500 毫秒合并等待重算,等待期间不能读取保存前的配置。直接发起的检测会取消尚未到期的合并任务,避免迟到的重复检测再次清空刚准备好的配置。完成时同时校验工作区代次、检测 UUID 和取消状态,再通知同一工作区的 Java 会话;Maven 优先原地更新,Gradle 等下次会话启动。若运行中的会话拒绝 Maven 更新(例如它启动时没有 Maven 输入),调用现有 `reloadJavaWorkspace` 恢复,不能只留下警告。Java 准备协调器串行持有这项恢复:新发布先取消并等待旧恢复结束清理,再应用最新配置;旧恢复取消了自己创建的会话时,新发布继续承担重启,不能把此时的 `noSession` 当成无需处理。工作区替换、关闭、停用 Java 或会话关闭会取消恢复;包含同路径重开的代次检查阻止旧任务启动新会话。等待者有 30 秒期限,各自取消只释放自己的 continuation;停止或重置取消所有等待者,旧检测不能清除新检测或更新新工作区。 - 停用或移除 Maven 构建插件后,没有插件或兼容输入的活动会话也必须清除旧 Profiles、settings、仓库和工具路径。会话管理器记录启动时实际传入的 Maven 上下文,仅向带过 Maven 输入的同一会话发送空配置;纯 Gradle 会话首次启动仍传 `nil`,不能为了将来清除而给所有 Java 会话注入空 Maven 输入。停止或清除会话时同时释放记录,重开后的会话不能继承旧配置的清除资格。 diff --git a/.agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md b/.agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md new file mode 100644 index 0000000000..e282159c48 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md @@ -0,0 +1,122 @@ +# Agent 笔记:Java 插件按来源校验与独立发布基础 + +状态:已实现 + +## 先说结论 + +Java Support 的同一份清单现在区分两种来源:随应用发行的副本可以依靠宿主签名,用户下载安装的副本必须有 Lithe 发布者签名。两种来源使用同一插件 ID,经过校验的用户安装版本替代内置版本,不重复登记 Java 语言服务。官方发布工具能记录 ARM64、Intel 两个归档及各自哈希,并从发布标签读取对应 API 和能力声明;迁移保留启用选择并提供按需安装入口;主包只在完整双架构官方下载进入索引后移除资源。自动上架仍暂停,真实发布包按[独立分发提案](../../proposed/architecture/2026-10-24-java-support-independent-distribution.md)继续验收。 + +## 问题 + +Java 已经是声明式插件,但 `sameTeamAsHost` 原来只接受应用签名封装内的文件。直接把目录放到用户插件目录会被拒绝;直接把策略改成 `publisherPackage` 又会要求普通应用构建持有插件发布私钥。 + +旧发布脚本把索引 API 写死为 1,且每版只有一个 ZIP。Java 使用 API 3,又携带不同架构的 JDK,必须保留真实 `requires`(宿主必须支持的能力版本)和两个归档。历史版本重试上架时,还不能拿当前清单描述旧归档。 + +## 决策 + +### 同一清单,来源决定是否使用宿主签名 + +新增通用能力 `packageTrust: 1`,只在清单声明 `vendor.bundledSignatureRequirement` 时使用。Rust Core 只允许官方命名空间、API 3、主签名为 `publisherPackage`,且内置策略只能是 `sameTeamAsHost`。 + +macOS 官方目录固定主签名和内置策略。包管理器先校验清单与官方目录一致,再根据解析后的实际路径和安装来源验证:只有来源为 `bundled` 且文件确实位于宿主 app 内的副本可以依靠宿主签名。外部目录伪造来源无效;安装记录、回滚版本和磁盘导入同样不能借用该例外。任何来源只要带了签名文档,就必须验证它,不能因副本在 app 内而忽略坏签名。 + +旧宿主没有 `packageTrust`,会在市场过滤和清单校验时拒绝这一版。Windows 本轮不声明该能力,后续实现相同来源边界之后才允许安装,不将 Core 能校验等同于宿主能执行。 + +正确做法:下载新版后校验完整文件树的发布者签名,再把完整版本放到平台应用数据目录;重启读取同一 ID 时只选择一个生效来源。 + +不要这样做:外部目录沿用 `sameTeamAsHost`,或声称 `bundled` 就省略签名。也不能把官方 ID 的策略改成同时接受任意一种签名。 + +### 构建显式选择内置副本 + +应用打包继续使用 `--bundled-only`。只有显式走这条路径且清单声明内置策略时,构建脚本才省略发布者签名,最终由应用签名封住资源。普通单插件 release 构建要求发布者私钥;debug 的 `--development-unsigned` 只用于本地开发,正式宿主拒绝它。 + +JDT LS、JDK、Lombok、Debug 和 Test 扩展保持当前锁定版本。资源先组装,原生签名等会改字节的步骤完成后才生成发布者签名,最终归档解压后再验证。运行时启动继续复用现有 `jdt` 集成;安装检查新增对运行时启动的计划校验,拒绝缺失目标架构的 JDK、包内入口和相关固定资源,不运行 JDK、不创建验证状态目录。 + +### 安装切换保留用户选择 + +内置清单默认启用,下载清单默认禁用。安装成功后,包管理器把原内置模块的默认值保存为显式选择,再在重启时选择下载版;显式禁用不变,安装失败不修改选择。迁移只读取旧包已经拥有的模块,新模块保持清单默认值,避免把新模块或其他所有者的状态误当作旧选择。同一 ID 的插件设置继续使用原键,项目 JDK、运行配置和调试的 JDK 选择链不变,不复制或改写用户配置。 + +安装入口复用当前包管理器的统一安装流程。普通、市场和磁盘安装都在包校验与存储 +成功后迁移旧选择,再刷新已安装列表;不能在读取候选清单时提前改设置,也不能 +为了 Java 迁移把所有插件重新改成等待重启。Java 的宿主集成仍沿用重启边界; +已经支持运行中生效的插件继续按当前 Registry(实时能力目录)规则登记、撤回与通知 +其他窗口。回归同时验证三种入口的默认启用/显式禁用迁移,以及现有实时插件安装流程。 +官方签名离线包通过真实磁盘导入服务校验后仍走发布者签名安装路径;不能将它标为 +社区来源来绕过签名要求。`installDiskPackage` 仅处理用户确认的社区包。 + +旧 `.removed-bundled-plugins.json`(用户卸载内置包的记录)持续抑制内置来源。用户明确重新安装后,该记录不能清理已安装版本的状态;删除已安装更新时也记录内置移除,避免重启又启用被遮住的副本。跳过过渡宿主直接升级时,无包就保持未安装,显式禁用仍保留;只有用户确认安装才创建新版本,不自动下载。 + +打开 Java 文件或含 Java、Maven、Gradle 文件的项目时,无插件则显示每窗口会话一次的安装提示,支持前往市场和“从磁盘安装”的离线路径。新建项目页在 Java 插件缺失时提供模板安装入口,已有但禁用的插件继续走启用流程。离线或取消不重置设置,下一次会话可以重试。 + +缺包判断必须同时读取包身份和安装状态。包管理器会保留已移除(`.removed`)的 +内置副本快照,用于用户主动恢复;重启后这类快照不能抑制安装提示。已安装、等待更新 +或等待卸载重启的副本仍走现有管理流程,即使用户禁用了模块,也不能显示缺包提示。 +例如卸载并重启后打开 Java 文件应提示恢复;不要只收集快照 ID 来推断包仍然存在。 + +### 精简应用以已发布索引为前提 + +发布登记中的 `bundledUntilPublishedVersion: 1.1.0` 表示安全退出内置分发的最低插件版本。构建读取本工作树的官方索引:至少一个达到版本门槛的版本,其仓库、API、能力要求与当前插件一致,两个架构的 URL、平台与 SHA-256 齐全且唯一,才不再组装内置 Java,也不预下载 JDT/JDK。索引缺项仍带过渡副本,索引读写格式损坏直接中止构建,不能当作“无需内置”。后续插件源码升版时,兼容的旧下载仍可满足门槛,不要求下载版本等于当前源码版本。 + +打包用同一份索引作判断并嵌入应用,运行时不联网决定是否删资源。`LITHE_OFFICIAL_PLUGIN_INDEX` 仅覆盖构建输入,传给插件构建的 `--marketplace-index`,默认读取仓库索引;测试用临时虚构下载项证明精简路径,不能将 fixture 发布为真实索引。预览的 `--bundled-only` 使用同一默认策略。公开下载前默认 app 仍内置 Java;公开包和索引独立验收合并后,下一次应用构建自然精简。 + +正确做法:先发布并验证签名双架构包,再合并真实索引;应用构建从该索引得出不再内置。不要这样做:因为发布配置写好了就先删资源,或运行时下载到 app bundle 中补回文件。 + +### 发布按标签固定全部输入 + +`official-plugins.json` 登记 Java 两种架构的归档和上游构建输入,`releaseEnabled: false` 保持暂停自动发布。先有包含新能力的宿主版本,再通过独立改动开放上架;本次不生成线上索引或发布标签。 + +发布计划携带清单的真实 API 和 `requires`,组装全部架构后再创建同一个 Release。索引同步从对应 Git tag 读取当时的发布配置和清单,下载所有归档并计算 SHA-256 后一次写入该版本;任一归档缺失或哈希无效都不写部分版本。已发布插件的目录或登记的上游输入发生变化,需要提高插件版本。 + +### 资源所有权 + +- `dist/packages//` 是发布 job 的组装输出,`dist/verify//` 是归档验证副本,ZIP 和校验文件属于同一次发布。job 结束回收,不跨工作树复制。 +- `.artifacts/java-plugin-distribution/` 是本地验证输出,没有可转移的版本与工具链身份,验证者负责删除不用的临时副本;资源复用脚本明确拒绝。 +- JDT/JDK 下载缓存仍只能经原有资源校验路由复用。每个工作树从固定上游输入重新组装插件,不共享已签名包或用户安装目录。 +- 用户安装的插件版本是只读输入;索引、Equinox 配置、日志和锁仍由平台缓存和状态适配器持有。运行时不改 app bundle、插件安装资源或 Windows 发行目录,保持代码签名和 Sparkle 增量更新源基线。 + +## 考虑过的备选方案 + +- 对 Java 官方 ID 全局放宽签名:实现简单,但外部文件会获得内置包才有的信任,拒绝。 +- 应用构建也要求插件发布私钥:统一使用包签名,但普通开发和应用 PR 构建不应该依赖发布私钥,拒绝。 +- 生成两份不同来源清单:减少清单字段,但会造成同一插件版本有不同清单和能力要求,使用同一声明按来源验证。 +- 为旧宿主新增最低应用版本字段:当前能力协商已能拒绝未知语义,不额外引入另一套版本比较。 +- 索引重试使用最新清单:容易实现,但旧归档会被描述为具有后来新增的能力,改为从不可变标签读取。 + +## 后果 + +收益是应用打包仍能独立完成,外部安装具有完整签名检查,Java 资源可以按架构独立发布。代价是首次下载版要求新宿主能力,签名策略不能只看一个枚举,还要核对官方目录、来源和真实路径。 + +代码覆盖可信安装、启用/卸载选择迁移、缺包引导和以发布索引为门槛的主包精简。当前索引未上架,所以主包仍含 Java;正式签名发布、真实市场下载及完整 Java 产品工作流尚未验收,提案保持 `proposed`,不能把测试 fixture 当作上线证据。 + +## 验证 + +- `./scripts/verify-rust-core-comments.sh`、`./scripts/verify-shared-contracts.sh`:契约、注释和共享输入一致。 +- Rust 的 `declarative_plugins` 与 `marketplace_plugins`:来源策略必须声明能力、非法组合拒绝、旧宿主在下载前拒绝。 +- `PluginBundledPublisherTrustTests`:真实 Core、生产包管理器及注入测试公钥验证签名安装、单一来源、包篡改、伪造来源和缺失运行时,比较安装资源内容。 +- 同一套包存储回归覆盖卸载后重新启动扫描得到 `.removed` 快照时的提示,状态矩阵保留禁用、暂存更新和待重启副本的管理流程;`PluginInstallationGuidancePresentationTests` 用真实原生视图核对深浅主题的缺包通知、模板入口及主动打开市场的按钮,不执行安装或工具。 +- `./scripts/verify-macos-package.sh --slim`:以临时完整索引构建精简 universal app,拒绝 JDT/JDK 下载输入,检查资源缺失、签名和 DMG;默认模式检查保留过渡包。 +- `node --test scripts/test-official-plugin-distribution.mjs`:完整与部分上架、来源/能力不匹配和后续版本门槛。 +- `node --test scripts/official-plugin-releases.test.mjs`:双架构索引、部分归档拒绝、历史标签元数据以及上游输入变更约束。 +- `node scripts/test-reuse-worktree-resources.mjs`、`./scripts/verify-runtime-bundle-immutability.sh`:生成资源拒绝复用、发行目录只读边界。 +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` 和 macOS 计时入口:相关测试必须有单项时长与 HTML/JUnit 结果。 +- `.github/workflows/release-official-plugins.yml` 用 actionlint 校验;本地组装两种架构资源,验证 JDK 原生签名和发布归档的完整性。 + +上述验证不能替代正式发布者私钥签名、真实市场下载、Intel 真机及全部 Java 产品工作流。上线前继续按独立分发提案的退出条件执行,不能靠测试公钥通过就开放发布。 + +## 适用范围 + +- `Plugins/mac/Official/JavaSupport/` +- `Plugins/marketplace/official-plugins.json` +- `shared/contracts/plugins/v1/` +- `rust/lithe-core/src/plugins/` +- `macos/Sources/LitheModuleAPI/` +- `macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift` +- `macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift` +- `macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift` +- `scripts/build-official-plugins.sh` +- `scripts/official-plugin-releases.mjs` +- `.github/workflows/release-official-plugins.yml` +- `scripts/worktree-resources.json` +- `scripts/reuse-worktree-resources.mjs` +- `docs/ci-builds.md` diff --git a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md new file mode 100644 index 0000000000..d2bc5b9f4a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md @@ -0,0 +1,339 @@ +# Agent 笔记:Build Provider 原生任务执行 + +状态:已实现 + +## 先说结论 + +构建插件现在可以在自己的工作区 Provider(提供构建能力的后端进程)里执行任务、发现和运行测试,而不是把每个操作都展开为宿主命令。原生 Build 窗口复用现有工作区会话、输出、诊断、取消和进程清理,测试报告复用现有解析器,不要求插件增加网页或调用私有函数。这是 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495) 阶段 C 的构建、测试与应用启动准备基础能力;Maven/Java 业务迁移、多服务编排,及 Java/JDT 调试准备不在本决策已实现的范围内。插件自有原生程序的调试准备已经接到既有调试适配器注册表。 + +## 问题 + +现有 `buildProviders: 1` 只承载模型和依赖。普通任务由 Core 展开参数、宿主启动 CLI(命令行工具),因此后端即使拥有工程快照,也不能从原生入口掌握构建执行。直接调用插件私有函数会让宿主猜测参数、结果和错误码;另建一种工作区后端则重复 Provider 已有的进程、权限、取消和生命周期。 + +## 决策 + +### 类型化执行进入现有 Provider + +`buildProviders: 2` 增加任务的 `source: "provider"`,`litheBuild` 在进程初始化时协商版本 2。两者分别负责安装准入与进程实际协议,不可互相替代。只协商版本 1 的后端明确拒绝执行,不退回 CLI 或私有函数。 + +Core 继续校验工程/目标/模型任务选择、路径与生效选项,返回 Provider 计划。后端收到任务声明 ID、选择、选项、参数与可选工具路径后,自行解释并执行领域业务。显式无效的工具或运行时选择沿用现有解析器的终止失败,不许可后端换一套自动发现。普通声明式命令任务继续走原来的公共命令能力,这不是 Maven/Java 旧业务 fallback。 + +这里只支持有界任务和测试;`application/service` 不借执行 RPC(请求/回复调用)伪装接入。测试发现使用独立的方法,不把发现误当成运行。正式形状、限制和共享用例在 `shared/contracts/plugins/v1/`,不在本笔记重复字段全集。 + +### 测试执行使用独立的版本准入 + +`buildProviders: 3` 和进程选择 `litheBuild` 版本 3 后,原生测试入口才能发送 `lithe/build/test`。版本 3 继续支持构建执行;选择版本 1/2 的进程明确拒绝测试,不转成构建任务或命令。测试声明仍持有作用范围、支持的筛选类型和报告位置,参数模板必须为空,也不能指定宿主领域集成的报告解析。 + +套件、用例和附加参数分别交给插件,由插件使用已有上游测试引擎执行。宿主不猜测 Java/Gradle 的选择器语法、不发现第二份测试模型。报告继续复用仓库现有的 JUnit XML 与标准测试结果解析、时间与路径检查;例如后端调用上游测试引擎生成 JUnit 报告,原生结果列表只读解析后的通用结果。不要把用例名拼进宿主命令后,再假定插件运行了完全相同的筛选条件。 + +测试与任务共用执行结果、进度及唯一操作身份,但各自有明确方法。工作区全部测试仍要求需要模型的构建系统已加载最新模型,以覆盖所有子工程报告。后端回复之后,运行身份与配置检查继续覆盖诊断和报告解析;改选项或关闭工作区不能发布迟到报告。 + +### 测试发现复用上游事实与已有树 + +插件显式声明 `test.discovery`,同时支持套件和用例筛选,原生窗口才提供发现入口。用户展开“已发现的测试”或点击发现时,宿主通过协议 3 的 `lithe/build/discoverTests` 请求整个实例的清单;不会在打开工程时扫描源码或后台执行工具。列表里的每个条目都必须能通过同一测试计划精确运行,Core(共享校验层)拒绝错误范围、路径、筛选类型和超限清单,不用部分成功假装已经列全测试。 + +插件复用其已选择的上游测试引擎回答套件、用例、工程及目标;宿主只显示现有共享树的节点。不增加另一套 Java 注解扫描、框架探测、工程图或 Test Explorer。条目身份包括工程和目标,所以两个模块中的同名测试不会互相覆盖。 + +发现和执行共用唯一原生操作槽及工作区进程,避免又建一个后台任务运行时。加载状态、成功空列表和失败是不同结果;失败可由用户重试。发现缓存仅在同一插件包、生效选项、启动设置及配置代次下可用,任何工作区源码修改都会清除清单,并取消正在进行的发现。旧工具栏选择运行前必须用 ID 重新查验当前清单,不能只保存旧请求然后直接执行。停用、更新、工作区重开和退出继续通过既有会话释放资源;报告和清单不写进插件包。 + +### 每个操作只有一个持有方 + +原生入口 → `BuildSystemService` → `MacBuildProviderSessions` → `LanguageServerRuntimeSession` → Core `lsp.buildRequest` → 同一 Provider 进程。模型、依赖、任务/测试执行及插件自己的函数可复用这个进程,但领域调用和私有函数继续有各自契约。 + +Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍在等待的同一执行请求转换成宿主操作 ID。取消、超时、退出或回复后删除等待项;晚到回复与进度不能复活操作,宿主 ID 复用也不串用旧消息。超时最长一小时,取消走原来的 `$/cancelRequest`,卡住的写入继续使用既有逃生与后代进程清理路径。 + +结果区分工具的非零退出码、无效协议结果和后端拒绝。最终输出用于现有诊断解码;已流式显示的输出不重复追加。进度和结果都有限制,不能把无限后台服务放进单次任务。 + +### 工作区与配置失效 + +每个进程继续按插件、清单/包、构建系统及工作区隔离。`BuildSystemService` 在任务上绑定运行 ID、工作区代次、生效选项指纹、启动设置及被监听构建文件的配置代次。停用/移除插件、工作区关闭/替换、取消,以及同一构建系统选项或被监听配置文件变化,都停止或使对应操作失效;后端迟到输出不能进入替换运行或新工作区。 + +同一路径重开仍是新的服务代次。插件更新先停止旧 Provider 再替换包;配置变化后的下一次调用按现有规则重新建立会话。Windows 当前尚未接入 Build Provider 宿主,继续不声明此能力,不以 Core 支持版本 2 代替 Windows 实机实现。 + +生产者自身的模型是否过期,使用该实例独立的配置代次判断;它只随所属构建系统的 +选项或包配置变化而递增。另一个生产者开始或完成正常加载,只撤销消费者的共享 +快照,不能让本实例变成过期。全局共享快照代次继续用于拒绝消费者的旧回复; +文件代次与工作区身份仍分别检查。这样同时加载 A、B 时,两者可按任意回复顺序 +成为 ready;A 自己的选项改为另一值再改回(ABA)时,旧回复仍保持 stale。 + +### 资源与数据边界 + +本次没有新增下载入口、生成目录、缓存路径或用户数据迁移。执行复用既有 `workspaceStateDir` 和进程生命周期;插件包与发行目录只读,运行状态、日志、锁和输出仍由现有平台存储 adapter 提供位置。可变工作区状态不跨工作树共享,不影响代码签名或 Sparkle 增量更新的基线。 + +契约/协议变更合并前,用 `lithe-plugin-docs` 的 SDK 端到端工具验证当前源码;合并后按[契约发布规则](2026-10-23-plugin-contract-publishing.md)同步副本。已有 SDK 模型/依赖示例须继续协商版本 1 并正常工作;SDK 的执行处理器和 Maven 插件接入在独立仓库分别推进;本地源码验证、正式发版与旧生产路径删除必须分别记录,不互相代替。 + +### 正确做法与反例 + +正确做法:插件将构建任务声明为 Provider 来源,用户在原生窗口点击任务,宿主发送类型化执行请求;取消后,由同一个会话停止请求,旧输出不能写回新运行。 + +不要这样做:宿主通过 `functions.invoke("maven.build")` 猜测 Maven 私有函数;Provider 超时后再启动旧 `MavenService`;为获取工作区进程而创建空网页。它们分别绕过领域契约、保留旧业务回退和错误绑定页面生命周期。 + +### 应用准备与应用进程分别管理 + +`buildProviders: 4` 与进程协商版本 4 增加应用启动准备。Provider 先使用自己的上游工程状态完成同步、构建并给出启动计划;Core 只检查目标身份、作用范围、参数大小、可执行工具引用与工作目录。比如后端从自己的工程引擎取得 classpath,返回分开的参数;宿主不要解析 POM 或源码再算另一份 classpath,也不要把启动命令拼成 shell 字符串。 + +本批只开放模型目标上的应用入口。运行入口不再生成一个空命令配置:Build 树按目标标签列出入口,点击时重新查询当前模型 ID。准备过程仍在 Build Output 显示进度,成功后交给现有 RunService,并切到 Run 窗口。RunService 继续持有进程、工具解析器、输出和 Java 长参数临时文件的租约;应用退出、停止或启动失败时释放租约。Provider 回复只是准备完成,不代表应用已经结束。 + +准备中的源码、模型、配置、插件或工作区变化使旧结果失效,解码完成后也要再次检查。应用启动后不因普通源码编辑就终止;停用所属插件或关闭工作区才按对应操作 ID 停止,旧操作 ID 不能停止新进程。运行目录只能是工作区内、属于当前实例的已有目录;符号链接解析后的目录也必须满足这一限制。没有新增运行资源目录、下载或跨工作树缓存,既有平台进程与临时参数 adapter 继续负责可写位置,安装包保持只读。 + +Build 树的应用点击在第一次异步等待之前就登记现有运行槽的 ID。文档保存作为 +这次操作的就绪步骤,由同一任务持有;保存成功后再检查运行 ID、工作区、源文件和 +模型身份,才能请求 Provider。取消或保存失败都不能进入准备。不要在 AppModel +另起无句柄的保存任务,再在保存结束时创建一次新运行:旧点击可能因此停止较新的 +任务或测试、清空它们的输出。后续运行、停止、重载、工作区切换及插件撤回都能撤销 +保存阶段;即使保存忽略取消后才返回,也不能重占槽或启动应用。 + +首次从 Build 树运行仍在 Build 窗口管理准备;从 Run 重启时,Run 的停止动作取消对应准备 ID,成功启动后改由进程 ID 管理。应用输出不再要求旧式检测器先生成 Run 配置,也不会因缺少 generated.json 被配置引导页遮住。多服务编排与 Java/JDT 调试准备仍未接入,不能把此增量表述为阶段 C 或 Java/Maven 迁移全部完成。 + +### 保存选择器,重启时重新准备 + +用户可以把当前 Provider 入口显式保存到已有 `.lithe/run/local.json`,不另建最近运行数据库或镜像设置。Core 只生成文档变更,平台适配器检查原文件快照后原子写入,并补齐本地文件的忽略规则;写入后重新读取,失败明确报告。相同插件/实例/目标/声明重复保存会更新原条目并保留 ID,不产生重复配置,也不改动其他条目和 JDK 默认值。 + +保存的是“由哪个插件的哪个实例准备哪个模型目标”,加上原始参数数组,不是后端上一次返回的 classpath、命令或环境。比如保存 `Main` 后重启,宿主重新查找当前已启用的插件和模型 ID,再发送 `prepareRun`;不能直接执行上次的 `java -cp ...`。首次保存不需要生成器建立一份空模型,读取时仅对明确保存的 Provider 入口允许空生成层;其他未识别工程保留原缺失语义。 + +原生点击和重启先等待工作区文档保存,保存失败或工作区代次改变便停止,不发送准备请求。准备失败、用户停止、插件停用和工作区切换各自终结对应身份;旧准备完成不能启动进程,旧终止事件不能结束替换准备。应用仍使用单个主运行槽,不宣称已经提供通用 Services 或多应用并行编排。 + +### SDK 使用同一组类型化入口 + +配套 Node/Java SDK 源码在独立的 `lithe-plugin-docs` 仓库实现任务、测试、发现与运行准备处理器,不复制进主仓库。SDK 选择宿主明确提供的最高共同协议版本;版本 1 不能调用执行、版本 2 不能调用测试、版本 3 不能调用运行准备,即使后端注册了对应处理器也一样。清单能力准入与进程协商是两层独立检查,SDK 不用私有函数代替领域方法。 + +执行、测试和运行准备的上下文保留 Core 分配的线缆操作身份,进度只带这份身份。处理器改动自己的参数对象不能换掉进度归属;取消和结束后不再报告。Node 的类型声明与 Java 的具名入口共同表达这条边界,Core 继续负责选择器、工作区路径与领域输出校验,SDK 不再维护一份工程引擎。 + +两套无页面示例使用虚构引擎展示协议,端到端回归通过真实 Core 检查构建退出码、精确筛选、运行前构建与准备计划、并行工作区隔离、取消后的工具清理,以及包含 SDK/JAR 的完整包字节不变。SDK 处理器属于开发中的源码,不代表已发布 SDK 或现有 Maven/Java 插件完成迁移;发布契约副本仍必须等主仓库合并后运行同步工具。 + +### 调试启动保留已选适配器的身份 + +现有调试适配器注册表(DebugAdapter Registry)已经定义插件 ID 与适配器 ID 组成的身份,但 macOS 会话过去只按源文件选择语言默认适配器。工程目标可能没有打开的源文件,同一语言也可能有多个调试器,所以明确选中的适配器现在通过 `DebugLaunchConfiguration.adapterProviderID` 交给原有会话管理器。原有插件文件启动入口也携带这份身份,重试与配置变换继续保留它。 + +开发者要从已安装、已启用的注册中取得 `插件 ID/适配器 ID`,并在启动前完成现有 Core 参数及权限校验。正确例子是把已校验的 LLDB 启动参数交给 `dev.example.native/lldb`;不要只传一个 `.c` 文件,再期待宿主碰巧选中同一个调试器。明确身份已经消失、被停用或不支持该请求时,启动在创建进程前失败。不能因此改用同语言的另一插件,也不能把身份当成可调用私有函数、任意进程或端口的权限。 + +宿主保留所有适配器的完整身份,包括只能附加到进程的适配器;它们不会挤占文件启动的默认项。源码断点传给匹配语言的已注册适配器(未限制语言的原生适配器接收全部源码),并在之后启动时重放;函数、异常与数据断点按当前会话的适配器身份更新。新增会话启动失败只关闭自己的资源,原会话和选择继续保留。这里不增加资源目录、下载或缓存;插件进程仍由既有平台 adapter 放在原有可写状态位置,安装包保持只读。 + +`buildProviders: 5` 增加独立的原生程序调试准备请求。运行声明只引用同插件适配器;后端先构建,再返回程序路径、参数、工作目录和环境,不得夹带适配器身份、进程、端口或任意调试协议参数。Core 检查程序属于当前工程实例且为已有文件;平台交接前再次检查可执行权限和符号链接,随后复用注册表的目标绑定生成参数。`debugAdapters: 2` 只增加参数与环境的类型化目标绑定,原有 DAP 引擎、适配器资源与权限规则继续生效。 + +Build 树和已保存的 Provider Run 配置都有 Debug 入口,启动前先保存文档。异步准备、Core 解码和宿主交接共享已有运行准备的配置、源码与模型失效判断;调试停止只取消对应准备,准备成功后由 Debug 会话负责资源。正确例子是插件自己的构建引擎生成当前程序,再交给自己声明的 LLDB 入口;不要把 `prepareRun` 返回的命令改名成调试计划,更不能从 Provider 返回另一个插件的私有函数或开放端口。两个 SDK 同步增加类型化入口并保留版本准入;正式契约副本仍在合并后同步。 + +这个纵向增量只覆盖插件自有原生可执行程序。Java/JDT、跨插件准备与长期服务仍未迁移,不能据此声称整个阶段 C 或 Java Support 拆分已完成。 + +### 已启用 Provider 之间只共享只读工程输入 + +构建插件与另一个插件交换工程事实,先通过宿主提供快照,不让后端互相调用。 +生产者明确开放项目、目标和内容目录,以及选定的普通构建选项;接收者明确声明 +插件 ID 与构建系统 ID。Core 检查数据范围并删除任务、运行计划、设置和未公开的 +选项值,宿主只提供当前工作区内已启用包的事实。工程图仍由已有上游引擎产生, +Core 不从源码或构建脚本再推导一份。 + +例如构建插件开放项目路径与选中的 profile,另一个 Provider 可以据此准备自己的 +工程模型。没有生产者、还在加载、失败或模型已经过期,都有单独的结果,不能拿上 +一次的模型冒充 ready。配置改了又改回也产生新的快照代次;宿主立即取消消费者, +并在后端回复和解析完成后再次检查,防止忽略取消的旧结果发布。 + +这是 `buildModelInputs: 1` 与 Provider 协议 6 的窄入口,不是插件安装依赖或公开 +函数系统。第一版生产者不能同时消费输入,避免递归转发和失效循环;缺失生产者 +不会被下载或自动启动。比如消费者不应借公开项目数据取得生产者的进程、权限或 +私有函数。完整安装闭包、包版本选择、信任提示和事务回滚仍按 +[插件依赖与导出提案](../../proposed/architecture/2026-10-25-plugin-dependencies-and-exports.md) +独立设计,本增量没有提前实现这些决策。 + +这项能力已接入原生模型、依赖、构建、测试、发现与启动准备的计划入口,并不等于 +Java 语言后端、Maven 插件已经改用它;现有 Java/Maven 业务迁移仍待后续交付。 +共享快照只存在于请求内存,复用既有工作区资源 adapter,不引入缓存目录或下载物, +不修改安装包、签名或 Sparkle 增量更新基线。Windows 宿主仍未接入,不能以 Core +识别协议代替 Windows 功能验收。 + +### Maven 插件的首个真实执行接入 + +独立社区插件 [lithe-plugin-maven](https://github.com/Lithe-IDEA/lithe-plugin-maven) +的本地后续实现把生命周期、模型中的插件目标、自定义目标和测试改成 Provider 来源, +删除清单中对应的命令模板。后端沿用自己的 Maven 核心扩展读取反应堆(当前 Maven +构建包含的模块)和插件目标,在执行前重新确认所选工程与目标属于当前模型;不把 +宿主传来的一个 properties.goal 当作已授权的模型事实。 + +模型读取和执行都消费同一组生效选项,由插件生成 Maven 参数。嵌套工程的选择按 +工作区相对路径解释,不能再在构建根前重复拼接一段目录。显式测试请求使用 Surefire +公开的类/方法选择接口;依赖模块可以没有所选用例,但整个请求必须产生所选测试的 +新报告。比如请求不存在的方法时不能拿“退出码 0、零测试”或者旧 XML 当作成功。 +报告的完整展示继续走已有 Core JUnit XML 解析器,插件这里只核对真实执行证据。 +依据为 [Maven CLI](https://maven.apache.org/ref/3-LATEST/maven-embedder/cli.html) 与 +[Surefire 测试选择](https://maven.apache.org/surefire/maven-surefire-plugin/test-mojo)。 + +共享模型只公开规范化事实与 profiles/offline/skipTests 的生效值;不开放 settings.xml +内容、未声明选项、运行计划或私有函数。执行复用 SDK 的有界输出、取消及子进程清理, +模型快照继续使用既有工作区状态目录。构建产物和测试报告写用户工程 target,插件 +安装包保持只读,没有新增产品下载入口或运行缓存。 + +这批源码在独立仓库的 [Maven Draft PR #7](https://github.com/Lithe-IDEA/lithe-plugin-maven/pull/7) 提交,配套 SDK 见 [Draft PR #4](https://github.com/Lithe-IDEA/lithe-plugin-docs/pull/4),均尚未合并或发布。正式 SDK 锁文件仍指向已经发布的旧版, +没有伪造新校验值,也不放松正式 package.mjs 的 SHA-256 校验。发布前必须先交付配套 +Java SDK 并更新插件锁定值。开发验证通过显式 SDK JAR 构建独立临时包,记录 SDK +哈希与包文件清单,既不生成发行 ZIP,也不改 dist 或 SDK 锁文件;这些本地构建状态 +不跨工作树复用。 + +真实 Core C ABI、插件 Java 进程和 Maven 三模块工程已经一起验证模型读取、模块编译、 +精确单方法测试、报告解析及公开模型投影;没有启动完整 macOS UI。旧 MavenService、 +Agent 专属入口、项目设置、运行配置和 Java/JDT 协同仍未迁移,因此只算阶段 D 的一个 +执行切片,不能宣布阶段 C 的全部官方插件同步或阶段 D 完成。 + +## 考虑过的备选方案 + +- **扩展独立页面 backend。** 它是全应用范围的私有服务;改为工作区服务需要重复已有 Provider 的作用域和清理。执行首先复用现有工作区机制。 +- **只加入任意方法调用。** 代码较少,但 Core 无法判断允许的方法、结果及进度归属,容易成为通用 JSON-RPC 隧道;仅开放明确的构建执行方法。 +- **继续要求所有任务返回 argv。** 对简单工具仍然合适,但拥有工程状态的后端不能控制执行与结果;作为并列来源保留,后端任务没有失败回退。 +- **让测试复用任务方法并让宿主推导筛选命令。** 无法证明旧后端支持测试,也容易扩大筛选范围;单独协商测试方法,保留选择器为类型化数据。报告解析复用现有实现,不增加另一套测试报告系统。 +- **只在宿主源码里找测试,或返回不可运行的标签。** 宿主会拥有第二份框架事实,测试清单和实际运行容易不一致。插件返回上游选择器,Core 只复用现有测试计划校验;当前不支持精确筛选的工具暂不声明发现能力。 +- **一次补齐全部 Run/Test/Debug。** 各领域有不同目标和状态要求;先交付可独立验证的执行路径,后续基于真实需求补类型化能力,复用 #1210 的调试契约。 +- **让 Provider 准备后伪造一个带语言后缀的文件来选调试器。** 会把工程目标误当成文件,遇到同语言多个适配器或插件停用时选错。直接传递现有注册身份,不增加另一套调试器路由与进程机制。 + +## 后果 + +收益是无页面的原生入口可以调用工作区后端执行,复用现有进程和 UI,并通过同一套协议证明进度、失败和取消。代价是多一个明确的能力/协议版本;后端作者需要实现执行处理器,宿主不能自行把未知任务变成命令。Maven/Java 仍有旧生产调用,只有对应纵向迁移完成并删除旧实现,#1495 才能最终关闭。 + +## 验证 + +共享 fixture 覆盖版本准入、类型化请求/回复、路径与大小限制,以及本地 Run 选择器。Core 回归使用独立计时进程验证旧协议拒绝新方法、进度映射、取消、注入时间的超时和晚到结果;保存回归验证不生成空模型、重复保存保留 ID、其他命名空间的元数据和工具链不丢失、缺省参数归一化、非法进程字段明确拒绝。 + +macOS 使用固定的 Swift 6.3.3 工具链和原生计时 harness。相关服务回归控制准备开始、解码暂停、停止或工作区切换的顺序;原生存储用真实 Core 和隔离临时目录验证保存后读回。Run 的对外操作身份从准备到应用退出保持不变,底层准备/进程各自按身份取消;Agent 启动返回同一主运行记录,授权仍沿用既有入口。插件停用、进程替换和工作区切换不能留下旧输出、订阅或可操作的旧主运行记录。 + +并行生产者回归分别暂停两个 command/Provider 模型请求,按两种顺序释放,检查 +两个模型都保持 ready、消费者能同时收到它们,并保留生产者自身的选项 ABA 回归。 +保存回归让旧应用的保存忽略取消,确认新任务或测试的身份和输出不受影响;停止、 +工作区、选项、源码、插件和模型失效均阻止迟到准备。原生视图回归另检查 Build 树 +发现的测试和 Debug 入口,以及缺少 generated.json 时的 Run 输出,覆盖深浅两种 +主题;其受控 Provider 数据和进程替身不代替发行包完整工作流验收。 + +Agent 入口回归从真实本地 Run 配置读取选择器,用可控 Provider 准备与进程替身依次验证启动、准备中停止、重启、输出和退出状态。准备到进程的交接不得更换公开操作 ID;重启产生新 ID,旧 ID 的停止请求必须报告失效,不能取消新准备。替身不启动系统工具,这项证据不代替真实插件后端的端到端验收。 + +可复验的检查入口: + +```bash +./scripts/verify-agent-notes.sh +./scripts/verify-shared-contracts.sh +./scripts/verify-runtime-bundle-immutability.sh +./scripts/verify-service-boundaries.sh +./scripts/verify-windows-boundaries.sh +./scripts/verify-rust-core-comments.sh +./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --jobs 6 --filter 'BuildSystemServiceTests|ExecutionModuleTests|MacBuildProviderSessionsTests|RustBuildSystemOperationsTests|BuildToolProviderResolutionTests|PluginWebBridgeTests|BuildSystemToolWindowTests|RunConfigurationIntegrationTests|RunWorkflowCoordinatorTests|IdeCapabilitiesTests' +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'BuildSystemServiceTests|RustBuildSystemOperationsTests|MacBuildProviderSessionsTests|LitheDebugModuleTests|PluginDebugAdapterTests|PluginLiveActivationTests|RunConfigurationIntegrationTests|RunWorkflowCoordinatorTests' +cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems +cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib run_configuration +cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib provider_debug +cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib lsp +``` + +当前本地逐项计时证据为 `saved-provider-complete-macos.json`、`saved-provider-core.json`、`provider-sdk-unit.json` 与 `provider-sdk-native.json`,对应 HTML/JUnit 保存在 `.artifacts/test-stability/`。SDK 兼容场景另外使用契约锁定的 Core,原生工作流场景使用显式工作区源码快照,不能把日志里的基线 SHA 当作完整已提交实现。Java 产物检查 Java 8 字节码;本地运行时和 Windows 实机证据分别记录,不能用编译替代运行。 + +调试身份交接的本地回归记录在 `provider-debug-routing-final.json` 及对应 HTML/JUnit:182 个测试通过,包含真实 Rust Core 桥接后 DAP 参数不混入宿主身份、无源文件目标、明确身份失效、请求类型不匹配、多适配器断点、失败后恢复原会话及同步回调替换会话后的精确清理。测试使用受控会话,不代表真实插件适配器、Provider 调试准备或 Windows 已验收。 + +原生调试准备的本地增量报告是 `provider-debug-preparation-macos.json`(375 项)、`provider-debug-preparation-core.json`(1,304 项)、`provider-debug-sdk-unit.json`(114 项)和 `provider-debug-sdk-native.json`(6 项),均有对应 HTML/JUnit。报告覆盖当前 Swift/Node/JDK 运行时的受控宿主与真实 SDK 进程;原生调试准备取消后构建子进程结束、另一工作区仍可用,完整插件包保持只读。平台交接回归另验程序不可执行、程序/实例别名变化以及插件撤回和替换;没有启动真实 LLDB、JDT 或 Windows 产品,所以功能矩阵继续保持 `pending`。 + +只读模型共享增量的本地报告为 `provider-model-inputs-macos.json`(413 项)、 +`provider-model-inputs-core.json`(1,307 项)、`provider-model-inputs-sdk-unit.json` +(117 项)与 `provider-model-inputs-sdk-native.json`(6 项),均有对应 HTML/JUnit。 +其中两个独立 SDK 后端经过真实 Core 交换公开投影,消费结果保留共享快照代次与项目数; +包文件清单不变。宿主回归覆盖停用、重载、构建文件、工作区替换、选项 ABA,以及 +后台解析后仍不能发布旧事实;生产者跨 ABA 加载的模型也保持 stale。 +这些仍是本地受控与进程集成证据,不代表真实 Java/Maven 插件迁移或 Windows 验收。 + +Maven 首个执行接入的本地报告以 `provider-maven-` 为前缀:`native-maven`(12 项)、 +`native-maven-core`(7 项)、`maven-integration`(16 项)、`backend`(16 项)和 +`process-regressions`(8 项),共 59 项回归;独立开发打包另外有 3 项记录。 +本机使用 Microsoft JDK 21 与 Maven 3.9.16,后端 52 个类和扩展 5 个类都检查为 +Java 8 字节码(major 52),这不代表已经在 Java 8 运行。原生单方法测试经过 Core +计划、真实 Maven/Surefire 和 Core 报告解析;完整测试类名不能误匹配其他包的同名后缀;断言失败时的测试清理也会回收 SDK +另起进程组的工具。完整报告、命令与源码哈希保存在本地 `.artifacts/test-stability/`。 + +宿主集成测试 `MacNativeBuildProviderIntegrationTests` 使用生产 +`MacBuildProviderSessions` 和链接的 Rust Core,直接启动开发包中的真实 Java SDK +后端,完成三模块模型读取、单模块编译、精确单方法测试和 Core 报告解码。 +它不创建插件页面,后端私有函数列表为空;工具路径由测试环境显式选择,既不探测 +也不改写用户运行时设置。结束后等待生产会话的释放回调,再比较整个插件包的 +SHA-256 与权限清单。初次本地计时为 3.552 秒,报告是 `stage-c-native-macos.json` +及对应 HTML/JUnit;这项证据覆盖真实宿主 adapter,仍不等于完整窗口或发行包验收。 + +SDK 的 [显式宿主 CI 运行](https://github.com/Lithe-IDEA/lithe-plugin-docs/actions/runs/40531099687) +在 Node 20、Java 8 上对主仓库 `58ca81cf66c5c6e281c7df73ac67adb3526174f3` +通过真实 Core 原生工作流与锁定旧契约的兼容场景。整轮工作流仍因 preview 的调试 +契约已更新而报告镜像漂移失败;不能把单个端到端作业通过说成整轮 CI 通过, +也不能提前把未合并分支写进正式契约锁。Maven 的显式 SDK 开发验证同样独立于 +普通 PR/发行打包,只有源码与最低运行时验证,不生成发行 ZIP 或改写正式 SDK 锁。 + +Maven 的 [Java 8 开发验证](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40533306865) +已在 SDK 固定提交 `82e602c47ad80603ad31d503ddf0d2d229240e08` 上通过,覆盖当前 Maven +和最低支持的 3.6.3,每组各 55 项计时检查(包括 3 项开发打包)。产物身份与 JAR +哈希随报告上传;普通正式打包仍因旧版 SDK 锁定而失败,两个结果不能相互替代。 + +合并 `preview` 的 `edb8c3691415439c8bedc5f45478d42df6530cf2` 后,Core 的 1,327 项测试、 +macOS 19 组共 505 项回归与独立仓库的 6 项真实 SDK 进程集成通过。远程连接选择器 +的旧夹具补齐 attach/connection 声明,继续验证它不能成为文件启动的默认适配器, +没有放松生产准入。生产 macOS Maven 集成此次计时为 3.440 秒。 + +显式 `MacNativeProviderDebugIntegrationTests` 已通过真实 LLDB 验收:同一工作区的 +公开 Node SDK Provider 用 Clang 编译 C 程序,经 Core 准备、Registry 明确身份与 +生产 stdio 传输启动 LLDB,命中第 4 行断点、读取 main 栈帧,带保留空格的参数继续 +运行,核对输出和退出码 0。当前计时 2.083 秒,报告为 `stage-c-native-lldb.json` +及对应 HTML/JUnit;SDK/插件源码、权限和包文件 SHA-256 保持不变,无本次启动的 +LLDB、debugserver、后端或被调试程序残留。这是生产 adapter/Registry/Core 的集成 +证据,尚不代替完整应用窗口或正式插件发行验收。 + +测试显式提供 `LITHE_STAGE_C_NODE_SDK`、`LITHE_STAGE_C_NODE`、 +`LITHE_STAGE_C_CLANG`、`LITHE_STAGE_C_LLDB_DAP`,不改用户工具路径设置。 +本机系统开发者调试授权起初关闭,独立 LLDB CLI 同样停在 run;经用户明确授权后 +开启 `DevToolsSecurity`,LLDB 可启动目标。测试中编译器的绝对源码路径与 DAP 源码 +路径保持一致,避免 macOS 临时目录的 `/var` 与 `/private/var` 别名导致未验证断点。 +测试退出前固定 LLDB 子孙进程的 PID/开始时间,使用平台有界清理,因为 debugserver +可能新建会话且不继承环境;失败同样清理,在 worker queue 等待而不阻塞主 actor。 + +最终组合计时报告 `stage-c-final-macos.json` 共 20 组 506 项通过,真实 Maven 和 +LLDB 集成分别为 3.776 秒与 2.032 秒;两项有对应 HTML/JUnit 和完整清理断言。 + +准备解除 Draft 时,再与 `preview` 的 `0beb43c936f61e68b983533805d6534a01fdf8b4` +合并。新增的用户显式 DAP 端点与本 PR 的适配器 Registry 身份是两个独立维度: +先校验所选身份与 launch/attach 能力,再经已获授权的端点工厂连接;缺少身份、 +能力、端点工厂或授权时均不能退回按源码匹配的本地进程。配置编码、步进过滤器 +变换和 Java 重启准备保留两项字段;普通启动不复用已有 TCP 会话。组合回归同时 +覆盖默认/明确适配器身份与 launch/attach,避免合并后只保留一条路径。最终 `stage-c-ready-macos.json` +在 21 组记录 536 项通过,包括真实 Maven、LLDB 与新 TCP 传输;合并后的 Core +1,336 项单元/集成检查通过。 + +在 `lithe-plugin-docs` 运行以下入口可以复验 SDK 部分: + +```bash +node --test sdk/node/test/*.test.mjs scripts/*.test.mjs +npm ci --prefix sdk/node --ignore-scripts +npm run --prefix sdk/node test:types +mvn -B -ntp -f sdk/java/pom.xml verify +node scripts/sdk-e2e.mjs +node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --working-tree --build-workflows +``` + +工作区快照工具只复制 `rust/` 和 `shared/` 的暂存、未暂存与未跟踪源码,源目录和 Git index 保持只读,忽略的构建缓存不复制。SDK 验证使用独立构建目录,不与主工作树共享可变 Cargo 状态;这些临时验证输入不是产品运行资源或可跨工作树复用缓存。 + +这些证据不替代真实插件工程、发行包和 Windows 实机验收,功能矩阵保持 `pending`。没有新增下载物或产品资源目录;本地用户配置不属于可跨工作树共享的构建缓存。新增目录或依赖时仍须遵循资源复用和安装包只读规则。 + +剩余合并发布、Maven 全生产入口、Java/JDT 与跨平台收尾按[四个大任务执行计划](../../../../docs/development/issue-1495-plugin-migration-remaining-plan.md)推进;计划不是已完成功能清单。 + +## 适用范围 + +- `rust/lithe-core/src/build/`、`rust/lithe-core/src/lsp/interface/`、`rust/lithe-core/src/plugins/` +- `rust/lithe-core/src/execution/configuration.rs` +- `macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift` +- `macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift` +- `macos/Sources/LitheExecutionModule/Services/RunService.swift` +- `macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift` +- `macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift` +- `macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift` +- `macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift` +- `macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift` +- `macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift` +- `shared/contracts/plugins/v1/` +- `shared/platform-feature-matrix/features/build-system-plugins.json` +- `shared/contracts/run-configuration-v2.schema.json` +- `shared/fixtures/run-configuration/build-provider.json` +- `macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift` +- `macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift` +- `macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift` +- `macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift` +- `macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift` +- `macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift` +- `shared/fixtures/debug/adapter-selection-v1.json` diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-external-document-changes-and-guarded-save.md b/.agents/notes/implemented/bug-fix/2026-09-15-external-document-changes-and-guarded-save.md index bd4cbebe3a..023d9d41f7 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-external-document-changes-and-guarded-save.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-external-document-changes-and-guarded-save.md @@ -134,6 +134,20 @@ Windows 是独立前端实现,写入进行中到期的那次延迟自动保存 磁盘 B 不符,B 和 C 都保留。认可 B 后外部又写入 D,仍必须重新提示。 不要把“保留编辑器版本”实现成永久 `force=true`。 +### 构建输出先筛选,再判断事件洪峰(#1176) + +512 条阈值现在统计筛选后的有效变化。普通工作区事件由 Core 的默认目录策略 +排除构建输出;Windows 原生宿主在跨 Tauri 边界前筛选,macOS 使用同一批量 +命令。相对路径以最具体的显式监听根计算,项目根本身叫 build 不会被误排除。 +显式监听的文件与生成源码目录仍有自己的事件。重命名先分解两端,再独立筛选, +从 target 移入 src 的文件仍能出现。 + +系统报告事件丢失或监听失败时始终恢复重扫,不能因为路径在 target 下就忽略。 +不采用暂停 watcher、延迟几秒或屏蔽整批事件:构建期间可能同时编辑源码。 +代价是原生 watcher 仍收集构建输出,改善的是跨平台业务刷新量,不宣称减少 +操作系统通知量;Git 和已打开文档监听保持独立。共享 fixture、输出洪峰混合 +源码、显式监听、跨过滤边界重命名及原生丢失标记回归共同约束该边界。 + ## 考虑过的备选方案 - 无界的全项目递归监听会让资源和事件量随整个项目增长,也无法解决漏通知后的 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md b/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md index 23b64c237f..be3ba15d19 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md @@ -78,6 +78,28 @@ Current File 当作最终配置执行。配置文档可读不等于运行项目 工具链探测暂停首个加载,验证加载中不能启动、重叠加载选用配置默认项,以及 完成后的用户选择能够保留。不要通过增加 CI 等待时间掩盖这类状态提交竞态。 +### 不可读目录不是已删除目录(#1389) + +自动发现、指纹、Node 清单和 Windows Java 文件列表使用 Core 的同一遍历策略。 +先排除已知缓存和构建输出,再进入子目录;不能按“点开头”排除所有隐藏源码。 +工作区根不可读仍失败,可选子目录不可读则继续兄弟目录,并记录未知路径。 +重新生成保留未知路径下的旧配置及输入摘要;检查返回 incompleteProjectScan, +不从不完整快照推断删除或新鲜度。即使后续目录可读,旧基线也要完整重新生成后 +才能解除诊断。两端 Run 面板都必须接纳这个诊断代码,并通过共享 i18n 显示 +权限恢复与重新识别的提示;不能只在 Core 返回却被面板筛选隐藏。 +代价是权限恢复前可能保留一个实际已删除的入口,优于静默丢配置。 + +不要只给 Windows 加 catch 并返回空数组:这样会把访问失败写成成功的空快照, +且 macOS 的指纹仍使用另一套规则。扫描不接管 JDT 的 Java 入口事实或 Maven +声明模块;只替换已有的启发式文件遍历。注入目录读取失败的测试覆盖兄弟目录、 +必需根失败和链接不跟随,保留测试覆盖旧配置与输入摘要的未知/已删除区别。 + +Java 文件枚举的数量上限也必须限制后续目录访问,不能完整扫描后再截取数组。 +共享遍历逐目录交付结果;Java 列表收集满上限后立即停止,不保留整个工作区的 +文件名快照。已遇到的不可读路径仍保留在遍历诊断中,完整指纹扫描不受这个 +列表上限影响。测试注入目录读取器,在达到 8,000 个 Java 文件后若继续打开 +后续目录就立即失败,并验证顺序稳定、零上限与完整扫描仍能继续访问。 + ## 考虑过的备选方案 - 持久化大小和修改时间以复用摘要:同大小文件可在保留时间戳时发生内容变化, diff --git a/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md b/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md index 8293df7b1e..92243d2ec5 100644 --- a/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md +++ b/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md @@ -4,11 +4,13 @@ ## 先说结论 -PR #37 是第一阶段 UI Contribution(插件声明的界面入口)基础设施:稳定 ID、宿主窗口管理和模块生命周期继续作为后续基础。静态文本窗口与打开自身窗口的命令只是 API level 2 的首个可验证子集,不能成为最终公共插件接口的能力上限。后续采用版本化的原生 UI 声明和领域 Provider(提供业务数据、行为或进程的能力实现),由宿主接入既有编辑器、Build、Run、Debug 和 Services。 +PR #37 建立了第一阶段 UI Contribution(插件声明的界面入口);到 2026-10-28,Mac 已有原生 Tree/List/Form、包内网页和独立后端,不再只是静态文本窗口。下一步先走 macOS 路线,把已有编辑器、文件和命令功能开放成有授权、版本与生命周期的公共 API,再接续领域 Provider(提供业务数据、行为或进程的实现)。不恢复任意第三方 Swift Bundle 动态加载,也不把 WASM 运行时作为补齐 API 的前置条件;任务与公开文档待办见 [macOS 插件 API 实施计划](../../../../docs/development/macos-plugin-api-plan.md)。 ## 问题 -当前 `ui.toolWindows` 至少包含一个窗口且要求 `text`,`ui.commands[].toolWindowID` 必填;Rust 拒绝既无语言声明也无 UI 的插件。这些规则适合验证第一条接入链路,却不能表达独立刷新命令、纯构建插件或调试适配器。`database.services` 仍由数据库模块和 `DatabaseServicesView` 提供,组合根也仍分别装配 `MavenService` 和按语言选择的 `debugFactories`。这些是当前实现,不是未来所有扩展点都必须依赖语言或数据库的决定。 +最初的 API level 2 要求窗口使用 `text`,命令只能打开自身窗口,插件校验依赖语言或 UI。这是第一阶段的历史背景,不是今天的完整能力表:当前 Core 已允许纯 Build/VCS/Debug/项目生成器插件,Mac 的 `ui:3` 也已经支持原生树、列表和表单。 + +当前缺口在于通用接入:`ui.commands` 仍绑定自身窗口;原生动态数据源绑定自身构建系统和唯一实例;`backend:1` 是每插件一个应用级进程,只由自身网页调用。函数协议 v2 已有本插件凭据服务,但尚不是通用工作区/编辑器 SDK。开发者不能因为内部 Swift 模块能操作文件或编辑器,就直接从第三方后端调用它。领域能力的进一步迁移也不能与通用宿主 API 混为一谈。 ## 提案 @@ -18,20 +20,38 @@ PR #37 是第一阶段 UI Contribution(插件声明的界面入口)基础设 2026-10-18 起,插件也可以在宿主管理的窗口里放自己的包内网页(`ui` 能力版本 2),这是可选项,不替代组件树。外壳规则不变,加载与桥接边界见[插件自带网页界面](../../implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md)。 -内置或受信任的原生模块可通过 Native View/Renderer Factory(宿主登记的原生视图构造器)接入复杂 SwiftUI 内容,并共用同一套窗口管理。这个路径不等于恢复任意第三方 Swift Bundle 动态加载;二进制接口(ABI)、签名、分发与隔离仍需单独设计。Windows 使用自己的宿主适配共享数据契约,不复制 SwiftUI 类型,也不据此要求重写当前平台实现。 +内置或受信任的原生模块可通过 Native View/Renderer Factory(宿主登记的原生视图构造器)接入复杂 SwiftUI 内容,并共用同一套窗口管理。这个路径不等于恢复任意第三方 Swift Bundle 动态加载;二进制接口(ABI)、签名、分发与隔离仍需单独设计。数据契约不含 SwiftUI 类型,但本轮只实施和验收 Mac,不要求先完成 Windows 适配。 + +截至 2026-10-28,原生 Tree/List/Form 及面板动作已落地,见[声明式原生插件树与动作](../../implemented/feature/2026-10-21-declarative-native-plugin-ui.md)。后续应扩展通用数据源与分页,而不是重新实现一套控件;面板内动作不等于已支持独立全局命令。 ### 动作独立于窗口 后续动作独立声明处理器、可用条件和上下文条件,命令可以没有 Tool Window。菜单、右键菜单、工具栏、状态栏与设置项可引用动作或其他类型化贡献;打开窗口只是动作的一种行为。具体条件模型和处理器传输形式需通过新契约定义,不把任意表达式或脚本当成现成实现。 +### Mac 优先的宿主能力与隔离 + +公共接入分成四种:插件声明贡献、插件提供业务回调、插件调用宿主服务、宿主发布语义事件。SDK(供插件作者调用的开发库)封装这些方向,但不提供绕过权限的万能调用器。先以独立命令读取未保存选区,再完成按文档版本修改、一次撤销、目录树和 HTML 预览;不能把“监听按钮”实现成截获任意原生控件或其他插件输入。 + +继续复用现有外部后端和 JSON-RPC(带请求 ID 的进程通信)生命周期,不另建一套进程管理。无网页激活、工作区上下文及新宿主服务需要明确的新能力协商,保持 `backend:1` 的应用级、页面入口和旧包语义。每次调用都绑定插件会话、窗口、工作区代次和所操作的文档版本,宿主核验授权;不能让迟到结果落到后来切换的工程,也不能缺上下文时猜“当前窗口”。 + +后端调用宿主的原语与宿主调用领域 Provider 是不同方向。[函数契约](../../implemented/architecture/2026-10-20-plugin-function-contract.md)中的递归拒绝继续有效;不得借新 `commands.execute` 候选入口将完整 Build/函数/IDE dispatcher 暴露给 Provider,使处理器等待自己。已有单独授权的 IDE/MCP broker 也不自动成为第三方后端的权限。 + +现有文件端口、文档模型和 LSP 编辑链路是可复用的 owner,不是公共访问已经安全的证据。文件访问仍需路径/符号链接竞态防护;编辑先检查明确文档与预期版本,保护 dirty 内容、撤销和输入法。包内网页、工程 HTML 预览和系统浏览器分开授权,任意 HTML 不能继承插件桥接身份。 + +进程隔离与宿主授权不等于操作系统沙盒;原生后端可以拥有自己的系统权限,文档不能承诺代理之外的访问都被限制。WASM(可移植字节码)可在未来用 Wasmtime/WASI 等运行时承载,但仍需同一组 API、权限、取消和预算,且执行不进入应用主进程。它不依赖 Tauri,也不解决公共 API 缺失,因此暂不列为首版条件。 + +公开文档和 SDK 继续只发布到 lithe-plugin-docs;主仓库计划只列内部任务与验收,不提供第二套开发者手册。新增契约与 Core 一起改、合并后同步副本,SDK 源码、正式发布物、契约锁定版本与 Mac 实际执行能力分别记录,避免“SDK 有方法但宿主不能执行”。 + ### 领域扩展点与宿主调度 -公共 Extension Contract(插件和宿主交换能力声明及调用结果的协议)覆盖 Language/LSP、BuildSystem/Task、RunConfiguration/Test、DebugAdapter 和 ServiceProvider。Provider 提供业务行为、进程协议或数据,宿主负责发现、调度、权限、展示、取消、超时、失效结果和资源生命周期。下一版契约允许纯 Build/Debug/Services 等没有 UI、没有语言的插件;不能为了通过校验要求插件造空语言或空窗口。 +公共 Extension Contract(插件和宿主交换能力声明及调用结果的协议)覆盖 Language/LSP、BuildSystem/Task、RunConfiguration/Test、DebugAdapter 和 ServiceProvider。Provider 提供业务行为、进程协议或数据,宿主负责发现、调度、权限、展示、取消、超时、失效结果和资源生命周期。纯 Build/Debug 等入口已存在;尚待定义的通用 Services 与独立命令也不应为了通过校验而造空语言或空窗口。 版本控制(VCS)也作为领域扩展点接入,内置 Git 与插件 VCS 共用同一统一模型,见[统一 VCS 模型与 Provider 接入](2026-10-22-unified-vcs-provider-model.md)。 内置模块和插件尽量实现相同领域 Provider 契约。Build/Run/Test/Debug 等采用类型化 Registry(按能力类型登记和查询实现的目录),组合根负责装配通用适配器,不按新增插件名追加分支。先盘点并复用已有语言服务器、构建工具、运行/测试和 DAP(调试适配器协议)链路;上游拥有的项目、任务和依赖事实仍由原工具提供,避免再造一套解释器、工程模型或调试 UI。 +2026-10-28 盘点时,[PR #1517](https://github.com/1lck/Lithe-IDEA/pull/1517) 正在扩展原生 Build/Run/Test/Debug 准备与只读工程模型输入,尚未合并。相关工作先跟踪其最终版本,不另建协议,也不把该 PR 的能力记成当前 Mac 已发布支持。 + Debug 的统一内核、三层能力、插件声明与迁移验收见[统一调试内核与调试适配器 Registry](2026-10-25-unified-debug-engine-and-adapter-registry.md),跟踪当前仓库的 [#1210](https://github.com/1lck/Lithe-IDEA/issues/1210)。寄存器、内存、反汇编等可选能力另见[可选调试扩展](2026-10-25-optional-debug-extensions.md),插件依赖与导出另见[平台级独立提案](2026-10-25-plugin-dependencies-and-exports.md);两者都不作为 #1210 的前置条件。这些提案没有开放新字段,也不改变其他领域 Provider 的设计。 ### 通用 Services 与资源所有权 @@ -42,7 +62,7 @@ Services 接入容器、Spring Boot、数据库和本地服务。后续统一设 ### 版本与兼容性 -`pluginAPIVersion=2` 保持当前字段、校验和行为。交互组件使用单独版本化的 UI Schema;独立动作和新增领域扩展点进入明确的新契约版本,具体版本号及协商字段在后续任务中确定。文档中的未来能力不会被 v2 校验器默许,也不能在新增版本时让已发布 v2 包必须改写。未知版本和未实现能力继续由宿主与市场兼容性检查明确拒绝;共享 fixtures 验证各版本的接受、拒绝与迁移。 +已发布 API level 和能力版本保持既有字段、校验和行为,包括 `pluginAPIVersion=2`。交互组件使用单独版本化的 UI Schema;独立动作和新增领域扩展点进入明确的新契约版本,具体版本号及协商字段在后续任务中确定。文档中的未来能力不会被旧校验器默许,也不能让已发布包因新增版本而必须改写。未知版本和未实现能力继续由宿主与市场兼容性检查明确拒绝;共享 fixtures 验证各版本的接受、拒绝与迁移。 ### 正确做法 @@ -61,20 +81,30 @@ Services 接入容器、Spring Boot、数据库和本地服务。后续统一设 - 直接载入第三方 Swift Bundle:可快速表达复杂原生内容,但没有稳定 ABI,签名与隔离无法覆盖,不采用。 - 第三方 Webview UI:原先不采用。2026-10-18 改为可选路径,限定为包内页面、宿主外壳和白名单桥接,理由与边界见[插件自带网页界面](../../implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md)。 - 在 #37 一次完成全部组件和 Provider:会扩大未验证协议及迁移面;先保留基础设施,再按领域纵向验收。 +- 先全面改为 WASM 再开放 API:可以研究更严格的执行限制,但不能替代文件、编辑器和命令语义,还会增加工具链与分发工作;先交付 API,运行时以后单独验证。 +- 直接导出 AppModel、完整命令 dispatcher 或原生模块接口:接入快,但会泄漏内部类型、扩大权限并造成递归/生命周期耦合;只开放有类型、有授权且可撤销的能力。 ## 验收标准 - 原生 Tree + 独立刷新命令;只提供命令的插件不声明窗口也能注册和调用。 +- 无窗口选区转换插件读取 dirty 文档、按版本应用编辑并一次撤销;冲突/只读/IME/越权安全处理,多窗口与失效结果不串工程。 +- 分页文件树和 HTML 预览样例完成安装到停用的全过程;工程网页没有插件桥,外部浏览器与内嵌预览的未保存内容语义明确。 - 第三方 Build Task 出现在既有 Build;运行与测试复用既有配置、输出和取消链路。 - 第三方 ServiceProvider 出现在 Services;禁用清理插件资源,同时用户独立服务继续运行。 - 第三方 DAP 进入既有 Debug UI,完成断点、启动、停止和会话清理。 - 各样例覆盖安装、授权、启用/禁用、失败/晚到结果、恢复与布局持久化;无窗口 Provider 验证不会生成窗口或污染布局。 - 已发布 API v2 fixtures 继续通过;新版本 schema、兼容性拒绝和市场能力检查先于平台适配落地。 -- Windows 独立适配共享契约与 fixtures,双端状态分别记录,模型检查不代替实机验收。 +- 本轮只要求 Mac 产品验收;共享契约保持平台无关,功能矩阵如实记录各端现状,不把 Windows 适配作为本计划前置条件。 +- SDK、公开文档和样例在 lithe-plugin-docs 随各切片交付;契约副本锁定合并后的 preview commit,源码支持和正式 release 不混写。 ## 风险 -交互事件与进程调用会扩大权限和异步状态边界,需要明确归属、预算、取消及错误语义。内置模块迁移要保持已有项目、运行、调试、服务及布局行为;先迁移一个可回退 Provider,验证后再推广。公共 API 尚未实现前,不能把概念名称写成已发布字段或允许 v2 接受它们。 +交互事件与进程调用会扩大权限和异步状态边界,需要明确归属、预算、取消及错误语义。内置模块迁移要保持已有项目、运行、调试、服务及布局行为;先验证一个 Provider 的完整流程,并保留发布层面的迁移回退手段,不做运行时双实现静默回退。公共 API 尚未实现前,不能把概念名称写成已发布字段或允许旧版本接受它们。Mac 优先降低首版交付面,但文档/SDK 与宿主的版本同步仍是成本;原生后端没有 OS 沙盒的风险不能靠权限 UI 掩盖。 + +## 验证 + +- 本次计划只校验文档与边界:`./scripts/verify-agent-notes.sh`、`./scripts/verify-shared-contracts.sh`、`./scripts/verify-runtime-bundle-immutability.sh`、`./scripts/verify-platform-feature-matrix.sh`。 +- 后续实现按实施计划的三个真实插件验收,包含 SDK 端到端、Mac 实机、越权/取消/失效结果、进程清理与 bundle 哈希不变;本 Note 不将计划中的验收记成已经通过。 ## 适用范围 @@ -84,7 +114,7 @@ Services 接入容器、Spring Boot、数据库和本地服务。后续统一设 - `macos/Sources/LitheApplicationKernel/` - `macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift` - `macos/Sources/Lithe/Views/Workbench/` -- `windows/` +- `docs/development/macos-plugin-api-plan.md` ## 跟踪任务 @@ -96,4 +126,4 @@ Services 接入容器、Spring Boot、数据库和本地服务。后续统一设 - [通用 ServiceProvider 与资源归属 #42](https://github.com/Lithe-IDEA/Lithe-IDEA/issues/42)。 - [四个纵向端到端样例 #14](https://github.com/Lithe-IDEA/Lithe-IDEA/issues/14)。 -本 Note 描述未实现方案,当前已实现边界见 [第一阶段工作台 UI 贡献](../../implemented/architecture/2026-10-12-workbench-ui-contributions.md)。 +本 Note 保留公共扩展的未完成方向;已落地部分以[第一阶段工作台 UI 贡献](../../implemented/architecture/2026-10-12-workbench-ui-contributions.md)、[原生插件 UI](../../implemented/feature/2026-10-21-declarative-native-plugin-ui.md)及[插件契约](../../../../shared/contracts/plugins/v1/README.md)为准,不能再把第一阶段限制当成全部当前能力。 diff --git a/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md b/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md index f0e75919d6..f50138aed1 100644 --- a/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md +++ b/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md @@ -4,7 +4,7 @@ ## 先说结论 -Java Support 已经是声明式插件,JDT LS、JDK、Lombok 和调试、测试扩展都由插件包拥有,本提案不重复做插件化。建议下一步把它从“随 Lithe.app 发行”改成“官方签名、独立发布、按需安装”,让不使用 Java 的用户不再下载这部分资源。先完成可信安装、旧版本兼容和老用户迁移,再从主安装包移除资源;现有 `jdt` 宿主集成与 Java 运行、测试、调试实现保持当前边界。 +Java Support 已经是声明式插件,JDT LS、JDK、Lombok 和调试、测试扩展都由插件包拥有,本提案不重复做插件化。建议下一步把它从“随 Lithe.app 发行”改成“官方签名、独立发布、按需安装”,让不使用 Java 的用户不再下载这部分资源。先完成可信安装、版本匹配拒绝和用户配置迁移,再从主安装包移除资源。现有 `jdt` 宿主集成与 Java 运行、测试、调试是分发提案的当前边界;完整领域后端迁移由 [#1495 清单](2026-10-26-maven-java-domain-plugin-migration.md)继续推进,完成后删除这些宿主专属实现。 ## 问题 @@ -38,7 +38,7 @@ Java Support 已经是声明式插件,JDT LS、JDK、Lombok 和调试、测试 ### 一、目标与范围 -首个交付只完成独立分发,不要求把所有 Java 专属逻辑从宿主删除。 +首个交付只完成独立分发,不要求在分发 PR 同时删除所有 Java 专属逻辑;但这不是 #1495 整体完成的判据。按最新确认方向,只支持迁移后的最新宿主与匹配插件,不保留旧宿主业务 fallback、双写或长期兼容层;用户设置和数据仍需完整迁移。 | 位置 | 首个交付后的责任 | | --- | --- | @@ -49,7 +49,7 @@ Java Support 已经是声明式插件,JDT LS、JDK、Lombok 和调试、测试 沿用当前插件 ID,不用新 ID 让用户重新配置 Java。下载版采用新插件版本,不得覆盖已发行 `1.0.0` 的内容;精确版本号在实施和发布时确定。 -本轮不拆 Spring/MyBatis,不把 Maven/Gradle 构建模型搬进 Java 插件,不设计新的 Java 语义分析器,也不顺带把 Run/Test/Debug 改为公共扩展点。后者是独立的架构任务,继续由[公共扩展契约提案](2026-10-13-public-extension-contract-and-provider-api.md)跟踪。 +本轮不拆 Spring/MyBatis,不把 Maven/Gradle 构建模型搬进 Java 插件,不设计新的 Java 语义分析器,也不顺带把 Run/Test/Debug 改为公共扩展点。后者按 [#1495 领域迁移](2026-10-26-maven-java-domain-plugin-migration.md)与关联 #1210 调试能力推进;历史[公共扩展契约提案](2026-10-13-public-extension-contract-and-provider-api.md)只供核对已有设计,具体字段须随正式契约评审。 ### 二、分发与源码位置 @@ -69,7 +69,7 @@ Java Support 已经是声明式插件,JDT LS、JDK、Lombok 和调试、测试 - 下载版清单和宿主官方目录一致要求 `publisherPackage`,签名必须覆盖所有最终资源;代码签名、资源规范化等会改变字节的构建步骤全部在包签名之前完成。 - 旧内置包只能在宿主自身签名封装的 bundle 内按旧策略读取;用户目录里的同名、同版本包不能因此获得旧信任。过渡期允许两种来源时,必须明确检查来源、清单和版本,不能把 Java 的全局校验放宽成“任意一种签名都可以”。 - 实测下载包里的 JDK 可执行文件及动态库的签名、解压后的执行权限、隔离属性和系统启动限制;沿用或调整原生签名、公证方式的选择由这轮证据确定,不能靠启动时改写包或移除系统保护来通过。 -- 发布前确定旧宿主的兼容过滤方案。当前索引没有可直接填写的最低宿主版本字段,不能虚构字段,也不能仅凭 `pluginAPIVersion: 3` 判定可安装。若现有能力表不足以表达信任边界变化,先通过通用契约变更补齐拒绝机制,再上架新包。 +- 发布前确定版本不匹配的拒绝方案;不为旧宿主补业务兼容路径。当前索引没有可直接填写的最低宿主版本字段,不能虚构字段,也不能仅凭 `pluginAPIVersion: 3` 判定可安装。若现有能力表不足以表达信任边界变化,先通过通用契约变更补齐拒绝机制,再上架新包。 - 如需新增兼容或信任能力,契约在主仓库随 Core 修改,合并后按[两仓库同步规则](../../implemented/architecture/2026-10-23-plugin-contract-publishing.md)发布到 `lithe-plugin-docs`;未来独立插件仓库不能直接修改契约副本。 首期在主仓库受保护的官方插件发布流程中完成签名,不引入跨仓库私钥分发。私钥不进入插件、源码、构建缓存或日志;本提案不要求读取或转移私钥。未来迁移独立仓库时,另行确定发布权限和签名流程。 @@ -80,15 +80,15 @@ Java Support 已经是声明式插件,JDT LS、JDK、Lombok 和调试、测试 老用户采用先具备安装能力、再移除内置资源的顺序: -1. **过渡宿主仍带内置包。** 新宿主先实现新信任策略、官方源和下载安装;下载失败或取消时继续使用现有内置包。 +1. **先具备可信安装能力。** 新宿主先实现新信任策略、官方源和下载安装。分发过渡期如仍有内置包,只有它与当前宿主能力匹配时才可作为尚未切换的资源来源;不能把旧 JDT 宿主业务保留为后端迁移失败的兜底。 2. **安装可信下载版。** 从已发布签名包安装到用户目录,经验证和重启后切换。不要复制旧 bundle 中的包再让客户端自行签名,也不要尝试修改已安装 app。 -3. **单一生效来源。** 同一 ID 只能有一个生效版本:已验证的用户安装版本优先,过渡宿主中的内置包只作为尚未完成切换时的来源。选择规则由通用包管理器执行,避免两个 Java 所有者或两个 JDT 会话同时注册。 +3. **单一生效来源。** 同一 ID 只能有一个与当前宿主匹配的生效版本:已验证的用户安装版本优先,过渡宿主中的内置包只作为尚未完成切换时且能力匹配的资源来源。选择规则由通用包管理器执行,避免两个 Java 所有者或两个 JDT 会话同时注册。 4. **保留用户选择。** 已启用、已禁用和已卸载分别迁移;旧 `.removed-bundled-plugins.json` 标记不能被当作重新启用的授权。插件设置、项目 JDK 和已有运行配置不重置。 5. **再发布精简主包。** 只有下载、离线安装、迁移和失败恢复都验证后,才删除主包组装步骤。延后安装的用户届时仍可编辑普通文本,但 Java 语言服务、测试和调试需要完成插件安装;产品提示要说明这一变化。 不能假设每位用户都安装过过渡版本。直接从旧版升级到精简主包时,也要识别旧设置、卸载标记和失效资源路径,提供明确安装入口并保留用户选择;网络不可用时保留配置和可重试状态,不把“升级成功”显示成“Java 已可用”。 -更新沿用宿主现有重启边界:活动会话绑定实际使用的插件版本,切换和卸载前释放本插件资源,进程退出后才能删除该版本目录。新包验证或启动失败时保留可恢复的已验证旧版本;回滚也必须重新执行签名和宿主兼容检查。 +更新沿用宿主现有重启边界:活动会话绑定实际使用的插件版本,切换和卸载前释放本插件资源,进程退出后才能删除该版本目录。新包验证失败须保留原子安装前的有效状态和用户数据;能否恢复某个已验证包,仍须重新执行签名与当前宿主能力匹配检查。不匹配时报告失败并提示更新,不以回滚名义唤起旧宿主业务或保留双轨实现。 插件路径变化可能触发 JDT 索引重建。配置、项目运行时选择和 `.lithe/run/` 中的稳定配置 ID 保持有效;缓存按新版本指纹重新生成,不把旧索引跨版本复制作为优化。 @@ -121,16 +121,19 @@ Windows 插件包使用自己的原生运行时、入口和存储适配器,复 ### 八、分期与退出条件 +当前代码的实现边界见[Java 插件按来源校验与独立发布基础](../../implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md)。该实现登记双架构发布输入但保留 `releaseEnabled: false`,尚未向用户公开下载。迁移选择、缺包提示及以完整索引为门槛的精简构建已在同一实现 PR 中补齐,过渡宿主发行、真实发布包和完整产品迁移仍按本提案验收。 + + | 阶段 | 交付内容 | 进入下一阶段前必须证明 | | --- | --- | --- | | 0:确认基线 | 盘点两种 macOS 架构的实际资源、压缩体积、JDK 消费者和离线行为;记录上游版本与许可证 | 明确本轮只改变分发;列出需保留的用户工作流和资源消费者 | -| 1:可信下载安装 | 信任迁移、旧宿主兼容拒绝、真实 API/能力索引、多架构发布、签名包安装;保留内置包 | 干净环境可构建;下载包能在对应架构实际启动;正式构建拒绝伪造、损坏和未签名包 | +| 1:可信下载安装 | 信任迁移、不匹配宿主/插件的拒绝、真实 API/能力索引、多架构发布、签名包安装;分发过渡期只允许匹配的内置来源 | 干净环境可构建;下载包能在对应架构实际启动;正式构建拒绝伪造、损坏和未签名包 | | 2:老用户切换 | 验证下载版选择、重启切换、设置迁移、失败恢复,以及跳过过渡版的升级路径 | 同一 ID 只有一个生效版本;启用、禁用、卸载选择均正确保留;迁移不依赖用户逐版升级 | | 3:移除内置资源 | 删除默认应用组装中的 Java 包,更新推荐安装、离线说明、资源清单与矩阵 | 精简应用不含 JDT/JDK 副本;安装插件后完整 Java 工作流可用 | | 后续:独立仓库 | 可选迁移插件源码、专属测试和发布来源,固定宿主测试资源输入 | 无第二份可编辑源码;用户无需重新安装或重置设置 | | 后续:Windows 对齐 | 补齐必需宿主能力、平台包和真实 Windows 回归 | 同一版本能力声明可执行,Windows 安装目录哈希不变 | -首期按三个实现 PR 推进:可信下载安装、老用户切换、移除内置资源,阶段 0 的基线记录随第一项交付。完成阶段 3 即可验收 macOS 独立分发,不等待独立仓库或 Windows;Windows 另行验收前不声明双端完成。每项实现从最新 `preview` 创建独立分支,本地验证后先提交 Draft PR;不通过反复推送试探完整 CI。 +按用户要求,可信下载安装、老用户切换与受发布索引保护的精简构建在同一个实现 PR 中交付,阶段 0 的基线记录一并保留。完成阶段 3 即可验收 macOS 独立分发,不等待独立仓库或 Windows;Windows 另行验收前不声明双端完成。实现分支从最新 `preview` 创建,本地验证后先提交 Draft PR;不通过反复推送试探完整 CI。 ### 正确做法与反例 @@ -142,7 +145,7 @@ Windows 插件包使用自己的原生运行时、入口和存储适配器,复 ### 保持随应用发行 -首次离线体验最完整,迁移成本最低;但不使用 Java 的用户持续承担资源和更新成本,卸载不能释放空间。若产品仍坚持“全新安装立即离线开发 Java”,应保留当前方案,不宣称同时达成精简默认包。 +首次离线体验最完整,迁移成本最低;但不使用 Java 的用户持续承担资源和更新成本,卸载不能释放空间。若产品仍坚持“全新安装立即离线开发 Java”,应在发行形态上另行取舍,不宣称同时达成精简默认包;这不改变 #1495 的领域后端目标。 ### 只把 JDK 改成宿主共享下载 @@ -162,7 +165,7 @@ Windows 插件包使用自己的原生运行时、入口和存储适配器,复 - 两种 macOS 架构均能从官方源或磁盘安装可信包;缺包、取消、离线、错误架构、校验失败及旧宿主不兼容都有明确结果。 - 安装后真实完成补全、诊断、跳转、Java 主类运行、Spring Boot 运行、测试发现与执行,以及工具栏断点调试;不能只验证启动器或接口调用。 - 插件语言服务器 JDK 与用户程序 JDK 的选择符合现有规则,显式无效配置不会静默回退。 -- 老用户的设置和启用选择保留,失败安装不破坏过渡期可用的旧包;更新、回滚、禁用、卸载没有重复会话和残留进程。 +- 老用户的设置和启用选择保留,失败安装保留数据与安装前有效状态;恢复包仍须匹配当前宿主,更新、禁用、卸载没有重复会话和残留进程,不调用旧宿主业务 fallback。 - 用户跳过过渡版本直接升级时,配置和卸载选择仍保留;离线和下载失败可重试,不错误显示 Java 已就绪。 - 项目生成器及其 Maven/Gradle 依赖提示与迁移前一致;停用 Java 插件后相应贡献按当前契约撤销。 - 应用发行目录和插件安装版本在典型运行流程前后清单、哈希不变;缓存与临时目录由明确 owner 清理。 @@ -196,7 +199,7 @@ node scripts/verify-java-semantic-ownership.mjs ## 风险 - **首次离线行为改变。** 精简默认包与当前离线承诺不能同时保留;本提案建议通过明确安装入口和磁盘包支持离线准备,实施前在产品说明中确认这一改变。 -- **旧版兼容与信任策略耦合。** 直接翻转官方目录策略会使旧内置包失效;直接发布新清单会让旧宿主错误展示可安装。阶段 1 未解决前不得上架。 +- **版本匹配与信任策略耦合。** 直接翻转官方目录策略会使旧内置包失效;直接发布新清单会让旧宿主错误展示可安装。阶段 1 未解决前不得上架。 - **独立更新仍受宿主能力限制。** Java 原始协议适配仍在宿主,插件不能任意升级上游版本;每次升级要在声明兼容的宿主上验证,不能宣传为与宿主版本完全无关。 - **独立仓库资源来源漂移。** 主仓库真实 JDT 测试和 Windows 仍消费上游输入时,需要固定关系和升级责任,避免插件与宿主测试各验证不同版本。 - **索引重建与磁盘峰值。** 安装期间旧包、新包、归档和新索引可能同时占空间;在下载前检查空间,按版本使用和回滚策略回收,不能删除仍被进程使用的目录。 diff --git a/.agents/notes/proposed/architecture/2026-10-26-maven-java-domain-plugin-migration.md b/.agents/notes/proposed/architecture/2026-10-26-maven-java-domain-plugin-migration.md new file mode 100644 index 0000000000..99898cb5ad --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-26-maven-java-domain-plugin-migration.md @@ -0,0 +1,268 @@ +# Agent 笔记:Maven/Java 领域插件迁移与删除条件 + +状态:提议中 + +## 先说结论 + +Java Support 已拥有语言服务器资源,Maven 也已有插件后端,但宿主和 Rust Core 仍执行一部分 Maven/Java 专属业务。本提案按 [Issue #1495](https://github.com/1lck/Lithe-IDEA/issues/1495) 固定当前入口、调用关系、数据和删除条件:后续迁移必须让同一用户流程全部使用插件结果,再删除被替代的生产路径。只支持迁移后的最新宿主与匹配插件,保留用户数据迁移,不保留旧业务回退。 + +阶段 A 的交付是下述静态清单与边界决策;后续能力契约、代码删除和真实工作流验收尚未完成。本笔记留在 `proposed/`,不能据此宣称 Maven 或 Java 已完成领域后端迁移。 + +## 问题 + +### 固定基线与证据边界 + +核对日期为 2026-10-26(Asia/Shanghai)。主仓库使用提交前重新核对的最新 [`preview@1a7a95c8ce9d254bb6b8811af73e93d86a7badc1`][baseline],而非 Issue 初次审计的 `e854cfd98`;下文源码链接均固定到该提交。实现前应在各自最新 `preview` 再检查增量,尤其是调试与配置相关工作。本次已复核从 `26c42fe65` 到此提交的增量:#1510 新增调试适配器声明契约,其他下列生产调用链未被这项变更替换。 + +Maven 插件单独使用 [`Lithe-IDEA/lithe-plugin-maven@bfa56dc80914df22ab60967ec859f8932870b494`][maven-plugin](清单版本 `0.1.1`)核对。它证明源码具备哪些能力,不证明用户安装了该包,也不证明主仓库与该包已通过联合运行验收。 + +本次依据生产构造、调用、清单和 Git 删除历史;没有启动应用、执行 Maven/Java 项目或进行 Windows 实机验收。源码存在、历史 Issue 已关闭、功能矩阵已有记录,均不能代替当前工作流的运行证据。[PR #1483](https://github.com/1lck/Lithe-IDEA/pull/1483) 是独立分发相关的 Draft 工作,不在本基线内,也不是领域迁移完成的证据。 + +### 三类判定 + +| 分类 | 本基线事实 | 允许采取的动作 | +| --- | --- | --- | +| 已确认失去产品入口,并已删除 | [`f5458a243` / #1498][removed] 已删除 `CodeEditorView`、`HighlightedRangeCache`、`EditorSyntaxHighlightState`、`LanguageServerSetupView`、`AgentHistoryPopover`、`MavenModuleOperations`、`MonacoDiffEditor`、旧 `DiffPairing`、`LitheUnavailableView`、`ExternalAuthorizationCallbackRouting`。当前生产源文件中已无这些声明。 | 阶段 B 已有独立交付,不在本次重复删除;沿用该变更迁移过的有效场景。 | +| 仍被生产调用,但最终应由插件执行 | 下表 M1–M8、J1–J9 的领域部分;组合根仍创建 `MavenService`,Java 清单仍启用 `hostIntegration: jdt`。 | 先补能力与完整消费者迁移,达到对应删除条件后,在同一纵向交付中删除旧路径。 | +| 删除依据不足,或同时承载公共能力 | `RunService`、`StandardLanguageTestProvider`、LSP engine、通用 DAP/套接字、配置文件读写 adapter、共享解码器、`SyntaxHighlighter`、原生 Diff 高亮、`macos/Experiments/Monaco/`。 | 不整类、整目录删除。按实际语言/工具分支证明所有消费者替换;证明不了就保留公共部分并记录阻塞点。 | + +第三类并非“确认废弃”:搜索替换预览调用 `SyntaxHighlighter`,原生 Diff 和调试展示调用 `DiffSyntaxHighlighter`;当前 `LocalHistoryDiffPairing` 是本地历史功能的实现,不能因名字相近而当成已删除的旧 `DiffPairing`。`JavaRunService` 只是 `LitheExecutionModule.RunService` 的类型别名,不能把它算成第二套独立运行引擎。 + +## 提案 + +### 一、最终所有者与最新版本策略 + +插件后端是独立进程中处理领域请求的部分;Provider 是向宿主提供某种能力的插件接口。两者都不意味着新增一套通用进程或 UI 框架。 + +| 所有者 | 应持有的内容 | 代码迁移动作 | +| --- | --- | --- | +| Maven 插件后端 | Maven 工程模型、Profiles、settings/仓库语义、目标参数、执行与报告;复用 Maven 自己的模型和解析结果。 | 接管 M1–M8 的 Maven 决策,向所有消费者提供同一生效快照。 | +| Java 插件后端 | JDT 启动和专属适配、入口、运行准备、测试/调试、Spring/MyBatis;复用成熟 Java 引擎。 | 接管 J1–J9 的 Java 决策,撤销宿主 `jdt` 专属入口及对应 Core 分支。 | +| 宿主与平台 adapter | 原生窗口、编辑器、通用 Build/Run/Test/Debug 展示;能力路由、权限、进程、终端、凭据、平台文件读写、取消及资源释放。 | 按类型化结果执行用户动作,不推导主类、Maven 参数、Java 类路径或框架事实。 | +| 通用 Rust Core | 通用契约校验、路径安全、确定性规范化、LSP(语言服务器协议)、DAP(调试适配器协议)与通用操作状态。 | 拆掉 Maven/Java 专属决策;保留仍有公共消费者的校验、传输和状态能力。 | +| Maven、JDT 及扩展 | 各自负责的项目模型、源码根、依赖、符号、编译、测试、调试事实。 | 以引擎输出为事实源;不从几条命令另建一份长期同步的工程引擎。 | + +只交付最新宿主与对应插件的组合。阶段 C 确定协议和能力版本后,不匹配的组合明确拒绝并提示更新;不增加老版本宿主业务 shim(兼容转接层)、双写、旧模型兜底或静默降级。 + +一次性配置/数据迁移与旧业务兼容不同:迁移须能重试、校验并保留失败源数据,成功后删除旧读取入口。通用包管理的原子安装失败恢复仍有必要,但不能借“恢复”重新启用不匹配插件或旧宿主 Maven/JDT 实现。 + +### 二、当前能力与真实缺口 + +| 可复用的现有机制 | 本基线能力 | 迁移尚缺什么 | +| --- | --- | --- | +| [通用构建服务][build-service]与[工作区 Provider 会话][build-sessions] | 工程识别、模型/依赖请求、声明式 CLI(命令行)任务、诊断/报告展示、选项、取消和失效;按插件、构建系统、工作区管理进程。 | 原生领域调用及执行/运行/测试等能力如何复用该会话,需要阶段 C 定义。不能重建另一套进程管理器。 | +| [Build Provider v1][build-protocol] | `lithe/build/model`、`lithe/build/dependencies` 两个方法。已有模型和依赖后端,不等于执行已在后端。 | Maven 普通任务当前由宿主执行插件声明的 CLI 计划;需要补齐后端执行、进度、结果与报告能力,才能迁走执行决策。 | +| [`backend: 1`][page-backend]及[macOS 会话][page-sessions] | 每插件一个进程,供插件自己的网页函数/设置动作调用;要求网页窗口,不绑定工作区,拒绝 `workspaceStateDir`。 | 无页面时可用、工作区隔离的原生领域入口。不能用空网页绕过限制,也不能把任意私有函数开放为宿主业务 API。 | +| 现有函数协商与 `BuildSystemService.invokePluginFunction` | 已有协商、调用和构建 Provider 目标定位;不能把它描述为“完全没有函数调用”。 | 调用存在不等于已有公开、类型化的 Run/Test/Debug/Maven 业务契约;权限、版本、参数与生命周期仍须定义。 | +| 通用 Debug 服务及 [#1210 提案][debug-note] | 已有 #1508 基础调试重构、#1509 macOS 多会话;#1510 已将 API 4/`debugAdapters` 声明及校验纳入[正式契约][debug-contract]。 | DebugAdapter Registry(适配器注册表)与宿主运行时接入仍待完成;新 Core 启动计划和包资源检查尚无生产调用方。复用已发布声明,不能把声明校验通过写成插件调试已可用。 | +| 插件契约与两个后端 SDK(开发工具包) | 主仓库拥有契约和 Core,`lithe-plugin-docs` 拥有契约副本与 Node/Java SDK。 | 新契约随 Core/fixtures 评审,合并后同步副本;协议实现合并前用两个 SDK 的真实后端跑端到端验证。 | + +新能力必须明确插件 ID/版本、工作区身份与重开代次、配置/工程快照身份、`operationID`、进度、取消、超时和晚到结果。停用、更新、崩溃、切换/关闭工作区及退出,必须撤销能力并结束对应进程/订阅。以上是职责要求,不是已经发布的新字段或方法名;正式协议只在 `shared/contracts/plugins/v1/` 随实现定义。 + +### 三、Maven 当前入口、调用链与替换能力 + +以下 M/J 编号用于把调用链与下一节的数据、删除及验收逐项对应。“目标能力”是职责名称,不是新 API。清单以 macOS 当前用户入口为准;Windows 现有共享消费者另列。 + +| 编号 / 用户入口 | 当前调用链与所有者 | 目标插件能力 / 公共部分 | +| --- | --- | --- | +| M1:Build 工具窗口,识别、模块/Profiles、依赖加载及 Reload | `BuildSystemToolWindowView` → `BuildSystemsFeatureModel` → [`BuildSystemService`][build-service] → Core `build.*` → [Maven Provider][maven-backend]。后端用 Maven 扩展一次取得模型与依赖,宿主持有通用展示。 | Maven 后端拥有工程/依赖快照及强制刷新;宿主保留通用树和加载状态。复用现有扩展,不再独立解析一份完整 POM 模型。 | +| M2:构建生命周期、自定义目标、Skip Tests、输出/诊断/测试报告 | 通用窗口 → `BuildSystemService` → 插件声明的任务 CLI → 宿主进程 → 通用解码。另有 [`MavenService.run/runCustomGoal`][maven-service] → `maven.launchPlan` → 专用进程的旧路径。 | Maven 后端负责参数/任务选择、执行与 Maven 报告;宿主复用进程权限、输出与通用报告展示。不能只把新窗口接通后留下旧执行入口。 | +| M3:Agent Maven 查看/执行与环境配置 | [`IdeCapabilitiesFeatureModel.execute`][agent] 激活 execution 模块后直接访问 `MavenFeatureModel`;Maven 选项与 `environment.configure` 又写 runtime/旧 Maven 配置。 | Agent 通过宿主的类型化路由调用与 UI 相同的 Maven 能力及配置源,保留授权、操作 ID、输出游标和通用错误。 | +| M4:打开/重开工程、运行入口生成 | [`ProjectDevelopmentFeatureModel.loadProject`][execution-models] 判断 `pom.xml` → `MavenFeatureModel/MavenService.loadProject` → `maven.scan`;将旧 `MavenProject` 传给 `RunFeatureModel/RunService`。组合根仍构造旧服务。 | Maven 后端提供工程/模块信息;Java 后端提供 Java 入口;通用 Run 消费结果。移除宿主 POM 特判与旧模型注入。 | +| M5:项目 JDK/Maven 设置、Profiles/settings/仓库修改 | [`AppModel+FeatureState`][runtime-settings] 从 Run toolchain 与旧 Maven 字段准备表单,保存时更新旧服务与 Run 项目配置;Build 选项另经 `MacBuildOptionsStore` 持久化。 | Maven 后端解释 Maven 配置来源和优先级,Java 后端解释项目 JDK;平台执行受保护读写,UI 显示实际值、来源和显式覆盖。与 #818/#1493 共用一次迁移。 | +| M6:Java 导入、Profiles 热更新、Reload | [`AppModel+BuildSystems`][build-import] 等待插件导入输入,有值时使用插件 `mavenContext`,否则读取旧 `mavenFeatureIfActive.launchContext`;准备协调器 → `LanguageToolingSessionManager` → JDT。 | Maven 发布生效配置/工程快照;Java 消费并更新 JDT。按工作区代次与配置身份失效,删除旧上下文 fallback。Gradle 导入也须继续可用。 | +| M7:Dependencies 侧栏、依赖源码/类文件导航、框架元数据 | [`DependencySidebarView`][dependencies] → Run feature 的通用依赖服务;Java 依赖来自语言会话。组合根为 Run 配置依赖提供者,插件依赖结果缺失时仍读旧 Maven artifact paths。 | Maven 提供解析后的依赖,Java 提供语义/虚拟源码导航;宿主保留树、编辑器、下载权限与通用展示。禁止拿旧 Maven 依赖填补插件失败。 | +| M8:Maven 模块/框架运行、测试准备及停止 | [`RunService`][run-service] 持有旧 Maven 工程/context,生成服务与计划;[`StandardLanguageTestProvider`][test-provider] 的 Java/Maven 分支选择 Maven 参数,旧 Maven 路径负责报告;Java Main 和有入口源码的 Spring Boot 已走 JDT 准备。 | Maven 后端拥有构建/测试目标与 Maven 报告,Java 后端拥有 Java 目标和运行前准备。通用 Run/Test 仍管理会话、停止、输出和结果树。不能把所有 Java 运行重新退回 Maven reactor 执行。 | + +### 四、Java 当前入口、调用链与替换能力 + +| 编号 / 用户入口 | 当前调用链与所有者 | 目标插件能力 / 公共部分 | +| --- | --- | --- | +| J1:安装/启停 Java Support、语言服务启动与索引维护 | [Java 清单][java-manifest]声明资源及 `hostIntegration: jdt` → [`MacJdtHostIntegration`][jdt-host] → `MacJdtLaunchResources/MacJdtWorkspaceState` → Core `jdt*`;Java 准备协调器与会话管理器仍在宿主。 | Java 后端拥有资源解释、JDT 启动/专属状态和维护。宿主管理包信任、平台存储、通用语言会话/启停。独立下载只改变分发,不自动完成这项。 | +| J2:Java 编辑、补全/诊断/重构、语义导航与 `jdt://` 依赖文档 | Monaco/`JavaFeatureModel`/`AppModel+JavaNavigation` → [`LanguageToolingSessionManager`][language-sessions] → Core LSP engine 的 JDT/Java 分支及上游命令;另有 `java.codeVision/structure/sourceDefinition` 等领域投影。 | Java 后端适配 JDT 语义、虚拟文档与 Java 专属投影。通用文档同步、LSP 传输和编辑器展示保留;不迁移通用 Monaco 组件。 | +| J3:工程激活、Maven/Gradle 导入、Java 就绪与重建索引 | `JavaLanguageServerPreparationCoordinator`、`AppModel+BuildSystems` → 会话管理器 → Core `java.workspacePolicy`、JDT workspace/configuration/progress/build;Maven 上下文见 M6。 | Java 后端维护 Java 工程事实与就绪状态,消费构建插件快照;宿主只展示通用准备状态及执行取消。保留首次导入、外部改动与同路径重开的失效保护。 | +| J4:工具栏/代码边栏 Run、主类选择与运行配置编辑 | [`AppModel+JavaEntrypoints`][java-entries] → 会话 `javaEntrypoints/javaMainMethods` → JDT;Run 生成/编辑 → `MacRunConfigurationStore` → Core `runConfig.*` 与 `java.runMarkers`。 | Java 后端发现入口、解释 Java 配置及运行目标;通用配置 ID、合并、编辑器和受保护文件写入按实际公共消费者保留。不能按命令前缀整体删除 `runConfig.*`。 | +| J5:运行前保存、编译、类路径/JDK 解析、长类路径与服务更新 | `RunService` → 会话 `prepareJavaRunLaunchTarget/buildJavaServiceUpdate` → Java Debug Server/JDT 构建与 classpath;平台启动 JVM。失败构建的继续/取消、Spring Boot DevTools 更新和 Hot Code Replace 属于现有能力。 | Java 后端负责完整“保存 → 同步/构建 → 解析 → 启动准备/更新”顺序;宿主执行通用保存、权限、进程与 UI。运行和调试使用同一项目 JDK,显式无效选择不得切到语言服务器 JVM。 | +| J6:测试窗口/边栏,发现、单方法/类/模块执行与结果 | `StandardLanguageTestProvider`、Test workflow → 会话 `discoverJavaTestItems`/`javaTestItems` → Java Test 扩展;Java/Maven 测试选择和启动参数还在宿主/Core。 | Java 后端适配 JUnit/TestNG 发现与选择;Maven 负责其执行/报告部分。保留通用测试树、运行状态和定位;不能用源码文件名或正则重建测试语义。 | +| J7:工具栏/测试 Debug、多会话、断点、停止/重启 | `DebugLaunchConfigurationResolver` 的 Java/Maven 分支、`JavaTestDebugLaunchService` → 会话解析目标/`startJavaDebugServer`;`MacJavaDebugAdapterTransport` 查 JDT 调试端口,Core `debug/java_test.rs` 生成测试调试配置。 | Java 后端贡献调试适配器及目标、Java Test 启动/结果适配;复用 #1210 注册表。宿主保留通用 DAP、套接字、断点/线程/变量、会话标签和生命周期。 | +| J8:Spring 配置补全/诊断、Bean/注入导航、Endpoints;MyBatis Java/XML 跳转 | [`SpringFeatureModel`][spring]、[`MybatisFeatureModel`][mybatis] → `RustJavaMavenOperations` → Core `spring.index/mybatis.index`;FeatureModel 中仍有专属补全、文件识别和导航逻辑。 | Java 生态后端持有框架语义及索引;不要求本阶段另拆两个插件。正式选型先核对许可和上游扩展完整能力,不能假定当前 JDT 已提供全部 Spring/MyBatis 功能。 | +| J9:新建 Java 工程及项目运行时选择 | Java Support 清单已有项目生成器图标/模板;宿主通用生成器展示并创建项目。`ProjectRuntimeService` 负责平台工具发现,Java JDK/Maven 选择与配置存在领域语义。 | Java/Maven 专属生成字段、校验和工具要求由插件声明/后端拥有;通用模板写入、目录选择及工具发现 adapter 保留。保护项目 JDK 与语言服务器 JDK 的不同用途。 | + +### 五、数据/资源、删除条件与逐项验收 + +可写位置均由平台存储 adapter 解析。下表列的是迁移对象与生命周期,不表示本次新建目录;没有新增下载、生成资源或可复用缓存。 + +| 数据/资源编号 | 当前来源与生命周期 | 迁移规则 | +| --- | --- | --- | +| R1:Maven 用户选项 | `.lithe/maven/config.json` 的便携配置;[`MacMavenConfigurationStore`][maven-store] 在 Application Support 的 `Lithe/Maven/.json` 存本机路径,身份含工作区与 reactor;`MacBuildOptionsStore` 另用 `BuildSystems.Options.*` 及显式空多选标记保存 Build 选项。 | #818/#1493 共同确定逐字段来源/优先级与冲突策略,迁走读取者后删除旧存储代码。保留自定义 Profiles、Skip Tests、显式空值、本机路径和用户选择;不能直接删文件或把所有源静默合并。 | +| R2:运行配置与工程工具链 | `.lithe/run/configurations.json`、`local.json`、`generated.json`;[`MacRunConfigurationStore`][run-store] 负责文件/迁移 adapter。项目 JDK/Maven defaults 在 `local.json`,另有旧偏好迁移。 | 稳定配置 ID、用户参数/环境/工具链和本机/共享范围保留;仅可重建的生成结果可失效。区分通用文件格式与 Java/Maven 字段,完整读回成功后才移除旧读取。 | +| R3:工具的真实配置与仓库 | 项目 `pom.xml`、`.mvn/`、Wrapper、用户/全局 `settings.xml`、Maven 本地仓库;上游工具拥有实际配置和依赖。 | 默认发现和显示生效值/来源,编辑草稿后由用户确认;受保护写回明确来源并读回,冲突/只读/解析失败保留源和草稿;敏感值不回显。插件状态不得复制成第二份生效 settings。 | +| R4:构建/依赖/测试产物 | Provider 的 `workspaceStateDir` 与 `maven-snapshot.json`;通用输出 staging;旧 `MacMavenDependencyOutputStore` 在临时目录 `Lithe/maven-dependency-trees//` 写操作输出;工程的构建目录与测试报告。 | 每工作区/插件/操作隔离,配置或模型失效时拒绝旧结果;临时物由操作清理,缓存可重建;用户项目产物由工具规则管理,迁移不能清空用户仓库或所有构建目录。 | +| R5:Java 插件固定资源 | JavaSupport 包内 JDT/JDK/Lombok/Debug/Test、生成器模板/图标;来源及校验在 `third_party/jdtls/manifest.json`、`third_party/jdk/manifest.json`。 | 构建阶段固定来源、平台/架构/许可并签名;已安装 app/插件版本只读。迁移打包入口时同步资源清单与工作树复用校验;不复制可编辑源码或运行时改包。 | +| R6:JDT 专属可写状态 | 平台缓存的 `jdtls/` 工作区索引、`jdtls-configuration/` Equinox 可写配置,以及 profile/settings 副本、日志、锁和使用标记。 | Java 后端管理内容,宿主提供位置与权限;状态按版本/工作区隔离、可重建,不跨工作树共享。退出/停用先结束进程,再删除可丢弃状态,不能把索引迁到插件安装目录。 | +| R7:操作/调试/框架状态 | 宿主内存中的会话、取消任务、Java 调试端口和测试结果 listener(监听器)、框架索引、导航标记与未保存文档投影。 | 同一操作/工作区代次拥有资源;后端崩溃、取消、停用、切换工作区、退出时有界释放;晚到结果不能发布。框架投影跟随文档版本,不产生宿主/插件双索引事实源。 | + +删除须同时满足:新能力具备生产入口;该行全部消费者已切换;数据迁移完成且失败不丢源;当前 Windows 消费者已处理;对应用户流程有运行证据。搜索零引用是最后一道检查,不是全部证明。 + +| 关联清单 | 数据 | 满足上述条件后删除的领域路径 | 必须留下的验收证据 | +| --- | --- | --- | --- | +| M1 | R1/R3/R4 | Core `build/integrations/maven.rs` 中被插件模型替代的 Maven 解释;旧 `project/maven.rs` 扫描部分;保留通用模型校验。 | 单/多模块、多个 reactor、父 POM/Profiles、自定义 Profiles、Reload/离线/取消;模型和依赖来自同次生效配置。 | +| M2 | R1/R3/R4 | `MavenService` 的目标/参数/执行/报告路径及 Core Maven launch/diagnostic/report 专属实现;通用 Build 服务和通用报告解码不可整删。 | lifecycle、自定义目标、模块目标、Skip Tests、失败诊断/定位、测试报告、停止及迟到输出;UI/Agent 结果一致。 | +| M3 | R1/R2/R4 | Agent 的 `MavenFeatureModel` 访问与直接配置写入分支。 | Agent 查看、修改配置、执行、取输出和取消;权限拒绝与未就绪正确,生效值与 UI 一致。 | +| M4 | R1/R2/R4 | `ProjectDevelopmentFeatureModel` 的 POM 特判与旧 Maven load,Run 的旧 MavenProject 注入、Core Maven 检测分支。 | 打开、同路径重开、混合工程、多个构建根;旧加载器不再构造,非 Maven 运行入口保持。 | +| M5 | R1/R2/R3 | 旧 `MacMavenConfigurationStore`、`MavenConfigurationWriter`、runtime 表单的旧镜像写入与冲突 fallback;通用文件/偏好 adapter 保留。 | 来源发现 → 回显 → 编辑确认/取消 → 冲突保护 → 保存读回 → 重开;显式空值、坏配置、秘密与迁移重试覆盖。 | +| M6 | R1/R3/R6 | `javaLanguageImportConfiguration` 的旧 Maven fallback、会话中的旧 Maven provider 路径、Core `jdt_maven_settings` 等被替代业务。 | 改 Profiles/settings/仓库 → 同一快照到 JDT → 符号可见;移除 Maven 插件清除旧输入;纯 Java/Gradle 不受损。 | +| M7 | R3/R4/R6 | 组合根旧 artifact-path fallback、`MavenService` 依赖解析/临时输出专属存储与被替代 Core dependency-tree 分支。 | 依赖树刷新、源码/JAR/虚拟文档跳转、取消/失败、无源码依赖;切换工作区无串用,插件失败不会读取旧依赖。 | +| M8 | R1/R2/R4/R7 | `RunService` 的 Maven 业务分支、`StandardLanguageTestProvider` 的 Maven 参数/选择、旧 Maven 测试报告入口。 | 模块/框架 Run/Test、重启/停止、单方法选择、JUnit/TestNG 结果;配置覆盖与工程默认值一致,其他语言测试保持。 | +| J1 | R5/R6/R7 | `HostIntegrations/Jdt/` 专属实现、清单 `hostIntegration: jdt` 及 Core JDT 启动/状态特例。 | 无页面启动、重建索引、插件安装/停用/更新/卸载;不重复会话,状态可写且包哈希不变。 | +| J2 | R5/R6/R7 | `JavaFeatureModel`/AppModel 中专属语义、Core `java*`/`jdt*` 对应投影与 engine 专属分支。 | 补全、诊断、定义/实现/引用、重构、Code Vision、结构与依赖虚拟文档;未保存文档和取消/晚到结果正确。 | +| J3 | R1/R3/R6/R7 | Java 准备协调器、会话管理器中 Java 专属协作和 Core workspace/profile/build 策略。 | 普通 Java、Maven、Gradle;导入/保存/热更新、超时/重试、取消、同路径重开;就绪显示对应当前快照。 | +| J4 | R2/R7 | AppModel Java 入口协调、Java 配置生成/解析和 run marker 分支;`runConfig.*` 逐命令拆,保留公共 CRUD/合并/校验。 | 工具栏与边栏相同主类、生成/编辑/重开不丢 ID/参数、过期入口失效、无入口/多入口与非 Java 配置保持。 | +| J5 | R2/R5/R6/R7 | 宿主 Java launch lease/准备/服务更新和 Core Java launch/classpath/server-port 领域部分。 | 保存后构建并运行最新代码;构建失败继续/取消、长 classpath、显式坏 JDK、Spring DevTools/HCR;一次尝试不重复构建。 | +| J6 | R2/R4/R5/R7 | 会话中的 Java Test 适配、语言测试提供者的 Java 分支、Core Java 测试语义;通用 Test 生命周期保留。 | JUnit/TestNG 发现、单方法/类/模块执行、结果定位、取消与工作区替换;无误用源码命名规则。 | +| J7 | R2/R5/R6/R7 | `DebugLaunchConfigurationResolver` Java/Maven 部分、`JavaTestDebugLaunchService` 专属配置、JDT 端口发现及 `debug/java_test.rs`。 | Main/测试/Attach 调试,断点/步进/变量、多会话/子会话、停止/重启;JDK 与 Run 一致,端口/listener/进程全部释放。 | +| J8 | R3/R4/R7 | `SpringFeatureModel/MybatisFeatureModel` 领域索引/补全/导航,`spring.index/mybatis.index` 实现与桥接。 | 配置补全/诊断、Bean/注入/Endpoints 搜索、Java/XML 双向跳转;未保存编辑、元数据变化、工作区切换正确。 | +| J9 | R2/R3/R5 | 宿主/Core 中仅为 Java/Maven 服务的生成字段/运行时选择特判;通用生成器与工具探测保留。 | 已有模板创建后可导入/运行;显式/默认/无效项目 JDK、插件 JDK 更新/卸载;普通语言生成器不回归。 | + +`MavenService`、`MavenFeatureModel` 及 `RustJavaMavenOperations` 的 Maven 部分,须在 M1–M8 全部替换后整体移除,不能把只剩 Agent 或设置调用的服务标成“完成”。Java/Core 文件按 J1–J9 实际职责分批删,不能把 `lsp/languages/`、`RunService` 或通用调试框架整目录迁走。 + +### 六、共享 Core 命令与当前 Windows 消费者 + +这里核对的是当前消费者,非旧版本兼容承诺。共享命令删除/改义时,同一变更切换这些消费者及 adapter;Windows 独有且 macOS 从未采用的历史功能不在清理范围。表中未检出直接调用的命令也不能立即删,仍需核对统一 dispatcher、操作枚举、fixtures 和桥接。 + +| 命令/操作族 | 当前 macOS 位置 | 当前 Windows 生产消费者 | 处理条件 | +| --- | --- | --- | --- | +| `maven.scan/launchPlan/dependencyPlan/dependencies/diagnostics/testResults` | `RustJavaMavenOperations`、旧 Maven/Run/Test 路径 | [`features/maven/api/maven-core-api.ts`][win-maven],Maven store、运行与测试流程 | 随 M1/M2/M4/M7/M8 切到 Maven 插件;同步去掉旧命令/专属 fixture 与契约说明。 | +| `runConfig.inspect/updateOptions/generate/resolve/createLaunchPlan/saveEditorChanges`;macOS 另有 `selectJava/createUser` | `MacRunConfigurationStore`、Run 配置与 Debug 解析 | [`features/run/api/run-core-api.ts`][win-run]及 Run store | 拆 Java/Maven 语义,公共文档与其他语言功能按消费者保留;“Windows 没直接调用某方法”不能作为删除依据。 | +| `java.runMarkers/sourceDefinition` | Java markers 与源导航桥接 | `features/run/services/java-run-markers.ts`、`features/editor/lsp/lombok-accessor-navigation.ts` | 随 J2/J4 切到 Java 后端;保留通用 gutter/导航。 | +| `java.workspacePolicy/jdtWorkspaceFingerprint/jdtCacheRetention` | Java 激活与 `MacJdtWorkspaceState` | `platform/java-workspace-policy.ts`、`src-tauri/src/lsp/jdt_workspace.rs`;Java 启动还有 `lsp_resolve_java_launch` 平台命令 | Java 后端接管策略/指纹内容,平台 adapter 保留文件与进程;处理两端的旧 JDT 资源路径。 | +| `javaEntrypoints/javaMainMethods/javaTestItems` LSP 操作;`java.navigationMarkers/resolveNavigation` | `LanguageToolingSessionManager`、LSP engine | [`platform/lsp-core-adapter.ts`][win-lsp];Run、run-actions 和 Maven Java test discovery | 随 J2/J4/J6 切换语义调用;通用 `lsp.request` 和进程会话不整删。 | +| Java 构建、classpath、调试目标操作 | 会话的 Java 准备、Debug resolver、Java Test 调试 | `platform/lsp-core-adapter.ts` 使用 `vscode.java.buildWorkspace/resolveClasspath/startDebugSession`,Run/debugger 使用其结果 | J5/J7 同步调整两端;不要因为上游方法名不以 `java.` 开头而漏掉领域调用。 | +| `spring.index/mybatis.index` | Spring/MyBatis FeatureModel 与 Rust bridge | `features/spring/api/spring-index-api.ts`、`features/mybatis/api/mybatis-index-api.ts` | J8 切换当前消费者后删除 Core 实现;两端框架能力分别验收。 | +| `java.codeVision/className/serverPort/structure` 等剩余 Java 命令 | `RustCoreBridge` / `RustJavaMavenOperations` | 本次 Windows 静态检索未找到以上四个命令的直接字面量调用 | 列入 J2/J5 删除审计,检查间接路由/测试引用;不据此声称无消费者。 | +| `debug.javaTestLaunch`;`debug.steppingFilters` 中的 Java 默认值 | `RustDebugProtocolCore`、Debug resolver | 本次未检出这两个命令的 Windows 直接字面量调用;Windows debugger 仍有平台会话/调试端口 API | 随 J7 迁移 Java 专属默认值/启动配置;保留通用调试协议、端口与会话管理,删除前复核间接调用。 | +| 通用 `build.*` / `lsp.*` / `debug.*` | 两类服务与通用调试 | 依据各端当前接入情况;本基线 Windows 构建窗口并未等同于 macOS Provider 接入 | 保留通用能力,移除其中 Maven/Java 专属策略;任何新第二平台行为先补 shared fixture。 | + +`shared/contracts/application-boundary.md`、`rust-core-api.md` 中的当前 Java/Maven Core 归属描述仍反映旧实现;随对应命令/路径真正迁移而更新,不能在阶段 A 把现存契约直接改成未来事实。 + +### 七、上游复用与未决选型 + +| 已固定输入 | 当前可复用能力 | 后续必须核对的边界 | +| --- | --- | --- | +| [JDT LS `1.61.0`、Lombok `1.18.46`][jdt-lock] | Java 工程导入、语义、补全/导航及已有配置入口。 | 在 Java 插件后端复用完整 JDT 工程状态,保留 Maven/Gradle 协同;不由宿主或另一个 Core parser 重建 classpath/源码根。 | +| 同一清单的 Java Debug 扩展 `0.59.0` / server `0.53.2` | 入口、构建、运行 classpath、调试会话与服务更新所用命令。 | 迁移完整准备顺序与失败语义,复用现有通用 DAP;启动语言服务器的 Java 与项目 Java 区分。 | +| Java Test 扩展 `0.46.0` / plugin `0.43.1` | JUnit/TestNG 语义发现与测试运行所需资源。 | 核实测试启动/结果协议、选择和资源清理;不能只迁“发现”而留下宿主测试选择器。 | +| [固定 Maven 插件后端与 extension][maven-plugin] | Maven 自身的模型/依赖;后端按工作区和根管理快照/指纹、刷新与 Maven 子进程。 | 继续扩展当前后端与 Maven 扩展,补执行/配置能力;不另建 POM 继承、模块/依赖解析引擎。 | +| [JDK 锁定清单][jdk-lock] | macOS 两架构的 `21.0.12.1+1`,JDT 最低 Java 21。Windows arm64 清单实际为 `21.0.12+8`。 | 每平台按具体条目核对架构与哈希,不把顶层版本当成所有平台均相同;阶段 A 不升级工具链。 | +| Spring/MyBatis 现存能力 | 本基线由 Lithe 的领域索引实现,J8 有明确产品消费者。 | 在 J8 实现前单独核对可分发的成熟上游/公开扩展及许可;若仍需保留自有实现,放入对应后端并记录缺口证据,不能虚构当前 JDT 已覆盖。 | + +正确做法:用户改 Profiles 后,Maven 后端发布一个配置/工程快照,Java 后端通过宿主路由消费它;运行和 Agent 使用同一身份,旧结果被拒绝。 + +不要这样做:Build 窗口用新 Provider,Agent 继续读 `MavenService`;Java 插件直接打开 Maven 插件的 `maven-snapshot.json`;插件失败时宿主重新扫描 POM。这样分别留下两套生产路径、跨插件私有缓存依赖和旧业务回退。 + +### 八、可独立验收的后续交付与删除配对 + +顺序表示依赖,不是未经评审的协议设计。阶段 C 的基础设施本身没有替代业务时,可先交付契约/会话验证;不得因此提前宣称 D/E 完成。此后每个纵向 PR 必须包含其替换的旧路径删除,而非先长期增加第二套实现。 + +| 交付单元 | 对应 Issue 阶段 / 依赖 | 新路径与同项删除 | 退出证据 | +| --- | --- | --- | --- | +| A:清单与边界 | 本笔记及旧 Note 修订 | 无功能替换,不删仍在用的生产代码;B 已由 #1498 交付。 | 固定基线、M/J/R 清单、Windows 消费者、上游/配置/删除条件与静态校验完整。 | +| C1:工作区领域会话与调用 | C;复用 Provider/函数会话;与 #1210 协调 | 原生无页面调用、工作区身份、取消/失效/退出;避免新增重复运行时。协议进入主仓库,SDK 同步验证。 | 无页面、并行工作区、崩溃/超时/停用/退出有界完成;尚不删除业务。 | +| C2:最小领域能力 | C;在 C1 与 #1210 公共注册能力上补缺口 | 复用 API 4 调试声明并补实际注册/启动;评审类型化执行、运行准备、测试及跨插件快照的剩余缺口,不用任意私有函数替代公共契约。 | shared fixtures 与 Node/Java SDK 端到端通过,版本不匹配明确拒绝;具体能力随下一批纵向消费者一起启用。 | +| D1:Maven 模型/配置/导入/依赖 | D;C;复用 #818/#1493 | M1/M4/M5/M6/M7 一组消费者统一快照,完成 R1/R2 配置迁移;删除该组旧模型/配置/导入 fallback/依赖路径。若旧服务仍因 M2/M3/M8 存活,列明剩余方法,不另增旧 API。 | 打开 → 实际配置 → 修改/重开 → 导入 → 依赖导航完整运行,失败源数据保留;Windows 对应消费者同步切换。 | +| D2:Maven 执行与全部入口收口 | D;D1/C2 | M2/M3/M8 的 Maven 部分,连同 Run/Test 消费者切换;删除 `MavenService/MavenFeatureModel` 及剩余 Maven Core 业务、旧存储入口。 | UI/Agent 构建与运行/测试都使用同快照,取消/报告正确;没有旧 Maven 构造和读取。 | +| E1:JDT 生命周期与项目协同 | E;C/D1 | J1/J2/J3,插件后端接管 JDT 启动、语义、导入/维护;同项删 `HostIntegrations/Jdt` 及对应 engine/会话分支。 | 普通 Java/Maven/Gradle 编辑与同步完整,资源哈希不变;Java 未安装/停用明确撤销能力。 | +| E2:Java Run/Test/Debug 与框架 | E;E1/C2/#1210 | 按 J4/J5、J6/J7、J8/J9 分纵向 PR,各自切完 UI/Agent/配置/Windows 消费者并删对应领域分支。 | 每行工作流验收,加多会话、坏 JDK、构建失败和服务更新;宿主只剩通用流程。 | +| F:迁移收尾 | F;D/E 实际完成 | 删除孤立命令/fixture/旧测试/打包脚本,复核数据库连接/历史/凭据迁移后再删过渡代码;公共图标契约保留。 | 最新组合的全清单验收、数据迁移与包只读证据; owning Notes/契约/矩阵/资源复用说明与实现相符。 | + +D1/D2 或 E1/E2 若出现无法独立完成的消费者依赖,应缩成更小的完整用户流程,或合并紧耦合交付;不能在两批之间制造依赖已删除服务、无法编译或配置丢失的中间状态。没有可审阅的真实运行证据时,相应能力保持 `verificationStatus: pending`。 + +本阶段不调整功能矩阵的实现/验证状态,不迁移数据库数据,不增加插件资源目录。后续新增下载/生成/缓存入口时,须同项更新 `scripts/worktree-resources.json`、复用脚本校验路由及测试和 `docs/ci-builds.md`;运行时状态继续隔离,不能因插件化加入跨工作树共享可变缓存。 + +## 考虑过的备选方案 + +- **只移资源或只换 Build 窗口。** 能降低发行体积或改善 UI,但 M3–M8、J1–J8 的现有生产调用仍在,无法达到 #1495 的职责迁移目标。 +- **长期保留旧服务并双写、失败回退。** 迁移看似容易,但配置/工程事实继续有两份,失败被掩盖;本提案采用最新组合,保留数据迁移和明确失败,删除旧业务。 +- **让 Java 直接读取 Maven 私有缓存。** 可以省一个调用,但缓存版本、工作区和失效策略变成隐式契约;采用宿主路由的类型化快照。 +- **重写完整 Maven/Java 工程引擎。** 容易控制接口,却需重复解决上游已处理的工程正确性;采用现有 Maven 扩展/JDT 及公开能力,先核实缺口再自建必要适配。 +- **复用 `backend: 1` 并伪造空页面,或新增另一套插件运行时。** 前者继承全应用、非工作区范围,后者重复进程/权限/清理状态;优先在既有 Provider/会话机制上补通用领域入口。 +- **一次移动整个 Rust/Run/Debug 目录。** 删除量大却会带走公共消费者;按 M/J 行完成端到端替换,再删除领域分支。 + +## 验收标准 + +阶段 A 完成只代表:有固定最新实施基线、三类判定、M1–M8/J1–J9 的当前入口与所有者、R1–R7 数据/资源、待删实现/验收、当前 Windows 消费者、上游复用及按删除条件拆分的交付;旧 Note 对长期 JDT 宿主特例/旧业务回退的表述已限定为历史或中间状态。它不满足 #1495 的整体关闭条件。 + +D/E/F 完成须逐行附上真实新路径、删除证据、工具/插件/平台版本与运行结果;覆盖 UI、Agent、设置、Run/Test/Debug、语言服务,配置迁移成功且失败源数据保留。任何一行仍调用旧领域服务、私有缓存或 fallback,都不能判定整个迁移完成。 + +## 风险 + +- 新协议/注册表与 #1210、配置 #818/#1493 并行变化;实施前必须重新核对基线,复用已合并成果。 +- Maven 配置已有多处来源,直接“搬文件”可能改变优先级或丢失显式空值;迁移映射和冲突保护是删除条件。 +- JDT 的工程状态、构建及 Java Test/Debug 有连续正确性要求;单个方法 mock 成功不能证明新流程等价。 +- 框架完整上游替代尚未选定;不得为了迁移速度删除用户已有功能,或引入不允许分发的实现。 +- Windows 的静态消费者清单不能代替实机验收;共享命令改动必须跟消费者一起交付,并分别报告两端结果。 +- 迁移资源路径可能触发索引重建;包必须只读,不能用清理/改写发行目录规避升级基线问题。 + +## 验证 + +本阶段只做文档与静态源码/Git 核对。交付前运行以下现有检查;执行结果随交付记录,不把静态通过写成运行时通过: + +```bash +./scripts/verify-agent-notes.sh +./scripts/verify-runtime-bundle-immutability.sh +git diff --check +``` + +后续实现按受影响边界运行共享契约、服务边界、Rust Core、macOS/Windows 与测试稳定性检查;修改插件协议时合并前执行 `lithe-plugin-docs` 的 Node/Java SDK 端到端验证,合并后按[契约发布规则][contract-note]同步逐字节副本。平台改动提交前通过实际 PR 基线的矩阵变更门禁。 + +真实工作流验收必须比较 app/Windows 安装资源与已安装插件版本的文件清单或哈希,证明典型启动/构建/运行/测试/调试后不变。只读静态脚本通过不代替此证据。本次没有启动应用;以后每次验证结束关闭本次启动的所有 Lithe/子进程、listener、订阅和临时资源,确认无残留后交付。 + +## 适用范围 + +- `macos/Sources/Lithe/Application/`、`macos/Sources/Lithe/Models/AppModel/`、`macos/Sources/Lithe/Services/` +- `macos/Sources/Lithe/Platform/MacOS/`、`macos/Sources/LitheExecutionModule/`、`macos/Sources/LitheLanguageIntelligenceModule/` +- `rust/lithe-core/src/build/`、`rust/lithe-core/src/project/`、`rust/lithe-core/src/execution/`、`rust/lithe-core/src/lsp/`、`rust/lithe-core/src/debug/` +- `Plugins/mac/Official/JavaSupport/`、`third_party/jdtls/manifest.json`、`third_party/jdk/manifest.json` +- `shared/contracts/`、`shared/fixtures/`、`shared/platform-feature-matrix/features/` +- `windows/tauri/src/features/`、`windows/tauri/src/platform/`、`windows/tauri/src-tauri/src/` +- 外部 Maven 后端:[`Lithe-IDEA/lithe-plugin-maven`][maven-plugin] +- 关联决策:[Java 声明式资源插件][java-note]、[通用 LSP Runtime][lsp-note]、[Build System 当前契约][build-note]、[Java 独立分发提案][distribution-note]、[真实配置读写][settings-note]、[#1210 调试提案][debug-note] + +[baseline]: https://github.com/1lck/Lithe-IDEA/commit/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1 +[removed]: https://github.com/1lck/Lithe-IDEA/commit/f5458a2431343ec63fc07ea0c51765f6b1a5fdbd +[maven-plugin]: https://github.com/Lithe-IDEA/lithe-plugin-maven/tree/bfa56dc80914df22ab60967ec859f8932870b494 +[maven-backend]: https://github.com/Lithe-IDEA/lithe-plugin-maven/blob/bfa56dc80914df22ab60967ec859f8932870b494/backend/src/main/java/io/github/litheidea/maven/backend/MavenBackend.java +[build-service]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +[build-sessions]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift +[build-protocol]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/rust/lithe-core/src/lsp/interface/build_provider.rs +[page-backend]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/rust/lithe-core/src/plugins/backend.rs +[page-sessions]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +[maven-service]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/LitheExecutionModule/Services/MavenService.swift +[agent]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift +[execution-models]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +[runtime-settings]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +[build-import]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Models/AppModel/AppModel+BuildSystems.swift +[dependencies]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Views/Workspace/DependencySidebarView.swift +[run-service]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/LitheExecutionModule/Services/RunService.swift +[test-provider]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift +[java-manifest]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/Plugins/mac/Official/JavaSupport/plugin.json +[jdt-host]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Platform/MacOS/Plugins/HostIntegrations/Jdt/MacJdtHostIntegration.swift +[language-sessions]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +[java-entries]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Models/AppModel/AppModel+JavaEntrypoints.swift +[spring]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift +[mybatis]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Application/Features/MybatisFeatureModel.swift +[maven-store]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenConfigurationStore.swift +[run-store]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift +[win-maven]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/windows/tauri/src/features/maven/api/maven-core-api.ts +[win-run]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/windows/tauri/src/features/run/api/run-core-api.ts +[win-lsp]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/windows/tauri/src/platform/lsp-core-adapter.ts +[jdt-lock]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/third_party/jdtls/manifest.json +[jdk-lock]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/third_party/jdk/manifest.json +[java-note]: ../../implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md +[build-note]: ../../implemented/architecture/2026-10-17-build-system-plugin-contract.md +[distribution-note]: 2026-10-24-java-support-independent-distribution.md +[debug-note]: 2026-10-25-unified-debug-engine-and-adapter-registry.md +[settings-note]: ../../implemented/architecture/2026-10-25-settings-native-configuration-editing.md +[contract-note]: ../../implemented/architecture/2026-10-23-plugin-contract-publishing.md +[lsp-note]: ../../implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md +[debug-contract]: https://github.com/1lck/Lithe-IDEA/blob/1a7a95c8ce9d254bb6b8811af73e93d86a7badc1/shared/contracts/plugins/v1/README.md#api-level-4-debug-adapters diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 48c4140218..182d03a925 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -479,6 +479,7 @@ jobs: LITHE_DMG_DIAGNOSTICS: "1" run: | ./scripts/verify-macos-package.sh + ./scripts/verify-macos-package.sh --slim - name: Verify Sparkle update packaging run: | diff --git a/.github/workflows/release-official-plugins.yml b/.github/workflows/release-official-plugins.yml index ce3cfaa921..226a579522 100644 --- a/.github/workflows/release-official-plugins.yml +++ b/.github/workflows/release-official-plugins.yml @@ -14,12 +14,27 @@ on: - "Plugins/mac/Official/**" - "Plugins/marketplace/**" - "scripts/official-plugin-releases.mjs" + - "scripts/official-plugin-releases.test.mjs" + - "scripts/official-plugin-distribution.mjs" + - "scripts/test-official-plugin-distribution.mjs" + - "scripts/build-official-plugins.sh" + - "scripts/prepare-jdtls.sh" + - "scripts/prepare-jdk.sh" + - "third_party/jdtls/manifest.json" + - "third_party/jdk/manifest.json" + - ".github/workflows/release-official-plugins.yml" push: branches: - preview paths: - "Plugins/mac/Official/**" - "Plugins/marketplace/official-plugins.json" + - "scripts/build-official-plugins.sh" + - "scripts/prepare-jdtls.sh" + - "scripts/prepare-jdk.sh" + - "third_party/jdtls/manifest.json" + - "third_party/jdk/manifest.json" + - ".github/workflows/release-official-plugins.yml" workflow_dispatch: permissions: @@ -44,7 +59,7 @@ jobs: fetch-tags: true - name: Test release planning - run: node --test scripts/official-plugin-releases.test.mjs + run: node --test scripts/official-plugin-releases.test.mjs scripts/test-official-plugin-distribution.mjs - name: Require a new version for changed plugins run: node scripts/official-plugin-releases.mjs check @@ -56,7 +71,7 @@ jobs: release: name: Release ${{ matrix.release.tag }} needs: plan - if: github.repository == 'Lithe-IDEA/Lithe-IDEA' && github.event_name != 'pull_request' && github.ref == 'refs/heads/preview' && needs.plan.outputs.releases != '[]' + if: github.repository == '1lck/Lithe-IDEA' && github.event_name != 'pull_request' && github.ref == 'refs/heads/preview' && needs.plan.outputs.releases != '[]' runs-on: macos-26 timeout-minutes: 30 permissions: @@ -78,54 +93,61 @@ jobs: - name: Set up Swift 6.3.3 and Xcode uses: ./.github/actions/setup-macos-toolchain - - name: Build and sign the plugin package + - name: Build, sign, and verify every architecture env: LITHE_PLUGIN_PACKAGE_PRIVATE_KEY: ${{ secrets.LITHE_PLUGIN_PACKAGE_PRIVATE_KEY }} - PLUGIN_ID: ${{ matrix.release.id }} - ASSET_NAME: ${{ matrix.release.assetName }} + RELEASE_JSON: ${{ toJSON(matrix.release) }} run: | + set -euo pipefail test -n "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" || { echo "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY before releasing plugins"; exit 1; } - # Declarative packages contain no native code, so one archive serves - # every macOS architecture listed in official-plugins.json. - plugin_root=$(scripts/build-official-plugins.sh \ - --configuration release \ - --triple arm64-apple-macosx \ - --plugin-id "$PLUGIN_ID" \ - --output dist/official-plugins) - ditto -c -k --sequesterRsrc --keepParent "$plugin_root/$PLUGIN_ID" "dist/$ASSET_NAME" - shasum -a 256 "dist/$ASSET_NAME" | cut -d' ' -f1 > "dist/$ASSET_NAME.sha256" - - - name: Verify the archived package - env: - PLUGIN_ID: ${{ matrix.release.id }} - VERSION: ${{ matrix.release.version }} - ASSET_NAME: ${{ matrix.release.assetName }} - run: | - # The build script already verified the publisher signature; check - # that the archive users download holds exactly that package. - mkdir -p dist/verify - ditto -x -k "dist/$ASSET_NAME" dist/verify - manifest="dist/verify/$PLUGIN_ID/plugin.json" - test "$(plutil -extract id raw "$manifest")" = "$PLUGIN_ID" - test "$(plutil -extract version raw "$manifest")" = "$VERSION" - test -f "dist/verify/$PLUGIN_ID/lithe-plugin-signature.json" - - - name: Publish the plugin release + id=$(jq -r .id <<<"$RELEASE_JSON") + version=$(jq -r .version <<<"$RELEASE_JSON") + swift build --product LitheOfficialPluginVerifier + swift_bin=$(swift build --show-bin-path) + jq -c '.assets[]' <<<"$RELEASE_JSON" | while read -r asset; do + name=$(jq -r .assetName <<<"$asset") + arch=$(jq -r '.arch // "arm64"' <<<"$asset") + plugin_root=$(scripts/build-official-plugins.sh \ + --configuration release --triple arm64-apple-macosx --arch "$arch" \ + --plugin-id "$id" --output "dist/packages/$arch") + ditto -c -k --keepParent "$plugin_root/$id" "dist/$name" + verify="dist/verify/$arch" + mkdir -p "$verify" + ditto -x -k "dist/$name" "$verify" + manifest="$verify/$id/plugin.json" + test "$(plutil -extract id raw "$manifest")" = "$id" + test "$(plutil -extract version raw "$manifest")" = "$version" + "$swift_bin/LitheOfficialPluginVerifier" "$verify/$id" + signer="$(swift build --configuration release --triple arm64-apple-macosx --show-bin-path)/LithePluginPackageSigner" + "$signer" --verify "$verify/$id" + # Preserve the pinned upstream runtime's native signatures. The + # publisher signature alone does not prove macOS can launch it. + runtime="$verify/$id/runtime/$arch" + if [ -d "$runtime" ]; then + lipo "$runtime/bin/java" -verify_arch "$arch" + codesign --verify --strict "$runtime/bin/java" + codesign --verify --strict "$runtime/lib/server/libjvm.dylib" + fi + shasum -a 256 "dist/$name" | cut -d' ' -f1 > "dist/$name.sha256" + done + + - name: Publish all architectures in one release env: GH_TOKEN: ${{ github.token }} - TAG: ${{ matrix.release.tag }} - ASSET_NAME: ${{ matrix.release.assetName }} - PLUGIN_ID: ${{ matrix.release.id }} - VERSION: ${{ matrix.release.version }} + RELEASE_JSON: ${{ toJSON(matrix.release) }} run: | - # Plugin releases must never take the repository's "Latest" slot: - # app updates resolve releases/latest/download/appcast-*.xml, which - # only the Lithe app releases publish. - gh release create "$TAG" "dist/$ASSET_NAME" \ - --latest=false \ - --target "$GITHUB_SHA" \ - --title "$PLUGIN_ID $VERSION" \ - --notes "Official Lithe plugin $PLUGIN_ID $VERSION. SHA-256: $(cat "dist/$ASSET_NAME.sha256")" + set -euo pipefail + tag=$(jq -r .tag <<<"$RELEASE_JSON") + id=$(jq -r .id <<<"$RELEASE_JSON") + version=$(jq -r .version <<<"$RELEASE_JSON") + assets=() + while read -r asset; do + assets+=("dist/$asset" "dist/$asset.sha256") + done < <(jq -r '.assets[].assetName' <<<"$RELEASE_JSON") + printf 'Official Lithe plugin %s %s. Each architecture has its own archive and SHA-256 file.\n' "$id" "$version" > dist/release-notes.md + # App update checks own the repository's Latest slot. + gh release create "$tag" "${assets[@]}" --latest=false --target "$GITHUB_SHA" \ + --title "$id $version" --notes-file dist/release-notes.md sync-index: name: List released plugins in the official index @@ -135,7 +157,7 @@ jobs: # a listing that failed earlier is retried. if: >- !cancelled() - && github.repository == 'Lithe-IDEA/Lithe-IDEA' + && github.repository == '1lck/Lithe-IDEA' && github.event_name != 'pull_request' && github.ref == 'refs/heads/preview' && needs.plan.result == 'success' @@ -167,24 +189,27 @@ jobs: id=$(jq -r .id <<<"$release") version=$(jq -r .version <<<"$release") tag=$(jq -r .tag <<<"$release") - asset=$(jq -r .assetName <<<"$release") branch="automation/plugin-index-$tag" if [ "$(gh pr list --head "$branch" --state open --json number --jq length)" != "0" ]; then echo "$tag already has an open index pull request" continue fi - # The checksum comes from the asset users will download, not from - # a local build, so a rerun lists exactly what was published. + # A retry reads the tagged configuration and manifest, and hashes + # every published archive before listing this version atomically. rm -rf dist && mkdir -p dist - gh release download "$tag" --pattern "$asset" --dir dist - sha=$(sha256sum "dist/$asset" | cut -d' ' -f1) + checksums='{}' + while read -r asset; do + gh release download "$tag" --pattern "$asset" --dir dist + sha=$(sha256sum "dist/$asset" | cut -d' ' -f1) + checksums=$(jq -c --arg name "$asset" --arg sha "$sha" '. + {($name): $sha}' <<<"$checksums") + done < <(jq -r '.assets[].assetName' <<<"$release") released=$(gh release view "$tag" --json publishedAt --jq '.publishedAt[0:10]') git switch --force-create "$branch" origin/preview - node scripts/official-plugin-releases.mjs update-index "$id" "$version" "$sha" "$released" + node scripts/official-plugin-releases.mjs update-index "$id" "$version" "$checksums" "$released" git add Plugins/marketplace/index.json git commit -m "chore(plugins): 将 $id $version 加入官方插件索引" git push --force origin "$branch" - gh pr create --base preview --head "$branch" \ - --title "chore(plugins): 官方插件索引加入 $id $version" \ - --body "插件发布流程已发布 \`$tag\`(资产 \`$asset\`,SHA-256 \`$sha\`)。本 PR 只把该版本的下载地址和 SHA-256 写入 \`Plugins/marketplace/index.json\`,合并后用户在插件市场刷新即可看到新版本。" + printf '发布流程已发布 %s。本 PR 将该版本全部架构的下载地址、实际归档 SHA-256、API 等级和能力要求写入官方索引。\n' "$tag" > dist/index-pr.md + gh pr create --draft --base preview --head "$branch" \ + --title "chore(plugins): 官方插件索引加入 $id $version" --body-file dist/index-pr.md done diff --git a/Plugins/mac/Official/JavaSupport/plugin.json b/Plugins/mac/Official/JavaSupport/plugin.json index a82aedeeb0..f2740960dd 100644 --- a/Plugins/mac/Official/JavaSupport/plugin.json +++ b/Plugins/mac/Official/JavaSupport/plugin.json @@ -2,13 +2,14 @@ "schemaVersion": 2, "id": "dev.lithe.plugin.java-support", "displayName": "Java Support", - "version": "1.0.0", + "version": "1.1.0", "pluginAPIVersion": 3, - "requires": {"languages": 1, "languageServers": 1, "serverProcess": 1, "settings": 2, "projectGenerators": 1}, + "requires": {"languages": 1, "languageServers": 1, "serverProcess": 1, "settings": 2, "projectGenerators": 1, "packageTrust": 1}, "vendor": { "id": "dev.lithe", "displayName": "Lithe", - "signatureRequirement": "sameTeamAsHost" + "signatureRequirement": "publisherPackage", + "bundledSignatureRequirement": "sameTeamAsHost" }, "permissions": ["languageServer.launch", "project.generate"], "activationEvents": ["onLanguage:java"], diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index 1063141b25..d393edc54a 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -11,7 +11,7 @@ does not need to be rebuilt for each Lithe release; compatibility is decided by | Package | Distribution | Language server | | --- | --- | --- | -| `JavaSupport` | Bundled in Lithe.app, same Team ID as the host | JDT LS packaged with Lithe; New Project entries for Java, Maven Archetype, and Spring Boot ([project generators](../../shared/contracts/plugins/v1/README.md#project-generators)) | +| `JavaSupport` | Publisher-signed download; sealed transition copy until both architectures are listed | JDT LS packaged with Lithe; New Project entries for Java, Maven Archetype, and Spring Boot ([project generators](../../shared/contracts/plugins/v1/README.md#project-generators)) | ## Go and PHP moved to Lithe Store @@ -61,9 +61,10 @@ its manifest fixture, and the Core tests; the SDK's sample provider ## Downloadable official plugins -No downloadable official plugin is configured right now: -`Plugins/marketplace/official-plugins.json` and `Plugins/marketplace/index.json` -are empty. The release flow (`scripts/build-official-plugins.sh`, +Java Support now has a two-architecture release configuration, with automatic +publication disabled (`releaseEnabled: false`) until the host trust capability +and migration are released. No downloadable official plugin is listed yet: +`Plugins/marketplace/index.json` is empty. The release flow (`scripts/build-official-plugins.sh`, `scripts/official-plugin-releases.mjs`, and `.github/workflows/release-official-plugins.yml`) is kept for future official plugins. A new one gets its own directory under `Official/`, its own tag series @@ -98,3 +99,19 @@ rejected for release plugin builds. A package that does contain a signature document is always verified, whichever way it was installed. Plugins never delete user tools or project files. + +Java Support's manifest now requires `packageTrust: 1`: user-installed copies +require a publisher signature, while the explicitly pinned bundled copy can +rely on the host application's seal. `--bundled-only` selects that application +build; an ordinary standalone release build must carry the publisher signature. +Existing hosts without this capability refuse the new version before download. + +Java publication remains paused (`releaseEnabled: false`). App assembly retains +the transition copy until the checked-in official index lists a compatible +release at or above `bundledUntilPublishedVersion` with both architecture +archives. Then `--bundled-only` omits Java and app packaging skips its downloads. +The same index is embedded in the app. `LITHE_OFFICIAL_PLUGIN_INDEX` (app build) +and `--marketplace-index` (plugin build) select an explicit read-only build input; +test fixture indexes must never be published. Installation preserves saved +choices and settings; missing Java support has Marketplace and offline disk +installation entry points. diff --git a/Plugins/marketplace/official-plugins.json b/Plugins/marketplace/official-plugins.json index 25d9ad23f3..ba5d5db351 100644 --- a/Plugins/marketplace/official-plugins.json +++ b/Plugins/marketplace/official-plugins.json @@ -1,5 +1,26 @@ { "schemaVersion": 1, "description": "Official plugins released through their own tag series and listed in index.json. A plugin is released when its packageDirectory changes and its plugin.json version has no tag yet; changing the directory without a new version fails CI.", - "plugins": [] + "plugins": [ + { + "id": "dev.lithe.plugin.java-support", + "releaseEnabled": false, + "bundledUntilPublishedVersion": "1.1.0", + "tagPrefix": "plugin-java-support-v", + "packageDirectory": "Plugins/mac/Official/JavaSupport", + "inputPaths": ["third_party/jdtls/manifest.json", "third_party/jdk/manifest.json", "scripts/prepare-jdtls.sh", "scripts/prepare-jdk.sh", "scripts/build-official-plugins.sh"], + "assets": [ + {"assetName": "Lithe-Java-Support-{version}-darwin-arm64.zip", "platforms": ["darwin-arm64"], "arch": "arm64"}, + {"assetName": "Lithe-Java-Support-{version}-darwin-x64.zip", "platforms": ["darwin-x64"], "arch": "x86_64"} + ], + "index": { + "name": "Java Support", + "author": "Lithe", + "repository": "https://github.com/1lck/Lithe-IDEA", + "description": "Java language support with JDT LS, Lombok, debug and test extensions, a language-server JDK, and project generators.", + "tags": ["java", "language"], + "license": "Multiple; see bundled third-party licenses" + } + } + ] } diff --git a/docs/assets/java-installation-guidance/README.md b/docs/assets/java-installation-guidance/README.md new file mode 100644 index 0000000000..4e1a18ffa3 --- /dev/null +++ b/docs/assets/java-installation-guidance/README.md @@ -0,0 +1,29 @@ +# Java Support 安装引导的界面验证 + +这些截图对应 #1483 的 review 补验,由真实 SwiftUI/AppKit 视图生成。 +通知使用现有通知模型和展开操作;新建项目页使用真实 Core 生成器目录、空插件 +清单和隔离偏好。市场动作由测试记录,不会联网、安装插件、创建项目或执行工具。 + +| 场景 | 浅色 | 深色 | +| --- | --- | --- | +| Java 缺包通知,展开全文 | [截图](java-missing-notification-light.png) | [截图](java-missing-notification-dark.png) | +| 新建项目页的 Java/Maven/Spring 模板安装入口 | [截图](java-new-project-light.png) | [截图](java-new-project-dark.png) | + +本轮修复改变缺包判断,没有调整布局。卸载后的 `.removed` 快照在重启后允许 +显示通知;仍有待重启变更的副本、已安装但禁用的副本继续使用原管理流程。 +回归另外通过真实包存储执行安装、卸载、启动准备和重新扫描,核对移除快照下的 +Java 文件与 Maven 项目提示,不把单独渲染通知当作完整卸载流程的证明。 + +复验入口: + +```bash +LITHE_JAVA_GUIDANCE_CAPTURE_DIR="$PWD/.artifacts/test-stability/java-installation-guidance" \ + ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh \ + -- --filter 'PluginBundledPublisherTrustTests|PluginInstallationGuidancePresentationTests' +``` + +测试使用默认桌面隔离模式,仍创建并关闭真实窗口。截图证明原生视图的绘制和 +按钮动作,不证明桌面合成、真实市场下载、正式双架构发行或 Windows 独立分发。 + +本轮计时报告 `pr1483-review-macos` 共 75 项测试、5 组套件通过;完整冷编译与测试 +分别执行,最终测试仍使用原有计时上限和默认桌面隔离。 diff --git a/docs/assets/java-installation-guidance/java-missing-notification-dark.png b/docs/assets/java-installation-guidance/java-missing-notification-dark.png new file mode 100644 index 0000000000..c4502a1709 Binary files /dev/null and b/docs/assets/java-installation-guidance/java-missing-notification-dark.png differ diff --git a/docs/assets/java-installation-guidance/java-missing-notification-light.png b/docs/assets/java-installation-guidance/java-missing-notification-light.png new file mode 100644 index 0000000000..7a5a967e0e Binary files /dev/null and b/docs/assets/java-installation-guidance/java-missing-notification-light.png differ diff --git a/docs/assets/java-installation-guidance/java-new-project-dark.png b/docs/assets/java-installation-guidance/java-new-project-dark.png new file mode 100644 index 0000000000..63a57188a9 Binary files /dev/null and b/docs/assets/java-installation-guidance/java-new-project-dark.png differ diff --git a/docs/assets/java-installation-guidance/java-new-project-light.png b/docs/assets/java-installation-guidance/java-new-project-light.png new file mode 100644 index 0000000000..2142af9c31 Binary files /dev/null and b/docs/assets/java-installation-guidance/java-new-project-light.png differ diff --git a/docs/assets/provider-workflow-review/README.md b/docs/assets/provider-workflow-review/README.md new file mode 100644 index 0000000000..8bf65b4e8b --- /dev/null +++ b/docs/assets/provider-workflow-review/README.md @@ -0,0 +1,28 @@ +# Provider 工作流的原生视图检查 + +这些截图对应 PR #1517 的 review 补验,来自真实 SwiftUI/AppKit 视图。 +Build 侧使用真实 Rust Core 计划和受控 Provider 回复,Run 侧使用现有 RunService +和进程替身;没有启动构建工具、Provider 后端或应用子进程。 + +| 场景 | 浅色 | 深色 | +| --- | --- | --- | +| 展开前的 Build 树 | [截图](build-collapsed-light.png) | [截图](build-collapsed-dark.png) | +| 展开后的测试清单与 Debug 入口 | [截图](build-discovery-debug-light.png) | [截图](build-discovery-debug-dark.png) | +| 缺少 generated.json 时的 Provider Run 输出 | [截图](provider-run-output-light.png) | [截图](provider-run-output-dark.png) | + +Build 的前后两图是同一版本的折叠/展开状态对照,不是 preview 与 PR 的代码版本 +对照。通过共享树的原生无障碍操作展开,测试同时等待发现的用例和 Debug 入口出现, +不注入私有视图状态。本轮两处修复改变异步生命周期,没有调整界面布局。 + +复验方式: + +```bash +LITHE_BUILD_CAPTURE_DIR="$PWD/.artifacts/test-stability/provider-ui" \ + ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh \ + -- --filter 'ProviderWorkflowPresentationTests|preparedProviderOutputRendersWithoutGeneratedConfigurations' +``` + +默认桌面隔离仍创建真实窗口并截图,结束时关闭窗口、清理工作区和服务;不证明 +WindowServer 合成、跨应用焦点、真实发行包完整工作流或 Windows 已通过验收。 +本地逐项计时结果见忽略的 `.artifacts/test-stability/pr1517-review-macos.html` +和对应 JSON/JUnit:358 项测试、15 组套件通过。 diff --git a/docs/assets/provider-workflow-review/build-collapsed-dark.png b/docs/assets/provider-workflow-review/build-collapsed-dark.png new file mode 100644 index 0000000000..fb49f6571b Binary files /dev/null and b/docs/assets/provider-workflow-review/build-collapsed-dark.png differ diff --git a/docs/assets/provider-workflow-review/build-collapsed-light.png b/docs/assets/provider-workflow-review/build-collapsed-light.png new file mode 100644 index 0000000000..8157479195 Binary files /dev/null and b/docs/assets/provider-workflow-review/build-collapsed-light.png differ diff --git a/docs/assets/provider-workflow-review/build-discovery-debug-dark.png b/docs/assets/provider-workflow-review/build-discovery-debug-dark.png new file mode 100644 index 0000000000..f94df40d7c Binary files /dev/null and b/docs/assets/provider-workflow-review/build-discovery-debug-dark.png differ diff --git a/docs/assets/provider-workflow-review/build-discovery-debug-light.png b/docs/assets/provider-workflow-review/build-discovery-debug-light.png new file mode 100644 index 0000000000..96005f3232 Binary files /dev/null and b/docs/assets/provider-workflow-review/build-discovery-debug-light.png differ diff --git a/docs/assets/provider-workflow-review/provider-run-output-dark.png b/docs/assets/provider-workflow-review/provider-run-output-dark.png new file mode 100644 index 0000000000..4d5dc3440e Binary files /dev/null and b/docs/assets/provider-workflow-review/provider-run-output-dark.png differ diff --git a/docs/assets/provider-workflow-review/provider-run-output-light.png b/docs/assets/provider-workflow-review/provider-run-output-light.png new file mode 100644 index 0000000000..d929eb9bed Binary files /dev/null and b/docs/assets/provider-workflow-review/provider-run-output-light.png differ diff --git a/docs/ci-builds.md b/docs/ci-builds.md index dbc151d0a4..5a99e82c84 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -684,7 +684,49 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono 可下载的官方插件包是声明式插件包(只含 `plugin.json` 和它声明的资源,不含宿主加载的代码,与架构无关;自带运行时的 Java Support 见下文),在 `.build///OfficialPlugins` 中独立组装,`--triple` 只决定输出目录和签名工具的构建布局;组装后通过 `LitheOfficialPluginVerifier` 验证声明、打包的可执行文件和“Mach-O 原生代码只出现在清单声明的运行时目录内”;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端按宿主内置官方插件策略和 publisher 公钥验证后才允许安装。该 secret 的值是与客户端内置公钥匹配的 base64 编码 32 字节 Ed25519 私钥,可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 配置;私钥文件和 shell 历史都不得进入仓库或日志。没有该 key 时,普通 debug 全量构建会跳过 publisher 签名包,指定其中一个包 ID 的构建会失败,不会留下缺少签名文档的目录。本地调试插件时可以显式传入 `--development-unsigned`(仅 debug 配置可用),生成不含签名文档的开发包,只能由 debug 构建的 Lithe 从磁盘安装;这类开发包同样不跨工作树复用。无可靠 identity stamp,不跨工作树复制。插件安装后打包的语言服务器位于 `/Lithe/Plugins//versions//language-server`,由插件版本目录拥有,运行时只读,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。Go Support 和 PHP Support 已迁移为 Lithe Store 社区插件(`Lithe-IDEA/lithe-plugin-go`、`Lithe-IDEA/lithe-plugin-php`),不再由本仓库构建。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 -Java Support(`dev.lithe.plugin.java-support`)是唯一随 Lithe.app 发行的官方插件包, +### Java Support 独立下载安装与迁移 + +Java Support 当前仍随应用发行,独立下载的构建配置已登记,但 +`Plugins/marketplace/official-plugins.json` 中的 `releaseEnabled: false` 暂停自动发布。 +等支持 `packageTrust: 1` 的宿主发行、迁移验收和真实下载包验证完成后,再单独启用发布; +不要在基础设施 PR 中提前上架旧宿主无法安装的包。 + +`bundledUntilPublishedVersion: 1.1.0` 控制过渡副本的退出条件:只有仓库官方索引 +包含达到门槛且 API/能力匹配的完整双架构版本,应用才跳过 JDT/JDK 准备及 Java 插件组装。 +空索引或缺少某个架构仍内置;损坏索引会中止构建,不能静默省去插件。安装使用同一 ID, +启用、禁用、旧卸载标记及设置保留;打开 Java 文件/项目或新建项目时有明确安装入口, +离线可使用可信磁盘包。当前无线上发布,默认构建仍保留过渡包。 + +构建输入 `LITHE_OFFICIAL_PLUGIN_INDEX` 默认为 `Plugins/marketplace/index.json`,只读, +同时决定内置分发和应用内嵌索引;传给 `build-official-plugins.sh --marketplace-index`。 +没有下载或缓存步骤,不允许从其他工作树的生成索引或用户运行时索引复用。 +`verify-macos-package.sh --slim` 在本次临时目录生成双架构索引 fixture,并验证在缺少 +JDT/JDK 输入时仍能生成不含 Java 资源的 universal app 和 DMG;fixture 不是可发布索引, +随测试目录清理,不影响签名或 Sparkle 发行基线。 + +同一份清单要求下载版的 `publisherPackage` 签名,并声明内置副本的 +`bundledSignatureRequirement: sameTeamAsHost`。应用组装必须显式用 `--bundled-only`, +由 app 自身签名封住完整包;普通单插件构建在正式配置中始终要求发布者私钥。 +本地开发只能用 debug + `--development-unsigned`,不能把开发包用于正式发布。 +原生 JDK 使用构建时固定、校验过的上游签名,包内最终字节完成后再生成 Ed25519 签名。 + +发布计划包含 `darwin-arm64` 和 `darwin-x64` 两个 ZIP,各自一个 SHA-256; +源码和资源输入变化后需要新的插件版本。重试索引发布时,从对应 tag 读取 +配置、清单和 API/`requires`,下载并计算全部资产哈希后一次写入版本,不能读取 +当前工作树的清单来描述历史包。目录如下: + +- `dist/packages///`:本次发布组装的最终签名包。 +- `dist/verify///`:归档解压后的签名验证副本。 +- `dist/Lithe-Java-Support--darwin-.zip`:按架构的发布归档。 +- `.artifacts/java-plugin-distribution/`:本地真实资源组装、归档、签名和启动检查输出。 + +这些输出和 `.build/.../OfficialPlugins` 一样没有可跨工作树转移的 identity stamp, +全部进入 `official-plugin-packages` 排除路由;不允许复制或共享。需要复用的只有 +经既有 `jdtls` / `jdk` 路由核验的下载缓存,再在目标工作树重新组装。 +临时验证副本由本次验证清理,发布 runner 的 `dist` 随 job 结束回收;持久安装的 +版本包由宿主包管理器负责,运行时保持只读。它们不改变应用签名或 Sparkle 源基线。 + +Java Support(`dev.lithe.plugin.java-support`)是过渡期唯一随 Lithe.app 发行的官方插件包, 也是唯一自带运行时的包,因此它与架构有关: - **资源路径**:组装结果在 `.build///OfficialPlugins/dev.lithe.plugin.java-support/`, diff --git a/docs/development/issue-1495-plugin-migration-remaining-plan.md b/docs/development/issue-1495-plugin-migration-remaining-plan.md new file mode 100644 index 0000000000..da3da386b1 --- /dev/null +++ b/docs/development/issue-1495-plugin-migration-remaining-plan.md @@ -0,0 +1,85 @@ +# #1495 剩余插件迁移:四个大任务 + +这是执行计划,不代表下面的迁移已经完成。总目标见 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495),职责和取舍以 [Build Provider 决策笔记](../../.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md) 为准。每个大任务可以包含多个可验收 PR,不能只增加新接口而一直保留旧业务入口。 + +## 当前交付边界 + +- 阶段 A 清单 [PR #1513](https://github.com/1lck/Lithe-IDEA/pull/1513) 与独立分发 [PR #1483](https://github.com/1lck/Lithe-IDEA/pull/1483) 已合并,保持原范围,不重复认领。 +- 宿主/Core 的原生构建、测试发现/执行、运行/原生调试准备、保存的运行配置和只读模型共享在 [PR #1517](https://github.com/1lck/Lithe-IDEA/pull/1517)。配套实现为已合并的 [SDK PR #4](https://github.com/Lithe-IDEA/lithe-plugin-docs/pull/4) 和 [Maven Draft PR #7](https://github.com/Lithe-IDEA/lithe-plugin-maven/pull/7);SDK 源码合并不代表新版已经正式发布。 +- Maven 插件已完成本地原生执行切片:生命周期、插件目标、自定义目标和测试由工作区后端执行;模型仍来自现有 Maven 扩展。它尚未替换宿主全部 Maven 消费者。 +- 这些是开发源码和本地验证;正式 SDK/插件发行、完整 macOS UI、Java/JDT 和 Windows 实机验收不能据此勾选完成。#1495 保持开放。 + +## 任务一:完成宿主、契约、SDK 与 Maven 插件的合并发布闭环 + +**目标:** 用户能从正常插件渠道安装一组明确兼容、校验通过的最新版本,不依赖本地 SDK JAR。 + +**范围与顺序:** + +1. 完成 #1517 的评审和本地/CI 验证,合入 preview;确认原生 Provider 与只读模型共享的最终契约。 +2. 在 `lithe-plugin-docs` 用合并后的 preview commit 运行 `node scripts/contract.mjs sync --revision `;不手改镜像或伪造锁定值。 +3. 已合并的 Node/Java SDK 实现按最终契约重新验证,在锁定 Core 与当前 preview 上运行真实端到端场景;补齐 Node 20、Java 8 运行证据后,按其发布流程发布实际的新版本。 +4. Maven 插件将 `sdk.lock.json` 和构建依赖更新到实际发布的版本、URL、SHA-256;用正式 `package.mjs` 验证,开发构建覆盖不能进入发行流程。 +5. 验证最新宿主的安装、更新、能力拒绝提示和插件包只读。只有实际符合来源、签名和市场规则的工件才进入分发。 + +**退出条件:** 三仓 PR 和依赖顺序可追踪;契约副本逐字节一致;SDK 和 Maven 包都有真实发行地址与校验值;正式打包、最低支持运行时和当前平台流程通过。未实现的 Windows Provider 能力继续明确拒绝。这个任务不负责删除全部旧 Maven/Java 业务。 + +### 阶段 C 的验收记录与交付门槛 + +| 验收项 | 当前证据与剩余动作 | +| --- | --- | +| 无页面的工作区 Provider、执行与测试 | Core、生产 macOS 会话表与真实 Maven 后端已经完成多模块读取、模块编译、精确单方法执行和报告解码;集成测试显式选择开发包与工具,关闭后核对包清单不变。 | +| Run、原生 Debug 与适配器贡献 | 原生入口、保存配置、Agent 操作身份和类型化准备已经实现;真实 SDK 进程覆盖准备、失败、取消及另一工作区隔离。真实 LLDB 已通过 Provider 编译→Registry 启动→命中断点→读取栈帧→带参数继续执行→退出及清理,插件包保持只读;完整 UI/发行包流程仍需验收,Java/JDT 领域迁移属于任务三。 | +| 配置快照与跨插件协作 | 协议 6 的只读模型输入公开选定事实,配置/源码/模型/插件/工作区变化撤销旧输入;两个真实 SDK 后端经过 Core 交换公开投影。不能据此声称 Java 已消费 Maven 全部配置。 | +| 最低 SDK 运行时 | 历史[显式宿主 CI](https://github.com/Lithe-IDEA/lithe-plugin-docs/actions/runs/40531099687) 的 Node 20、Java 8 原生端到端作业通过,宿主固定为 `58ca81cf66c5c6e281c7df73ac67adb3526174f3`。该轮契约漂移检查失败;SDK #4 后续已合并且 main CI 通过,镜像仍需在 #1517 合并后按最终宿主提交同步。 | +| Maven 最低运行时 | [固定 SDK 提交的开发验证](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40533306865) 已通过 Java 8 后端及当前/3.6.3 Maven,每组 55 项计时检查(含 3 项打包);不能代替旧 SDK 锁定下仍失败的正式打包检查。 | +| 正式合并、契约同步与发行 | SDK #4 已合并;宿主 #1517 保持 Ready,Maven #7 因正式 SDK 锁定依赖继续保持 Draft。最终契约同步与新版发行尚未完成。必须依次合并宿主、用最终 preview commit 同步契约、发布 SDK,再更新 Maven 的真实版本/URL/SHA-256 和完成正式包验收;不能先填预期校验值。 | + +阶段 C 不能仅凭接口存在或本地开发包通过就勾选完成。下面的任务二至四不阻塞通用接口继续评审,但不会代替上表中尚未完成的验收与发行条件。 + +## 任务二:完成 Maven 全部生产入口、配置和用户数据迁移 + +**依赖:** 任务一可安装的宿主/SDK/插件组合;复用 [#818](https://github.com/1lck/Lithe-IDEA/issues/818)、[#1493](https://github.com/1lck/Lithe-IDEA/issues/1493) 的真实配置来源与存储规则。 + +**目标:** Maven 工程、依赖、配置与任务只有插件后端这一份领域持有方,UI 和 Agent 消费同一份生效配置。 + +**范围:** + +- 切换原生 Build、Agent Maven 能力、项目加载、项目设置、依赖导航、运行配置生成及 Java 导入配置等全部当前调用链;不能仅迁工具窗口。 +- 处理 Profiles、Skip Tests、settings、仓库与工具路径的真实来源、优先级、草稿确认、冲突保护、保存后读回和一次性旧配置迁移。失败保留源数据,不增加影子配置或双写。 +- 运行、测试、调试消费当前工程/配置快照;Maven 事实继续来自 Maven 上游,不再由宿主另解析一份 POM、模块图或依赖模型。 +- 每条新路径成立后删除对应 `MavenService`、`MavenFeatureModel`、POM 专属加载、旧存储及 fallback。移除共享 Maven 命令前,同时迁移其当前 Windows 消费者;不能为了删除行数破坏另一平台。 + +**退出条件:** 多模块工程从打开、配置读写、同步、构建,到 UI/Agent 触发相关操作使用一致配置;重开、失败、取消、配置 ABA 与工作区切换可验收;旧 macOS Maven 服务、配置副本和 fallback 已删除。共享命令的现有 Windows 调用也完成切换,迁移证据和删除清单可逐项对照。 + +## 任务三:完成 Java/JDT 领域后端及 Run/Test/Debug/框架迁移 + +**依赖:** 任务二的 Maven 配置所有权;复用 [#1210](https://github.com/1lck/Lithe-IDEA/issues/1210) 的 DebugAdapter Registry 与当前通用 LSP/DAP,不另建调试器或项目引擎。 + +**目标:** Java 插件真正拥有 Java 领域逻辑,宿主与 Core 保留通用交互、校验和生命周期。 + +**范围与里程碑:** + +1. 盘点当前 JDT LS、Java Debug Server、测试扩展及 Maven/JDT 协作的公开能力;优先复用完整上游子系统。确定 Java 消费工程配置和调试准备所需的最小公共契约,先落共享 fixture,再接生产入口;当前只读模型输入并不等于全部 Java 协作能力已经具备。 +2. 把 JDT 启动、资源解析、工作区状态和 Java 专属协调移入插件后端;运行期状态经平台可写目录提供,完整语言服务器资源在构建阶段固定来源、校验并签入插件包。 +3. 迁移入口发现、保存后同步/构建、类路径与模块路径解析、Run、精确 Test、Debug、JDT 调试端口、Spring/MyBatis,以及现有长期 Java 应用所需的生命周期。由真实迁移需求决定必要的通用服务能力,不顺带重建一个无限范围的 Services 平台。 +4. 把 AppModel、FeatureModel、平台 JDT host integration 和 Rust LSP engine 中相应特例切换到公共接口,再删除被替代的 Java/Maven 领域分支。SDK、契约和当前 Windows 消费者随具体接口同步迁移。 + +**退出条件:** 当前 macOS Java 工程清单逐项有新路径;保存→同步/构建→解析运行路径→启动的完整流程通过;测试、调试和框架行为保持;停用插件正确撤销能力且不会唤起旧实现;宿主/Core 不再保留仅供官方 Java 插件使用的领域特例。真实 JDT/DAP 和发行插件的运行证据齐全。 + +## 任务四:完成跨平台与迁移收尾,验收后关闭总任务 + +**依赖:** 前三项的代码与发行工件;Windows 同步修改随前面接口迁移推进,不等到最后才发现当前消费者已断开。 + +**范围:** + +- 复核 Windows 当前共享命令、Provider 会话、平台 adapter 与发行资源;执行真实 Windows 构建及产品流程,不用 macOS 静态边界检查代替运行验收,也不顺带删除 Windows 独有历史功能。 +- 清理被替代命令、fixture、旧测试、资源打包入口及过时文档,每项有现存调用证据、新能力及删除条件。 +- 单独复核数据库过渡入口:连接、历史与凭据先完成必要迁移;失败保留源数据。公共图标契约等仍被使用的通用资源不随过渡代码删除。 +- 更新决策笔记、共享契约、功能矩阵与资源复用清单;新下载/打包资源有版本/平台/架构身份,运行状态与可变索引不跨工作树共享。 +- 用正式包验证安装/升级/停用/关闭、并行工作区、崩溃/取消、无进程残留和 bundle/插件文件清单不变,核对代码签名与增量更新基线。 + +**退出条件:** #1495 的 macOS 能力和数据清单逐项验收;所有改变过的当前 Windows 消费者有独立验证;旧业务、旧存储和 fallback 已删除;用户数据迁移可追溯。独立分发、基础 API 或某个插件 PR 合并都不能单独作为关闭 #1495 的依据。 + +## 执行纪律 + +默认顺序为任务一→任务二→任务三→任务四。每项按完整用户流程组织里程碑与 PR,报告新增行为、删除范围、实测证据和未验证平台;PR 默认 Draft,满足本地与依赖门禁后再 ready。不要提前发布版本、放松校验或把待实施计划写成已完成。 diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md new file mode 100644 index 0000000000..5dee8ed40d --- /dev/null +++ b/docs/development/macos-plugin-api-plan.md @@ -0,0 +1,285 @@ +# macOS 插件公共 API 与开发文档实施计划 + +状态:待实施计划。盘点日期:2026-10-28。 + +## 1. 目标与范围 + +让第三方开发者不修改 Lithe 源码,就能完成“读取选区并转换文本”“显示工程文件树”“打开 HTML 预览”等完整工作流。优先把 Mac 已有功能接成有权限、版本、生命周期和文档的公共 API,而不是重写 IDE 或先换插件运行时。 + +本轮只规划 macOS。没有 Windows 实现任务、时间表或双端验收前置条件;共享数据协议仍不嵌入 SwiftUI/AppKit 类型。本 PR 不实现 API、不修改 schema/SDK,也不把未来能力记成已实现。 + +这是**主仓库内部执行计划和文档待办**,不是可直接使用的开发者 API 参考。下文所有“候选”方法、事件及文档路径都需要后续契约 PR 定义;不能直接写进现有 `plugin.json`。公开指南、API 参考、SDK 和示例最终交付到 [lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs),不在本仓库维护第二套开发者手册。 + +架构选择由[公共扩展契约提案](../../.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md)持有;本文件负责能力盘点、任务拆分和验收。契约发布遵循[两仓库同步决策](../../.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md)。 + +### 盘点基线 + +| 对象 | 固定版本与证据 | 使用限制 | +| --- | --- | --- | +| Lithe | [preview `2cc32e60b2a4`](https://github.com/1lck/Lithe-IDEA/tree/2cc32e60b2a4129d4a4c19350e97a7ae62440232) | 本文件的本地源码路径均相对此版本;源码存在不代表本次做过实机验收 | +| 插件文档/SDK | [main `be779d2000e3`](https://github.com/Lithe-IDEA/lithe-plugin-docs/tree/be779d2000e38415ea047fc2e2b2b7973e7d8e07) | `contract.lock.json` 锁定主仓库 `32021a86f0c6c4ad2964dbbe3c2bb7854d84ad67`,不能与本次 preview 基线混用 | +| OpenIDEA | [`8ff8f229407d`](https://github.com/l0ng-ai/openidea/tree/8ff8f229407df73e722d0a933f8e0aebd48f516c) | 比较其公开源码,不推断全量功能成熟度或产品安全结论 | +| 原生 Provider 工作流 | [PR #1517](https://github.com/1lck/Lithe-IDEA/pull/1517),head `360255a6721680f0ae54bbedf3a64e7667a2c774` | 盘点时 OPEN、未合并;不得计入当前 Mac 已支持能力 | + +后续认领任务时先刷新这些基线和 PR 状态,避免重做已合并能力。这里的“需补”表示在列出的公共入口中没有对应通用契约,不表示 IDE 内部完全没有该功能。 + +## 2. 结论:缺的是公共接入面,不是整台 IDE + +### 2.1 已有能力与真正的缺口 + +| 表面 | 当前事实 | 仍需补齐 | +| --- | --- | --- | +| 清单与能力协商 | Mac 支持声明式 API level 1–4;`PluginManifest.hostCapabilities` 是宿主实际声明的版本表 [S1] | 每个新 API 同步完成 Core 校验、Mac 执行、权限、文档、旧版拒绝路径 | +| 原生插件界面 | `ui:3` 已有 Tree/List/Form、工具栏、右键和本地动作,不只是静态文本 [S3] | 动态数据源目前绑定自身 `buildSystemID` 和唯一匹配实例;需通用后端来源、懒加载和分页 | +| 命令 | `ui.commands` 打开自己的工具窗口;面板内动作可以调用函数 [S1/S3] | 不依赖窗口/构建系统的命令处理器,以及语义事件、上下文菜单、快捷键接入 | +| 自有网页 | 包内页面可走白名单 Web bridge [S4] | 不是完整文件/编辑器 API;新能力必须分别授权,不能开放 AppModel、DOM 或任意脚本入口 | +| 独立后端 | `backend:1` 是每插件一个应用级外部进程,只服务自己的网页;`${stateDir}` 可用,`${workspaceStateDir}` 被拒绝 [S2] | 没有窗口的命令/事件插件不能直接复用该声明;需新版本上下文和激活模型,不能悄悄改 v1 的作用域 | +| 函数与宿主服务 | 私有函数已有注册、进度、取消、截止时间、撤回和失效结果处理;函数协议 v2 的宿主服务目前只有本插件凭据读写删除 [S2] | 编辑器、工作区、通知等宿主 API 尚需逐项开放;不能把私有函数当跨插件公共服务 | +| 领域插件 | Core 已允许没有 UI/语言的纯 Build、VCS、Debug、项目生成器插件 [S1] | 纯命令插件仍是独立缺口;Build/Run/Test/Debug 工作流按 #1517 接续,不从零另造 Provider | +| IDE 自动化 | 已有单独授权的 IDE/MCP broker,含项目/环境、Maven、Run 和操作输出等 13 个工具 [S15] | 它不是声明式后端默认可用的 SDK;原生 `IDECapabilitiesProviding` 不等于公共插件授权 | +| 生命周期 | 外部进程、网页、原生面板已有自己的所有权和清理路径 [S2/S3/S4] | 不等于所有插件都能热加载;当前纯 Debug Adapter 的热激活范围不能扩大宣传 [S18] | + +Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSystems:1/2`、`debugAdapters:1`、`executionProviders:1`、`executableResolution:1`、`languageServers:1/2`、`languages:1`、`packageTrust:1`、`projectGenerators:1`、`serverProcess:1`、`settings:1/2`、`ui:1/2/3`、`vcsProviders:1`。Core 能校验某版本不等于 Mac 会执行它,例如不能因为 Core 有 `serverProcess:2` 就给 Mac 标记支持。 + +当前 Web bridge 的方法白名单为 `build.instances/refresh/runTask/runTests/cancel/dependencies/setOptions`、`workspace.openFile`、`ui.getState/setState`、`functions.list/invoke/cancel`、`dialog.openFile/saveFile`。后两个选择文件方法已在 Mac bridge v2 实现;盘点不能只依据较早 README 的列表。网页调用权限也不自动转授给后端进程。 + +### 2.2 参考 OpenIDEA 和 VS Code 的哪些部分 + +| 参考对象 | 已核对的做法 | Lithe 应借鉴/不照搬的部分 | +| --- | --- | --- | +| OpenIDEA [WIT 接口](https://github.com/l0ng-ai/openidea/blob/8ff8f229407df73e722d0a933f8e0aebd48f516c/wit/plugin.wit) | v0.1 定义日志、命令注册、通知、工程根/当前文件、读文件、插入文本/替换选区、语言/主题注册,以及激活/停用/命令回调 | 借鉴“小而明确的宿主能力面”;不把这份短接口当完整 IDE SDK,也不直接复制全局隐式上下文或缺少版本参数的编辑设计 | +| OpenIDEA [运行时](https://github.com/l0ng-ai/openidea/blob/8ff8f229407df73e722d0a933f8e0aebd48f516c/crates/ide-plugin/src/runtime.rs) | 使用 Wasmtime Component、WASI、内存限制、fuel 与 epoch 截止时间 | WASM 由运行时和宿主适配提供,不是 Tauri 自动提供;Lithe 的 Rust/Mac 宿主也能接,但仍需同样的 API、权限、分发和清理工作 | +| VS Code [能力概览](https://code.visualstudio.com/api/extension-capabilities/overview)、[贡献点](https://code.visualstudio.com/api/references/contribution-points)、[API](https://code.visualstudio.com/api/references/vscode-api) | 声明贡献、可调用 API、事件与 Provider 分工;使用稳定资源和注册/释放模型 | 借鉴分层和文档组织,不承诺兼容 `vscode` 包、VSIX 或照搬全部接口 | +| VS Code [Extension Host](https://code.visualstudio.com/api/advanced-topics/extension-host)、[运行时安全](https://code.visualstudio.com/docs/configure/extensions/extension-runtime-security) | 桌面扩展可在 Node.js 扩展宿主执行,Web 扩展使用受限浏览器运行环境;进程隔离不等于 OS 沙盒 | 继续让第三方执行逻辑位于外部宿主;权限代理只能约束代理通道,不能承诺限制任意原生后端的一切系统调用 | +| VS Code [Webview](https://code.visualstudio.com/api/extension-guides/webview)、[Custom Editor](https://code.visualstudio.com/api/extension-guides/custom-editors)、[WASM](https://code.visualstudio.com/blogs/2024/05/08/wasm) | 网页界面、文档编辑器和可移植运行时是不同扩展层 | 原生 Tree/Form 优先;复杂网页可选;WASM 不能代替编辑器保存/撤销、权限和生命周期协议 | + +## 3. API 任务清单 + +记号:**H** = 插件调用宿主服务;**P** = 插件提供业务数据/行为,由宿主调用;**C** = 清单声明贡献;**E** = 宿主推送语义事件。一个 SDK 可以封装这四类,但不能把它们合成绕过权限的 `invokeAnything`。 + +阶段:**M0** 协商与作用域;**M1** 独立命令/只读编辑器;**M2** 安全文本修改;**M3** 文件树/面板/预览;**M4** 领域接续;**后续** = 有真实需求再立项,不阻塞首版。每行都有稳定任务 ID;正式签名、参数和数字预算在实现 PR 定稿。权限栏是评审分类,不是现有清单字段名。 + +### 3.1 激活、命令与事件 + +| ID / 方向 | 候选能力与现状/所有者 | 权限、作用域与验收 | 阶段 | +| --- | --- | --- | --- | +| API-01 H/C | `host.getCapabilities`、激活/停用、`subscriptions.dispose`;扩展已有协商和会话 [S1/S2] | 新版本才允许无网页后端;返回实际可用能力与预算,不以版本号推测权限。旧 `backend:1` 仍按应用级、页面入口工作 | M0 | +| API-02 C/P/H | `commands.register`、受限 `commands.execute`;复用命令目录/快捷键 [S8],补独立 handler | 命令 ID 归插件;只允许自身命令及明确列出的宿主命令;后端回调不得绕回自身 Provider。无窗口无构建系统的命令能出现、调用并卸载 | M1 | +| API-03 C/H | `menus.contribute`、`keybindings.contribute`、`context.set`、状态栏项;已有内部入口 [S8] | 上下文键命名空间化、条件表达式有白名单;不截获任意按钮或系统快捷键。冲突可见,用户快捷键优先;菜单隐藏不是权限检查 | M1:命令入口;M3:其他贡献 | +| API-04 E | `workspace.onDidChange`、`documents.onDidOpen/Change/Save/Close`、`editor.onDidChangeActive/Selection`;内部观察不等于公开事件 [S5/S6] | 订阅前授权;事件带工作区/文档版本;高频事件合并、限流、有丢失后重取快照规则。切换/关闭项目及 dispose 后不再交付旧事件 | M1:活动编辑器/选区;M3:其余 | + +“监听按钮”的对外模型应当是“我贡献的命令被执行”“我的树节点被激活”“文档已保存”,而不是监听任意 SwiftUI 控件的 click。界面布局变化不应使插件失效,也不能借监听入口窃取其他插件输入。 + +### 3.2 工作区、文件、搜索与文档 + +| ID / 方向 | 候选能力与现状/所有者 | 权限、作用域与验收 | 阶段 | +| --- | --- | --- | --- | +| API-05 H/E | `workspace.getFolders/getContext`、工作区变更;复用工程模型 [S6] | 只读元数据授权;返回宿主签发的上下文和相对资源标识。即使 v1 仅一个根,也显式表达根 ID;无工程时明确为空 | M0/M1 | +| API-06 H | `workspace.readDirectory/stat/readFile`;已有文件端口 [S6] | 文件读取授权;目录分页、深度/字节限额、编码/二进制结果;隐藏/忽略规则和符号链接策略写入契约。不会整棵树一次读入或逃逸根目录 | M3 | +| API-07 H/E | `workspace.watch`;复用目录/文件观察端口 [S6] | watcher 归订阅与工作区,关闭/禁用后释放;溢出报告需重扫,事件不是每次 OS 变更的精确日志。旧工作区事件不能刷新新树 | M3 | +| API-08 H | `workspace.findFiles/findText`;已有搜索 owner [S7] | 读取授权、取消、分页/截断、忽略规则;明确搜索磁盘还是包含未保存缓冲区。复用索引,不让每个插件再扫描全盘 | M3 | +| API-09 H/E | `documents.list/getSnapshot`、`editor.getActive/getSelections`;已有文档和 Monaco 模型 [S5] | 编辑器读取与磁盘读取分开授权;返回文档 ID、版本、语言、只读/dirty、编码/EOL。选区读到未保存内容,而不是偷偷读盘 | M1 | +| API-10 H | `editor.applyEdits/replaceSelections/insertText`;已有编辑与 LSP WorkspaceEdit 链路 [S5] | 编辑授权、明确文档/预期版本;先做单文档完整校验再应用,形成一个撤销组。版本冲突、越界、重叠范围或输入法组合中安全拒绝/延后,不覆盖用户新输入 | M2 | +| API-11 H | `documents.save/saveAs/close`、`workspace.create/rename/copy/trash`;文件端口已有部分保护 [S5/S6] | 保存/文件变更单独授权;打开文档走文档 owner,校验磁盘身份及用户确认,不以直接写盘绕过 dirty/编码。首版不提供无确认永久删除 | M3:保存;后续:文件变更 | +| API-12 H/P | `workspace.applyEdit`、`documents.registerContentProvider`、`editor.showDiff`;已有虚拟文档和 diff [S5] | 多文件预览、逐项版本和只读校验;明确部分失败/恢复语义,不承诺现有 LSP 实现已具备跨文件原子回滚。只读虚拟资源先行 | M2:复用 diff 预览;后续:虚拟/多文件 | + +文件树至少区分两件事:读取授权目录的数据,以及取得工作台 Project 视图的选中项。前者是文件 API,后者是受窗口约束的 UI 上下文;不把整套内部树模型或原生视图对象暴露出去。 + +### 3.3 原生 UI、网页与浏览器 + +| ID / 方向 | 候选能力与现状/所有者 | 权限、作用域与验收 | 阶段 | +| --- | --- | --- | --- | +| API-13 C/P/E | Tree/List/Form 通用数据源、`views.refresh/getChildren/reveal`、选择/激活/提交;已有 `ui:3` [S3] | UI 贡献权限;数据源不再被迫声明假构建系统。稳定节点 ID、懒加载、分页、错误保留旧数据;选中不等于执行,表单不逐按键调用后端 | M3 | +| API-14 C/H/E | 自有 Webview 的 `postMessage/onMessage`、显隐/销毁/状态;已有包内网页与 bridge [S4] | 只面向自己的页面;origin/主框架/页面 generation 验证、内容安全策略、消息/队列限额;关闭页面取消所属请求。不能控制编辑器 Webview 或别的插件页面 | M3 | +| API-15 H | `editor.open/reveal`;复用 `workspace.openFile` 与文档 owner [S4/S5] | 文件打开/编辑器访问权限,明确窗口、预览标签与定位范围;已打开 dirty 文档不被磁盘内容替换,后台窗口不抢焦点 | M1:已有打开入口接续;M3:统一选项 | +| API-16 H | `preview.openHTML`、`browser.openExternal`;已有内嵌 HTML 浏览器与系统打开 adapter [S9] | 分开授权本地预览和外部链接;内嵌可用当前缓冲区,外部本地文件读取磁盘,未保存时明确告知/请求保存。远程 URL 仅 http(s),本地文件只接授权资源句柄,禁止任意 scheme/脚本注入 | M3 | +| API-17 H | `window.showInformation/Warning/Error`、`showQuickPick/showInputBox`、文件选择;已有平台 UI 和 bridge 文件对话框 [S4/S9] | 绑定窗口、频率/队列限额、取消结果;文件选择返回有范围/有效期的授权资源,不把选一次文件变成全盘读取许可 | M1:必要通知;M3:选择/输入 | +| API-18 H | `clipboard.readText/writeText`、`workspace.revealInFinder`;已有平台能力 [S9] | 剪贴板读写分别授权、敏感读取需用户触发;Finder 只显示授权目标。不得将系统剪贴板作为插件间隐式数据通道 | 后续 | + +“用网页浏览器模式打开文件”不能混成一个万能 URL 接口:编辑器打开、内嵌 HTML 预览、自有包内 Webview、系统默认浏览器是四种安全边界。工程 HTML 和任意网页即使能执行 JavaScript,也不能获得插件 bridge 身份。 + +### 3.4 配置、状态、工具与语言能力 + +| ID / 方向 | 候选能力与现状/所有者 | 权限、作用域与验收 | 阶段 | +| --- | --- | --- | --- | +| API-19 H/C/E | `settings.get/inspect/update/onDidChange`;已有 settings 1/2 和注册表 [S10] | 只读/写入分权;区分 Lithe 偏好与工具真实配置,显示来源/作用域/有效值。工具配置显式 Apply、冲突保护、写后重读,不建立无效影子设置 | M3 | +| API-20 H | `storage.global/workspace/session`、包内只读资源;已有平台 state store [S2/S4] | 插件命名空间和配额;状态放平台可写位置,迁移/卸载策略明确。工作区状态不是 v1 应用级 `${stateDir}` 的别名,不写插件包/bundle | M0:作用域;M3:扩展接口 | +| API-21 H | `secrets.get/set/delete`;已有函数协议 v2 credentials 服务 [S2] | 保持本插件隔离和现有凭据授权;不得读取 IDE/Agent/其他插件密钥,不将 secret 放日志、普通配置或状态。先补 SDK/文档一致性,不另造密码库 | M1 | +| API-22 H/E | `output.createChannel/append/dispose`、`log`、`progress.withCancellation`;已有函数进度与输出生命周期 [S2/S11] | 插件/操作归属、日志脱敏与字节预算;取消/超时后不再更新 UI。只复用已有进度状态机,不把 stdout 协议流与业务日志混用 | M1:命令结果/进度;M3:独立输出 | +| API-23 H | `toolchains.resolve/list`、受管 `process.run/cancel`;已有 executable/runtime 解析与进程链路 [S1/S11] | 显式执行授权,executable/argv 分离,cwd/环境白名单、输出和超时上限、进程树清理;不提供任意 shell 字符串,也不自动下载运行时 | M4 | +| API-24 H/E | `terminal.create/show/sendText/onExit`;已有终端模块 [S12] | 创建与写终端都按执行授权处理;只控制自身终端,不能读用户其他会话或注入命令。禁用释放插件拥有资源,不停止用户独立终端 | 后续 | +| API-25 C/P | 语言/语法/LSP、补全/悬浮/定义/符号/格式化/重命名/Code Action;已有语言服务器贡献 [S1/S13] | 优先接成熟 LSP 的完整能力;自定义 Provider 仅补上游确实缺失项,按文档版本取消旧请求。避免让 SDK 成为第二套语言/工程引擎 | M4:已支持能力文档;后续:新 Provider | +| API-26 H/P/E | `diagnostics.createCollection/set/delete/onDidChange`;已有 Problems/诊断 owner [S13] | 只能写本插件 collection,带文档版本和稳定范围;编辑/卸载后旧诊断可撤回。不能覆盖其他 LSP 结果或伪造宿主诊断身份 | M4 | +| API-27 C/H/P | 主题/图标/片段、只读装饰、CodeLens/Inlay;已有内部 theme/editor 能力 [S5/S16] | 数据贡献/资源路径校验、预算与卸载;图标/主题用宿主 token,不允许 CSS/原生视图注入。语言信息优先走 LSP,UI 资源不得执行脚本 | 后续 | +| API-28 C/P/H | `customEditors.register`、打开/保存/撤销/恢复;已有特殊文档视图不等于公开扩展点 [S5/S9] | 先说明唯一文档 owner 和 readonly/dirty/backup/undo 语义;可选 Webview 仅做视图。崩溃后可恢复且不丢未保存内容才可发布 | 后续 | + +设置分类遵循[macOS 设置层级](../../.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md),原生配置编辑遵循[真实来源决策](../../.agents/notes/implemented/architecture/2026-10-25-settings-native-configuration-editing.md)。插件提供的文本/上游诊断不伪装成宿主翻译;宿主新增按钮、权限说明和错误必须走 `shared/i18n/`。 + +### 3.5 领域工作流与可选扩展 + +| ID / 方向 | 候选能力与现状/所有者 | 权限、作用域与验收 | 阶段 | +| --- | --- | --- | --- | +| API-29 P/C | Build/Task/Test/Run Preparation、只读工程模型输入;已有 buildProviders 1 和执行模型 [S11] | 接续 #1517 的 buildProviders 2–6、debugAdapters 2,不重复定义。原生 Build/Test/Run 展示、准备、取消共用宿主;快照带源/版本,不能成为第二份 classpath/工程真相 | M4,依赖 #1517 状态 | +| API-30 P/C/E | Debug Adapter 注册、配置和会话事件;已有 DAP 宿主 [S14] | 进程/连接授权、launch 与 attach 所有权、终止和断开区别;复用断点/调试 UI。高级内存/反汇编按独立提案,不阻塞基础 API | M4 | +| API-31 P/C | VCS 仓库/状态/diff/提交等领域能力;已有 vcsProviders 1 [S14] | 读取与修改仓库分权;危险操作有明确确认,结果有仓库/操作身份。插件私有函数不能代替宿主可理解的 VCS 协议 | M4 | +| API-32 P/C/E | Services 的实例/状态/动作/日志;数据库已有自身路径,通用 ServiceProvider 仍为提案 [S17] | 插件拥有的连接/进程与用户外部服务分开;停用只清理前者,不默认停用户数据库/容器。服务接入现有 Services,不再画一套工作台 | 后续 | +| API-33 P/C | 项目生成器发现、参数、预览与生成;已有 projectGenerators 1 [S14] | 写目标目录需确认,禁止路径逃逸/无提示覆盖;只生成选择的项目,不下载未声明程序。先补现有文档,再评估新增 hooks | M4 | +| API-34 H/P | 受限 IDE/AI/MCP 能力;已有单独 IDE broker [S15] | 项目 opt-in 和 read/configure/execute 分权不变;不转交 broker token、全量 dispatcher 或 Agent 历史。是否开放、哪些调用方可用需独立威胁建模 | 后续 | +| API-35 H/P/C | 插件依赖、显式导出服务、远程工作区适配 [S17] | 跨插件调用需要新授权/版本/循环依赖规则;远程资源标明 authority,不能把远程路径当本机路径。首版对未实现环境明确拒绝,不静默本地执行 | 后续 | +| API-36 运行时 | 可选 WASM Component/WASI 宿主;当前能力表无 WASM [S1] | 复用相同公共 API/权限;独立验证 WIT 版本、fuel/内存/时间预算、取消、沙盒可达性、签名、工具链和分发。不得预挂载整个 HOME/工作区或绕过主进程隔离 | 后续,非 M0–M4 前置 | + +### 3.6 通用配套能力 + +| ID / 方向 | 候选能力与现状/所有者 | 权限、作用域与验收 | 阶段 | +| --- | --- | --- | --- | +| API-37 H/E | `environment.getInfo`、插件语言包/`l10n`、语言/主题变化;已有应用语言和主题 owner [S16/S19] | 只返回必要的产品/能力/界面语言信息,不导出进程环境、HOME 或账号;宿主文案走共享 i18n,插件文案用自己的包内语言资源并明确回退 | M1:最低能力信息;后续:完整语言包 | +| API-38 H | 可选受管 `network.fetch/download`;现有 HTTP adapter 服务具体业务,不是通用插件 API [S19] | 单独声明网络目标与授权;限制重定向、协议、内网目标、响应大小和超时,不转授宿主 cookie/代理凭据。下载只到平台可写临时目录,校验后显式交付,不能自动执行 | 后续 | +| API-39 H/P/E | 可选 `authentication.getSession/registerProvider` 与登录变化;已有 GitHub/Agent 专属账号流程 [S19] | 凭据存储不是“读取 IDE 账号”权限;须按插件、服务、账号、scope 显式授权,撤销和注销可见,复用系统浏览器/Keychain,不向任意插件提供宿主 GitHub 或 Agent token | 后续,独立安全评审 | + +上述三项补齐常见 SDK 配套需求,不意味着为了首个文本插件就实现网络、账号平台或新的国际化引擎。原生后端自行联网与宿主代理联网是不同控制面,不能通过新增 `network` 权限假称已有后端被 OS 沙盒限制。 + +## 4. 每个实现 PR 必须回答的问题 + +以下为契约评审清单,不是新增的已生效协议。正式字段、错误码、权限项和 fixtures 与 Core 实现一起进入后续契约 PR;长期边界见公共扩展契约提案。 + +### 4.1 身份、调用方向与兼容性 + +- 谁能调用:包内网页、命令处理器、原生面板数据源和领域 Provider 分别列授权矩阵。声明能力不等于已授权,隐藏菜单不等于拒绝调用。 +- 谁发起、操作哪里:插件 ID/包版本、后端 session、窗口、工作区及 generation、文档版本、调用/订阅 ID 由宿主核验。ID 不是可自行拼造的授权凭证;禁止缺少上下文时回退到“当前活动窗口”。 +- 如何复用:扩展已有 JSON-RPC、函数注册、取消及进程管理。新无窗口激活入口与工作区能力单独协商;M0 在“按工作区会话”和“共享进程、隔离调用上下文”之间作出有测试依据的选择,明确身份与授权句柄寿命,不能把应用级 `backend:1` 悄悄解释为每工程一进程。 +- 哪些回调会递归:初期 `commands.execute` 只覆盖明确允许的用户交互入口;不能让 Provider 后端借它回调 `lithe/build/*`、私有函数或完整 IDE dispatcher。现有递归拒绝规则保持有效,新调用方向先证明无循环等待。 +- 哪些旧行为不变:API level、`requires` 能力版本、函数协议版本和 SDK 版本分别列出。未知/未实现能力明确拒绝;不靠更新 SDK 或提升一个大版本宣称全量可用。 + +### 4.2 权限和数据保护 + +- 分开评审工程元数据、磁盘读取、编辑器读取/修改、文件变更、执行、网络、外部打开、剪贴板和凭据。使用现有授权时明确复用关系,新增权限要解释用户如何理解、授予和撤销。 +- 文件标识使用根 ID 与 `/` 分隔的工作区相对路径。平台 adapter 校验符号链接、大小写/Unicode 归一、路径穿越及检查后替换的竞态;不能仅用字符串前缀或一次 `realpath` 认定安全。 +- 工作区外选择文件使用单独授权句柄;OS 绝对路径不作为可持久化公共资源 ID。远程/虚拟资源需要独立 scheme/authority 和访问策略。 +- 进程分离、包签名和 capability grant 都不等于操作系统沙盒。原生后端本来可以使用自己的 OS 权限;文档必须区分“宿主代理拒绝访问”和“阻止恶意程序的一切文件/网络访问”。 +- 发布 app、插件包及包内语言服务器只读。状态、日志、索引、锁和下载物走平台 storage/cache adapter;新增资源同时遵循 worktree 资源清单规则,不在此计划新增资源目录。 + +### 4.3 异步、事件与资源预算 + +- 复用已有 operation ID、取消、截止时间和迟到丢弃语义;请求进入终态后释放关联,旧结果不能落在复用的 ID 上。重试写操作前核对结果,不能默认跨崩溃“恰好执行一次”。 +- 每个订阅、watcher、页面和进程写清创建者与释放者。关闭工作区、插件禁用/更新/卸载、崩溃和授权撤销都要走有界清理;插件自有任务与用户独立操作不能混淆。 +- 事件写清顺序范围、合并、溢出和重取快照机制,不能宣称全局严格顺序或每次磁盘变化都送达。订阅与初始快照之间要用版本/游标避免静默漏变更。 +- 每个接口确定可测试的页大小、总字节、并发数、队列深度、速率、超时及截断行为;数值由现有预算或测量支持。慢插件不能堵塞主线程、编辑输入或其他插件。 +- 错误区分未支持、未授权、上下文失效、版本冲突、参数/范围错误、超限、取消、超时及后端退出。具体稳定码在契约 PR 定义,平台细节放 `details`,凭据和敏感内容不能泄漏。 + +### 4.4 编辑器一致性与上游复用 + +- 磁盘文件和未保存文档是不同数据源。读取、搜索、预览和保存都要声明使用哪个;文档 owner 是 dirty/版本/撤销的唯一协调者,禁止插件直接写盘替代编辑。 +- 产品位置保持一基行号,列单位、范围端点、空选区和多选区排序必须明确;与 Monaco/LSP 的零基行号、UTF-16 列之间只在适配边界转换。用 emoji、组合字符、CRLF、中文输入法验证,而不是只测 ASCII。 +- `LanguageWorkspaceEditService` 是现有接入点,不是任意第三方编辑已经安全的证据。M2 需补身份、版本、完整预校验和 undo 验收;多文件事务/失败恢复单独设计。 +- 语言、构建、依赖、运行与调试事实继续来自成熟的 LSP/DAP/构建工具。先检查已有上游版本和扩展点;Lithe 提供权限、取消、稳定结果和原生展示,不复制 Maven/JDT 等工程语义。 + +## 5. SDK 与公开文档交付清单 + +### 5.1 先消除“源码支持 = 已发版 = 宿主可用”的误解 + +以下是固定 SDK commit 的源码盘点,不是本次运行 SDK 测试的结果。 + +| SDK | 源码已见能力 | 仍需核对/补齐 | 已核实发布物 | +| --- | --- | --- | --- | +| [Node 源码](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/be779d2000e38415ea047fc2e2b2b7973e7d8e07/sdk/node/src/backend.mjs) | Build、私有 Functions v1、VCS;开发中 Build 2–6 的执行、测试、发现、Run/Debug 准备、模型输入 | 通用 host API、Functions v2/credentials 等不能因 Rust SDK 有就宣称 Node 也有;开发态 Build 扩展依赖新宿主 | [sdk-node-v0.1.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-node-v0.1.0),2026-10-23;源码新功能不等于已在此包发布 | +| [Java 源码](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/be779d2000e38415ea047fc2e2b2b7973e7d8e07/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java) | Build、私有 Functions v1;开发中 Build 2–6 | 本次源码未见 VCS/credentials 实现,不能写成与 Node/Rust 全量对等 | [历史 sdk-java-v0.1.0](https://github.com/1lck/Lithe-IDEA/releases/tag/sdk-java-v0.1.0),2026-10-20,JAR 与 SHA-256 | +| [Rust 说明](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/be779d2000e38415ea047fc2e2b2b7973e7d8e07/sdk/rust/README.md) | Functions v1/v2、credentials,独立后端示例 | 明确尚不实现 Build/VCS;文档描述 release 方案不等于该 tag 已发布 | 本次未核实到 Rust SDK 正式 release,不提供可用发布承诺 | + +实施时优先把 Node/TypeScript 的类型和异步调用体验做完整,再以同一 fixtures 对齐 Java/Rust 的所选能力。**Node 优先不是把所有插件强制改成 JavaScript**;已有后端协议继续允许其他语言。每个 SDK 标注“源码实现/已发布版本/最低 Mac 能力/测试证据”,不以“官方 SDK”掩盖缺口。 + +### 5.2 需要编写或更新的文档 + +路径均相对 **lithe-plugin-docs**,是后续交付目标,不是本 PR 新建文件。已有指南优先更新,不重复写第二份。每个 API 任务完成时同步补相应文档与可运行示例,不等全部 API 完成后才写教程。 + +| 文档 ID | 拟交付页面/更新位置 | 必须讲清楚什么 | 对应任务/阶段 | +| --- | --- | --- | --- | +| DOC-01 | `README.md`、`guides/macos-plugin-quickstart.md` | 声明式/原生面板/网页/后端怎么选;安装、运行依赖、最小命令插件;Mac-only 支持范围 | API-01/02,M0–M1 | +| DOC-02 | `reference/capabilities-and-versions.md` | API level、requires、协议、SDK 与 release 四维矩阵;未知能力和兼容性错误;已支持/开发中分开 | 全部,M0 起 | +| DOC-03 | `guides/security-and-permissions.md` | 用户授权/撤销、工作区/窗口范围、路径与网络边界、原生进程非沙盒事实、密钥与日志 | 全部,M0 起 | +| DOC-04 | `reference/lifecycle-and-errors.md` | 激活条件、无窗口后端、dispose、取消/超时、失败、崩溃恢复、重试和错误表 | API-01/04/20/22,M0–M1 | +| DOC-05 | `reference/commands-and-events.md` | handler/贡献/事件区别;菜单、快捷键、上下文键;事件过滤、初始快照和背压 | API-02–04,M1/M3 | +| DOC-06 | `reference/workspace-and-files.md` | 目录分页/懒加载、读取/编码、watch、搜索、授权资源句柄、文件修改 | API-05–08/11,M3 | +| DOC-07 | `reference/documents-and-editor.md` | dirty 与磁盘、版本/范围/UTF-16、选区、单次 undo、安全保存、diff/只读/多文件限制 | API-09–12/15,M1–M3 | +| DOC-08 | `guides/native-ui-and-webviews.md` | 原生 Tree/List/Form 与包内网页的选择;数据源、动作、页面消息、恢复/释放、限制 | API-13/14,M3 | +| DOC-09 | `guides/browser-and-preview.md` | 编辑器/内嵌 HTML/自有网页/外部浏览器四分法;未保存内容、网络及 bridge 隔离 | API-15/16,M3 | +| DOC-10 | `reference/settings-storage-and-secrets.md` | 真实工具配置与偏好区别、scope、Apply/冲突、状态迁移/卸载、凭据授权 | API-19–21,M1/M3 | +| DOC-11 | `reference/window-output-and-execution.md` | 通知/选择/进度/输出、剪贴板、toolchain/process/terminal 权限和所有权 | API-17/18/22–24,随对应阶段 | +| DOC-12 | 更新 `guides/lsp-plugin-lifecycle.md`、`guides/build-tool-integration.md`、`guides/native-build-workflows.md`、`guides/debug-adapter-plugin.md`;补 VCS/项目生成器参考 | 领域 Provider 接入原生工作流、协议最低版本、#1517 合并状态、上游复用和端到端链路 | API-25/26/29–31/33,M4 | +| DOC-13 | `reference/optional-contributions.md` 与独立提案链接 | 主题/片段/装饰/自定义编辑器/Services/依赖/远程/AI/WASM;未实现项标为提案,不能混入稳定 API 表 | API-27/28/32/34–36,后续 | +| DOC-14 | 各 `sdk/*/README.md`、类型/API 文档、`guides/plugin-development-and-debugging.md` | 脚手架、打包校验、development 安装、日志/断点、错误定位、锁版本与 SHA-256、签名/上架、迁移/弃用 | 每个可交付切片 | +| DOC-15 | `examples/` 或现有各 SDK examples 目录 | 选区转换、文件树、HTML 预览三例;从干净目录仅靠已发布 SDK/文档即可构建,不能偷偷依赖主仓库源码 | M2–M3 | +| DOC-16 | `reference/environment-and-localization.md` | 必要环境信息、能力发现与插件语言资源、回退/切语言事件;禁止导出完整环境变量 | API-37,随对应阶段 | +| DOC-17 | `reference/network-and-authentication.md` | 受管联网/下载与原生进程边界,账号/权限 scope、用户同意、撤销、密钥不继承 | API-38/39,后续 | + +每个正式 API 页统一包含:稳定 ID/方法签名,调用方向,参数/结果/示例,最低宿主能力与 SDK 发布版本,权限与授权方式,窗口/工作区/插件作用域,dirty/路径/编码/位置语义,取消与截止时间,事件/释放,限制/分页,稳定错误,安全注意事项,以及成功、拒绝、旧版本和失效结果的测试链接。没有实现与发布证据的候选名不得出现在“可用 API”导航中。 + +### 5.3 两仓库交付顺序 + +1. 主仓库同一实现 PR 修改 Core、`shared/contracts/plugins/v1/`、Mac adapter、fixtures 和真实能力表。API 计划文档不代替 schema/契约,也不先放开未实现字段。 +2. 涉及后端协议变更,合并前在 lithe-plugin-docs 运行 `node scripts/sdk-e2e.mjs --source <本仓库路径> --ref <实现分支>`;覆盖 Node/Java 既有示例,修改 Rust SDK 时也覆盖 Rust 后端场景。 +3. 主仓库合并后,在 lithe-plugin-docs 运行 `node scripts/contract.mjs sync --revision <合并后的 preview commit>`,提交逐字节副本与 lock;不手改 `contract/v1/`。 +4. SDK、指南、示例和兼容性矩阵在文档仓库配套提交,锁定实际已支持宿主;正式 release 与哈希校验成功后再把该能力标成“已发布”。不能把正在开发的 main 当稳定 SDK 下载入口。 +5. 主仓库不重新保存 SDK、不让 CI 隐式依赖文档仓库浮动 main。本次纯计划未改契约/协议,因此不触发契约副本同步或 SDK 发版。 + +## 6. 分阶段 PR 与交付门槛 + +阶段不是一个大 PR。每个切片都带契约/实现/测试/文档配套;M0 的最低可用协商先落地,不把所有未来能力塞进“基础设施”阻塞第一条工作流。 + +| 阶段 | 建议切片 | 最小交付与退出条件 | +| --- | --- | --- | +| M0 | 作用域/授权威胁模型;无网页激活和能力协商 | API-01/05/20 的必要部分;旧 backend/UI/Provider fixture 不变,新增调用方向和身份隔离有正反用例;DOC-01–04 有可评审草稿 | +| M1 | 独立命令;只读文档与选区;最小事件/通知/进度 | 一个不声明假窗口、假语言、假构建系统的插件能通过命令入口读取当前未保存选区并显示结果;换窗口/关闭工作区/撤权后旧上下文无效;Node SDK 有类型和示例 | +| M2 | 安全单文档编辑;预览与一次撤销 | 选区转换例完成“读取版本 → 计算 → 校验版本 → 编辑 → Undo”;用户同时输入时拒绝旧结果。无权限/只读/IME/多选区/Unicode 场景通过;不包含通用多文件事务 | +| M3a | 目录/搜索/watch;通用原生 Tree 数据源 | 分页文件浏览器不借 BuildProvider 启动;展开按需加载,刷新可取消,关闭/重开有明确状态规则;巨型树和符号链接边界有预算测试 | +| M3b | 打开/HTML 预览/系统浏览器;网页与对话框边界 | 三种打开行为按目标与 dirty 状态可预测,工程 HTML 无法调用插件桥;原有包内页面功能不回归 | +| M3c | 设置/状态/独立输出;菜单/表单/事件完善 | 真实配置 Apply/冲突/重读可验收;订阅与页面反复启停无泄漏;公共 UI 保持 Mac 原生控件及可访问性 | +| M4 | #1517 合并后重盘点,逐个完成 Build/Run/Test/Debug/VCS/语言/生成器文档与缺口 | 不重写在途 Provider 协议;真实项目完成 save → 同步模型 → build → run/test/debug → cancel/cleanup;未合并能力不作为稳定前提 | +| 后续 | 主题/自定义编辑器/Services/终端/跨插件/远程/AI/WASM、完整语言包/网络/账号各自立项 | 有实际插件需求、资源预算、许可/分发与风险证据后再进入实施;不是 M1–M3 发布门槛 | + +M1–M3 与 #1517 的领域扩展可独立推进;若修改同一协议/入口,需要按其最终合并版本协调,不能复制一份 Provider manager。领域迁移另由[Maven/Java 迁移提案](../../.agents/notes/proposed/architecture/2026-10-26-maven-java-domain-plugin-migration.md)跟踪,其特定版本匹配策略不能不加区分地变成整个公共 API 的兼容政策。 + +## 7. Mac 验收样例与发布检查 + +### 三个必须跑通的真实插件 + +1. **选区转换器**:只声明独立命令和所需权限;读取 dirty 文档与选区,异步处理后按版本应用;用户一次 Undo 恢复。没有假窗口/语言/构建系统,无权限时不读数据也不改文档。 +2. **分页文件浏览器**:用目录/搜索 API 和原生 Tree 展示工程;展开加载子节点、取消搜索、刷新并定位编辑器。多窗口两个工程不会串数据,停用后 watcher/订阅/后端均按所有权清理。 +3. **HTML 预览启动器**:从文件上下文命令分别在文本编辑器、内嵌预览、系统浏览器打开;未保存内容差异明确。恶意 HTML、外部导航、关闭后迟到消息均不能获得插件 host 权限。 + +### 每个切片的共用检查 + +- 契约与 Core:清单/协商/授权成功与拒绝、旧版本、未知字段、预算、取消/超时、重复/迟到消息、崩溃和重入;fixture 与实际调用结果一致。 +- Mac 产品:多窗口/工程切换、无工程、dirty/只读/外部改盘、版本冲突、emoji/组合字符/CRLF/IME、深浅色与键盘/无障碍;组件测试不能代替实际安装插件的工作流。 +- 安全和清理:路径穿越、符号链接切换竞态、越权跨插件/页面/窗口、消息洪泛、撤权/禁用/更新/卸载;有界等待,记录并清理本轮启动的进程及临时资源,不杀用户独立进程。 +- 发布:app bundle 与已安装插件的文件清单/哈希在典型工作流前后不变;平台可写存储范围可核对,签名和 Sparkle 增量更新基线不受影响。 +- SDK/文档:使用锁定发布物重建三个样例;记录 Mac、Core、契约 lock 和 SDK 版本。Node/Java/Rust 分别注明实际覆盖面,未测不填“通过”。 + +后续实现变更必须更新真实的平台功能矩阵并跑相应 change gate;本计划不改状态,不把 Mac 功能实施强行扩大成 Windows 任务。矩阵按仓库规则准确记录未实现/未验收状态即可。 + +本次计划 PR 的验证范围只有文档链接、Agent Notes、共享契约静态校验、i18n、bundle 只读静态检查和矩阵文档门禁;没有构建/启动应用,也不把以上未来验收记成已经通过。 + +## 8. 源码与决策索引 + +这些入口供后续认领任务时定位 owner,不表示可直接导出内部 Swift 类型。外部比较来源见 2.2;SDK 源码均以第 1 节锁定 commit 为准。 + +| 索引 | 所有者与证据 | +| --- | --- | +| S1 | [插件契约](../../shared/contracts/plugins/v1/README.md)、[Core 能力](../../rust/lithe-core/src/plugins/capabilities.rs)、[清单校验](../../rust/lithe-core/src/plugins/declarative.rs)、[Mac 能力表](../../macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift) | +| S2 | [backend 1](../../rust/lithe-core/src/plugins/backend.rs)、[函数协议](../../rust/lithe-core/src/lsp/interface/plugin_function.rs)、[Mac 后端会话](../../macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift)、[宿主凭据服务](../../macos/Sources/Lithe/Application/Features/PluginHostServices.swift) | +| S3 | [原生 UI 功能](../../macos/Sources/Lithe/Application/Features/PluginNativeUIFeature.swift)、[Core UI 校验](../../rust/lithe-core/src/plugins/native_ui.rs)、[已实现原生 UI Note](../../.agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md) | +| S4 | [Web bridge](../../macos/Sources/Lithe/Application/Features/PluginWebBridge.swift)、[包内内容](../../macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebContent.swift)、[页面生命周期](../../macos/Sources/Lithe/Application/Features/PluginWebPageLifecycle.swift)、[状态存储](../../macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebStateStore.swift) | +| S5 | [文档功能](../../macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift)、[LSP 编辑入口](../../macos/Sources/Lithe/Application/Features/LanguageWorkspaceEditService.swift)、[Monaco model](../../frontend/editor/src/model-source.ts)、[diff review](../../frontend/editor/src/diff-review.ts) | +| S6 | [文件端口](../../macos/Sources/LitheCoreContracts/Workspace/WorkspaceFileOperations.swift)、[Mac 文件适配](../../macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceFileOperations.swift)、[目录变更](../../macos/Sources/Lithe/Core/Ports/DirectoryChangeSource.swift)、[工程功能](../../macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift) | +| S7 | [搜索端口](../../macos/Sources/LitheSearchModule/Ports/SearchOperations.swift)、[搜索功能](../../macos/Sources/LitheSearchModule/Application/SearchFeatureModel.swift) | +| S8 | [命令目录](../../macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift)、[快捷键功能](../../macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift) | +| S9 | [HTML 浏览器](../../macos/Sources/Lithe/Platform/MacOS/HTMLPreviewBrowser.swift)、[HTML 视图](../../macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift)、[平台 UI](../../macos/Sources/Lithe/Core/Ports/PlatformUI.swift)、[Mac UI adapter](../../macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift) | +| S10 | [插件设置注册](../../macos/Sources/Lithe/Application/Features/PluginSettingsRegistry.swift)、[源配置决策](../../.agents/notes/implemented/architecture/2026-10-25-settings-native-configuration-editing.md) | +| S11 | [Build 服务](../../macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift)、[Mac Provider 会话](../../macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift)、[Run 服务](../../macos/Sources/LitheExecutionModule/Services/RunService.swift) | +| S12 | [终端端口](../../macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift)、[终端功能](../../macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift) | +| S13 | [语言 Provider 目录](../../macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift)、[LSP 资源归属](../../.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md)、[诊断功能](../../macos/Sources/Lithe/Application/Features/DiagnosticsFeatureModel.swift) | +| S14 | [Debug 会话](../../macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift)、[VCS 功能](../../macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift)、[项目生成服务](../../macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift) | +| S15 | [独立 IDE API](../../shared/contracts/ide-api/v1.md)、[原生模块接口](../../macos/Sources/LitheModuleAPI/Plugins/IDECapabilities.swift)、[授权功能](../../macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift)、[工具目录](../../rust/lithe-ide-host/src/catalog.rs) | +| S16 | [Mac 主题](../../macos/Sources/Lithe/Theme/LitheTheme.swift)、[图标入口](../../macos/Sources/Lithe/Theme/LitheIcons.swift) | +| S17 | [Services/公共扩展提案](../../.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md)、[数据库后端](../../.agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md)、[依赖与导出提案](../../.agents/notes/proposed/architecture/2026-10-25-plugin-dependencies-and-exports.md) | +| S18 | [有限热激活范围](../../.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md)、[既有函数调用方向](../../.agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md) | +| S19 | [应用语言](../../macos/Sources/LitheModuleAPI/Localization/InterfaceLanguage.swift)、[GitHub HTTP adapter](../../macos/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubHTTPTransport.swift)、[GitHub 服务](../../macos/Sources/Lithe/Services/GitHub/GitHubService.swift)、[Agent 登录端口](../../macos/Sources/Lithe/Core/Ports/AgentAccountSignIn.swift) | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 8c5f8b0b2b..dddde39069 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1186,6 +1186,8 @@ "Disconnect" = "Disconnect"; "Disconnected" = "Disconnected"; "Discover MCP servers" = "Discover MCP servers"; +"Discover Tests" = "Discover Tests"; +"Discovered Tests" = "Discovered Tests"; "Discovering Maven…" = "Discovering Maven…"; "Discovering…" = "Discovering…"; "Disk" = "Disk"; @@ -1748,6 +1750,7 @@ "Install ACP adapters, check the runtime, and manage local or custom providers." = "Install ACP adapters, check the runtime, and manage local or custom providers."; "Install AutoBuild" = "Install AutoBuild"; "Install Codex CLI to discover its skills." = "Install Codex CLI to discover its skills."; +"Install Java Support for Java, Maven and Spring templates…" = "Install Java Support for Java, Maven and Spring templates…"; "Install Node.js and npm before installing the adapter." = "Install Node.js and npm before installing the adapter."; "Install PHP Support Plugin" = "Install PHP Support Plugin"; "Install PHP Support from a signed plugin package." = "Install PHP Support from a signed plugin package."; @@ -1820,6 +1823,7 @@ "Java SDK" = "Java SDK"; "Java Stepping Filters" = "Java Stepping Filters"; "Java Stepping Filters…" = "Java Stepping Filters…"; +"Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept." = "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept."; "Java entries appear after the Java language service lists runnable classes." = "Java entries appear after the Java language service lists runnable classes."; "Java entries could not be refreshed" = "Java entries could not be refreshed"; "Java entry-point freshness check failed" = "Java entry-point freshness check failed"; @@ -1966,7 +1970,9 @@ "Load more" = "Load more"; "Load more commits" = "Load more commits"; "Load more debugger variables" = "Load more debugger variables"; +"Load or reload the project model before running all tests, so subproject reports are collected." = "Load or reload the project model before running all tests, so subproject reports are collected."; "Load the %@ model" = "Load the %@ model"; +"Load the current build model and enable its provider before running this configuration." = "Load the current build model and enable its provider before running this configuration."; "Load the dependencies again" = "Load the dependencies again"; "Load the dependencies of %@" = "Load the dependencies of %@"; "Load the project model" = "Load the project model"; @@ -2401,6 +2407,7 @@ "No subagents yet" = "No subagents yet"; "No tasks yet" = "No tasks yet"; "No test runner is configured for %@." = "No test runner is configured for %@."; +"No tests discovered." = "No tests discovered."; "No textual diff available" = "No textual diff available"; "No textual difference" = "No textual difference"; "No tools running" = "No tools running"; @@ -2752,6 +2759,7 @@ "Project run configuration is invalid" = "Project run configuration is invalid"; "Project run configuration not found" = "Project run configuration not found"; "Project runtime settings are saved locally for this project." = "Project runtime settings are saved locally for this project."; +"Project scan is incomplete" = "Project scan is incomplete"; "Project skills" = "Project skills"; "Project toolchain needs attention" = "Project toolchain needs attention"; "Project will be created in: %@" = "Project will be created in: %@"; @@ -3143,6 +3151,7 @@ "Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway." = "Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway."; "Run checked configurations without restarting running services" = "Run checked configurations without restarting running services"; "Run configuration" = "Run configuration"; +"Run configuration changed while saving. Reload and try again." = "Run configuration changed while saving. Reload and try again."; "Run configurations" = "Run configurations"; "Run configurations and Maven goals started in Lithe. They always stop when their project or window closes or Lithe quits, together with the processes they started; this only decides whether Lithe lists them and asks first." = "Run configurations and Maven goals started in Lithe. They always stop when their project or window closes or Lithe quits, together with the processes they started; this only decides whether Lithe lists them and asks first."; "Run configurations are unavailable. Enable the execution module and try again." = "Run configurations are unavailable. Enable the execution module and try again."; @@ -3210,6 +3219,7 @@ "Save local changes with" = "Save local changes with"; "Save message" = "Save message"; "Save or discard unsaved files before deleting this item" = "Save or discard unsaved files before deleting this item"; +"Save provider run configuration to .lithe/run/local.json" = "Save provider run configuration to .lithe/run/local.json"; "Save scope" = "Save scope"; "Save shared instructions" = "Save shared instructions"; "Save the active document" = "Save the active document"; @@ -3223,6 +3233,7 @@ "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git." = "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git."; "Saved to %@" = "Saved to %@"; "Saves, connects, and opens the remote folder in this window." = "Saves, connects, and opens the remote folder in this window."; +"Saving build provider run configurations is unavailable." = "Saving build provider run configurations is unavailable."; "Saving replaces the current GitHub metadata." = "Saving replaces the current GitHub metadata."; "Saving writes to" = "Saving writes to"; "Scala Support" = "Scala Support"; @@ -3486,6 +3497,7 @@ "Solidity Support" = "Solidity Support"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying."; "Some dependencies are not shown." = "Some dependencies are not shown."; +"Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." = "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations."; "Sort by Last Updated" = "Sort by Last Updated"; "Sort by Name" = "Sort by Name"; "Sort plugins" = "Sort plugins"; @@ -3755,7 +3767,9 @@ "The branch now contains the completed plan. Its original history remains available through the recovery reference." = "The branch now contains the completed plan. Its original history remains available through the recovery reference."; "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message." = "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message."; "The build provider failed to start." = "The build provider failed to start."; +"The build provider returned an invalid launch plan." = "The build provider returned an invalid launch plan."; "The build provider stopped before it was ready." = "The build provider stopped before it was ready."; +"The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled." = "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled."; "The build tool exited with code %lld." = "The build tool exited with code %lld."; "The build tool output is too large or not UTF-8." = "The build tool output is too large or not UTF-8."; "The build tool timed out." = "The build tool timed out."; @@ -3830,6 +3844,7 @@ "The interface language changes immediately. English is the default." = "The interface language changes immediately. English is the default."; "The item can be recovered from the macOS Trash." = "The item can be recovered from the macOS Trash."; "The language server could not apply this setting. Restart it to use the new value." = "The language server could not apply this setting. Restart it to use the new value."; +"The language server this build system uses is not available. Open a file of the language and try again." = "The language server this build system uses is not available. Open a file of the language and try again."; "The language service that hosts the debug adapter is unavailable." = "The language service that hosts the debug adapter is unavailable."; "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release." = "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release."; "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release." = "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release."; @@ -3900,6 +3915,7 @@ "The repository will be opened in detached HEAD state." = "The repository will be opened in detached HEAD state."; "The request has no secret." = "The request has no secret."; "The response stopped at the model's token limit." = "The response stopped at the model's token limit."; +"The saved provider run configuration is unavailable." = "The saved provider run configuration is unavailable."; "The selected AI provider has an invalid API URL or model." = "The selected AI provider has an invalid API URL or model."; "The selected AI provider has no API key." = "The selected AI provider has no API key."; "The selected background image authorization is invalid." = "The selected background image authorization is invalid."; @@ -5602,6 +5618,8 @@ "native.disconnect" = "Disconnect"; "native.disconnected" = "Disconnected"; "native.discoverMcpServers" = "Discover MCP servers"; +"native.discoverTests" = "Discover Tests"; +"native.discoveredTests" = "Discovered Tests"; "native.discovering" = "Discovering…"; "native.discoveringMaven" = "Discovering Maven…"; "native.disk" = "Disk"; @@ -6284,6 +6302,7 @@ "native.interface" = "Interface"; "native.interrupt" = "Interrupt"; "native.interrupted" = "Interrupted"; +"native.invalidBuildProviderLaunchPlan" = "The build provider returned an invalid launch plan."; "native.invalidGitTagName" = "Invalid Git tag name."; "native.invalidHookFileGuards" = "Invalid Hook file guards."; "native.invalidIdeApiRequest" = "Invalid IDE API request"; @@ -6520,11 +6539,13 @@ "native.load" = "Load"; "native.loadArg1More" = "Load %1$lld more"; "native.loadDiskVersion" = "Load Disk Version"; +"native.loadModelBeforeWorkspaceTests" = "Load or reload the project model before running all tests, so subproject reports are collected."; "native.loadMore" = "Load More"; "native.loadMore2" = "Load more"; "native.loadMoreCommits" = "Load more commits"; "native.loadMoreDebuggerVariables" = "Load more debugger variables"; "native.loadProjectModel" = "Load Project Model"; +"native.loadProviderModelBeforeRun" = "Load the current build model and enable its provider before running this configuration."; "native.loadTheDependenciesAgain" = "Load the dependencies again"; "native.loadTheDependenciesOf" = "Load the dependencies of %1$@"; "native.loadTheModel" = "Load the %1$@ model"; @@ -6968,6 +6989,7 @@ "native.noSubagentsYet" = "No subagents yet"; "native.noTasksYet" = "No tasks yet"; "native.noTestRunnerIsConfiguredFor" = "No test runner is configured for %1$@."; +"native.noTestsDiscovered" = "No tests discovered."; "native.noTextualDiffAvailable" = "No textual diff available"; "native.noTextualDifference" = "No textual difference"; "native.noToolsRunning" = "No tools running"; @@ -7333,9 +7355,12 @@ "native.providedByLitheForThisLanguage" = "Provided by Lithe for this language"; "native.provider" = "Provider"; "native.providerConfigurationParsingIsUnavailable" = "Provider configuration parsing is unavailable."; +"native.providerDebugUnavailable" = "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled."; "native.providerDetails" = "Provider details"; "native.providerName" = "Provider name"; "native.providerRequiresAnAPIKey" = "Provider requires an API key"; +"native.providerRunSaveConflict" = "Run configuration changed while saving. Reload and try again."; +"native.providerRunSavingUnavailable" = "Saving build provider run configurations is unavailable."; "native.providers" = "Providers"; "native.provides" = "Provides"; "native.pruneStaleRecords" = "Prune Stale Records"; @@ -7755,6 +7780,7 @@ "native.saveMetadata" = "Save Metadata"; "native.saveOrDiscardUnsavedFilesBeforeDeletingThisItem" = "Save or discard unsaved files before deleting this item"; "native.savePatch" = "Save Patch…"; +"native.saveProviderRunConfiguration" = "Save provider run configuration to .lithe/run/local.json"; "native.saveScope" = "Save scope"; "native.saveSharedInstructions" = "Save shared instructions"; "native.saveTheActiveDocument" = "Save the active document"; @@ -7766,6 +7792,7 @@ "native.savedInLitheRunConfigurationsJsonForTheWholeTeamLocalJDKPathsAreNeverShared" = "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared."; "native.savedInLitheRunLocalJsonAndExcludedFromGit" = "Saved in .lithe/run/local.json and excluded from Git."; "native.savedOnlyForThisProjectOnThisComputerInLitheRunLocalJsonAndExcludedFromGit" = "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git."; +"native.savedProviderRunUnavailable" = "The saved provider run configuration is unavailable."; "native.savedTo" = "Saved to %1$@"; "native.savesConnectsAndOpensTheRemoteFolderInThisWindow" = "Saves, connects, and opens the remote folder in this window."; "native.savingReplacesTheCurrentGitHubMetadata" = "Saving replaces the current GitHub metadata."; @@ -8355,6 +8382,7 @@ "native.theLanguageServerIsDisabledInThisWorkspace" = "The %1$@ language server is disabled in this workspace. Enable it in Settings > LSP."; "native.theLanguageServerOfThisPluginIsNotAvailable" = "The %1$@ language server of this plugin is not available."; "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter" = "The %1$@ language server returned an invalid TCP port for its debug adapter."; +"native.theLanguageServerThisBuildSystemUsesIsNotAvailable" = "The language server this build system uses is not available. Open a file of the language and try again."; "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable" = "The language service that hosts the debug adapter is unavailable."; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisAutoBuildAfterYouConbfd63f6a8e" = "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release."; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisPreviewAfterYouConfic01b41840e" = "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release."; @@ -8971,6 +8999,8 @@ "npm was not found. It is normally installed with Node.js." = "npm was not found. It is normally installed with Node.js."; "or" = "or"; "outline.expandAll" = "Expand All"; +"plugins.java.installNotice" = "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept."; +"plugins.java.installTemplates" = "Install Java Support for Java, Maven and Spring templates…"; "plugins.title" = "Plugins"; "ports %@" = "ports %@"; "project Maven Wrapper" = "project Maven Wrapper"; @@ -8981,6 +9011,8 @@ "results" = "results"; "revert" = "revert"; "run configuration" = "run configuration"; +"run.incompleteProjectScanMessage" = "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations."; +"run.incompleteProjectScanTitle" = "Project scan is incomplete"; "running for %@" = "running for %@"; "scope managed from %@" = "scope managed from %@"; "scope not updated to %@" = "scope not updated to %@"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 504e574df2..2c0ac70823 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1186,6 +1186,8 @@ "Disconnect" = "断开连接"; "Disconnected" = "已断开"; "Discover MCP servers" = "发现 MCP 服务器"; +"Discover Tests" = "发现测试"; +"Discovered Tests" = "已发现的测试"; "Discovering Maven…" = "正在检测 Maven…"; "Discovering…" = "正在检测…"; "Disk" = "磁盘"; @@ -1748,6 +1750,7 @@ "Install ACP adapters, check the runtime, and manage local or custom providers." = "安装 ACP 适配器、检查运行环境,管理本机配置或自定义供应商。"; "Install AutoBuild" = "安装 AutoBuild"; "Install Codex CLI to discover its skills." = "请先安装 Codex CLI,再查看它发现的技能。"; +"Install Java Support for Java, Maven and Spring templates…" = "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…"; "Install Node.js and npm before installing the adapter." = "请先安装 Node.js 和 npm,再安装适配器。"; "Install PHP Support Plugin" = "安装 PHP Support 插件"; "Install PHP Support from a signed plugin package." = "请从已签名的插件包安装 PHP Support。"; @@ -1820,6 +1823,7 @@ "Java SDK" = "Java SDK"; "Java Stepping Filters" = "Java 步进过滤器"; "Java Stepping Filters…" = "Java 步进过滤器…"; +"Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept." = "Java Support 现已独立安装。请从插件市场安装,或使用“从磁盘安装”导入离线包。原有 Java 设置会保留。"; "Java entries appear after the Java language service lists runnable classes." = "Java 语言服务列出可运行的类之后,Java 入口才会出现。"; "Java entries could not be refreshed" = "无法刷新 Java 入口"; "Java entry-point freshness check failed" = "Java 入口点时效性检查失败"; @@ -1966,7 +1970,9 @@ "Load more" = "加载更多"; "Load more commits" = "加载更多提交"; "Load more debugger variables" = "加载更多调试变量"; +"Load or reload the project model before running all tests, so subproject reports are collected." = "运行全部测试前,请加载或重新加载项目模型,以便收集子项目的测试报告。"; "Load the %@ model" = "加载 %@ 的模型"; +"Load the current build model and enable its provider before running this configuration." = "请先加载当前构建模型并启用对应提供方,再运行此配置。"; "Load the dependencies again" = "重新加载依赖"; "Load the dependencies of %@" = "加载 %@ 的依赖"; "Load the project model" = "加载项目模型"; @@ -2401,6 +2407,7 @@ "No subagents yet" = "暂无子代理"; "No tasks yet" = "暂无任务"; "No test runner is configured for %@." = "没有为 %@ 配置测试运行器。"; +"No tests discovered." = "未发现测试。"; "No textual diff available" = "没有可用的文本 Diff"; "No textual difference" = "没有文本差异"; "No tools running" = "暂无工具运行"; @@ -2752,6 +2759,7 @@ "Project run configuration is invalid" = "项目运行配置无效"; "Project run configuration not found" = "未找到项目运行配置"; "Project runtime settings are saved locally for this project." = "项目运行时设置会保存在本机的项目配置中。"; +"Project scan is incomplete" = "项目扫描不完整"; "Project skills" = "项目技能"; "Project toolchain needs attention" = "项目工具链需要处理"; "Project will be created in: %@" = "项目将在以下位置创建:%@"; @@ -3143,6 +3151,7 @@ "Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway." = "运行你自行安装的其他 ACP Agent。它必须支持通过自定义 API 密钥网关登录。"; "Run checked configurations without restarting running services" = "启动勾选的配置,已运行的服务保持运行"; "Run configuration" = "运行配置"; +"Run configuration changed while saving. Reload and try again." = "保存期间运行配置已更改。请重新加载后再试。"; "Run configurations" = "运行配置"; "Run configurations and Maven goals started in Lithe. They always stop when their project or window closes or Lithe quits, together with the processes they started; this only decides whether Lithe lists them and asks first." = "在 Lithe 中启动的运行配置和 Maven 目标。项目或窗口关闭、或退出 Lithe 时,它们总会连同自己启动的进程一起停止;这里只决定 Lithe 是否先列出并询问。"; "Run configurations are unavailable. Enable the execution module and try again." = "运行配置暂不可用,请启用执行模块后重试。"; @@ -3210,6 +3219,7 @@ "Save local changes with" = "本地更改保存方式"; "Save message" = "保存信息"; "Save or discard unsaved files before deleting this item" = "删除此项目之前,请先保存或丢弃未保存的文件"; +"Save provider run configuration to .lithe/run/local.json" = "将提供方运行配置保存到 .lithe/run/local.json"; "Save scope" = "保存范围"; "Save shared instructions" = "保存共享指令"; "Save the active document" = "保存当前文档"; @@ -3223,6 +3233,7 @@ "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git." = "仅为这台电脑上的此项目保存在 .lithe/run/local.json,并已从 Git 中排除。"; "Saved to %@" = "已保存到 %@"; "Saves, connects, and opens the remote folder in this window." = "保存、连接,并在当前窗口打开远程文件夹。"; +"Saving build provider run configurations is unavailable." = "当前无法保存构建提供方运行配置。"; "Saving replaces the current GitHub metadata." = "保存后将替换当前 GitHub 元数据。"; "Saving writes to" = "保存将写入"; "Scala Support" = "Scala 支持"; @@ -3486,6 +3497,7 @@ "Solidity Support" = "Solidity 支持"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "部分 MCP 配置文件未能恢复:%@。请刷新并检查这些文件后重试。"; "Some dependencies are not shown." = "部分依赖未显示。"; +"Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." = "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。"; "Sort by Last Updated" = "按最近更新排序"; "Sort by Name" = "按名称排序"; "Sort plugins" = "插件排序"; @@ -3755,7 +3767,9 @@ "The branch now contains the completed plan. Its original history remains available through the recovery reference." = "操作计划已执行完成。仍可通过恢复引用查看分支的原始历史。"; "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message." = "分项需要开启“模型调用统计”(默认开启)。开启后请重新连接并发送消息。"; "The build provider failed to start." = "构建 Provider 启动失败。"; +"The build provider returned an invalid launch plan." = "构建提供方返回了无效的启动计划。"; "The build provider stopped before it was ready." = "构建 Provider 在就绪前已停止。"; +"The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled." = "构建插件的调试程序或适配器不可用。请重新构建,并检查插件是否已启用。"; "The build tool exited with code %lld." = "构建工具已退出,退出码 %lld。"; "The build tool output is too large or not UTF-8." = "构建工具的输出过大或不是 UTF-8 编码。"; "The build tool timed out." = "构建工具超时。"; @@ -3830,6 +3844,7 @@ "The interface language changes immediately. English is the default." = "界面语言会立即生效。默认语言为英文。"; "The item can be recovered from the macOS Trash." = "可以从 macOS 废纸篓中恢复此项目。"; "The language server could not apply this setting. Restart it to use the new value." = "语言服务器无法应用此设置。请重启它以使用新值。"; +"The language server this build system uses is not available. Open a file of the language and try again." = "此构建系统使用的语言服务器不可用。请打开该语言的文件后重试。"; "The language service that hosts the debug adapter is unavailable." = "承载调试适配器的语言服务不可用。"; "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release." = "将全量下载最新正式版,在你确认安装后替换当前 AutoBuild。项目和设置会保留,但 AutoBuild 专有的设置可能无法在正式版中使用。"; "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release." = "将全量下载最新正式版,在你确认安装后替换当前预览版。项目和设置会保留,但预览版专有的设置可能无法在正式版中使用。"; @@ -3900,6 +3915,7 @@ "The repository will be opened in detached HEAD state." = "仓库将以 detached HEAD 状态打开。"; "The request has no secret." = "请求不包含密钥。"; "The response stopped at the model's token limit." = "回复已达到模型的 token 上限,已停止。"; +"The saved provider run configuration is unavailable." = "已保存的提供方运行配置不可用。"; "The selected AI provider has an invalid API URL or model." = "所选 AI 服务商的 API 地址或模型无效。"; "The selected AI provider has no API key." = "所选 AI 服务商没有 API 密钥。"; "The selected background image authorization is invalid." = "所选背景图片的授权无效。"; @@ -5602,6 +5618,8 @@ "native.disconnect" = "断开连接"; "native.disconnected" = "已断开"; "native.discoverMcpServers" = "发现 MCP 服务器"; +"native.discoverTests" = "发现测试"; +"native.discoveredTests" = "已发现的测试"; "native.discovering" = "正在检测…"; "native.discoveringMaven" = "正在检测 Maven…"; "native.disk" = "磁盘"; @@ -6284,6 +6302,7 @@ "native.interface" = "界面"; "native.interrupt" = "中断"; "native.interrupted" = "已中断"; +"native.invalidBuildProviderLaunchPlan" = "构建提供方返回了无效的启动计划。"; "native.invalidGitTagName" = "Git 标签名称无效。"; "native.invalidHookFileGuards" = "Hook 文件校验信息无效。"; "native.invalidIdeApiRequest" = "IDE API 请求无效"; @@ -6520,11 +6539,13 @@ "native.load" = "加载"; "native.loadArg1More" = "再加载 %1$lld 项"; "native.loadDiskVersion" = "加载磁盘版本"; +"native.loadModelBeforeWorkspaceTests" = "运行全部测试前,请加载或重新加载项目模型,以便收集子项目的测试报告。"; "native.loadMore" = "加载更多"; "native.loadMore2" = "加载更多"; "native.loadMoreCommits" = "加载更多提交"; "native.loadMoreDebuggerVariables" = "加载更多调试变量"; "native.loadProjectModel" = "加载项目模型"; +"native.loadProviderModelBeforeRun" = "请先加载当前构建模型并启用对应提供方,再运行此配置。"; "native.loadTheDependenciesAgain" = "重新加载依赖"; "native.loadTheDependenciesOf" = "加载 %1$@ 的依赖"; "native.loadTheModel" = "加载 %1$@ 的模型"; @@ -6968,6 +6989,7 @@ "native.noSubagentsYet" = "暂无子代理"; "native.noTasksYet" = "暂无任务"; "native.noTestRunnerIsConfiguredFor" = "没有为 %1$@ 配置测试运行器。"; +"native.noTestsDiscovered" = "未发现测试。"; "native.noTextualDiffAvailable" = "没有可用的文本 Diff"; "native.noTextualDifference" = "没有文本差异"; "native.noToolsRunning" = "暂无工具运行"; @@ -7333,9 +7355,12 @@ "native.providedByLitheForThisLanguage" = "由 Lithe 为该语言提供"; "native.provider" = "识别器"; "native.providerConfigurationParsingIsUnavailable" = "供应商配置解析暂不可用。"; +"native.providerDebugUnavailable" = "构建插件的调试程序或适配器不可用。请重新构建,并检查插件是否已启用。"; "native.providerDetails" = "服务商详情"; "native.providerName" = "服务商名称"; "native.providerRequiresAnAPIKey" = "服务商需要 API 密钥"; +"native.providerRunSaveConflict" = "保存期间运行配置已更改。请重新加载后再试。"; +"native.providerRunSavingUnavailable" = "当前无法保存构建提供方运行配置。"; "native.providers" = "供应商管理"; "native.provides" = "提供"; "native.pruneStaleRecords" = "清理陈旧记录"; @@ -7755,6 +7780,7 @@ "native.saveMetadata" = "保存元数据"; "native.saveOrDiscardUnsavedFilesBeforeDeletingThisItem" = "删除此项目之前,请先保存或丢弃未保存的文件"; "native.savePatch" = "保存补丁…"; +"native.saveProviderRunConfiguration" = "将提供方运行配置保存到 .lithe/run/local.json"; "native.saveScope" = "保存范围"; "native.saveSharedInstructions" = "保存共享指令"; "native.saveTheActiveDocument" = "保存当前文档"; @@ -7766,6 +7792,7 @@ "native.savedInLitheRunConfigurationsJsonForTheWholeTeamLocalJDKPathsAreNeverShared" = "保存在 .lithe/run/configurations.json,供整个团队使用。本机 JDK 路径不会共享。"; "native.savedInLitheRunLocalJsonAndExcludedFromGit" = "保存在 .lithe/run/local.json,并已从 Git 中排除。"; "native.savedOnlyForThisProjectOnThisComputerInLitheRunLocalJsonAndExcludedFromGit" = "仅为这台电脑上的此项目保存在 .lithe/run/local.json,并已从 Git 中排除。"; +"native.savedProviderRunUnavailable" = "已保存的提供方运行配置不可用。"; "native.savedTo" = "已保存到 %1$@"; "native.savesConnectsAndOpensTheRemoteFolderInThisWindow" = "保存、连接,并在当前窗口打开远程文件夹。"; "native.savingReplacesTheCurrentGitHubMetadata" = "保存后将替换当前 GitHub 元数据。"; @@ -8355,6 +8382,7 @@ "native.theLanguageServerIsDisabledInThisWorkspace" = "此工作区已停用 %1$@ 语言服务器。请在“设置 > LSP”中启用它。"; "native.theLanguageServerOfThisPluginIsNotAvailable" = "此插件的 %1$@ 语言服务器不可用。"; "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter" = "%1$@ 语言服务器为其调试适配器返回了无效的 TCP 端口。"; +"native.theLanguageServerThisBuildSystemUsesIsNotAvailable" = "此构建系统使用的语言服务器不可用。请打开该语言的文件后重试。"; "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable" = "承载调试适配器的语言服务不可用。"; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisAutoBuildAfterYouConbfd63f6a8e" = "将全量下载最新正式版,在你确认安装后替换当前 AutoBuild。项目和设置会保留,但 AutoBuild 专有的设置可能无法在正式版中使用。"; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisPreviewAfterYouConfic01b41840e" = "将全量下载最新正式版,在你确认安装后替换当前预览版。项目和设置会保留,但预览版专有的设置可能无法在正式版中使用。"; @@ -8971,6 +8999,8 @@ "npm was not found. It is normally installed with Node.js." = "未找到 npm。它通常随 Node.js 一起安装。"; "or" = "或"; "outline.expandAll" = "全部展开"; +"plugins.java.installNotice" = "Java Support 现已独立安装。请从插件市场安装,或使用“从磁盘安装”导入离线包。原有 Java 设置会保留。"; +"plugins.java.installTemplates" = "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…"; "plugins.title" = "插件"; "ports %@" = "端口 %@"; "project Maven Wrapper" = "项目 Maven Wrapper"; @@ -8981,6 +9011,8 @@ "results" = "个结果"; "revert" = "反向提交"; "run configuration" = "运行配置"; +"run.incompleteProjectScanMessage" = "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。"; +"run.incompleteProjectScanTitle" = "项目扫描不完整"; "running for %@" = "已运行 %@"; "scope managed from %@" = "依赖管理前 scope 为 %@"; "scope not updated to %@" = "未提升为 %@ scope"; diff --git a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift index fa2107a67e..3aa544c134 100644 --- a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift +++ b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift @@ -81,6 +81,7 @@ enum WorkspaceProjectionComposition { }, onSnapshotLoaded: { [weak model] snapshot, isInitialLoad in guard let model else { return } + model.suggestJavaPluginIfNeeded(files: snapshot.files) // Loading a ready inventory also resumes deferred run actions. await model.loadProjectServices( at: snapshot.root.url, diff --git a/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift b/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift index f78ea3078d..4c733afc2d 100644 --- a/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift @@ -237,9 +237,22 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { guard let configuration = run.configurations.first(where: { $0.id == id && $0.kind != .currentFile }) else { return failed("NOT_FOUND", "Choose an existing project run configuration") } guard !run.moduleSessions.contains(where: { $0.configurationID == id && $0.isRunning }) else { return failed("BUSY", "This configuration is already running") } if run.isRunning && run.lastConfiguration?.id == id { return failed("BUSY", "This configuration is already running") } + let previousPrimary = run.primaryExecutionID let previous = run.moduleSessions.first { $0.configurationID == id }?.executionID await model.startAPIConfiguration(configuration) try check(model, identity, grant: grant) + if configuration.buildProvider != nil { + guard let operationID = run.primaryExecutionID, operationID != previousPrimary, + run.lastConfiguration?.id == id else { + return failed("LAUNCH_FAILED", "Launch did not start. Check the IDE project readiness and any pending launch decision.") + } + if !run.isRunning, let exitCode = run.lastExitCode, exitCode != 0 { + var result = failed("LAUNCH_FAILED", "Launch failed. Read the operation output for details.") + result["operationID"] = "run:" + operationID + return result + } + return ["operationID": "run:" + operationID] + } guard let session = run.moduleSessions.first(where: { $0.configurationID == id }), session.executionID != previous else { return failed("LAUNCH_FAILED", "Launch did not start. Check the IDE project readiness and any pending launch decision.") } if !session.isRunning, let exitCode = session.exitCode, exitCode != 0 { var result = failed("LAUNCH_FAILED", "Launch failed. Read the operation output for details.") diff --git a/macos/Sources/Lithe/Application/Features/JavaPluginTransition.swift b/macos/Sources/Lithe/Application/Features/JavaPluginTransition.swift new file mode 100644 index 0000000000..5da934c288 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/JavaPluginTransition.swift @@ -0,0 +1,21 @@ +import Foundation +import LitheModuleAPI + +// Note: Java 独立分发与迁移见 .agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md + +/// Suggests installation only when Java is needed and the package is absent. +/// Disabled, staged and pending-uninstall packages keep their management flow. +/// Removed packages can be restored explicitly; a suggestion never installs one. +enum JavaPluginTransition { + static func shouldSuggest(files: [URL], plugins: [PluginManagementSnapshot]) -> Bool { + guard !plugins.contains(where: { + $0.id == OfficialPluginCatalog.javaPluginID + && ($0.installationStatus != .removed || $0.hasChangesPendingRestart) + }), + let java = OfficialPluginCatalog.entry(for: OfficialPluginCatalog.javaPluginID) else { return false } + return files.contains { + java.fileExtensions.contains($0.pathExtension.lowercased()) + || java.projectFileNames.contains($0.lastPathComponent.lowercased()) + } + } +} diff --git a/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift index 3e8bd53125..3eaccc3829 100644 --- a/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift @@ -56,6 +56,9 @@ final class NewProjectFeatureModel: ObservableObject { static let defaultLocation = "~/LitheProjects" @Published private(set) var entries: [ProjectGeneratorEntry] = [] + var isJavaSupportMissing: Bool { + !sources.contains { $0.pluginID == OfficialPluginCatalog.javaPluginID } + } @Published private(set) var catalogError: String? @Published var selection: Selection = .empty { didSet { if oldValue != selection { selectionChanged() } } diff --git a/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift b/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift index 24636d0ee0..6ae0642ccb 100644 --- a/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift +++ b/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift @@ -492,9 +492,17 @@ final class PluginMarketplaceFeatureModel: ObservableObject { return .installedFromOtherSource(sourceName: name) } if let available = offer.entry.availability.download, - let availableVersion = PluginVersion(available.version), - availableVersion > snapshot.manifest.version { - return .updateAvailable(installed: installedVersion, available: available.version) + let availableVersion = PluginVersion(available.version) { + // A sealed transition copy can move to the independent + // distribution without changing the plugin version. This is + // still an explicit installation, never an automatic download. + if snapshot.origin == .bundled, snapshot.installationStatus == .removed { + return .installable + } + if availableVersion > snapshot.manifest.version + || (snapshot.origin == .bundled && availableVersion == snapshot.manifest.version) { + return .updateAvailable(installed: installedVersion, available: available.version) + } } return .installed(version: installedVersion) } @@ -548,6 +556,10 @@ final class PluginMarketplaceFeatureModel: ObservableObject { if snapshot.origin == .marketplace, official != nil { return PluginMarketplaceSource.officialID } + if snapshot.origin == .bundled, official?.distribution == .download, + official?.bundledSignatureRequirement == .sameTeamAsHost { + return PluginMarketplaceSource.officialID + } return nil } diff --git a/macos/Sources/Lithe/Core/Ports/RunLaunchEnvironmentResolving.swift b/macos/Sources/Lithe/Core/Ports/RunLaunchEnvironmentResolving.swift new file mode 100644 index 0000000000..819f101a8f --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/RunLaunchEnvironmentResolving.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Platform discovery supplies homes; Core owns precedence for every launch step. +protocol RunLaunchEnvironmentResolving { + func resolveLaunchEnvironment( + toolchain: String?, + selectedHome: String?, + inherited: [String: String], + overrides: [String: String] + ) throws -> [String: String] +} diff --git a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift index 84b2ca699f..6fbcd43a75 100644 --- a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift @@ -1,5 +1,6 @@ import Foundation import LitheCoreContracts +import LitheModuleAPI /// Typed `build.*` operations. Rust Core owns detection, command expansion, /// and every decoder for both products; this adapter only translates @@ -74,6 +75,15 @@ struct RustBuildSystemOperations: BuildSystemPlanning { return outcome.value.options } + func resolveModelInputs(_ target: BuildSystemTarget, revision: UInt64, sources: [BuildModelInputSource]) throws -> ToolingJSONValue { + struct Source: Encodable { let manifest: ToolingJSONValue; let instances: [BuildModelInputSource.Instance] } + struct Request: Encodable { let revision: UInt64; let sources: [Source] } + struct Reply: Decodable { let modelInputs: ToolingJSONValue } + let request = Request(revision: revision, sources: try sources.map { Source(manifest: try Self.json($0.manifest), instances: $0.instances) }) + let outcome: Outcome = try execute("build.resolveModelInputs", target: target, request: request) + return outcome.value.modelInputs + } + func modelPlan( _ target: BuildSystemTarget, context: BuildPlanContext, @@ -89,13 +99,13 @@ struct RustBuildSystemOperations: BuildSystemPlanning { } func taskPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTaskRequest) throws - -> BuildProcessPlan { + -> BuildOutputPlan { let outcome: Outcome = try execute( "build.taskPlan", target: target, request: TaskRequest(context: context, request: request) ) - return try outcome.value.plan.processPlan() + return try outcome.value.plan.outputPlan() } func testPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTestRequest) throws @@ -106,7 +116,91 @@ struct RustBuildSystemOperations: BuildSystemPlanning { request: TestRequest(context: context, request: request) ) let plan = outcome.value.plan - return BuildTestPlan(process: try plan.processPlan(), results: plan.results?.results) + return BuildTestPlan(execution: try plan.outputPlan(), results: plan.results?.results) + } + + func testDiscoveryPlan(_ target: BuildSystemTarget, context: BuildPlanContext) throws -> BuildProviderRequest { + let outcome: Outcome = try execute( + "build.testDiscoveryPlan", target: target, + request: OutputRequest(context: context, project: nil, outputFile: nil, hints: BuildProviderHints(reason: .refresh)) + ) + guard case .provider(let request) = try outcome.value.plan.outputPlan() else { + throw DeclarativePluginRejection(code: "plugin.build.unavailable", message: "Expected a provider test discovery plan.") + } + return request + } + + func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + try preparationPlan(target, context: context, run: run, project: project, selectedTarget: selectedTarget, + userArguments: userArguments, command: "build.prepareRunPlan") + } + + func prepareDebugPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + try preparationPlan(target, context: context, run: run, project: project, selectedTarget: selectedTarget, + userArguments: userArguments, command: "build.prepareDebugPlan") + } + + private func preparationPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String], command: String) throws -> BuildProviderRequest { + struct Request: Encodable { + let root: String + let pluginDirectory: String? + let workspaceStateDirectory: String? + let modelInputs: ToolingJSONValue? + let options: [String: ToolingJSONValue] + let run: String + let project: BuildSelection + let target: BuildSelection + let userArguments: [String] + } + let outcome: Outcome = try execute(command, target: target, + request: Request(root: context.root, pluginDirectory: context.pluginDirectory?.path, + workspaceStateDirectory: context.workspaceStateDirectory?.path, modelInputs: context.modelInputs, options: context.options, run: run, + project: project, target: selectedTarget, userArguments: userArguments)) + guard case .provider(let request) = try outcome.value.plan.outputPlan() else { + throw DeclarativePluginRejection(code: "plugin.build.invalidOutput", message: L10n.string("The build provider returned an invalid launch plan.")) + } + return request + } + + func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, + result: ToolingJSONValue) throws -> BuildRunPreparation { + struct Request: Encodable { let workspaceRoot: String; let root: String; let result: ToolingJSONValue } + struct Reply: Decodable { let launch: BuildRunPreparation } + let outcome: Outcome = try execute("build.decodeRunPreparation", target: target, + request: Request(workspaceRoot: workspaceURL.path, root: root, result: result)) + return outcome.value.launch + } + + func decodeDebugPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, run: String, + result: ToolingJSONValue) throws -> BuildDebugPreparation { + struct Request: Encodable { let workspaceRoot: String; let root: String; let run: String; let result: ToolingJSONValue } + struct Reply: Decodable { let preparation: BuildDebugPreparation } + let outcome: Outcome = try execute("build.decodeDebugPreparation", target: target, + request: Request(workspaceRoot: workspaceURL.path, root: root, run: run, result: result)) + return outcome.value.preparation + } + + func decodeTestDiscovery( + _ target: BuildSystemTarget, context: BuildPlanContext, result: ToolingJSONValue + ) throws -> BuildTestDiscovery { + struct Request: Encodable { + let root: String + let options: [String: ToolingJSONValue] + let result: ToolingJSONValue + let pluginDirectory: String? + let workspaceStateDirectory: String? + let modelInputs: ToolingJSONValue? + } + struct Reply: Decodable { let discovery: BuildTestDiscovery } + let outcome: Outcome = try execute( + "build.decodeTestDiscovery", target: target, + request: Request(root: context.root, options: context.options, result: result, + pluginDirectory: context.pluginDirectory?.path, workspaceStateDirectory: context.workspaceStateDirectory?.path, modelInputs: context.modelInputs) + ) + return outcome.value.discovery } func dependencyPlan( @@ -310,6 +404,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let options: [String: ToolingJSONValue] let pluginDirectory: String? let workspaceStateDirectory: String? + let modelInputs: ToolingJSONValue? let project: BuildSelection? let outputFile: String? let refresh: Bool @@ -320,6 +415,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { } init(context: BuildPlanContext, project: BuildSelection?, outputFile: URL?, hints: BuildProviderHints) { + modelInputs = context.modelInputs root = context.root options = context.options pluginDirectory = context.pluginDirectory?.path @@ -336,6 +432,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let options: [String: ToolingJSONValue] let pluginDirectory: String? let workspaceStateDirectory: String? + let modelInputs: ToolingJSONValue? let task: String let project: BuildSelection? let target: BuildSelection? @@ -343,6 +440,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let userArguments: [String] init(context: BuildPlanContext, request: BuildTaskRequest) { + modelInputs = context.modelInputs root = context.root options = context.options pluginDirectory = context.pluginDirectory?.path @@ -360,6 +458,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let options: [String: ToolingJSONValue] let pluginDirectory: String? let workspaceStateDirectory: String? + let modelInputs: ToolingJSONValue? let project: BuildSelection? let target: BuildSelection? let suite: String? @@ -368,6 +467,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let projects: [String] init(context: BuildPlanContext, request: BuildTestRequest) { + modelInputs = context.modelInputs root = context.root options = context.options pluginDirectory = context.pluginDirectory?.path diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 4f837e5b44..d453533235 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -693,6 +693,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { struct Extensions: Codable, Sendable { let maven: Maven? let java: Java? + var build: BuildProviderRunReference? = nil } let id: String let name: String @@ -2192,6 +2193,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let type: String let module: String let mainClass: String + var build: BuildProviderRunReference? = nil } private struct LaunchPlanRequest: Encodable { struct JavaLaunch: Encodable { @@ -2934,6 +2936,13 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } + func createBuildProviderRunConfiguration(at rootURL: URL, reference: BuildProviderRunReference, + name: String) -> Result { + executeResult(command: "runConfig.createUserConfiguration", payload: RunConfigurationCreateUserRequest( + root: rootURL.standardizedFileURL.path, scope: "local", name: name, type: "buildProvider", + module: "", mainClass: "", build: reference)) + } + func createUserRunConfiguration( at rootURL: URL, draft: RunConfigurationDraft diff --git a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift index 61fda844c5..27448a2dd5 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift @@ -191,7 +191,8 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec path: request.path, processID: request.processID, workingDirectory: request.workingDirectory, - executable: request.executable + executable: request.executable, + arguments: request.arguments, environment: request.environment ) ) ) @@ -615,6 +616,8 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec let processID: Int? let workingDirectory: String? let executable: String? + let arguments: [String]? + let environment: [String: String]? } let registration: Registration diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift index 234fdde986..dba219621a 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift @@ -75,7 +75,7 @@ extension AppModel { scope: target.scope) return } - Task { [weak self] in await self?.startDebuggingAfterActivation() } + runWorkflowCoordinator.startModuleOperation { [weak self] in await self?.startDebuggingAfterActivation() } } func startOrRestartDebugging() { @@ -193,6 +193,18 @@ extension AppModel { } return } + if selectedConfiguration.buildProvider != nil { + guard await saveAllDocuments(), isCurrentWorkspace(identity), !Task.isCancelled, + let feature = buildSystemsFeatureIfActive else { return } + let started = feature.debugProviderConfiguration(selectedConfiguration, workspace: identity.url) { [weak self] source, preparation in + guard let self, self.isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + try self.launchPreparedProviderDebug(source: source, preparation: preparation, + workspaceURL: identity.url, title: selectedConfiguration.name) + } + if started != nil { showToolWindow(.buildOutput) } + else { showNotification("The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.") } + return + } let activeDocumentIsLaunchable = selectedConfiguration.usesCurrentEditorFile ? await isLaunchableJavaEntrypoint(activeDocument, in: workspaceURL) : false @@ -243,7 +255,34 @@ extension AppModel { await startGenericDebuggingAfterActivation(fileURL: sourceURL, document: document) } + func debugBuildProviderApplication(entryID: String, request: BuildRunRequest, title: String) { + runWorkflowCoordinator.startModuleOperation { [weak self] in + guard let self, let identity = currentWorkspaceIdentity, + await saveAllDocuments(), isCurrentWorkspace(identity), + await activateDebugModule() != nil, isCurrentWorkspace(identity), + !Task.isCancelled, let feature = buildSystemsFeatureIfActive else { return } + if feature.debugApplication(entryID, request: request, title: title, launch: { [weak self] source, preparation in + guard let self, self.isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + try self.launchPreparedProviderDebug(source: source, preparation: preparation, workspaceURL: identity.url, title: title) + }) != nil { showToolWindow(.buildOutput) } + } + } + + private func launchPreparedProviderDebug(source: BuildSystemPluginSource, preparation: BuildDebugPreparation, + workspaceURL: URL, title: String) throws { + guard let feature = genericDebugFeatureIfActive else { throw CancellationError() } + let configuration = try debugLaunchConfigurationResolver.resolveProvider(source: source, preparation: preparation, + workspaceURL: workspaceURL, title: title) + guard feature.startAdditional(fileURL: workspaceURL.appendingPathComponent(preparation.program), + rootURL: workspaceURL, configuration: configuration) else { + throw NSError(domain: "BuildProviderDebug", code: 1, userInfo: [NSLocalizedDescriptionKey: feature.errorMessage + ?? L10n.string("The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.")]) + } + showToolWindow(.debug) + } + func stopDebugging() { + buildSystemsFeatureIfActive?.cancelDebugPreparation() runWorkflowCoordinator.cancelModuleOperation() cancelJavaTestDebugLaunch() guard let feature = genericDebugFeatureIfActive else { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift new file mode 100644 index 0000000000..f82eadeb08 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift @@ -0,0 +1,21 @@ +import Foundation +import LitheModuleAPI + +extension AppModel { + /// One notice per window session keeps offline failures retryable on the + /// next launch, without repeating it for each file in a Java project. + func suggestJavaPluginIfNeeded(files: [URL]) { + guard !hasSuggestedJavaPlugin, + JavaPluginTransition.shouldSuggest( + files: files, + plugins: services.pluginManager.snapshots + ) else { return } + hasSuggestedJavaPlugin = true + showNotification( + L10n.string("Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept."), + action: WorkbenchNotification.Action(title: L10n.string("Open Marketplace")) { [weak self] in + self?.showPluginMarketplace(searching: "Java Support") + } + ) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift index 37f91d78a7..807e4a6ff3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift @@ -6,6 +6,16 @@ import LitheModuleAPI /// Run configuration entry points and workspace readiness gates. @MainActor extension AppModel { + func runBuildProviderApplication(entryID: String, request: BuildRunRequest, title: String) { + guard let identity = currentWorkspaceIdentity, let feature = buildSystemsFeatureIfActive else { return } + guard feature.runApplication(entryID, request: request, title: title, beforePreparing: { [weak self] in + guard let self, !Task.isCancelled, isCurrentWorkspace(identity), + await saveAllDocuments(), !Task.isCancelled, isCurrentWorkspace(identity) else { return false } + return true + }) != nil else { return } + showToolWindow(.buildOutput) + } + func updateRunningService(_ session: RunSession) { guard let feature = runFeatureIfActive, let identity = currentWorkspaceIdentity else { return } Task { [weak self] in @@ -287,6 +297,9 @@ extension AppModel { return } guard let configuration = runFeature.lastConfiguration else { return } + if runFeature.lastConfiguration?.buildProvider != nil { + guard await saveAllDocuments(), isCurrentWorkspace(identity) else { return } + } if !(await activateLanguageRunExtensionIfNeeded( for: configuration, currentFileURL: runFeature.lastRunFileURL, @@ -389,6 +402,9 @@ extension AppModel { case .stale: return } + if configuration.buildProvider != nil { + guard await saveAllDocuments(), isCurrentWorkspace(identity) else { return } + } guard await activateLanguageRunExtensionIfNeeded( for: configuration, currentFileURL: activeDocument?.url, @@ -539,7 +555,7 @@ extension AppModel { func stopSelectedRun() { if let feature = runFeatureIfActive, let configuration = feature.selectedConfiguration, - configuration.kind != .currentFile { + configuration.kind != .currentFile, configuration.buildProvider == nil { if let session = feature.moduleSessions.first(where: { $0.configurationID == configuration.id }) { feature.stopModule(session) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index d5b695202b..e3daa4111f 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -13,6 +13,7 @@ import LitheCoreContracts @MainActor final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { let id = UUID() + var hasSuggestedJavaPlugin = false lazy var debugPerformanceFeature = DebugPerformanceFeatureModel(sampler: services.debugPerformanceSampler) lazy var testPerformanceFeature = TestJVMPerformanceFeatureModel( sampler: services.debugPerformanceSampler, provider: services.testJVMPerformance) @@ -1072,6 +1073,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { displayPath: String? = nil, allowsOutsideWorkspace: Bool = false ) { + suggestJavaPluginIfNeeded(files: [url]) editorTabOrderFeature.repositoryDiffSelected = false selectedChange = nil closeBranchComparison() @@ -1089,6 +1091,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { } func openStandaloneFile(_ url: URL) { + suggestJavaPluginIfNeeded(files: [url]) workspaceSessionCoordinator.openStandaloneFile(at: url) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift index a108325da3..05d4d7666a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift @@ -100,9 +100,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { ) { case .languageServer(let languageID): guard let sessions = languageSessions?() else { - throw UnavailableError(message: String( - localized: "The language server this build system uses is not available. Open a file of the language and try again." - )) + throw UnavailableError(message: L10n.string("The language server this build system uses is not available. Open a file of the language and try again.")) } return try await sessions.requestBuildProvider( languageID: languageID, @@ -119,8 +117,34 @@ final class MacBuildProviderSessions: BuildProviderRequesting { } } + func execute( + _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void + ) async throws -> BuildProviderExecutionResult { + // Reuse the plugin-owned workspace process and its bounded startup. + let session = try await ownedSession(for: target) + try Task.checkCancellation() + let value = try await session.buildRequest( + method: request.method, params: request.params, timeout: Self.seconds(timeout), progress: progress + ) + return try JSONDecoder().decode(BuildProviderExecutionResult.self, from: JSONEncoder().encode(value)) + } + + func prepareDebug(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + try await prepareRun(request, on: target, timeout: timeout, progress: progress) + } + + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + let session = try await ownedSession(for: target) + try Task.checkCancellation() + return try await session.buildRequest(method: request.method, params: request.params, + timeout: Self.seconds(timeout), progress: progress) + } + func pluginFunctions(of target: BuildProviderTarget) async throws -> [String] { - try await functionSession(for: target).functionIDs + try await ownedSession(for: target).functionIDs } func invokePluginFunction( @@ -130,17 +154,17 @@ final class MacBuildProviderSessions: BuildProviderRequesting { timeout: Duration, progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void ) async throws -> ToolingJSONValue { - let session = try await functionSession(for: target) + let session = try await ownedSession(for: target) return try await session.invokeFunction( functionID, params: params, timeout: Self.seconds(timeout), progress: progress ) } /// The target's own process, started like `send` would. Functions are - /// served only by a process the plugin owns: a shared language server is + /// and native task execution are served only by a process the plugin owns: a shared language server is /// started by the host for every plugin of its language, so it never /// serves one plugin's functions. - private func functionSession(for target: BuildProviderTarget) async throws -> LanguageServerRuntimeSession { + private func ownedSession(for target: BuildProviderTarget) async throws -> LanguageServerRuntimeSession { try Task.checkCancellation() let settings = launchResolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL) if let session = try await runningSession(for: target, settings: settings) { return session } diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift new file mode 100644 index 0000000000..d8faaea4da --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift @@ -0,0 +1,43 @@ +import Foundation +import LitheCoreContracts + +/// Rechecks native filesystem and current package ownership at the debug handoff. +/// Provider facts never replace the adapter's own registered process or permissions. +@MainActor +struct MacBuildDebugLaunchResolver { + let planner: any DeclarativeLanguageServerLaunchPlanning + let entry: (String) -> PluginDebugAdapterEntry? + let generation: () -> Int + + func resolve(source: BuildSystemPluginSource, preparation: BuildDebugPreparation, + workspaceURL: URL, title: String) throws -> DebugLaunchConfiguration { + let identity = "\(source.pluginID)/\(preparation.adapter)" + guard let entry = entry(identity), + entry.plugin.descriptor.normalizedManifest == source.manifest, + entry.plugin.packageURL.standardizedFileURL == source.packageURL.standardizedFileURL else { + throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable + } + // Recheck canonical containment at the platform handoff, since + // a workspace symlink may change after Core decoded the result. + let workspace = workspaceURL.resolvingSymlinksInPath().standardizedFileURL + let program = workspace.appendingPathComponent(preparation.program).resolvingSymlinksInPath().standardizedFileURL + let cwd = workspace.appendingPathComponent(preparation.workingDirectory).resolvingSymlinksInPath().standardizedFileURL + let instance = workspace.appendingPathComponent(preparation.root).resolvingSymlinksInPath().standardizedFileURL + let prefix = instance.path.hasSuffix("/") ? instance.path : instance.path + "/" + let workspacePrefix = workspace.path.hasSuffix("/") ? workspace.path : workspace.path + "/" + var isDirectory: ObjCBool = false + guard instance == workspace || instance.path.hasPrefix(workspacePrefix), + program.path.hasPrefix(prefix), cwd == instance || cwd.path.hasPrefix(prefix), + MacPluginDebugExecutableResolver.isRunnableFile(program.path), + FileManager.default.fileExists(atPath: cwd.path, isDirectory: &isDirectory) && isDirectory.boolValue else { + throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable + } + let generation = generation() + let arguments = try planner.debugStartArguments( + manifest: source.manifest, adapterID: preparation.adapter, + request: DeclarativeDebugStartRequest(request: .launch, targetKind: "executable", path: program.path, + workingDirectory: cwd.path, registrationGeneration: generation, currentRegistrationGeneration: generation, + arguments: preparation.arguments, environment: preparation.environment)) + return DebugLaunchConfiguration(name: title, request: .launch, arguments: arguments, adapterProviderID: identity) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift b/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift index f0338d350f..34f9bba877 100644 --- a/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift +++ b/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift @@ -10,6 +10,7 @@ final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { } } + private let core = RustCoreBridge() private let configuration: DirectoryWatchConfiguration private let visibilityRules: FileVisibilityRules private let queue = DispatchQueue(label: "app.lithe.file-events", qos: .utility) @@ -175,7 +176,23 @@ final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { batch.requiresFullRescan = true batch.gitStateMayHaveChanged = true } - batch.workspacePaths = workspacePaths.sorted() + let rootPrefix = configuration.workspaceRoot.standardizedFileURL.path + "/" + let relativePaths = workspacePaths.sorted().map { path in + path.hasPrefix(rootPrefix) ? String(path.dropFirst(rootPrefix.count)) : "" + } + // One Core call per batch; native loss flags above remain unconditional. + let filtered: Result<[String], RustCoreBridge.CoreCallError> = core.executeResult( + command: "workspace.filterChanges", payload: relativePaths + ) + switch filtered { + case .success(let paths): + let accepted = Set(paths) + batch.workspacePaths = zip(workspacePaths.sorted(), relativePaths) + .filter { accepted.contains($0.1) }.map(\.0) + case .failure: + // A failed classifier cannot prove that a source change is irrelevant. + batch.requiresFullRescan = true + } return batch } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index fa32f95e59..c428ac3c48 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -187,6 +187,7 @@ final class MacServiceContainer { // debug. A language the host debugs itself keeps its own adapter. // Plugins that apply without a restart are in `livePlugins` instead. let registeredDebugAdapters = PluginDebugAdapterEntry.index(of: pluginStartup.registeredPlugins) + let registeredQualifiedDebugAdapters = PluginDebugAdapterEntry.qualifiedIndex(of: pluginStartup.registeredPlugins) let debugAdapterSessionsBox = DebugAdapterSessionsBox() let moduleEnablement = moduleStore let launchMode = moduleLaunchMode @@ -220,12 +221,18 @@ final class MacServiceContainer { } return snapshot.state != .disabled && !snapshot.isQuarantined && !snapshot.isSuppressedBySafeMode } - let activeDebugAdapter: @MainActor (String) -> PluginDebugAdapterEntry? = { languageID in - if let entry = registeredDebugAdapters[languageID], + let qualifiedDebugAdapters: @MainActor () -> [String: PluginDebugAdapterEntry] = { + registeredQualifiedDebugAdapters.filter { + isLaunchRegisteredAdapterUsable($0.value.plugin.descriptor.id.rawValue) + }.merging(PluginDebugAdapterEntry.qualifiedIndex(of: liveSnapshotBox.snapshot.activePackages)) { _, live in live } + } + let activeDebugAdapter: @MainActor (String) -> PluginDebugAdapterEntry? = { providerID in + if providerID.contains("/") { return qualifiedDebugAdapters()[providerID] } + if let entry = registeredDebugAdapters[providerID], isLaunchRegisteredAdapterUsable(entry.plugin.descriptor.id.rawValue) { return entry } - return liveSnapshotBox.snapshot.debugAdapters[languageID] + return liveSnapshotBox.snapshot.debugAdapters[providerID] } // A launch-registered plugin is not in the live registry, so turning its // debug module off reaches the running sessions through the module's @@ -498,7 +505,24 @@ final class MacServiceContainer { let executableResolver = providedRunExecutableResolver ?? RunExecutableResolver( runtimeService: runtimeService, toolchainRegistry: runToolchainRegistry, - metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner) + metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner), + environmentResolver: rustCore + ) + let runService = RunService( + runtime: runtimeService, + process: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution), + processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution) }, + fileAccess: MacRunFileAccess(storage: fileStorage), + preferences: MacRunPreferenceStore(store: store), + serverPortParser: javaMavenOperations, + runConfigurationOperations: providedRunConfigurationOperations ?? runConfigurationStore, + executableResolver: executableResolver, + languageProviderCatalog: languagePackRegistry.catalog, + languageRunProviders: languagePackRegistry.runProviders, + extensionRequiredLanguageIDs: runExtensionLanguageIDs, + languageSupports: installedLanguageSupports, + dependencyStore: MacWorkspaceDependencyStore(storage: fileStorage), + javaLaunchArgumentPreparer: rustCore ) let buildSystemPlugins = pluginStartup.registeredPlugins.filter { !$0.descriptor.buildSystems.isEmpty } let buildSystems = buildSystemPlugins.isEmpty ? nil : BuildSystemService( @@ -515,6 +539,7 @@ final class MacServiceContainer { ), runner: MacBuildCommandRunner(processRegistry: processRegistry), providers: buildProviderSessions, + runLauncher: runService, settings: { pluginSettings.values(forPlugin: $0) }, sources: { // Enablement is read on every detection, so turning a @@ -543,22 +568,7 @@ final class MacServiceContainer { configurationStore: MacMavenConfigurationStore(storage: fileStorage), dependencyOutputs: MacMavenDependencyOutputStore() ), - run: RunService( - runtime: runtimeService, - process: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution), - processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution) }, - fileAccess: MacRunFileAccess(storage: fileStorage), - preferences: MacRunPreferenceStore(store: store), - serverPortParser: javaMavenOperations, - runConfigurationOperations: providedRunConfigurationOperations ?? runConfigurationStore, - executableResolver: executableResolver, - languageProviderCatalog: languagePackRegistry.catalog, - languageRunProviders: languagePackRegistry.runProviders, - extensionRequiredLanguageIDs: runExtensionLanguageIDs, - languageSupports: installedLanguageSupports, - dependencyStore: MacWorkspaceDependencyStore(storage: fileStorage), - javaLaunchArgumentPreparer: rustCore - ), + run: runService, tests: LanguageTestService( catalog: languagePackRegistry.catalog, registry: languagePackRegistry.testProviders, @@ -631,7 +641,13 @@ final class MacServiceContainer { } ] let adapterSessions = DebugAdapterSessionManager( - providersSource: { debugProviderCache.providers() }, + providersSource: { + let languages = debugProviderCache.providers() + let qualified = qualifiedDebugAdapters().sorted { $0.key < $1.key }.map { + $0.value.provider(languages: languages) + } + return languages + qualified + }, makeSession: { descriptor, _ in if let factory = debugFactories[descriptor.id] { return factory() } guard let entry = activeDebugAdapter(descriptor.id) else { return nil } @@ -921,6 +937,11 @@ final class MacServiceContainer { languageProviderCatalogSnapshot: languageProviderCatalogSnapshot, debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver( javaTestLaunchResolver: rustCore, + providerLaunch: { source, preparation, workspaceURL, title in + try MacBuildDebugLaunchResolver(planner: declarativePluginOperations, + entry: { qualifiedDebugAdapters()[$0] }, generation: { liveSnapshotBox.snapshot.generation }) + .resolve(source: source, preparation: preparation, workspaceURL: workspaceURL, title: title) + }, javaExecutablePath: { [runtimeService] runtime in runtimeService.javaExecutableURL(forDebugging: runtime)?.path }, @@ -954,7 +975,8 @@ final class MacServiceContainer { return DebugLaunchConfiguration( name: documentURL.lastPathComponent, request: .launch, - arguments: arguments + arguments: arguments, + adapterProviderID: entry.providerID ) } ), diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift index 89aafab0e8..81c3e64fc7 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift @@ -15,6 +15,35 @@ struct PluginDebugAdapterEntry { let plugin: InstalledPluginPackage let adapter: DeclarativeDebugAdapter + var providerID: String { "\(plugin.descriptor.id.rawValue)/\(adapter.id)" } + + /// Every declared adapter has an explicit identity, independently of the + /// default debugger of its language or the presence of a source file. + static func qualifiedIndex(of packages: [InstalledPluginPackage]) -> [String: PluginDebugAdapterEntry] { + var entries: [String: PluginDebugAdapterEntry] = [:] + for plugin in packages.sorted(by: { $0.manifest.id < $1.manifest.id }) { + for adapter in plugin.descriptor.debugAdapters { + let entry = PluginDebugAdapterEntry(plugin: plugin, adapter: adapter) + entries[entry.providerID] = entry + } + } + return entries + } + + func provider(languages: [DebugProviderDescriptor]) -> DebugProviderDescriptor { + let supported = languages.filter { adapter.languageIDs.contains($0.id) } + return DebugProviderDescriptor( + id: providerID, + displayName: adapter.displayName, + fileExtensions: Set(supported.flatMap(\.fileExtensions)), + fileNames: Set(supported.flatMap(\.fileNames)), + fileNamePrefixes: Set(supported.flatMap(\.fileNamePrefixes)), + selectsByFile: false, + matchesAllSources: adapter.languageIDs.isEmpty, + requests: adapter.requests + ) + } + /// The adapter that launches a debug session for a language's open file, /// first package in ID order first. An adapter that only attaches, or only /// debugs executables and processes, is not chosen for a file. diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift index f2b90f0a3d..e74f74f28f 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift @@ -236,7 +236,7 @@ final class MacPluginManager: PluginManaging { let isLive = appliesLive(try packageStore.inspectPackage(at: packageURL)) let installed = try perform(!isLive) defer { rescanLivePlugins(for: installed.manifest.id, appliesLive: isLive) } - enableFirstInstallation(of: installed.manifest) + preserveEnablementAfterInstallation(of: installed.manifest) if !isLive { restartRequiredPluginIDs.insert(installed.manifest.id) } try refreshInstalledPlugins() } @@ -260,6 +260,9 @@ final class MacPluginManager: PluginManaging { throw PluginManagerError.unknownPlugin(pluginID) } beforePackageReplacement(pluginID) + if OfficialPluginCatalog.entry(for: pluginID)?.bundledSignatureRequirement == .sameTeamAsHost { + try packageStore.setBundledPluginRemoved(pluginID, removed: true) + } try packageStore.stageInvalidPackageUninstall(pluginID) restartRequiredPluginIDs.insert(pluginID) issues.removeAll { $0.pluginID == pluginID } @@ -293,6 +296,11 @@ final class MacPluginManager: PluginManaging { // The package itself lives in the read-only app bundle. try packageStore.setBundledPluginRemoved(pluginID, removed: true) } else { + // An installed update can shadow a sealed copy in a transition + // host. Uninstalling it must not resurrect that copy at restart. + if installed.manifest.vendor.bundledSignatureRequirement == .sameTeamAsHost { + try packageStore.setBundledPluginRemoved(pluginID, removed: true) + } try packageStore.stageUninstall(pluginID) } restartRequiredPluginIDs.insert(pluginID) @@ -351,8 +359,24 @@ final class MacPluginManager: PluginManaging { /// The user confirmed this installation, so its modules start enabled at /// the next launch. Module defaults stay off for packages that appear /// without that confirmation, and an update keeps the user's choice. - private func enableFirstInstallation(of manifest: PluginManifest) { - guard installedPlugins[manifest.id] == nil, !registeredPluginIDs.contains(manifest.id) else { return } + private func preserveEnablementAfterInstallation(of manifest: PluginManifest) { + if let existing = installedPlugins[manifest.id] { + // Bundled modules default on, while the same downloaded declaration + // defaults off. Materialize the old default only after installation + // succeeds. Explicit disabled choices and settings stay untouched. + if existing.installation.origin == .bundled { + let oldModules = Dictionary(uniqueKeysWithValues: existing.manifest.modules.map { ($0.manifest.id, $0.manifest) }) + for declaration in manifest.modules where configurationStore.enabledState(for: declaration.manifest.id) == nil { + // Only modules the old package owned have a choice to + // migrate. New modules keep their declaration's default. + guard let previous = oldModules[declaration.manifest.id] else { continue } + let enabled = existing.installation.status == .removed || previous.defaultState == .enabled + configurationStore.setEnabledState(enabled, for: declaration.manifest.id) + } + } + return + } + guard !registeredPluginIDs.contains(manifest.id) else { return } // Language module IDs are shared across plugin IDs. A package rejected // for an ownership collision at restart must not first enable its owner. // Reserve disabled/removed packages too, and keep the running graph's diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index 97e5d84072..2659a4df0f 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -200,6 +200,12 @@ final class MacPluginPackageStore { // The server of a bundled plugin removed last session has stopped by // now, so its state can go without pulling files from under it. for pluginID in removedBundledPluginIDs() { + // A confirmed installation may replace a previously removed sealed + // copy. The removal marker still suppresses that fallback, but it + // must never erase the installed version's state or credentials. + let directory = rootURL.appendingPathComponent(pluginID.rawValue, isDirectory: true) + if let record = try? installationRecord(at: directory), + record.status == .installed || record.status == .updateStaged { continue } removeState(of: pluginID) } } @@ -735,6 +741,10 @@ final class MacPluginPackageStore { private func validateLaunchResources(_ descriptor: DeclarativePluginDescriptor, at packageURL: URL) throws { let root = packageURL.standardizedFileURL.resolvingSymlinksInPath() for server in descriptor.languageServers { + if case .runtime = server.launch { + try validateRuntimeLanguageServerLaunch(server.languageID, of: descriptor, at: root) + continue + } guard case .packaged(let relativePath) = server.launch else { continue } let executableURL = root.appendingPathComponent(relativePath).standardizedFileURL let values = try? executableURL.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) @@ -753,6 +763,36 @@ final class MacPluginPackageStore { } } + /// A runtime launch can reference both an interpreter and entry/resources. + /// Validate the target architecture before installing a signed but broken + /// package, without starting the runtime or creating writable state. + private func validateRuntimeLanguageServerLaunch( + _ languageID: String, + of descriptor: DeclarativePluginDescriptor, + at root: URL + ) throws { + let missing = { (path: String) in PluginPackageStoreError.languageServerLaunchMissing(descriptor.id, path) } + guard let launchPlanner else { throw missing("runtime") } + let unused = fileManager.temporaryDirectory.appendingPathComponent("lithe-language-validation", isDirectory: true) + let plan: DeclarativeLanguageServerLaunchPlan + do { + plan = try launchPlanner.languageServerLaunchPlan( + manifest: descriptor.normalizedManifest, + languageID: languageID, + request: DeclarativeLanguageServerLaunchRequest( + pluginDirectory: root, + stateDirectory: unused.appendingPathComponent("state", isDirectory: true), + workspaceStateDirectory: unused.appendingPathComponent("workspace", isDirectory: true), + architecture: MacPluginLanguageServerLaunchResolver.hostArchitecture, + settings: [:] + ) + ) + } catch let rejection as DeclarativePluginRejection { + throw PluginPackageStoreError.declarationRejected(descriptor.id, rejection) + } + try validatePlannedFiles(plan, at: root, missing: missing) + } + /// Expands the backend launch with a throwaway state directory and /// checks every package path it points at, like a provider's. private func validateBackendLaunch(of descriptor: DeclarativePluginDescriptor, at root: URL) throws { @@ -852,7 +892,8 @@ final class MacPluginPackageStore { private func validateOfficialTrustPolicy(_ manifest: PluginManifest) throws { guard let trusted = OfficialPluginCatalog.entry(for: manifest.id) else { return } - guard manifest.vendor.signatureRequirement == trusted.signatureRequirement else { + guard manifest.vendor.signatureRequirement == trusted.signatureRequirement, + manifest.vendor.bundledSignatureRequirement == trusted.bundledSignatureRequirement else { throw PluginPackageStoreError.officialSignatureRequirementMismatch(manifest.id) } } @@ -999,17 +1040,22 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { // packages trusted through the host installation, so a forged or // damaged document is never silently ignored. let signatureRequirement = Self.signatureRequirement(for: manifest) - if signatureRequirement == .publisherPackage || Self.hasPackageSignature(at: packageURL) { + let hostBundlePath = hostBundleURL.standardizedFileURL.resolvingSymlinksInPath().path + "/" + let packagePath = packageURL.standardizedFileURL.resolvingSymlinksInPath().path + let usesHostSeal = origin == .bundled + && packagePath.hasPrefix(hostBundlePath) + && manifest.vendor.bundledSignatureRequirement == .sameTeamAsHost + && OfficialPluginCatalog.entry(for: manifest.id)?.bundledSignatureRequirement == .sameTeamAsHost + if (signatureRequirement == .publisherPackage && !usesHostSeal) || Self.hasPackageSignature(at: packageURL) { try verifyPublisherPackageSignature(packageAt: packageURL, manifest: manifest) } + if usesHostSeal { return } if signatureRequirement == .publisherPackage { return } // `sameTeamAsHost` has no code signature to compare any more: the // package is trusted only inside the host bundle, whose code signature // seals every resource it contains. - let hostBundlePath = hostBundleURL.standardizedFileURL.resolvingSymlinksInPath().path + "/" - let packagePath = packageURL.standardizedFileURL.resolvingSymlinksInPath().path guard origin == .bundled, packagePath.hasPrefix(hostBundlePath) else { throw PluginPackageStoreError.hostSealRequired(manifest.id) } diff --git a/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift index a2aced93ee..e635255745 100644 --- a/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift @@ -122,7 +122,8 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable sourcePath: java?.source, mavenReactorPath: maven?.reactorPath, debugAdapter: value.debug?.adapter, - disabled: value.disabled + disabled: value.disabled, + buildProvider: value.extensions?.build ), options: RunOptions( javaHomePath: java?.homePath ?? "", @@ -319,6 +320,22 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable } } + func saveBuildProviderConfiguration(_ reference: BuildProviderRunReference, name: String, at projectURL: URL) throws -> String { + let root = projectURL.standardizedFileURL + let url = root.appendingPathComponent(".lithe/run/local.json") + let baseline = storage.fileExists(at: url) ? try storage.readData(from: url, options: []) : nil + let result = try core.createBuildProviderRunConfiguration(at: root, reference: reference, name: name).get() + guard let id = result.id, let data = result.document.data(using: .utf8) else { throw MacRunConfigurationStoreError.generationFailed } + try ensureLocalRunIgnored(at: root) + let current = storage.fileExists(at: url) ? try storage.readData(from: url, options: []) : nil + guard current == baseline else { + throw MacRunConfigurationStoreError.invalidConfiguration(L10n.string("Run configuration changed while saving. Reload and try again.")) + } + try writeMutation(RunConfigurationDocumentMutation(configurationID: id, document: data), + to: root.appendingPathComponent(".lithe/run/local.json"), root: root) + return id + } + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { let root = projectURL.standardizedFileURL let fileName = draft.scope == .local ? "local.json" : "configurations.json" diff --git a/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunLaunchEnvironment.swift b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunLaunchEnvironment.swift new file mode 100644 index 0000000000..46a5e68ca6 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunLaunchEnvironment.swift @@ -0,0 +1,41 @@ +import Foundation + +extension RustCoreBridge: RunLaunchEnvironmentResolving { + private struct LaunchEnvironmentPayload: Encodable { + let toolchain: String? + let javaHome: String? + let mavenJavaHome: String? + let inherited: [String: String] + let overrides: [String: String] + } + + private struct LaunchEnvironmentResult: Decodable { + let environment: [String: String] + } + + func resolveLaunchEnvironment( + toolchain: String?, selectedHome: String?, + inherited: [String: String], overrides: [String: String] + ) throws -> [String: String] { + let result: LaunchEnvironmentResult = try executeResult( + command: "execution.resolveEnvironment", + payload: LaunchEnvironmentPayload( + toolchain: toolchain, javaHome: selectedHome, mavenJavaHome: selectedHome, + inherited: inherited, overrides: overrides + ) + ).get() + return result.environment + } +} + +@MainActor +extension RunExecutableResolver { + convenience init( + runtimeService: ProjectRuntimeService, + toolchainRegistry: RunToolchainRegistry? = nil, + metadataResolver: (any RunToolchainMetadataResolving)? = nil + ) { + self.init(runtimeService: runtimeService, toolchainRegistry: toolchainRegistry, + metadataResolver: metadataResolver, environmentResolver: RustCoreBridge()) + } +} diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift index 7013754ba2..ac04e3547f 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift @@ -4,6 +4,7 @@ import LitheCoreContracts enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable { case unsupportedProvider(String) + case providerDebugUnavailable case javaLaunchTargetUnavailable case invalidJavaAttachHost case invalidJavaAttachPort @@ -18,6 +19,8 @@ enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable { switch self { case .unsupportedProvider(let provider): return L10n.string("The \(provider) Debug Adapter is not installed yet.") + case .providerDebugUnavailable: + return L10n.string("The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.") case .javaLaunchTargetUnavailable: return L10n.string("The Java language service could not resolve a main class for this file.") case .invalidJavaAttachHost: @@ -71,11 +74,22 @@ struct DebugLaunchConfigurationResolver { /// when no adapter serves the language. private let pluginLaunch: (@MainActor (_ languageID: String, _ documentURL: URL, _ workspaceURL: URL) throws -> DebugLaunchConfiguration?)? + private let providerLaunch: (@MainActor (BuildSystemPluginSource, BuildDebugPreparation, URL, String) throws -> DebugLaunchConfiguration)? + + @MainActor + func resolveProvider(source: BuildSystemPluginSource, preparation: BuildDebugPreparation, + workspaceURL: URL, title: String) throws -> DebugLaunchConfiguration { + guard let providerLaunch else { throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable } + return try providerLaunch(source, preparation, workspaceURL, title) + } + init( javaTestLaunchResolver: (any JavaTestDebugLaunchResolving)? = nil, + providerLaunch: (@MainActor (BuildSystemPluginSource, BuildDebugPreparation, URL, String) throws -> DebugLaunchConfiguration)? = nil, javaExecutablePath: (@MainActor (DebugJavaRuntime) -> String?)? = nil, pluginLaunch: (@MainActor (String, URL, URL) throws -> DebugLaunchConfiguration?)? = nil ) { + self.providerLaunch = providerLaunch self.pluginLaunch = pluginLaunch self.javaTestLaunchResolver = javaTestLaunchResolver self.javaExecutablePath = javaExecutablePath @@ -116,7 +130,9 @@ struct DebugLaunchConfigurationResolver { name: configuration.name, request: configuration.request, arguments: arguments, - steppingFilters: configuration.steppingFilters + steppingFilters: configuration.steppingFilters, + adapterProviderID: configuration.adapterProviderID, + adapterEndpoint: configuration.adapterEndpoint ) } diff --git a/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift b/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift index 2f22d2c4ef..610abffd95 100644 --- a/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift +++ b/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift @@ -2,6 +2,7 @@ import Foundation @MainActor final class RunExecutableResolver: RunExecutableResolving { + private let environmentResolver: any RunLaunchEnvironmentResolving private let runtimeService: ProjectRuntimeService private let toolchainRegistry: RunToolchainRegistry private let metadataResolver: (any RunToolchainMetadataResolving)? @@ -10,8 +11,10 @@ final class RunExecutableResolver: RunExecutableResolving { init( runtimeService: ProjectRuntimeService, toolchainRegistry: RunToolchainRegistry? = nil, - metadataResolver: (any RunToolchainMetadataResolving)? = nil + metadataResolver: (any RunToolchainMetadataResolving)? = nil, + environmentResolver: any RunLaunchEnvironmentResolving ) { + self.environmentResolver = environmentResolver self.runtimeService = runtimeService self.toolchainRegistry = toolchainRegistry ?? .standard() self.metadataResolver = metadataResolver @@ -38,7 +41,7 @@ final class RunExecutableResolver: RunExecutableResolving { ) resolved = ResolvedRunExecutable( executableURL: toolchain.executableURL, - environment: toolchain.environment.merging(planEnvironment) { _, override in override } + environment: toolchain.environment ) case .command(let command): @@ -49,14 +52,20 @@ final class RunExecutableResolver: RunExecutableResolving { } resolved = ResolvedRunExecutable( executableURL: executable, - environment: runtimeService.processEnvironment(overrides: planEnvironment) + environment: runtimeService.processEnvironment() ) } + let identifier: String? + if case .toolchain(let id) = plan.executable { identifier = id } else { identifier = nil } + let environment = try environmentResolver.resolveLaunchEnvironment( + toolchain: identifier, selectedHome: resolved.environment["JAVA_HOME"], + inherited: resolved.environment, overrides: planEnvironment + ) return ResolvedRunExecutable( executableURL: resolved.executableURL, environment: RunLaunchEnvironment.augmenting( - resolved.environment, + environment, executableURL: resolved.executableURL, nodeCandidates: runtimeService.executableCandidates("node"), projectURL: projectURL, diff --git a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift index ed7312dd7a..83b7b491b2 100644 --- a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift +++ b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift @@ -81,7 +81,11 @@ struct BuildSystemToolWindowView: View { .onChange(of: rootNodeIDs) { _ in expandNewRoots() } .onChange(of: entries.map(\.model)) { _ in clearSelection(); resetOptionEditor() } .onChange(of: entries.map(\.id)) { _ in clearSelection(); resetOptionEditor() } + .onChange(of: entries.map { feature.testDiscovery($0.id) }) { _ in clearSelection() } .onChange(of: feature.workspaceURL) { _ in clearSelection(); optionEntryID = nil; resetOptionEditor() } + .onReceive(feature.applicationLaunched) { entryID in + if entries.contains(where: { $0.id == entryID }) { model.showToolWindow(.run) } + } .sheet(item: $argumentsPrompt) { prompt in BuildArgumentsSheet(prompt: prompt) { arguments in argumentsPrompt = nil @@ -499,6 +503,9 @@ struct BuildSystemToolWindowView: View { iconPath: "expui/nodes/testGroup.svg", entryID: entry.id, project: project, runnable: tests ) } + if entry.declaration.testDiscovery, project == nil || isRoot { + discoveredTestsNode(entry, ownerID: ownerID) + } if let scope = dependencyScope(entry, project: project, isRoot: isRoot) { dependencyNode(entry, project: scope.project, ownerID: ownerID) } @@ -514,12 +521,25 @@ struct BuildSystemToolWindowView: View { !$0.fromModel && $0.scopes.contains(.target) && Self.matches(target, tags: $0.targetTags) } let testable = declaration.testScopes.contains(.target) && Self.matches(target, tags: declaration.testTargetTags) - if !tasks.isEmpty || testable { + let runs = declaration.providerRuns.filter { Self.matches(target, tags: $0.targetTags) } + if !tasks.isEmpty || testable || !runs.isEmpty { treeNode( id: nodeID, title: target.name, subtitle: target.tags.isEmpty ? nil : target.tags.joined(separator: ", "), iconPath: "expui/nodes/runnableMark.svg" ) { + for run in runs { + leafRow(id: nodeID + ":run:" + run.id, title: run.displayName, help: nil, + iconPath: "expui/run/run.svg", entryID: entry.id, project: project, + runnable: .application(BuildRunRequest(run: run.id, projectID: project.id, targetID: target.id), + title: Self.title(run.displayName, scope: target.name)), target: target) + } + for run in runs where run.debugAdapter != nil { + leafRow(id: nodeID + ":debug:" + run.id, title: Self.title(L10n.string("Debug"), scope: run.displayName), help: nil, + iconPath: "expui/run/debug.svg", entryID: entry.id, project: project, + runnable: .debugApplication(BuildRunRequest(run: run.id, projectID: project.id, targetID: target.id), + title: Self.title(run.displayName, scope: target.name)), target: target) + } if testable { leafRow( id: nodeID + ":tests", title: L10n.string("Run Tests"), help: nil, @@ -690,6 +710,44 @@ struct BuildSystemToolWindowView: View { ) } + private func discoveredTestsNode(_ entry: BuildSystemEntry, ownerID: String) -> LitheTreeListNode { + let nodeID = ownerID + ":discovered-tests" + let state = feature.testDiscovery(entry.id) + let discover = { feature.discoverTests(entry.id) } + return treeNode(id: nodeID, title: L10n.string("Discovered Tests"), iconPath: "expui/nodes/testGroup.svg", + isLoading: state == .loading, onToggle: { + let opening = !expandedNodeIDs.contains(nodeID) + toggleNode(nodeID) + if opening, state == nil, !feature.isRunning { discover() } + }, menu: { + [.action("Discover Tests", isEnabled: !feature.isRunning, action: discover)] + }) { + switch state { + case .loaded(let discovery): + if discovery.tests.isEmpty { + LitheTreeListNode(id: nodeID + ":empty", isSelectable: false) { Text("No tests discovered.") } + } + for test in discovery.tests { + let title = [test.project?.path, test.target?.name, test.suite, test.testCase] + .compactMap { $0 }.joined(separator: " · ") + leafRow(id: nodeID + ":" + test.id, title: title, + help: test.project?.path, iconPath: "expui/nodes/testGroup.svg", entryID: entry.id, project: nil, + runnable: .discoveredTest(test.id, title: title)) + } + case .failed(let message): + LitheTreeListNode(id: nodeID + ":failed", isSelectable: false) { + Text(verbatim: message).foregroundStyle(LitheTheme.warning) + } + actionRow(id: nodeID + ":retry", title: L10n.string("Discover Tests"), + iconPath: "expui/general/refresh.svg") { if !feature.isRunning { discover() } } + case .loading: [] + case nil: + actionRow(id: nodeID + ":load", title: L10n.string("Discover Tests"), + iconPath: "expui/general/refresh.svg") { if !feature.isRunning { discover() } } + } + } + } + // MARK: Dependencies private struct DependencyScope { let project: BuildModel.Project? } @@ -886,6 +944,9 @@ struct BuildSystemToolWindowView: View { private enum Runnable: Equatable { case task(BuildTaskRequest, title: String, needsArguments: Bool) case tests(BuildTestRequest, title: String) + case discoveredTest(String, title: String) + case application(BuildRunRequest, title: String) + case debugApplication(BuildRunRequest, title: String) } private struct Selection: Equatable { @@ -914,6 +975,12 @@ struct BuildSystemToolWindowView: View { feature.runTask(entryID, request: request, title: title) case .tests(let request, let title): feature.runTests(entryID, request: request, title: title) + case .debugApplication(let request, let title): + model.debugBuildProviderApplication(entryID: entryID, request: request, title: title) + case .application(let request, let title): + model.runBuildProviderApplication(entryID: entryID, request: request, title: title) + case .discoveredTest(let testID, let title): + feature.runDiscoveredTest(entryID, testID: testID, title: title) } model.showToolWindow(.buildOutput) } diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 24b93f2f93..6f8c85fbf1 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -39,6 +39,9 @@ struct RunView: View { Text(message).font(.caption).textSelection(.enabled).padding(8) } + if feature.hasBuildProviderRun, let error = feature.configurationSaveError { + LitheBanner(status: .warning, layout: .editor) { Text(verbatim: error).textSelection(.enabled) } + } if !feature.portConflicts.isEmpty { portConflictBanner } @@ -47,9 +50,9 @@ struct RunView: View { configurationNoticeBanner(notice) } - if feature.configurationStatus != .ready { + if feature.configurationStatus != .ready && !feature.hasBuildProviderRun { configurationSetupView - } else if !hasRunnableConfigurations { + } else if !hasRunnableConfigurations || (feature.hasBuildProviderRun && selectedSessionID == nil) { OutputTextView( output: feature.output, searchRoots: feature.sourceSearchRoots, @@ -191,6 +194,13 @@ struct RunView: View { } private var configurationNotice: (title: String, message: String, systemImage: String)? { + if feature.configurationDiagnostics.contains(where: { $0.code == "incompleteProjectScan" }) { + return ( + runText("Project scan is incomplete"), + runText("Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations."), + "exclamationmark.triangle.fill" + ) + } if let diagnostic = feature.configurationDiagnostics.first(where: { $0.code == "staleFingerprint" }) { return ( runText("Run configurations may be out of date"), @@ -328,11 +338,11 @@ struct RunView: View { LitheContextMenuItem.separator LitheContextMenuItem.action( "Clear run output", - isEnabled: selectedSessionID == nil || selectedModuleSession != nil + isEnabled: (primaryOutputSelected && primaryRunMatchesSelection) || selectedModuleSession != nil ) { if let session = selectedModuleSession { feature.clearModuleOutput(session) - } else if selectedSessionID == nil { + } else if primaryOutputSelected && primaryRunMatchesSelection { feature.clearOutput() } } @@ -422,6 +432,16 @@ struct RunView: View { .accessibilityLabel(selectedSessionHasRun ? "Restart run" : "Run configuration") .disabled(feature.isLoadingProject) + if feature.hasBuildProviderRun && primaryOutputSelected && primaryRunMatchesSelection { + Button { feature.saveBuildProviderConfiguration() } label: { + Image(systemName: "square.and.arrow.down") + } + .buttonStyle(runActionButtonStyle) + .help("Save provider run configuration to .lithe/run/local.json") + .accessibilityLabel("Save provider run configuration to .lithe/run/local.json") + .disabled(feature.isLoadingProject) + } + Button { if let session = selectedModuleSession { feature.stopModule(session) @@ -531,25 +551,34 @@ struct RunView: View { return runnableConfigurations.first(where: { $0.id == selectedSessionID }) } + private var primaryRunMatchesSelection: Bool { + selectedRunnableConfiguration?.buildProvider == nil + || selectedRunnableConfiguration?.id == feature.lastConfiguration?.id + } + + private var primaryOutputSelected: Bool { + selectedSessionID == nil || selectedRunnableConfiguration?.buildProvider != nil + } + private var selectedSessionHasRun: Bool { - selectedSessionID == nil - ? feature.isRunning || feature.lastExitCode != nil + primaryOutputSelected + ? primaryRunMatchesSelection && (feature.isRunning || feature.lastExitCode != nil || feature.hasBuildProviderRun) : selectedModuleSession != nil } private var selectedSessionIsRunning: Bool { - guard selectedSessionID != nil else { return feature.isRunning } + guard !primaryOutputSelected else { return primaryRunMatchesSelection && feature.isRunning } return selectedModuleSession?.isRunning ?? false } private var selectedOutput: String { - guard selectedSessionID != nil else { return feature.output } + guard !primaryOutputSelected else { return primaryRunMatchesSelection ? feature.output : "" } return selectedModuleSession?.output ?? "" } @ViewBuilder private var selectedConfigurationContent: some View { - if let configuration = selectedRunnableConfiguration { + if let configuration = selectedRunnableConfiguration, configuration.buildProvider == nil { configurationContent(configuration) } else { OutputTextView( diff --git a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift index 3e7eec8d02..dbbf5d350a 100644 --- a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift +++ b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift @@ -60,6 +60,20 @@ struct NewProjectView: View { get: { if case .generator(let id) = feature.selection { return "generator:" + id }; return "empty" }, set: { _ in })) Rectangle().fill(LitheTheme.divider).frame(height: 1) + if feature.isJavaSupportMissing { + Button { + feature.openMarketplace(searching: "Java Support") + } label: { + Text("Install Java Support for Java, Maven and Spring templates…") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.link) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.litheNoPress) + .lithePointer() + .padding(.horizontal, 14) + .padding(.vertical, 10) + } Button { feature.openMarketplace(searching: "") } label: { diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 57761ce2e5..bac5f2f713 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -48,7 +48,7 @@ public enum DebugAdapterState: String, Equatable, Sendable { case idle, initializing, ready, launching, running, paused, terminated, failed } -public enum DebugRequestKind: String, Codable, Equatable, Sendable { +public enum DebugRequestKind: String, Codable, Hashable, Sendable { case launch, attach } @@ -69,6 +69,10 @@ public struct DebugLaunchConfiguration: Codable, Equatable, Sendable { public let request: DebugRequestKind public let arguments: [String: ToolingJSONValue] public let steppingFilters: DebugSteppingFilters? + /// Host-selected Registry identity (`pluginID/adapterID` for plugins). + /// A missing identity selects by source file; an unavailable explicit + /// identity must fail rather than select another adapter for that file. + public let adapterProviderID: String? /// An already running DAP server; absent means the provider's normal transport. public let adapterEndpoint: DebugAdapterEndpoint? @@ -77,12 +81,14 @@ public struct DebugLaunchConfiguration: Codable, Equatable, Sendable { request: DebugRequestKind, arguments: [String: ToolingJSONValue], steppingFilters: DebugSteppingFilters? = nil, + adapterProviderID: String? = nil, adapterEndpoint: DebugAdapterEndpoint? = nil ) { self.name = name self.request = request self.arguments = arguments self.steppingFilters = steppingFilters + self.adapterProviderID = adapterProviderID self.adapterEndpoint = adapterEndpoint } @@ -92,6 +98,7 @@ public struct DebugLaunchConfiguration: Codable, Equatable, Sendable { request: request, arguments: arguments, steppingFilters: steppingFilters, + adapterProviderID: adapterProviderID, adapterEndpoint: adapterEndpoint ) } diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift index b23134fb4f..493fe03ddf 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift @@ -10,22 +10,34 @@ public struct DebugProviderDescriptor: Identifiable, Hashable, Sendable { public let fileExtensions: Set public let fileNames: Set public let fileNamePrefixes: Set + /// Qualified plugin entries are explicit selections, not competing file + /// defaults. Their file patterns still identify supported breakpoints. + public let matchesAllSources: Bool + public let selectsByFile: Bool + public let requests: Set public init( id: String, displayName: String, fileExtensions: Set, fileNames: Set = [], - fileNamePrefixes: Set = [] + fileNamePrefixes: Set = [], + selectsByFile: Bool = true, + matchesAllSources: Bool = false, + requests: Set = [.launch, .attach] ) { self.id = id self.displayName = displayName self.fileExtensions = fileExtensions self.fileNames = fileNames self.fileNamePrefixes = fileNamePrefixes + self.matchesAllSources = matchesAllSources + self.selectsByFile = selectsByFile + self.requests = requests } public func matches(_ fileURL: URL) -> Bool { + if matchesAllSources { return true } let name = fileURL.lastPathComponent.lowercased() if fileNames.contains(name) { return true } if fileNamePrefixes.contains(where: name.hasPrefix) { return true } diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift index 84f5384aa5..75f8c48c57 100644 --- a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift @@ -12,7 +12,7 @@ public enum BuildScope: String, Codable, Hashable, Sendable { /// Tool-window grouping of a task. Unknown groups from a newer Core read as /// `other`. -public enum BuildTaskGroup: String, Hashable, Sendable, CaseIterable, Decodable { +public enum BuildTaskGroup: String, Hashable, Sendable, CaseIterable, Codable { case lifecycle, build, test, run, verification, documentation, other public init(from decoder: Decoder) throws { @@ -102,6 +102,25 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { public let testScopes: [BuildScope] /// Tags a target must carry to run its tests. public let testTargetTags: [String] + public struct ProviderRun: Equatable, Identifiable, Sendable { + public let id: String + public let displayName: String + public let targetTags: [String] + public let debugAdapter: String? + public init(id: String, displayName: String, targetTags: [String], debugAdapter: String? = nil) { + self.id = id; self.displayName = displayName; self.targetTags = targetTags; self.debugAdapter = debugAdapter + } + } + /// Optional public producer identities; never installation dependencies or private function access. + public struct ModelInput: Equatable, Sendable, Codable { + public let pluginID: String + public let buildSystemID: String + public init(pluginID: String, buildSystemID: String) { self.pluginID = pluginID; self.buildSystemID = buildSystemID } + } + public let sharesModel: Bool + public let modelInputs: [ModelInput] + public let providerRuns: [ProviderRun] + public let testDiscovery: Bool /// Empty when the build system lists no dependencies. public let dependencyScopes: [BuildScope] /// The build system declares a provider launch. Installation verifies @@ -111,12 +130,15 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { public init( id: String, displayName: String, icon: PluginUIIcon? = nil, modelSource: ModelSource?, options: [Option] = [], tasks: [Task], testScopes: [BuildScope], testTargetTags: [String] = [], - dependencyScopes: [BuildScope], declaresProvider: Bool = false + dependencyScopes: [BuildScope], declaresProvider: Bool = false, testDiscovery: Bool = false, providerRuns: [ProviderRun] = [], sharesModel: Bool = false, modelInputs: [ModelInput] = [] ) { self.id = id; self.displayName = displayName; self.icon = icon; self.modelSource = modelSource self.options = options; self.tasks = tasks self.testScopes = testScopes; self.testTargetTags = testTargetTags; self.dependencyScopes = dependencyScopes self.declaresProvider = declaresProvider + self.testDiscovery = testDiscovery + self.sharesModel = sharesModel; self.modelInputs = modelInputs + self.providerRuns = providerRuns } public func option(_ id: String) -> Option? { @@ -281,8 +303,8 @@ package struct BuildDetection: Equatable, Sendable, Decodable { } /// The normalized project model of one instance. Paths are workspace-relative. -package struct BuildModel: Equatable, Sendable, Decodable { - package struct Project: Equatable, Identifiable, Sendable, Decodable { +package struct BuildModel: Equatable, Sendable, Codable { + package struct Project: Equatable, Identifiable, Sendable, Codable { package let id: String package let path: String package let name: String @@ -311,7 +333,7 @@ package struct BuildModel: Equatable, Sendable, Decodable { private enum CodingKeys: String, CodingKey { case id, path, name, displayName, parentID, properties } } - package struct Target: Equatable, Identifiable, Sendable, Decodable { + package struct Target: Equatable, Identifiable, Sendable, Codable { package let id: String package let projectID: String package let name: String @@ -332,7 +354,7 @@ package struct BuildModel: Equatable, Sendable, Decodable { private enum CodingKeys: String, CodingKey { case id, projectID, name, path, tags, properties } } - package struct Task: Equatable, Identifiable, Sendable, Decodable { + package struct Task: Equatable, Identifiable, Sendable, Codable { package let id: String package let projectID: String package let name: String @@ -380,7 +402,7 @@ package struct BuildModel: Equatable, Sendable, Decodable { } } - package struct ContentRoot: Equatable, Sendable, Decodable { + package struct ContentRoot: Equatable, Sendable, Codable { package let projectID: String package let path: String /// `source`, `testSource`, `generatedSource`, `excluded`, …. @@ -595,8 +617,27 @@ package protocol BuildOptionsStoring: AnyObject { // MARK: Plans +/// Current workspace facts from installed enabled producers. Core alone selects the public projection. +package struct BuildModelInputSource: Sendable { + package let manifest: Data + package let instances: [Instance] + package struct Instance: Encodable, Sendable { + package let buildSystemID: String + package let root: String + package let state: String + package let model: BuildModel? + package let options: [String: ToolingJSONValue] + package init(buildSystemID: String, root: String, state: String, model: BuildModel?, options: [String: ToolingJSONValue]) { + self.buildSystemID = buildSystemID; self.root = root; self.state = state; self.model = model; self.options = options + } + } + package init(manifest: Data, instances: [Instance]) { self.manifest = manifest; self.instances = instances } +} + /// Inputs every plan request shares. package struct BuildPlanContext: Equatable, Sendable { + /// Public snapshot selected by Core, absent for declarations without inputs. + package let modelInputs: ToolingJSONValue? /// Workspace-relative instance root. package let root: String package let options: [String: ToolingJSONValue] @@ -605,15 +646,16 @@ package struct BuildPlanContext: Equatable, Sendable { package init( root: String, options: [String: ToolingJSONValue], - pluginDirectory: URL? = nil, workspaceStateDirectory: URL? = nil + pluginDirectory: URL? = nil, workspaceStateDirectory: URL? = nil, modelInputs: ToolingJSONValue? = nil ) { + self.modelInputs = modelInputs self.root = root; self.options = options self.pluginDirectory = pluginDirectory; self.workspaceStateDirectory = workspaceStateDirectory } } /// A project, target, or model task a plan addresses. -package struct BuildSelection: Equatable, Sendable, Encodable { +package struct BuildSelection: Equatable, Sendable, Codable { package let name: String /// Workspace-relative directory. package let path: String? @@ -659,6 +701,70 @@ package struct BuildTaskRequest: Equatable, Sendable { } } +/// Model identities are resolved again immediately before preparing a launch. +package struct BuildRunRequest: Equatable, Sendable { + package let run: String + package let projectID: String + package let targetID: String + package var userArguments: [String] = [] + package init(run: String, projectID: String, targetID: String, userArguments: [String] = []) { + self.run = run; self.projectID = projectID; self.targetID = targetID; self.userArguments = userArguments + } +} + +/// A persisted selector. Every launch asks the current Provider to prepare again. +package struct BuildProviderRunReference: Codable, Hashable, Sendable { + package let pluginID: String + package let buildSystemID: String + package let root: String + package let run: String + package let projectID: String + package let targetID: String + package let userArguments: [String] + package init(pluginID: String, buildSystemID: String, root: String, run: String, + projectID: String, targetID: String, userArguments: [String] = []) { + self.pluginID = pluginID; self.buildSystemID = buildSystemID; self.root = root; self.run = run + self.projectID = projectID; self.targetID = targetID; self.userArguments = userArguments + } + package var request: BuildRunRequest { + BuildRunRequest(run: run, projectID: projectID, targetID: targetID, userArguments: userArguments) + } +} + +/// Only Core-validated, portable process data crosses into the Run host. +package struct BuildRunPreparation: Codable, Equatable, Sendable { + package struct Executable: Codable, Equatable, Sendable { + package enum Kind: String, Codable, Sendable { case command, toolchain } + package let kind: Kind + package let value: String + } + package let executable: Executable + package let arguments: [String] + package let workingDirectory: String + package let environment: [String: String] + package var launchPlan: SharedLaunchPlan { + SharedLaunchPlan(executable: executable.kind == .command ? .command(executable.value) : .toolchain(executable.value), + arguments: arguments, workingDirectory: workingDirectory, environment: environment) + } +} + +/// Core-validated native facts. The adapter comes from the selected declaration, +/// never from the backend's reply; the platform checks executable permissions. +package struct BuildDebugPreparation: Decodable, Equatable, Sendable { + package let root: String + package let adapter: String + package let program: String + package let arguments: [String] + package let workingDirectory: String + package let environment: [String: String] +} + +@MainActor +package protocol BuildRunLaunching: AnyObject { + func launchPreparedRun(_ plan: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration?) throws -> String + func stopPreparedRun(_ operationID: String) +} + package struct BuildTestRequest: Equatable, Sendable { package let project: BuildSelection? package let target: BuildSelection? @@ -678,6 +784,26 @@ package struct BuildTestRequest: Equatable, Sendable { } } +/// Exact, Core-validated selectors owned by a plugin's upstream test engine. +package struct BuildTestDiscovery: Decodable, Equatable, Sendable { + package struct Item: Decodable, Equatable, Identifiable, Sendable { + package let id: String + package let suite: String + package let testCase: String + package let project: BuildSelection? + package let target: BuildSelection? + + private enum CodingKeys: String, CodingKey { + case id, suite, testCase = "case", project, target + } + + package var request: BuildTestRequest { + BuildTestRequest(project: project, target: target, suite: suite, testCase: testCase) + } + } + package let tests: [Item] +} + /// An argv the host runs as Core expanded it. package struct BuildProcessPlan: Equatable, Sendable { package enum Output: String, Equatable, Sendable { @@ -747,7 +873,7 @@ package struct BuildProviderHints: Equatable, Sendable { } } -/// A model or dependency plan: a command, or a request for the plugin's +/// A task, model or dependency plan: a command, or a request for the plugin's /// Provider process. package enum BuildOutputPlan: Equatable, Sendable { case process(BuildProcessPlan) @@ -761,11 +887,11 @@ package struct BuildTestPlan: Equatable, Sendable { case hostIntegration } - package let process: BuildProcessPlan + package let execution: BuildOutputPlan package let results: Results? - package init(process: BuildProcessPlan, results: Results?) { - self.process = process; self.results = results + package init(execution: BuildOutputPlan, results: Results?) { + self.execution = execution; self.results = results } } @@ -919,6 +1045,7 @@ package enum BuildLanguageImport: Equatable, Sendable, Decodable { /// `DeclarativePluginRejection`. Detection, decoding, and fingerprints read /// workspace files, so callers keep them off the main actor. package protocol BuildSystemPlanning: Sendable { + func resolveModelInputs(_ target: BuildSystemTarget, revision: UInt64, sources: [BuildModelInputSource]) throws -> ToolingJSONValue func detect( workspaceURL: URL, sources: [BuildSystemPluginSource], @@ -947,9 +1074,21 @@ package protocol BuildSystemPlanning: Sendable { providerHints: BuildProviderHints ) throws -> BuildOutputPlan func taskPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTaskRequest) throws - -> BuildProcessPlan + -> BuildOutputPlan func testPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTestRequest) throws -> BuildTestPlan + func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest + func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, + result: ToolingJSONValue) throws -> BuildRunPreparation + func prepareDebugPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest + func decodeDebugPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, run: String, + result: ToolingJSONValue) throws -> BuildDebugPreparation + func testDiscoveryPlan(_ target: BuildSystemTarget, context: BuildPlanContext) throws -> BuildProviderRequest + func decodeTestDiscovery( + _ target: BuildSystemTarget, context: BuildPlanContext, result: ToolingJSONValue + ) throws -> BuildTestDiscovery func dependencyPlan( _ target: BuildSystemTarget, context: BuildPlanContext, @@ -983,6 +1122,25 @@ package protocol BuildSystemPlanning: Sendable { ) throws -> BuildSyncState } +/// Bounded execution progress of one provider request, independent of pages. +package struct BuildProviderProgress: Equatable, Sendable { + package let message: String? + package let percentage: Int? + package let output: String? + + package init(message: String? = nil, percentage: Int? = nil, output: String? = nil) { + self.message = message; self.percentage = percentage; self.output = output + } +} + +/// The completed tool outcome; nonzero is a build failure, not a transport failure. +package struct BuildProviderExecutionResult: Codable, Equatable, Sendable { + package let exitCode: Int32 + package let output: String + + package init(exitCode: Int32, output: String) { self.exitCode = exitCode; self.output = output } +} + // MARK: Provider processes /// The Provider process of one build system of one plugin in one workspace. @@ -1016,6 +1174,16 @@ package protocol BuildProviderRequesting: AnyObject { to target: BuildProviderTarget, timeout: Duration ) async throws -> ToolingJSONValue + /// Prepares portable launch data, retaining execution progress and cancellation. + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue + func prepareDebug(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue + /// Runs a typed provider task in the same workspace session. + func execute( + _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void + ) async throws -> BuildProviderExecutionResult /// Stops every process whose target `keep` rejects. func retainProviders(_ keep: (BuildProviderTarget) -> Bool) /// The plugin functions the target's process registers now, sorted. @@ -1034,6 +1202,10 @@ package protocol BuildProviderRequesting: AnyObject { } package extension BuildProviderRequesting { + func prepareDebug(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + throw PluginFunctionFailure(code: "plugin.build.unavailable", message: L10n.string("The build provider returned an invalid launch plan.")) + } func pluginFunctions(of _: BuildProviderTarget) async throws -> [String] { throw PluginFunctionFailure( code: "plugin.function.unavailable", diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index a7f14a676d..0afd066775 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -295,6 +295,7 @@ package struct ProjectToolchainSelection: Codable, Equatable, Sendable { } package protocol RunConfigurationOperations: Sendable { + func saveBuildProviderConfiguration(_ reference: BuildProviderRunReference, name: String, at projectURL: URL) throws -> String /// Saves project defaults without modifying any service or requiring generated configurations. func saveProjectToolchain(_ toolchain: ProjectToolchainSelection, at projectURL: URL) throws func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection @@ -354,6 +355,9 @@ package protocol RunConfigurationOperations: Sendable { } package extension RunConfigurationOperations { + func saveBuildProviderConfiguration(_: BuildProviderRunReference, name: String, at _: URL) throws -> String { + throw RunConfigurationOperationFailure(message: L10n.string("Saving build provider run configurations is unavailable.")) + } func inspect( at projectURL: URL, checkFingerprint _: Bool, diff --git a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift index 03d3fb8fcb..f7072ae3c3 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift @@ -158,8 +158,8 @@ package enum RunConfigurationKind: Hashable, Identifiable, Sendable { .workingDirectory, .arguments, .environment, .javaRuntime, .javaVMArguments, .mavenProfiles, .mavenSkipTests, .jdwpDebug ] - case .process: - return .process + case .process(let provider): + return provider == "build.provider" ? [] : .process } } @@ -245,6 +245,7 @@ package struct RunConfiguration: Identifiable, Hashable, Sendable { package let mavenReactorPath: String? package let debugAdapter: String? package let disabled: Bool + package let buildProvider: BuildProviderRunReference? package var usesCurrentEditorFile: Bool { kind == .currentFile } @@ -263,7 +264,8 @@ package struct RunConfiguration: Identifiable, Hashable, Sendable { sourcePath: String? = nil, mavenReactorPath: String? = nil, debugAdapter: String? = nil, - disabled: Bool = false + disabled: Bool = false, + buildProvider: BuildProviderRunReference? = nil ) { self.id = id self.name = name @@ -275,6 +277,7 @@ package struct RunConfiguration: Identifiable, Hashable, Sendable { self.mavenReactorPath = mavenReactorPath self.debugAdapter = debugAdapter self.disabled = disabled + self.buildProvider = buildProvider } package var systemImage: String { kind.systemImage } diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index d16dfe17ee..6e945d1404 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -698,6 +698,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { id: $0.id, displayName: $0.displayName, adapterID: $0.adapterID ?? $0.id, + requests: Set($0.requests.compactMap(DebugRequestKind.init(rawValue:))), languageIDs: $0.languageIDs, fileLaunchLanguageIDs: $0.fileLaunchLanguageIDs, consoleCommands: $0.consoleCommands, @@ -847,7 +848,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { vendor: PluginVendor( id: decoded.vendor.id, displayName: decoded.vendor.displayName, - signatureRequirement: decoded.vendor.signatureRequirement + signatureRequirement: decoded.vendor.signatureRequirement, + bundledSignatureRequirement: decoded.vendor.bundledSignatureRequirement ), entrypoint: .declarative, modules: modules, @@ -891,6 +893,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let id: String let displayName: String let signatureRequirement: PluginSignatureRequirement + let bundledSignatureRequirement: PluginSignatureRequirement? } struct Dependencies: Decodable { let managementFileNames: [String] @@ -1243,9 +1246,19 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let presentation: String? let icon: PluginUIIcon? } + struct ProviderRun: Decodable { + struct Debug: Decodable { let adapter: String } + let debug: Debug? + struct Targets: Decodable { let tags: [String] } + let id: String + let source: String? + let name: String? + let fromTargets: Targets? + } struct Test: Decodable { let scopes: [BuildScope] let targetTags: [String]? + let discovery: Bool? } let id: String @@ -1255,8 +1268,11 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let model: Source? let tasks: [Task] let test: Test? + let run: [ProviderRun] let dependencies: Source? let declaresProvider: Bool + let sharesModel: Bool + let modelInputs: [DeclarativeBuildSystem.ModelInput] init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -1267,12 +1283,15 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { model = try values.decodeIfPresent(Source.self, forKey: .model) tasks = try values.decodeIfPresent([Task].self, forKey: .tasks) ?? [] test = try values.decodeIfPresent(Test.self, forKey: .test) + run = try values.decodeIfPresent([ProviderRun].self, forKey: .run) ?? [] dependencies = try values.decodeIfPresent(Source.self, forKey: .dependencies) declaresProvider = values.contains(.provider) + sharesModel = values.contains(.modelSharing) + modelInputs = try values.decodeIfPresent([DeclarativeBuildSystem.ModelInput].self, forKey: .modelInputs) ?? [] } private enum CodingKeys: String, CodingKey { - case id, displayName, icon, options, model, tasks, test, dependencies, provider + case id, displayName, icon, options, model, tasks, test, dependencies, provider, run, modelSharing, modelInputs } func declaration() -> DeclarativeBuildSystem { @@ -1301,7 +1320,11 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { // A host integration lists the whole instance and each // project; the other sources declare their scopes. dependencyScopes: dependencies.map { $0.scopes ?? [.workspace, .project] } ?? [], - declaresProvider: declaresProvider + declaresProvider: declaresProvider, + testDiscovery: test?.discovery ?? false, + providerRuns: run.filter { $0.source == "provider" }.map { + .init(id: $0.id, displayName: $0.name ?? $0.id, targetTags: $0.fromTargets?.tags ?? [], debugAdapter: $0.debug?.adapter) + }, sharesModel: sharesModel, modelInputs: modelInputs ) } } @@ -1371,6 +1394,7 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { public let displayName: String /// The `adapterID` of the DAP `initialize` request. public let adapterID: String + public let requests: Set /// Every language a selector of the adapter names, whatever the request or /// target kind. It says which languages the plugin has a debugger for, so a /// debug-only declaration is recognised; it does not choose the adapter for @@ -1398,6 +1422,7 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { id: String, displayName: String, adapterID: String, + requests: Set = [.launch, .attach], languageIDs: [String], fileLaunchLanguageIDs: [String]? = nil, consoleCommands: [DebugConsoleCommand] = [], @@ -1408,6 +1433,7 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { self.id = id self.displayName = displayName self.adapterID = adapterID + self.requests = requests self.languageIDs = languageIDs self.fileLaunchLanguageIDs = fileLaunchLanguageIDs ?? languageIDs self.consoleCommands = consoleCommands @@ -1510,13 +1536,16 @@ public struct DeclarativeDebugStartRequest: Equatable, Sendable { /// The program the host found for a launch whose adapter binds `program` to /// `target.executable`; an absolute path the host has checked is runnable. public let executable: String? + public let arguments: [String]? + public let environment: [String: String]? public let registrationGeneration: Int public let currentRegistrationGeneration: Int public init( request: Request, targetKind: String, path: String? = nil, processID: Int? = nil, workingDirectory: String? = nil, executable: String? = nil, - registrationGeneration: Int, currentRegistrationGeneration: Int + registrationGeneration: Int, currentRegistrationGeneration: Int, + arguments: [String]? = nil, environment: [String: String]? = nil ) { self.request = request self.targetKind = targetKind @@ -1524,6 +1553,8 @@ public struct DeclarativeDebugStartRequest: Equatable, Sendable { self.processID = processID self.workingDirectory = workingDirectory self.executable = executable + self.arguments = arguments + self.environment = environment self.registrationGeneration = registrationGeneration self.currentRegistrationGeneration = currentRegistrationGeneration } diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index d313aff3e5..5bd518c947 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -311,6 +311,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu configuration: DebugLaunchConfiguration( name: request.configuration.name, request: request.configuration.request, arguments: arguments, steppingFilters: request.configuration.steppingFilters, + adapterProviderID: request.configuration.adapterProviderID, adapterEndpoint: request.configuration.adapterEndpoint)) { registerJavaUpdateTarget(target) } @@ -783,7 +784,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu serviceUpdateFailed = false lastStartRequest = request activeFileURL = request.fileURL - providerID = sessionsProviderID(for: fileURL) + providerID = configuration.adapterProviderID ?? sessionsProviderID(for: fileURL) targetTitle = configuration.name replaceOutput(with: "") debuggeeOutputNormalizer.reset() @@ -804,7 +805,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedFrameID = nil stoppedFrame = nil selectedFrame = nil - for index in breakpoints.indices where sessionsProviderID(for: breakpoints[index].fileURL) == providerID { + for index in breakpoints.indices where providerHandles(breakpoints[index].fileURL, providerID: providerID) { breakpoints[index].verified = false breakpoints[index].message = nil breakpoints[index].resolvedLine = nil @@ -812,7 +813,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu do { try synchronizeRequestedBreakpoints(for: providerID) if !dataBreakpoints.isEmpty { - try sessions.setDataBreakpoints(coreDataBreakpoints, for: request.fileURL) + try sessions.setDataBreakpoints(coreDataBreakpoints, for: request.fileURL, adapterProviderID: providerID) } let effectiveConfiguration: DebugLaunchConfiguration if providerID == "java", let javaSteppingFilters { @@ -859,7 +860,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu outputPresentation.flush() if let activeFileURL { dataBreakpoints.removeAll { !$0.canPersist } - try? sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL) + try? sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL, adapterProviderID: providerID) } if let activeSessionID { let children = sessions.childSessionIDs(of: activeSessionID) @@ -2206,6 +2207,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu sessions.provider(for: fileURL)?.id } + private func providerHandles(_ fileURL: URL, providerID: String?) -> Bool { + providerID.flatMap { sessions.provider(id: $0) }?.matches(fileURL) == true + } + private func consume(_ event: DebugAdapterEvent) { switch event { case .initialized: @@ -2579,7 +2584,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private func synchronizeRequestedBreakpoints(for providerID: String?) throws { guard let providerID else { return } for fileURL in requestedBreakpointsByFile.keys.sorted(by: { $0.path < $1.path }) - where sessionsProviderID(for: fileURL) == providerID { + where providerHandles(fileURL, providerID: providerID) { try sessions.setBreakpoints(effectiveBreakpoints(for: fileURL), in: fileURL) } } @@ -2621,7 +2626,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu condition: $0.condition ) }, - for: activeFileURL + for: activeFileURL, + adapterProviderID: providerID ) } catch { record(error) @@ -2640,7 +2646,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu hitCondition: $0.hitCondition ) }, - for: activeFileURL + for: activeFileURL, + adapterProviderID: providerID ) } catch { record(error) @@ -2663,7 +2670,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private func synchronizeDataBreakpoints() { guard let activeFileURL else { return } do { - try sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL) + try sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL, adapterProviderID: providerID) } catch { record(error) } diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift index 34cfdd9fcf..b204eadcb1 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift @@ -172,12 +172,22 @@ public final class DebugAdapterSessionManager: ObservableObject { public var activeSessionIDs: Set { Set(sessions.keys) } public func provider(for fileURL: URL) -> DebugProviderDescriptor? { - providersSource().first { $0.matches(fileURL) } + providersSource().first { $0.selectsByFile && $0.matches(fileURL) } + } + + /// Reads current registrations even when an older session is still alive. + public func provider(id: String) -> DebugProviderDescriptor? { + providersSource().first { $0.id == id } } @discardableResult - public func activate(for fileURL: URL, rootURL: URL) throws -> any DebugAdapterSession { - let providerID = try descriptor(for: fileURL).id + public func activate(for fileURL: URL, rootURL: URL, adapterProviderID: String? = nil) throws -> any DebugAdapterSession { + let descriptor = try descriptor(for: fileURL, adapterProviderID: adapterProviderID) + return try activate(descriptor: descriptor, rootURL: rootURL) + } + + private func activate(descriptor: DebugProviderDescriptor, rootURL: URL) throws -> any DebugAdapterSession { + let providerID = descriptor.id if let sessionID = activeSessionIDsByProvider[providerID], let managed = sessions[sessionID], managed.adapterEndpoint == nil, @@ -188,7 +198,7 @@ public final class DebugAdapterSessionManager: ObservableObject { if activeSessionIDsByProvider[providerID] != nil { stop(providerID: providerID) } - return try activateNew(for: fileURL, rootURL: rootURL).session + return try activateNew(descriptor: descriptor, rootURL: rootURL).session } /// Creates a new independent session without replacing another session for @@ -196,9 +206,16 @@ public final class DebugAdapterSessionManager: ObservableObject { /// latest session as their compatibility target. @discardableResult public func activateNew( - for fileURL: URL, rootURL: URL, adapterEndpoint: DebugAdapterEndpoint? = nil + for fileURL: URL, rootURL: URL, adapterProviderID: String? = nil, + adapterEndpoint: DebugAdapterEndpoint? = nil + ) throws -> DebugSessionHandle { + try activateNew(descriptor: descriptor(for: fileURL, adapterProviderID: adapterProviderID), + rootURL: rootURL, adapterEndpoint: adapterEndpoint) + } + + private func activateNew( + descriptor: DebugProviderDescriptor, rootURL: URL, adapterEndpoint: DebugAdapterEndpoint? = nil ) throws -> DebugSessionHandle { - let descriptor = try descriptor(for: fileURL) let normalizedRoot = rootURL.standardizedFileURL let candidate: (any DebugAdapterSession)? if let adapterEndpoint { @@ -249,14 +266,22 @@ public final class DebugAdapterSessionManager: ObservableObject { if configuration.adapterEndpoint != nil { return try launchNew(for: fileURL, rootURL: rootURL, configuration: configuration).session } - let session = try activate(for: fileURL, rootURL: rootURL) + let descriptor = try launchDescriptor(for: fileURL, configuration: configuration) + let session = try activate(descriptor: descriptor, rootURL: rootURL) + let sessionID = activeSessionIDsByProvider[descriptor.id] guard let controlling = session as? any DebugAdapterControllingSession else { + if let sessionID { stop(sessionID: sessionID) } throw DebugProviderError.capabilityUnavailable( - provider: provider(for: fileURL)?.displayName ?? fileURL.pathExtension, + provider: descriptor.displayName, capability: "DAP launch control" ) } - try controlling.launch(configuration) + do { + try controlling.launch(configuration) + } catch { + if let sessionID { stop(sessionID: sessionID) } + throw error + } return controlling } @@ -267,13 +292,13 @@ public final class DebugAdapterSessionManager: ObservableObject { rootURL: URL, configuration: DebugLaunchConfiguration ) throws -> (id: DebugSessionID, session: any DebugAdapterControllingSession) { - let handle = try activateNew( - for: fileURL, rootURL: rootURL, adapterEndpoint: configuration.adapterEndpoint - ) + let descriptor = try launchDescriptor(for: fileURL, configuration: configuration) + let handle = try activateNew(descriptor: descriptor, rootURL: rootURL, + adapterEndpoint: configuration.adapterEndpoint) guard let controlling = handle.session as? any DebugAdapterControllingSession else { stop(sessionID: handle.id) throw DebugProviderError.capabilityUnavailable( - provider: provider(for: fileURL)?.displayName ?? fileURL.pathExtension, + provider: descriptor.displayName, capability: "DAP launch control" ) } @@ -288,29 +313,32 @@ public final class DebugAdapterSessionManager: ObservableObject { } public func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) throws { - guard let descriptor = provider(for: fileURL) else { + // Two explicit adapters can debug the same language concurrently. Each + // receives source breakpoints, including those recorded before launch. + let matching = providersSource().filter { $0.matches(fileURL) } + guard !matching.isEmpty else { throw DebugProviderError.noProvider( fileExtension: fileURL.pathExtension.lowercased() ) } - var values = requestedBreakpoints[descriptor.id] ?? [:] - values[fileURL.standardizedFileURL] = breakpoints - requestedBreakpoints[descriptor.id] = values + for descriptor in matching { + var values = requestedBreakpoints[descriptor.id] ?? [:] + values[fileURL.standardizedFileURL] = breakpoints + requestedBreakpoints[descriptor.id] = values + } + let providerIDs = Set(matching.map(\.id)) sessions.values - .filter { $0.descriptor.id == descriptor.id } + .filter { providerIDs.contains($0.descriptor.id) } .compactMap { $0.session as? any DebugAdapterControllingSession } .forEach { $0.setBreakpoints(breakpoints, in: fileURL) } } public func setExceptionBreakpoints( _ breakpoints: [DebugExceptionBreakpoint], - for fileURL: URL + for fileURL: URL, + adapterProviderID: String? = nil ) throws { - guard let descriptor = provider(for: fileURL) else { - throw DebugProviderError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } + let descriptor = try descriptor(for: fileURL, adapterProviderID: adapterProviderID) requestedExceptionBreakpoints[descriptor.id] = breakpoints sessions.values .filter { $0.descriptor.id == descriptor.id } @@ -320,13 +348,10 @@ public final class DebugAdapterSessionManager: ObservableObject { public func setFunctionBreakpoints( _ breakpoints: [DebugFunctionBreakpoint], - for fileURL: URL + for fileURL: URL, + adapterProviderID: String? = nil ) throws { - guard let descriptor = provider(for: fileURL) else { - throw DebugProviderError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } + let descriptor = try descriptor(for: fileURL, adapterProviderID: adapterProviderID) requestedFunctionBreakpoints[descriptor.id] = breakpoints sessions.values .filter { $0.descriptor.id == descriptor.id } @@ -336,13 +361,10 @@ public final class DebugAdapterSessionManager: ObservableObject { public func setDataBreakpoints( _ breakpoints: [DebugDataBreakpoint], - for fileURL: URL + for fileURL: URL, + adapterProviderID: String? = nil ) throws { - guard let descriptor = provider(for: fileURL) else { - throw DebugProviderError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } + let descriptor = try descriptor(for: fileURL, adapterProviderID: adapterProviderID) requestedDataBreakpoints[descriptor.id] = breakpoints sessions.values .filter { $0.descriptor.id == descriptor.id } @@ -500,7 +522,13 @@ public final class DebugAdapterSessionManager: ObservableObject { } } - private func descriptor(for fileURL: URL) throws -> DebugProviderDescriptor { + private func descriptor(for fileURL: URL, adapterProviderID: String? = nil) throws -> DebugProviderDescriptor { + if let adapterProviderID { + guard let descriptor = provider(id: adapterProviderID) else { + throw DebugProviderError.adapterUnavailable(adapterProviderID) + } + return descriptor + } guard let descriptor = provider(for: fileURL) else { throw DebugProviderError.noProvider( fileExtension: fileURL.pathExtension.lowercased() @@ -509,6 +537,16 @@ public final class DebugAdapterSessionManager: ObservableObject { return descriptor } + private func launchDescriptor(for fileURL: URL, configuration: DebugLaunchConfiguration) throws -> DebugProviderDescriptor { + let descriptor = try descriptor(for: fileURL, adapterProviderID: configuration.adapterProviderID) + guard descriptor.requests.contains(configuration.request) else { + throw DebugProviderError.capabilityUnavailable( + provider: descriptor.displayName, capability: configuration.request.rawValue + ) + } + return descriptor + } + private func updateSessionState(_ sessionID: DebugSessionID, state: DebugAdapterState) { guard let index = sessionSummaries.firstIndex(where: { $0.id == sessionID }), let existing = sessions[sessionID] else { return } diff --git a/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift b/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift index a1408bc597..35ab49fe6b 100644 --- a/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift +++ b/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift @@ -30,6 +30,7 @@ package final class BuildSystemsFeatureModel: ObservableObject { package var isRunning: Bool { service.isRunning } package var currentRunID: UUID? { service.currentRunID } package var runEnded: AnyPublisher { service.runEnded.eraseToAnyPublisher() } + package var applicationLaunched: AnyPublisher { service.applicationLaunched.eraseToAnyPublisher() } package var hasActiveWork: Bool { service.hasActiveWork } package var hasBuildSystemPlugins: Bool { service.hasBuildSystemPlugins } package var workspaceURL: URL? { service.workspaceURL } @@ -68,6 +69,30 @@ package final class BuildSystemsFeatureModel: ObservableObject { package func runTests(_ entryID: String, request: BuildTestRequest, title: String) -> UUID? { service.runTests(entryID, request: request, title: title) } + package func cancelDebugPreparation() { service.cancelDebugPreparation() } + + @discardableResult + package func debugApplication(_ entryID: String, request: BuildRunRequest, title: String, + launch: @escaping @MainActor (BuildSystemPluginSource, BuildDebugPreparation) throws -> Void) -> UUID? { + service.debugApplication(entryID, request: request, title: title, launch: launch) + } + + @discardableResult + package func debugProviderConfiguration(_ configuration: RunConfiguration, workspace: URL, + launch: @escaping @MainActor (BuildSystemPluginSource, BuildDebugPreparation) throws -> Void) -> UUID? { + service.debugProviderConfiguration(configuration, workspace: workspace, launch: launch) + } + + @discardableResult + package func runApplication(_ entryID: String, request: BuildRunRequest, title: String, + beforePreparing: @escaping @MainActor () async -> Bool = { true }) -> UUID? { + service.runApplication(entryID, request: request, title: title, beforePreparing: beforePreparing) + } + package func testDiscovery(_ entryID: String) -> BuildTestDiscoveryState? { service.testDiscovery(entryID) } + package func discoverTests(_ entryID: String) { service.discoverTests(entryID) } + package func runDiscoveredTest(_ entryID: String, testID: String, title: String) { + service.runDiscoveredTest(entryID, testID: testID, title: title) + } package func loadDependencies( _ entryID: String, project: BuildModel.Project?, reason: BuildLoadReason = .initial ) { diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index cb6eb89b53..504f0ac28c 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -261,20 +261,23 @@ package final class RunFeatureModel: ObservableObject { package var lastRunFileURL: URL? { service.lastRunFileURL } package var lastConfiguration: RunConfiguration? { service.lastConfiguration } /// Project configurations share session identity across toolbar and log selection. - /// Current File alone uses the primary output stream. + /// Provider applications and Current File use the primary output stream. package var selectedProjectSessionID: String? { guard let configuration = selectedConfiguration, configuration.kind != .currentFile else { return nil } return configuration.id } package var isSelectedConfigurationRunning: Bool { guard let configuration = selectedConfiguration else { return false } - if configuration.kind != .currentFile { + if configuration.kind != .currentFile && configuration.buildProvider == nil { return moduleSessions.contains { $0.configurationID == configuration.id && $0.isRunning } } return isRunning && lastConfiguration?.id == configuration.id } package var isLoadingProject: Bool { service.isLoadingProject } - package var isRunning: Bool { service.isRunning } + package var isRunning: Bool { service.isRunning || service.isPreparingBuildRun } + package var hasBuildProviderRun: Bool { service.hasBuildProviderRun } + @discardableResult + package func saveBuildProviderConfiguration() -> Bool { service.saveBuildProviderConfiguration() } package var runningTitle: String? { service.runningTitle } package var output: String { service.output } package var lastExitCode: Int32? { service.lastExitCode } diff --git a/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift b/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift index 4cd9a5fde3..35ad2a6ff2 100644 --- a/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift +++ b/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift @@ -36,6 +36,13 @@ package final class ExecutionFeatureGraph: NSObject, ExecutionServiceGraph { run.configureMavenContextProvider { [weak maven] in maven?.launchContext } + run.configureBuildProviderRuns(start: { [weak buildSystems] configuration, workspace in + buildSystems?.runProviderConfiguration(configuration, workspace: workspace) + }, cancel: { [weak buildSystems] id in buildSystems?.cancelRun(id) }) + buildSystems?.runEnded.sink { [weak run] ended in + let message: String? = if case .failed(let message) = ended.state { message } else { nil } + run?.finishBuildPreparation(ended.runID, message: message) + }.store(in: &activityObservers) maven.onProjectReloaded = { [weak run] workspace, project in run?.acceptMavenProject(project, at: workspace) } diff --git a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift index f317401eef..edf43f89be 100644 --- a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +++ b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift @@ -18,6 +18,12 @@ package enum BuildDependencyState: Equatable, Sendable { case failed(String) } +package enum BuildTestDiscoveryState: Equatable, Sendable { + case loading + case loaded(BuildTestDiscovery) + case failed(String) +} + package enum BuildRunState: Equatable, Sendable { case idle case running @@ -105,6 +111,14 @@ package final class BuildSystemService: ObservableObject { @Published package private(set) var outputRoots: [URL] = [] @Published package private(set) var diagnostics: [BuildDiagnostic] = [] @Published package private(set) var testResults: BuildTestResults? + @Published private var testDiscoveries: [String: BuildTestDiscoveryState] = [:] + private var discoverySnapshots: [String: DiscoverySnapshot] = [:] + private struct DiscoverySnapshot: Equatable { + let source: BuildSystemPluginSource + let fingerprint: String? + let settings: [String: ToolingJSONValue] + let revision: Int + } package private(set) var workspaceURL: URL? /// Nil while detection is pending. An empty value means no plugin import. @Published package private(set) var languageImport: JavaLanguageImportConfiguration? @@ -120,6 +134,9 @@ package final class BuildSystemService: ObservableObject { private let stateLocator: any BuildSystemStateLocating private let runner: any BuildCommandRunning private let providers: (any BuildProviderRequesting)? + private let runLauncher: (any BuildRunLaunching)? + private var preparingDebugID: UUID? + private var preparedApplication: (source: BuildSystemPluginSource, operationID: String)? private let settings: @MainActor (String) -> [String: ToolingJSONValue] private let waitForRedetection: @Sendable () async throws -> Void private let sources: @MainActor () -> [BuildSystemPluginSource] @@ -132,10 +149,17 @@ package final class BuildSystemService: ObservableObject { /// revisions are separate: a dependency result must not replace a model /// the detection just read. private var modelRevisions: [String: Int] = [:] + /// A producer's options/package may change and change back while it loads. + /// Other producers' ordinary publications only revoke consumer snapshots. + private var modelConfigurationRevisions: [String: UInt64] = [:] private var dependencyRevisions: [String: Int] = [:] /// Bumped when a watched workspace file changes, so a load that started /// before the change can tell its result is based on older files. private var fileRevision = 0 + private var providerConfigurationRevision = 0 + private var modelInputRevision: UInt64 = 0 + private var providerRunID: UUID? + private var preparingApplicationID: UUID? private var detectionTask: Task? private var redetectTask: Task? private var runTask: Task? @@ -180,6 +204,7 @@ package final class BuildSystemService: ObservableObject { stateLocator: any BuildSystemStateLocating, runner: any BuildCommandRunning, providers: (any BuildProviderRequesting)? = nil, + runLauncher: (any BuildRunLaunching)? = nil, settings: @escaping @MainActor (String) -> [String: ToolingJSONValue] = { _ in [:] }, waitForRedetection: @escaping @Sendable () async throws -> Void = { try await Task.sleep(for: .milliseconds(500)) @@ -192,6 +217,7 @@ package final class BuildSystemService: ObservableObject { self.stateLocator = stateLocator self.runner = runner self.providers = providers + self.runLauncher = runLauncher self.settings = settings self.waitForRedetection = waitForRedetection self.sources = sources @@ -200,8 +226,9 @@ package final class BuildSystemService: ObservableObject { /// Every run that ends, stopped or superseded runs included, so a caller /// that started one can tell how its own run ended. package let runEnded = PassthroughSubject() + package let applicationLaunched = PassthroughSubject() - /// The task or test run in progress; nil when none runs. + /// The task, test, or application preparation (including save) in progress. package var currentRunID: UUID? { runID } package var isRunning: Bool { runState == .running } package var isLoadingModel: Bool { !modelTasks.isEmpty } @@ -219,11 +246,13 @@ package final class BuildSystemService: ObservableObject { package func reset(workspaceURL: URL?) { stop() generation += 1 + modelInputRevision += 1 detectionTask?.cancel(); detectionTask = nil redetectTask?.cancel(); redetectTask = nil self.workspaceURL = workspaceURL?.standardizedFileURL entries = [] modelRevisions = [:] + modelConfigurationRevisions = [:] dependencyRevisions = [:] fileRevision = 0 issues = [] @@ -290,6 +319,7 @@ package final class BuildSystemService: ObservableObject { switch result { case .success(let detected): let current = Dictionary(uniqueKeysWithValues: self.entries.map { ($0.id, $0) }) + let publishedBefore = self.entries.filter { $0.declaration.sharesModel } self.entries = detected.entries.map { detectedEntry in guard let currentEntry = current[detectedEntry.id], currentEntry.instance.pluginID == detectedEntry.instance.pluginID @@ -313,8 +343,10 @@ package final class BuildSystemService: ObservableObject { // detection just read. merged.dependencies = currentEntry.dependencies } + if !merged.declaration.modelInputs.isEmpty, currentEntry.isReloadRequired { merged.isReloadRequired = true } return merged } + if publishedBefore != self.entries.filter({ $0.declaration.sharesModel }) { self.invalidateModelInputs() } self.issues = detected.issues self.persistDroppedOptions() let inputs = self.entries.map { entry in @@ -371,9 +403,18 @@ package final class BuildSystemService: ObservableObject { /// Ends what a now disabled plugin started and detects again with the /// plugins enabled now, so its instances leave the tool window. package func pluginsChanged() { + for producer in entries where producer.declaration.sharesModel { + modelConfigurationRevisions[producer.id, default: 0] += 1 + } + invalidateModelInputs() + if preparingApplicationID != nil { stopRun() } + if let runEntryID, testDiscoveries[runEntryID] == .loading { stopRun() } + testDiscoveries = [:] + discoverySnapshots = [:] let enabled = Set(sources().map(\.pluginID)) let removed = Set(entries.filter { !enabled.contains($0.instance.pluginID) }.map(\.id)) if let runEntryID, removed.contains(runEntryID) { stopRun() } + if let preparedApplication, !sources().contains(preparedApplication.source) { stopPreparedApplication() } for key in Array(modelTasks.keys) where removed.contains(key) { modelTasks.removeValue(forKey: key)?.cancel() } @@ -394,15 +435,29 @@ package final class BuildSystemService: ObservableObject { // A load can read a subproject file before the model lists it, so any // reported change invalidates a model load that is in flight. fileRevision += 1 + if preparingApplicationID != nil { stopRun() } let root = workspaceURL.path.hasSuffix("/") ? workspaceURL.path : workspaceURL.path + "/" let changed = Set(urls.compactMap { url -> String? in let path = url.standardizedFileURL.path + if path == workspaceURL.path { return "." } return path.hasPrefix(root) ? String(path.dropFirst(root.count)) : nil }) + // Source edits change discovery even when no build configuration did. + if !changed.isEmpty { + if let runEntryID, testDiscoveries[runEntryID] == .loading { stopRun() } + testDiscoveries = [:] + discoverySnapshots = [:] + } let affected = entries.contains { entry in entry.sync?.watch.contains(where: changed.contains) == true } guard affected else { return } + for entry in entries where entry.declaration.sharesModel && entry.sync?.watch.contains(where: changed.contains) == true { + update(entry.id) { $0.isReloadRequired = true } + } + invalidateModelInputs() + providerConfigurationRevision += 1 + if providerRunID != nil { stopRun() } // Reject the old input immediately; consumers wait for the coalesced // detection instead of starting Java with pre-save import settings. detectionTask?.cancel(); detectionTask = nil @@ -526,6 +581,7 @@ package final class BuildSystemService: ObservableObject { /// may answer from its own snapshot. package func loadModel(_ entryID: String, reason: BuildLoadReason = .initial) { guard let workspaceURL, let entry = entry(entryID) else { return } + if preparingApplicationID != nil, runEntryID == entryID { stopRun() } switch entry.instance.modelSource { case .static?, .hostIntegration?: detect() @@ -536,7 +592,12 @@ package final class BuildSystemService: ObservableObject { break } modelTasks[entryID]?.cancel() - update(entryID) { $0.modelState = .loading } + update(entryID) { + $0.modelState = .loading + if entry.declaration.sharesModel { $0.isReloadRequired = $0.model != nil } + } + noteModelChanged(entryID) + let publicationRevision = entry.declaration.sharesModel ? modelConfigurationRevisions[entryID, default: 0] : nil let generation = generation let id = UUID() // The model command depends on the options; a change while it runs @@ -557,6 +618,8 @@ package final class BuildSystemService: ObservableObject { switch result { case .success(let loaded): let configChanged: Bool = { + // An exporter cannot make an ABA-configured in-flight model public. + if let publicationRevision, self.modelConfigurationRevisions[entryID, default: 0] != publicationRevision { return true } guard self.fileRevision == startFileRevision else { return true } guard let current = self.entry(entryID), let currentFingerprint = self.optionFingerprint(current, workspaceURL: workspaceURL) @@ -621,7 +684,7 @@ package final class BuildSystemService: ObservableObject { ) let target = entry.target let root = entry.instance.root - return .success(try await Task.detached(priority: .userInitiated) { + let loaded = try await Task.detached(priority: .userInitiated) { let model = try planner.decodeModel( target, workspaceURL: workspaceURL, root: root, output: output, options: context.options ) @@ -635,7 +698,9 @@ package final class BuildSystemService: ObservableObject { projects: model.projects.map(\.path), options: context.options ) ) - }.value) + }.value + guard modelInputsAreCurrent(context.modelInputs) else { throw CancellationError() } + return .success(loaded) } catch { return .failure(error) } @@ -643,6 +708,192 @@ package final class BuildSystemService: ObservableObject { // MARK: Tasks and tests + /// Saved references resolve against the currently enabled plugin and model. + @discardableResult + package func runProviderConfiguration(_ configuration: RunConfiguration, workspace: URL) -> UUID? { + guard workspace.standardizedFileURL == workspaceURL, let reference = configuration.buildProvider, + let entry = entries.first(where: { $0.instance.pluginID == reference.pluginID + && $0.instance.buildSystemID == reference.buildSystemID && $0.instance.root == reference.root }) else { return nil } + return runApplication(entry.id, request: reference.request, title: configuration.name, configuration: configuration) + } + + package func cancelRun(_ id: UUID) { if runID == id { stopRun() } } + + package func cancelDebugPreparation() { if let id = preparingDebugID { cancelRun(id) } } + + /// Debug owns the launched session; this service owns only preparation. + @discardableResult + package func debugApplication(_ entryID: String, request: BuildRunRequest, title: String, + launch: @escaping @MainActor (BuildSystemPluginSource, BuildDebugPreparation) throws -> Void) -> UUID? { + prepareApplication(entryID, request: request, title: title, debugLaunch: launch) + } + + @discardableResult + package func debugProviderConfiguration(_ configuration: RunConfiguration, workspace: URL, + launch: @escaping @MainActor (BuildSystemPluginSource, BuildDebugPreparation) throws -> Void) -> UUID? { + guard workspace.standardizedFileURL == workspaceURL, let reference = configuration.buildProvider, + let entry = entries.first(where: { $0.instance.pluginID == reference.pluginID + && $0.instance.buildSystemID == reference.buildSystemID && $0.instance.root == reference.root }) else { return nil } + return debugApplication(entry.id, request: reference.request, title: configuration.name, launch: launch) + } + + /// The provider owns build/launch facts; the Run host owns the application. + @discardableResult + package func runApplication(_ entryID: String, request: BuildRunRequest, title: String, configuration: RunConfiguration? = nil, + beforePreparing: @escaping @MainActor () async -> Bool = { true }) -> UUID? { + prepareApplication(entryID, request: request, title: title, configuration: configuration, beforePreparing: beforePreparing) + } + + private func prepareApplication(_ entryID: String, request: BuildRunRequest, title: String, + configuration: RunConfiguration? = nil, + beforePreparing: @escaping @MainActor () async -> Bool = { true }, + debugLaunch: (@MainActor (BuildSystemPluginSource, BuildDebugPreparation) throws -> Void)? = nil) -> UUID? { + guard let workspaceURL, let entry = entry(entryID), let model = entry.model, + !entry.isReloadRequired, entry.modelState == .loaded, + let project = model.projects.first(where: { $0.id == request.projectID }), + let target = model.targets.first(where: { $0.id == request.targetID && $0.projectID == project.id }), + entry.declaration.providerRuns.contains(where: { $0.id == request.run && (debugLaunch == nil || $0.debugAdapter != nil) }) else { return nil } + let origin = configuration ?? RunConfiguration(id: "build:" + entryID + ":" + request.run + ":" + request.targetID, + name: title, kind: .process(provider: "build.provider"), execution: .application, modulePath: nil, mainClass: nil, + buildProvider: BuildProviderRunReference(pluginID: entry.instance.pluginID, buildSystemID: entry.instance.buildSystemID, + root: entry.instance.root, run: request.run, projectID: request.projectID, targetID: request.targetID, + userArguments: request.userArguments)) + if debugLaunch == nil { stopPreparedApplication() } + let sourceRevision = fileRevision + return startRun(entryID, title: title) { service, run in + // Own the save/readiness stage in the same slot as preparation. + // An ignored cancellation must not startRun again and replace newer work. + service.providerRunID = run.id + service.preparingApplicationID = run.id + defer { if service.preparingApplicationID == run.id { service.preparingApplicationID = nil } } + defer { if service.providerRunID == run.id { service.providerRunID = nil } } + guard await beforePreparing(), service.isCurrent(run), + service.fileRevision == sourceRevision, + let current = service.entry(entryID), current.source == entry.source, + current.model == model, current.modelState == .loaded, !current.isReloadRequired + else { throw CancellationError() } + guard let providers = service.providers, debugLaunch != nil || service.runLauncher != nil else { + throw BuildSystemServiceError(Self.providerUnsupportedMessage) + } + let context = try service.planContext(entry, workspaceURL: workspaceURL) + let planned: BuildProviderRequest + if debugLaunch != nil { + planned = try service.planner.prepareDebugPlan(entry.target, context: context, run: request.run, + project: BuildSelection(project: project), selectedTarget: BuildSelection(target: target), userArguments: request.userArguments) + } else { + planned = try service.planner.prepareRunPlan(entry.target, context: context, run: request.run, + project: BuildSelection(project: project), selectedTarget: BuildSelection(target: target), userArguments: request.userArguments) + } + let validConfiguration = service.runValidity(.provider(planned), entry: entry, workspaceURL: workspaceURL, run: run) + let isValid: @MainActor @Sendable () -> Bool = { [weak service] in + guard let service else { return false } + return validConfiguration() && service.fileRevision == sourceRevision + && service.entry(entryID)?.model == model && service.entry(entryID)?.modelState == .loaded + && service.entry(entryID)?.isReloadRequired == false + } + if debugLaunch != nil { service.preparingDebugID = run.id } + defer { if service.preparingDebugID == run.id { service.preparingDebugID = nil } } + let resolved = try await service.providerRequestWithTool(planned, entry: entry, workspaceURL: workspaceURL, run: run) + guard isValid() else { throw CancellationError() } + let progress: @MainActor @Sendable (BuildProviderProgress) -> Void = { [weak service] progress in + guard let service, isValid() else { return } + if let output = progress.output { service.appendOutput(output, in: service.outputDirectory(entry)) } + else if let message = progress.message { service.appendOutput(message + "\n", in: service.outputDirectory(entry)) } + } + let providerTarget = Self.providerTarget(entry, workspaceURL: workspaceURL) + let result: ToolingJSONValue + if debugLaunch != nil { + result = try await providers.prepareDebug(resolved, on: providerTarget, timeout: .seconds(3600), progress: progress) + } else { + result = try await providers.prepareRun(resolved, on: providerTarget, timeout: .seconds(3600), progress: progress) + } + guard isValid() else { throw CancellationError() } + let planner = service.planner + if let debugLaunch { + let preparation = try await Task.detached(priority: .userInitiated) { + try planner.decodeDebugPreparation(entry.target, workspaceURL: workspaceURL, root: context.root, + run: request.run, result: result) + }.value + guard isValid() else { throw CancellationError() } + try debugLaunch(entry.source, preparation) + return 0 + } + let preparation = try await Task.detached(priority: .userInitiated) { + try planner.decodeRunPreparation(entry.target, workspaceURL: workspaceURL, root: context.root, result: result) + }.value + guard isValid() else { throw CancellationError() } + guard let launcher = service.runLauncher else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } + let operationID = try launcher.launchPreparedRun(preparation, workspaceURL: workspaceURL, title: title, configuration: origin) + service.preparedApplication = (entry.source, operationID) + service.applicationLaunched.send(entryID) + return 0 + } + } + + private func stopPreparedApplication() { + if let preparedApplication { runLauncher?.stopPreparedRun(preparedApplication.operationID) } + preparedApplication = nil + } + + /// Discovery is user-triggered and independent of a plugin page. Cached + /// selectors are visible/runnable only for their exact configuration. + package func testDiscovery(_ entryID: String) -> BuildTestDiscoveryState? { + guard let workspaceURL, let entry = entry(entryID), + discoverySnapshots[entryID] == discoverySnapshot(entry, workspaceURL: workspaceURL) + else { return nil } + return testDiscoveries[entryID] + } + + private func discoverySnapshot(_ entry: BuildSystemEntry, workspaceURL: URL) -> DiscoverySnapshot { + DiscoverySnapshot(source: entry.source, fingerprint: optionFingerprint(entry, workspaceURL: workspaceURL), + settings: settings(entry.source.pluginID), revision: providerConfigurationRevision) + } + + @discardableResult + package func discoverTests(_ entryID: String) -> UUID? { + guard let workspaceURL, let entry = entry(entryID), entry.declaration.testDiscovery else { return nil } + testDiscoveries[entryID] = nil + discoverySnapshots[entryID] = nil + return startRun(entryID, title: L10n.string("Discover Tests")) { service, run in + let context = try service.planContext(entry, workspaceURL: workspaceURL) + let request = try service.planner.testDiscoveryPlan(entry.target, context: context) + let isValid = service.runValidity(.provider(request), entry: entry, workspaceURL: workspaceURL, run: run) + service.providerRunID = run.id + service.discoverySnapshots[entryID] = service.discoverySnapshot(entry, workspaceURL: workspaceURL) + service.testDiscoveries[entryID] = .loading + defer { if service.providerRunID == run.id { service.providerRunID = nil } } + do { + guard let providers = service.providers else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } + let planned = try await service.providerRequestWithTool(request, entry: entry, workspaceURL: workspaceURL, run: run) + guard isValid() else { throw CancellationError() } + let result = try await providers.send(planned, to: Self.providerTarget(entry, workspaceURL: workspaceURL), + timeout: Self.modelTimeout) + guard isValid() else { throw CancellationError() } + let planner = service.planner + let discovery = try await Task.detached(priority: .userInitiated) { + try planner.decodeTestDiscovery(entry.target, context: context, result: result) + }.value + guard isValid() else { throw CancellationError() } + service.testDiscoveries[entryID] = .loaded(discovery) + return 0 + } catch { + if isValid() { service.testDiscoveries[entryID] = .failed(error.localizedDescription) } + else if service.runID == run.id { service.testDiscoveries[entryID] = nil } + throw error + } + } + } + + @discardableResult + package func runDiscoveredTest(_ entryID: String, testID: String, title: String) -> UUID? { + guard case .loaded(let discovery) = testDiscovery(entryID), + let test = discovery.tests.first(where: { $0.id == testID }) else { return nil } + let request = BuildTestRequest(project: test.project, target: test.target, + suite: test.suite, testCase: test.testCase, + projects: entry(entryID)?.model?.projects.map(\.path) ?? []) + return runTests(entryID, request: request, title: title) + } + /// Runs a declared task, or a model task through its template. @discardableResult package func runTask(_ entryID: String, request: BuildTaskRequest, title: String) -> UUID? { @@ -650,13 +901,13 @@ package final class BuildSystemService: ObservableObject { return startRun(entryID, title: title) { service, run in let context = try service.planContext(entry, workspaceURL: workspaceURL) let plan = try service.planner.taskPlan(entry.target, context: context, request: request) - let collected = try await service.runStreamedCommand( - plan, entry: entry, workspaceURL: workspaceURL, owner: .run(run) - ) + let isValid = service.runValidity(plan, entry: entry, workspaceURL: workspaceURL, run: run) + defer { if service.providerRunID == run.id { service.providerRunID = nil } } + let collected = try await service.runExecutionPlan(plan, entry: entry, workspaceURL: workspaceURL, run: run) let diagnostics = await service.decodeDiagnostics(entry, workspaceURL: workspaceURL, output: collected.text) // The decode runs after the command; a stop, replacement, or // workspace switch in between must not publish into the new state. - guard service.isCurrent(run) else { throw CancellationError() } + guard isValid() else { throw CancellationError() } service.diagnostics = diagnostics return collected.exitCode } @@ -668,6 +919,8 @@ package final class BuildSystemService: ObservableObject { return startRun(entryID, title: title) { service, run in let context = try service.planContext(entry, workspaceURL: workspaceURL) let plan = try service.planner.testPlan(entry.target, context: context, request: request) + let isValid = service.runValidity(plan.execution, entry: entry, workspaceURL: workspaceURL, run: run) + defer { if service.providerRunID == run.id { service.providerRunID = nil } } if plan.results != nil, request.project == nil, entry.instance.modelSource != nil, entry.model == nil || entry.isReloadRequired { @@ -675,16 +928,16 @@ package final class BuildSystemService: ObservableObject { // model a new subproject's reports are invisible and a failing // run would decode as success. A plugin without a model has no // projects to discover, so its own reports stay readable. - throw BuildSystemServiceError(String( - localized: "Load or reload the project model before running all tests, so subproject reports are collected." + throw BuildSystemServiceError(L10n.string( + "Load or reload the project model before running all tests, so subproject reports are collected." )) } let started = Date() - let collected = try await service.runStreamedCommand( - plan.process, entry: entry, workspaceURL: workspaceURL, owner: .run(run) + let collected = try await service.runExecutionPlan( + plan.execution, entry: entry, workspaceURL: workspaceURL, run: run ) let diagnostics = await service.decodeDiagnostics(entry, workspaceURL: workspaceURL, output: collected.text) - guard service.isCurrent(run) else { throw CancellationError() } + guard isValid() else { throw CancellationError() } service.diagnostics = diagnostics if plan.results != nil { let resultsRequest = BuildTestResultsRequest( @@ -702,12 +955,12 @@ package final class BuildSystemService: ObservableObject { let results = try await Task.detached(priority: .userInitiated) { try planner.decodeTestResults(target, request: resultsRequest) }.value - guard service.isCurrent(run) else { throw CancellationError() } + guard isValid() else { throw CancellationError() } service.testResults = results } catch is CancellationError { throw CancellationError() } catch { - guard service.isCurrent(run) else { throw CancellationError() } + guard isValid() else { throw CancellationError() } service.appendOutput( "\n" + error.localizedDescription + "\n", in: service.outputDirectory(entry) ) @@ -723,9 +976,12 @@ package final class BuildSystemService: ObservableObject { var ended: BuildRunEnd? if let runID, let runEntryID { ended = BuildRunEnd(runID: runID, entryID: runEntryID, state: .cancelled, exitCode: nil) + if testDiscoveries[runEntryID] == .loading { testDiscoveries[runEntryID] = nil } } runTask?.cancel() runTask = nil + providerRunID = nil + preparingApplicationID = nil runID = nil runEntryID = nil if runState == .running { @@ -736,6 +992,7 @@ package final class BuildSystemService: ObservableObject { /// Ends every process this service started. package func stop() { + stopPreparedApplication() detectionTask?.cancel(); detectionTask = nil detectionID = nil redetectTask?.cancel(); redetectTask = nil @@ -744,6 +1001,8 @@ package final class BuildSystemService: ObservableObject { isDetecting = false finishImportWaiters(.failure(CancellationError())) stopRun() + testDiscoveries = [:] + discoverySnapshots = [:] for task in modelTasks.values { task.cancel() } for task in dependencyTasks.values { task.cancel() } modelTasks = [:] @@ -780,7 +1039,7 @@ package final class BuildSystemService: ObservableObject { runState = .running runningTitle = title runTask = Task { [weak self] in - guard let self else { return } + guard let self, self.isCurrent(run) else { return } let state: BuildRunState var exitCode: Int32? do { @@ -927,11 +1186,13 @@ package final class BuildSystemService: ObservableObject { ) let target = entry.target let root = entry.instance.root - return .success(try await Task.detached(priority: .userInitiated) { + let loaded = try await Task.detached(priority: .userInitiated) { try planner.decodeDependencies( target, workspaceURL: workspaceURL, root: root, project: project?.path, output: output ) - }.value) + }.value + guard modelInputsAreCurrent(context.modelInputs) else { throw CancellationError() } + return .success(loaded) } catch { return .failure(error) } @@ -991,6 +1252,10 @@ package final class BuildSystemService: ObservableObject { package func setOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) { guard let workspaceURL, let entry = entry(entryID) else { return } let buildSystemID = entry.instance.buildSystemID + invalidateTestDiscovery(buildSystemID: buildSystemID) + if providerRunID != nil, let runEntryID, self.entry(runEntryID)?.instance.buildSystemID == buildSystemID { + stopRun() + } var stored = optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) stored[optionID] = value var empty = optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) @@ -1001,6 +1266,9 @@ package final class BuildSystemService: ObservableObject { optionsStore.setEmptyMultiSelectOptions(empty, workspaceURL: workspaceURL, buildSystemID: buildSystemID) optionsStore.setOptions(stored, workspaceURL: workspaceURL, buildSystemID: buildSystemID) refreshOptions(buildSystemID: buildSystemID) + if entry.declaration.sharesModel { + invalidateProducerConfiguration(buildSystemID: buildSystemID) + } // The options take part in the sync fingerprint, so a changed value // marks a loaded command model stale. detect() @@ -1019,6 +1287,10 @@ package final class BuildSystemService: ObservableObject { return } let buildSystemID = entry.instance.buildSystemID + invalidateTestDiscovery(buildSystemID: buildSystemID) + if providerRunID != nil, let runEntryID, self.entry(runEntryID)?.instance.buildSystemID == buildSystemID { + stopRun() + } var stored = optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) let values: [ToolingJSONValue] if case .array(let items)? = stored[optionID] { values = items } else { values = [] } @@ -1035,9 +1307,19 @@ package final class BuildSystemService: ObservableObject { optionsStore.setEmptyMultiSelectOptions(empty, workspaceURL: workspaceURL, buildSystemID: buildSystemID) optionsStore.setOptions(stored, workspaceURL: workspaceURL, buildSystemID: buildSystemID) refreshOptions(buildSystemID: buildSystemID) + if entry.declaration.sharesModel { + invalidateProducerConfiguration(buildSystemID: buildSystemID) + } detect() } + private func invalidateTestDiscovery(buildSystemID: String) { + for entry in entries where entry.instance.buildSystemID == buildSystemID { + testDiscoveries[entry.id] = nil + discoverySnapshots[entry.id] = nil + } + } + private func refreshOptions(buildSystemID: String) { guard let workspaceURL else { return } let stored = optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) @@ -1171,10 +1453,72 @@ package final class BuildSystemService: ObservableObject { pluginID: entry.instance.pluginID, buildSystemID: entry.instance.buildSystemID, workspaceURL: workspaceURL - ) + ), + modelInputs: try resolvedModelInputs(entry, workspaceURL: workspaceURL) ) } + private func resolvedModelInputs(_ consumer: BuildSystemEntry, workspaceURL: URL) throws -> ToolingJSONValue? { + guard !consumer.declaration.modelInputs.isEmpty else { return nil } + let enabled = sources() + let required = Set(consumer.declaration.modelInputs.map(\.pluginID)) + let facts = enabled.filter { required.contains($0.pluginID) }.map { source in + BuildModelInputSource(manifest: source.manifest, instances: entries.filter { $0.source == source && $0.declaration.sharesModel }.map { producer in + let state: String + switch producer.modelState { + case .loading: state = "loading" + case .failed: state = "failed" + case .loaded where !producer.isReloadRequired && producer.model != nil: state = "ready" + default: state = "stale" + } + return BuildModelInputSource.Instance(buildSystemID: producer.instance.buildSystemID, root: producer.instance.root, + state: state, model: state == "ready" ? producer.model : nil, options: storedOptions(for: producer, workspaceURL: workspaceURL)) + }) + } + return try planner.resolveModelInputs(consumer.target, revision: modelInputRevision, sources: facts) + } + + private func modelInputsAreCurrent(_ snapshot: ToolingJSONValue?) -> Bool { + guard let snapshot else { return true } + guard case .object(let value) = snapshot, case .integer(let revision) = value["revision"] else { return false } + return revision >= 0 && UInt64(revision) == modelInputRevision + } + + private func modelInputsAreCurrent(_ request: BuildProviderRequest) -> Bool { + guard case .object(let params) = request.params else { return false } + return modelInputsAreCurrent(params["modelInputs"]) + } + + /// Producers are independent in v1: consumers cannot re-export their inputs. + /// Revoke consumers immediately; cancellation-ignoring replies still fail revision checks. + private func invalidateModelInputs() { + modelInputRevision += 1 + let consumers = Set(entries.filter { !$0.declaration.modelInputs.isEmpty }.map(\.id)) + if let runEntryID, consumers.contains(runEntryID) { stopRun() } + for id in consumers { + modelTasks.removeValue(forKey: id)?.cancel() + for key in Array(dependencyTasks.keys) where Self.entryID(ofDependencyTask: key) == id { + dependencyTasks.removeValue(forKey: key)?.cancel() + } + update(id) { + $0.isReloadRequired = $0.model != nil + if $0.modelState == .loading { $0.modelState = $0.model == nil ? .notLoaded : .loaded } + $0.dependencies = [:] + } + modelRevisions[id, default: 0] += 1 + testDiscoveries[id] = nil + discoverySnapshots[id] = nil + } + } + + private func invalidateProducerConfiguration(buildSystemID: String) { + for producer in entries where producer.instance.buildSystemID == buildSystemID { + if producer.declaration.sharesModel { modelConfigurationRevisions[producer.id, default: 0] += 1 } + update(producer.id) { $0.isReloadRequired = true } + } + invalidateModelInputs() + } + /// Stored values reduced to the ones Core accepted in `resolution`. Core /// applies declaration defaults itself, so plans and fingerprints receive /// only effective user choices and never a value the current model removed. @@ -1250,6 +1594,80 @@ package final class BuildSystemService: ObservableObject { return CollectedOutput(text: collector.text, exitCode: exitCode) } + /// Retain the provider configuration guard through every asynchronous decode, + /// including report parsing after the backend has already returned. + private func runValidity( + _ plan: BuildOutputPlan, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + ) -> @MainActor @Sendable () -> Bool { + let revision = providerConfigurationRevision + let launchSettings = settings(entry.source.pluginID) + return { [weak self] in + guard let self, self.isCurrent(run) else { return false } + guard case .provider(let request) = plan else { return true } + guard self.modelInputsAreCurrent(request), self.providerConfigurationRevision == revision, + self.settings(entry.source.pluginID) == launchSettings, + let current = self.entry(entry.id), current.source == entry.source else { return false } + return self.optionFingerprint(current, workspaceURL: workspaceURL) == request.optionFingerprint + } + } + + private func runExecutionPlan( + _ plan: BuildOutputPlan, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + ) async throws -> CollectedOutput { + switch plan { + case .process(let process): + return try await runStreamedCommand(process, entry: entry, workspaceURL: workspaceURL, owner: .run(run)) + case .provider(let request): + return try await runProviderTask(request, entry: entry, workspaceURL: workspaceURL, run: run) + } + } + + /// Provider tasks share model/dependency sessions, not plugin page sessions. + /// Progress/results are bound to this run and its effective configuration. + private func runProviderTask( + _ request: BuildProviderRequest, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + ) async throws -> CollectedOutput { + guard let providers else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } + providerRunID = run.id + let isValid = runValidity(.provider(request), entry: entry, workspaceURL: workspaceURL, run: run) + let planned = try await providerRequestWithTool(request, entry: entry, workspaceURL: workspaceURL, run: run) + guard isValid() else { throw CancellationError() } + let directory = outputDirectory(entry) + let streamed = OutputCollector(limit: Self.decodedOutputLimit) + let result = try await providers.execute( + planned, on: Self.providerTarget(entry, workspaceURL: workspaceURL), timeout: .seconds(3600) + ) { [weak self] progress in + guard let self, isValid() else { return } + if let text = progress.output { + streamed.append(text) + self.appendOutput(text, in: directory) + } else if let message = progress.message { + self.appendOutput(message + "\n", in: directory) + } + } + guard isValid() else { throw CancellationError() } + if result.output.utf8.starts(with: streamed.text.utf8) { + appendOutput(String(decoding: result.output.utf8.dropFirst(streamed.text.utf8.count), as: UTF8.self), in: directory) + } else { + // A malformed stream must not hide the final diagnostic output. + appendOutput(result.output, in: directory) + } + return CollectedOutput(text: result.output, exitCode: result.exitCode) + } + + private func providerRequestWithTool( + _ request: BuildProviderRequest, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + ) async throws -> BuildProviderRequest { + let tool = try await providerTool(entry, workspaceURL: workspaceURL, owner: .run(run)) + var params = request.params + if let tool, case .object(var object) = params { + object["tool"] = .object(["executable": .string(tool.path)]) + params = .object(object) + } + return BuildProviderRequest(method: request.method, params: params, optionFingerprint: request.optionFingerprint, + executesProjectCode: request.executesProjectCode, mayUseNetwork: request.mayUseNetwork) + } + /// Plans a model or dependency load. A provider request also carries the /// load reason and the build tool the host resolved, so the provider /// neither repeats the lookup nor answers a reload from a stale snapshot. @@ -1325,7 +1743,10 @@ package final class BuildSystemService: ObservableObject { appendOutput("\n▶ " + title + "\n→ " + request.method + "\n", in: outputDirectory(entry)) } let target = Self.providerTarget(entry, workspaceURL: workspaceURL) - return .providerResult(try await providers.send(request, to: target, timeout: timeout)) + guard modelInputsAreCurrent(request), canAppend(owner) else { throw CancellationError() } + let result = try await providers.send(request, to: target, timeout: timeout) + guard modelInputsAreCurrent(request), canAppend(owner), !Task.isCancelled else { throw CancellationError() } + return .providerResult(result) } } @@ -1433,6 +1854,7 @@ package final class BuildSystemService: ObservableObject { private func noteModelChanged(_ entryID: String) { modelRevisions[entryID, default: 0] += 1 + if entry(entryID)?.declaration.sharesModel == true { invalidateModelInputs() } } private func noteDependenciesChanged(_ entryID: String) { diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index fbefc8f187..110205ee1b 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -5,7 +5,7 @@ import LitheCoreContracts import LitheModuleAPI @MainActor -package final class RunService: ObservableObject { +package final class RunService: ObservableObject, BuildRunLaunching { @Published package private(set) var configurations: [RunConfiguration] = [.currentFile] package private(set) var defaultConfigurationID: String? @Published package var selectedConfigurationID = RunConfiguration.currentFileID { @@ -41,6 +41,68 @@ package final class RunService: ObservableObject { @Published package private(set) var dependencyRevision = 0 @Published package private(set) var dependencyConfigurationSaveError: String? + @Published package private(set) var isPreparingBuildRun = false + private var buildPreparationID: UUID? + private var buildRunWorkspace: URL? + private var buildRunHandler: (@MainActor (RunConfiguration, URL) -> UUID?)? + private var cancelBuildRun: (@MainActor (UUID) -> Void)? + package var hasBuildProviderRun: Bool { lastRunConfiguration?.buildProvider != nil } + + package func configureBuildProviderRuns(start: @escaping @MainActor (RunConfiguration, URL) -> UUID?, + cancel: @escaping @MainActor (UUID) -> Void) { + buildRunHandler = start; cancelBuildRun = cancel + } + + package func finishBuildPreparation(_ id: UUID, message: String?) { + guard buildPreparationID == id else { return } + buildPreparationID = nil + isPreparingBuildRun = false + runningTitle = nil + if let message { fail(message) } + } + + private func startBuildProviderRun(_ configuration: RunConfiguration) { + let workspace = projectURL ?? buildRunWorkspace + let available = configurations.contains { $0.id == configuration.id && $0.buildProvider == configuration.buildProvider } + || (lastRunConfiguration == configuration && buildRunWorkspace == workspace) + stop() + primaryExecutionID = UUID().uuidString + configurationSaveError = nil + output = ""; lastExitCode = nil + lastRunConfiguration = configuration + runningTitle = configuration.name + isPreparingBuildRun = true + guard available, let workspace, let id = buildRunHandler?(configuration, workspace) else { + isPreparingBuildRun = false + fail(L10n.string("Load the current build model and enable its provider before running this configuration.")) + return + } + buildPreparationID = id + primaryExecutionID = id.uuidString + } + + @discardableResult + package func saveBuildProviderConfiguration() -> Bool { + guard let configuration = lastRunConfiguration, let reference = configuration.buildProvider, + let workspace = buildRunWorkspace ?? projectURL else { return false } + configurationSaveError = nil + do { + let id = try runConfigurationOperations.saveBuildProviderConfiguration(reference, name: configuration.name, at: workspace) + let resolution = try resolveWithServiceToolchains(operations: runConfigurationOperations, projectURL: workspace, + mavenProject: mavenProject, preferredConfigurationID: id) + guard let saved = resolution.configurations.first(where: { $0.configuration.id == id }), + saved.configuration.buildProvider == reference else { + throw RunConfigurationEditorSaveFailure(stage: .reload, + message: L10n.string("The saved provider run configuration is unavailable.")) + } + configurationStatus = .ready + configurationDiagnostics = resolution.diagnostics + apply(resolution.configurations, projectToolchain: resolution.projectToolchain, preferredConfigurationID: id) + lastRunConfiguration = saved.configuration + return true + } catch { configurationSaveError = error.localizedDescription; return false } + } + private let process: any StreamingProcess private let processFactory: () -> any StreamingProcess private let fileAccess: any RunFileAccess @@ -128,20 +190,25 @@ package final class RunService: ObservableObject { }) dependencyWriter = WorkspaceDependencyWriter(store: dependencyStore) self.executableResolver = executableResolver + } + + /// Platform callbacks may already be queued when a process is replaced. + /// Retain the operation that produced them through the main-actor hop. + private func configurePrimaryProcess(operationID: String) { process.onOutput = { [weak self] chunk in Task { @MainActor [weak self] in - self?.append(chunk) + guard let self, self.activeOperationID == operationID else { return } + self.append(chunk) } } process.onTermination = { [weak self] exitCode in Task { @MainActor [weak self] in - self?.finishProcess(exitCode: exitCode) + guard let self, self.activeOperationID == operationID else { return } + self.finishProcess(exitCode: exitCode) } } process.onStateChange = { [weak self] event in - Task { @MainActor [weak self] in - self?.consumeLifecycle(event) - } + Task { @MainActor [weak self] in self?.consumeLifecycle(event) } } } @@ -407,6 +474,15 @@ package final class RunService: ObservableObject { let modelRevision = mavenModelRevision projectLoadID = loadID let workspace = projectURL.standardizedFileURL + if let buildRunWorkspace, buildRunWorkspace != workspace { + stop() + self.buildRunWorkspace = nil + if lastRunConfiguration?.buildProvider != nil { + lastRunConfiguration = nil + primaryExecutionID = nil + output = ""; lastExitCode = nil + } + } defaultConfigurationID = nil isLoadingProject = true projectLoadState = .loading(workspace: workspace) @@ -836,6 +912,68 @@ package final class RunService: ObservableObject { run(configuration: configuration, currentFileURL: currentFileURL, javaLaunch: javaLaunch) } + /// A build provider has already prepared and Core has validated the launch. + /// Reuse the native resolver/process/argument lease without a shadow run configuration. + package func launchPreparedRun(_ preparation: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration? = nil) throws -> String { + let plan = preparation.launchPlan + let workspace = workspaceURL.standardizedFileURL + if let projectURL, projectURL != workspace { throw CancellationError() } + let directory = workspace.appendingPathComponent(plan.workingDirectory, isDirectory: true).standardizedFileURL + guard (directory == workspace || directory.path.hasPrefix(workspace.path + "/")), + fileAccess.isDirectory(at: directory) else { + throw RunConfigurationOperationFailure(message: L10n.string("The build provider returned an invalid launch plan.")) + } + // Read the workspace's actual defaults even before Run configurations + // are generated; a cached selection may belong to a different workspace. + let inspection = runConfigurationOperations.inspect(at: workspace, checkFingerprint: false, javaEntrypoints: nil) + if case .invalid(let message) = inspection.status { + throw RunConfigurationOperationFailure(message: message) + } + let toolchain = inspection.projectToolchain ?? ProjectToolchainSelection() + let options = RunOptions(javaHomePath: toolchain.javaHomePath, + mavenExecutablePath: toolchain.mavenExecutablePath, mavenJavaHomePath: toolchain.mavenJavaHomePath) + let resolved = try executableResolver.resolve(plan, projectURL: workspace, options: options) + let arguments = try prepareJavaLaunch(executablePath: resolved.executableURL.path, arguments: plan.arguments) + // The preparation is handing off successfully, so Stop must not cancel + // the provider request that is currently returning this application. + let runIdentity = buildPreparationID == nil ? nil : primaryExecutionID + buildPreparationID = nil + isPreparingBuildRun = false + stop() + output = "" + lastExitCode = nil + lastRunConfiguration = configuration + buildRunWorkspace = workspace + selectedConfigurationID = configurations.first(where: { $0.id == configuration?.id })?.id ?? RunConfiguration.currentFileID + lastCurrentFileURL = nil + let operationID = UUID().uuidString + primaryExecutionID = runIdentity ?? operationID + activeOperationID = operationID + configurePrimaryProcess(operationID: operationID) + activeLaunchArgumentLease = arguments.lease + runningTitle = title + isRunning = true + append("$ " + resolved.executableURL.lastPathComponent + " " + plan.arguments.joined(separator: " ") + "\n\n") + do { + try process.start(ProcessRequest(operationID: operationID, executablePath: resolved.executableURL.path, + arguments: arguments.arguments, workingDirectory: directory.path, environment: resolved.environment)) + return operationID + } catch { + activeLaunchArgumentLease = nil + activeOperationID = nil + isRunning = false + runningTitle = nil + lastExitCode = 1 + append(error.localizedDescription + "\n") + throw error + } + } + + package func stopPreparedRun(_ operationID: String) { + guard activeOperationID == operationID else { return } + stop() + } + package func restart(javaLaunch: JavaDebugLaunchTarget? = nil) { guard let lastRunConfiguration else { return } run( @@ -850,6 +988,7 @@ package final class RunService: ObservableObject { currentFileURL: URL?, javaLaunch: JavaDebugLaunchTarget? = nil ) { + if configuration.buildProvider != nil { startBuildProviderRun(configuration); return } stop() output = "" lastExitCode = nil @@ -974,6 +1113,7 @@ package final class RunService: ObservableObject { let operationID = UUID().uuidString activeOperationID = operationID + configurePrimaryProcess(operationID: operationID) // The main process starts only after every compile step exits zero, so a // standalone Java file is compiled with `javac` before `java `. let startMain: @MainActor () -> Void = { [weak self] in @@ -1040,6 +1180,7 @@ package final class RunService: ObservableObject { _ configuration: RunConfiguration, javaLaunch: JavaDebugLaunchTarget? = nil ) { + if configuration.buildProvider != nil { startBuildProviderRun(configuration); return } guard configuration.kind != .currentFile else { return } stopModule(sessionID: configuration.id) startModuleSession(configuration, javaLaunch: javaLaunch) @@ -1085,6 +1226,10 @@ package final class RunService: ObservableObject { } package func stop() { + let preparation = buildPreparationID + buildPreparationID = nil + isPreparingBuildRun = false + if let preparation { cancelBuildRun?(preparation) } activeLanguageExecutionSession?.stop() activeLanguageExecutionSession = nil activePreLaunchProcess?.stop() @@ -1101,6 +1246,7 @@ package final class RunService: ObservableObject { stopAllServices() projectLoadID = UUID() projectURL = nil + buildRunWorkspace = nil projectLoadState = .idle selectedConfigurationIDsByProject = [:] projectFiles = [] diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index b57d862f74..501fa2335f 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -50,7 +50,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private var sessionID: String? private var pendingOperations: [String: PendingOperation] = [:] - /// Progress listeners of running plugin functions, by operation ID. + /// Progress listeners of running provider tasks, by operation ID. + private var buildProgress: [String: @MainActor @Sendable (BuildProviderProgress) -> Void] = [:] + /// Progress listeners of plugin-private calls remain a separate contract. private var functionProgress: [String: @MainActor @Sendable (PluginFunctionProgress) -> Void] = [:] private var documentVersions: [URL: Int] = [:] private var pollTask: Task? @@ -616,7 +618,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { package func buildRequest( method: String, params: ToolingJSONValue, - timeout: TimeInterval + timeout: TimeInterval, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void = { _ in } ) async throws -> ToolingJSONValue { guard let sessionID, state == .ready else { throw LanguageServerRuntimeSessionError.notReady @@ -631,12 +634,14 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { try Task.checkCancellation() throw LanguageServerRuntimeSessionError.requestRejected(error.userMessage) } + buildProgress[operation.operationID] = progress // Registration happens before this actor can run the cancellation // below, so a cancelled request always finds its pending entry and // ends with the cancellation event Core publishes. return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in - registerPendingOperation(operation, name: method) { result in + registerPendingOperation(operation, name: method) { [weak self] result in + self?.buildProgress[operation.operationID] = nil continuation.resume(with: result.flatMap { event in guard case .object(let object)? = event.result, let value = object["value"] else { return .failure(LanguageServerRuntimeSessionError.missingResult) @@ -1109,6 +1114,14 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { onFunctionsChange?(updated) return false case "requestProgress": + if let operationID = event.operationID, let listener = buildProgress[operationID], + case .object(let object)? = event.result { + let message: String? = if case .string(let text)? = object["message"] { text } else { nil } + let output: String? = if case .string(let text)? = object["output"] { text } else { nil } + let percentage: Int? = if case .integer(let number)? = object["percentage"] { number } else { nil } + listener(BuildProviderProgress(message: message, percentage: percentage, output: output)) + return false + } guard let operationID = event.operationID, let listener = functionProgress[operationID], case .object(let object)? = event.result else { return false @@ -1191,6 +1204,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { pollTask = nil stopWatchdog?.disarm() stopWatchdog = nil + buildProgress = [:] functionProgress = [:] failPendingOperations(with: LanguageServerRuntimeSessionError.sessionStopped) if let sessionID { diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 0d50b279f5..343108bac0 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -217,6 +217,9 @@ public enum OfficialPluginCatalog { public let id: PluginID public let displayName: String public let signatureRequirement: PluginSignatureRequirement + /// The host explicitly permits its own sealed copy to omit the + /// publisher signature. This never applies to user-installed copies. + public var bundledSignatureRequirement: PluginSignatureRequirement? = nil public let distribution: Distribution public let languageID: String public let languageDisplayName: String @@ -239,8 +242,9 @@ public enum OfficialPluginCatalog { Entry( id: javaPluginID, displayName: "Java Support", - signatureRequirement: .sameTeamAsHost, - distribution: .bundled, + signatureRequirement: .publisherPackage, + bundledSignatureRequirement: .sameTeamAsHost, + distribution: .download, languageID: "java", languageDisplayName: "Java", fileExtensions: ["java"], diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 3d52528fb7..bea2595b29 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -110,15 +110,20 @@ public struct PluginVendor: Equatable, Codable, Sendable { public let id: String public let displayName: String public let signatureRequirement: PluginSignatureRequirement + /// Only a host-pinned bundled package may use this alternate trust. A + /// downloaded or disk-imported copy always needs the primary signature. + public let bundledSignatureRequirement: PluginSignatureRequirement? public init( id: String, displayName: String, - signatureRequirement: PluginSignatureRequirement + signatureRequirement: PluginSignatureRequirement, + bundledSignatureRequirement: PluginSignatureRequirement? = nil ) { self.id = id self.displayName = displayName self.signatureRequirement = signatureRequirement + self.bundledSignatureRequirement = bundledSignatureRequirement } } @@ -503,9 +508,10 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// level 3 plugin that requires anything else. Validating a capability is /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ + "packageTrust": [1], "backend": [1], - "buildProviders": [1], - "buildSystems": [1, 2], "debugAdapters": [1], "executableResolution": [1], + "buildProviders": [1, 2, 3, 4, 5, 6], "buildModelInputs": [1], + "buildSystems": [1, 2], "debugAdapters": [1, 2], "executableResolution": [1], "executionProviders": [1], "languageServers": [1, 2], "languages": [1], diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index d6bb4eb5cb..c2bb87375c 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -7,11 +7,15 @@ import Testing @MainActor struct DebugModuleTests { - @Test(arguments: [DebugRequestKind.launch, .attach]) - func explicitEndpointUsesItsFactoryAndPreservesTheDAPRequest(request: DebugRequestKind) throws { + @Test(arguments: [DebugRequestKind.launch, .attach], [false, true]) + func explicitEndpointUsesItsFactoryAndPreservesTheDAPRequest(request: DebugRequestKind, explicit: Bool) throws { let descriptor = DebugProviderDescriptor(id: "python", displayName: "Python", fileExtensions: ["py"]) + let selected = DebugProviderDescriptor(id: "org.example/debug", displayName: "Explicit", + fileExtensions: ["py"], selectsByFile: false, requests: [.launch, .attach]) + let providerID = explicit ? selected.id : descriptor.id var localCount = 0 - let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + let manager = DebugAdapterSessionManager(providers: [descriptor, selected]) { provider, _ in + #expect(provider.id == providerID) localCount += 1 return DeferredInspectionDebugSession() } @@ -19,13 +23,14 @@ struct DebugModuleTests { let external = DeferredInspectionDebugSession() let endpoint = DebugAdapterEndpoint(host: "debug.example.org", port: 5678) manager.makeEndpointSession = { provider, destination, root in - #expect(provider.id == "python") + #expect(provider.id == providerID) #expect(destination == endpoint) #expect(root.path == "/workspace") return external } let configuration = DebugLaunchConfiguration( name: "Remote", request: request, arguments: ["connect": .object(["port": .integer(9000)])], + adapterProviderID: explicit ? selected.id : nil, adapterEndpoint: endpoint ) let source = URL(fileURLWithPath: "/workspace/main.py") @@ -33,7 +38,8 @@ struct DebugModuleTests { #expect(external.launchConfigurations == [configuration]) #expect(localCount == 0) // A subsequent ordinary launch must not reuse the external TCP session. - _ = try manager.activate(for: source, rootURL: source.deletingLastPathComponent()) + _ = try manager.activate(for: source, rootURL: source.deletingLastPathComponent(), + adapterProviderID: explicit ? selected.id : nil) #expect(localCount == 1) #expect(!external.isRunning) } @@ -74,6 +80,268 @@ struct DebugModuleTests { #expect(feature.canUpdateJavaService) } + @Test + func adapterSelectionMatchesTheSharedHostFixture() throws { + struct Fixture: Decodable { + struct Provider: Decodable { + let id: String + let displayName: String + let fileExtensions: Set + let selectsByFile: Bool + let requests: Set + } + struct Case: Decodable { + let id: String + let target: String + let configuration: DebugLaunchConfiguration + let expectedProviderID: String? + let error: String? + } + let providers: [Provider] + let cases: [Case] + } + let repository = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: repository + .appendingPathComponent("shared/fixtures/debug/adapter-selection-v1.json"))) + let providers = fixture.providers.map { + DebugProviderDescriptor(id: $0.id, displayName: $0.displayName, fileExtensions: $0.fileExtensions, + selectsByFile: $0.selectsByFile, requests: $0.requests) + } + let manager = DebugAdapterSessionManager(providers: providers) { _, _ in DeferredInspectionDebugSession() } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/fixture", isDirectory: true) + for example in fixture.cases { + defer { manager.stopAll() } + do { + let launch = try manager.launchNew(for: root.appendingPathComponent(example.target), + rootURL: root, configuration: example.configuration) + #expect(example.error == nil, "Unexpected launch: \(example.id)") + #expect(manager.sessionSummaries.first { $0.id == launch.id }?.providerID == example.expectedProviderID) + } catch let error as DebugProviderError { + switch error { + case .adapterUnavailable: + #expect(example.error == "unavailable", "Unexpected refusal: \(example.id)") + case .capabilityUnavailable: + #expect(example.error == "unsupported", "Unexpected request refusal: \(example.id)") + default: Issue.record("Unexpected selection error: \(error)") + } + #expect(manager.activeSessionIDs.isEmpty) + } + } + } + + @Test + func explicitAdapterIdentitySurvivesCodingAndSteppingFilterChanges() throws { + let configuration = DebugLaunchConfiguration( + name: "engine target", request: .launch, arguments: ["program": .string("bin/example")], + adapterProviderID: "dev.example.native/lldb", + adapterEndpoint: DebugAdapterEndpoint(host: "localhost", port: 5678) + ) + let filters = DebugSteppingFilters(classNameFilters: [], skipSynthetics: true, + skipStaticInitializers: false, skipConstructors: false, hideFilteredStackFrames: true) + let updated = configuration.applying(steppingFilters: filters) + let decoded = try JSONDecoder().decode(DebugLaunchConfiguration.self, from: JSONEncoder().encode(updated)) + #expect(decoded == updated) + #expect(decoded.adapterProviderID == "dev.example.native/lldb") + #expect(decoded.adapterEndpoint == configuration.adapterEndpoint) + let oldJSON = Data(#"{"name":"file","request":"launch","arguments":{}}"#.utf8) + #expect(try JSONDecoder().decode(DebugLaunchConfiguration.self, from: oldJSON).adapterProviderID == nil) + } + + @Test + func explicitAdapterCanLaunchAProjectWithoutAMatchingSourceFile() throws { + let automatic = DebugProviderDescriptor(id: "c", displayName: "Default", fileExtensions: ["c"]) + let explicit = DebugProviderDescriptor(id: "dev.example.native/lldb", displayName: "LLDB", + fileExtensions: ["c"], selectsByFile: false, requests: [.launch]) + var selected: [String] = [] + let session = DeferredInspectionDebugSession() + let manager = DebugAdapterSessionManager(providers: [automatic, explicit]) { descriptor, _ in + selected.append(descriptor.id) + return session + } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + let configuration = DebugLaunchConfiguration(name: "example", request: .launch, + arguments: [:], adapterProviderID: explicit.id) + let launched = try manager.launchNew(for: root, rootURL: root, configuration: configuration) + #expect(selected == [explicit.id]) + #expect(manager.sessionSummaries.first?.providerID == explicit.id) + #expect(session.launchConfigurations == [configuration]) + #expect(manager.provider(for: root.appendingPathComponent("main.c")) == automatic) + #expect(manager.session(id: launched.id) === session) + } + + @Test + func missingOrWithdrawnExplicitAdapterNeverFallsBackToAFileDefault() throws { + let automatic = DebugProviderDescriptor(id: "c", displayName: "Default", fileExtensions: ["c"]) + let explicit = DebugProviderDescriptor(id: "dev.example.native/lldb", displayName: "LLDB", + fileExtensions: ["c"], selectsByFile: false) + var providers = [automatic, explicit] + var created = 0 + let manager = DebugAdapterSessionManager(providersSource: { providers }) { _, _ in + created += 1 + return DeferredInspectionDebugSession() + } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + let source = root.appendingPathComponent("main.c") + let configuration = DebugLaunchConfiguration(name: "example", request: .launch, + arguments: [:], adapterProviderID: explicit.id) + _ = try manager.launch(for: source, rootURL: root, configuration: configuration) + providers = [automatic] + #expect(throws: DebugProviderError.adapterUnavailable(explicit.id)) { + try manager.launch(for: source, rootURL: root, configuration: configuration) + } + #expect(throws: DebugProviderError.adapterUnavailable("")) { + try manager.activateNew(for: source, rootURL: root, adapterProviderID: "") + } + #expect(created == 1) + } + + @Test + func unsupportedExplicitRequestIsRejectedBeforeCreatingATransport() throws { + let attach = DebugProviderDescriptor(id: "dev.example.native/attach", displayName: "Attach", + fileExtensions: [], selectsByFile: false, requests: [.attach]) + var created = 0 + let manager = DebugAdapterSessionManager(providers: [attach]) { _, _ in + created += 1 + return DeferredInspectionDebugSession() + } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + #expect(throws: DebugProviderError.capabilityUnavailable(provider: "Attach", capability: "launch")) { + try manager.launchNew(for: root, rootURL: root, configuration: DebugLaunchConfiguration( + name: "example", request: .launch, arguments: [:], adapterProviderID: attach.id)) + } + #expect(created == 0) + _ = try manager.launchNew(for: root, rootURL: root, configuration: DebugLaunchConfiguration( + name: "example", request: .attach, arguments: [:], adapterProviderID: attach.id)) + #expect(created == 1) + } + + @Test + func sourceBreakpointsReachBothExplicitAdaptersBeforeAndAfterLaunch() throws { + let first = DebugProviderDescriptor(id: "dev.example.first/lldb", displayName: "First", + fileExtensions: ["c"], selectsByFile: false) + let second = DebugProviderDescriptor(id: "dev.example.second/lldb", displayName: "Second", + fileExtensions: ["c"], selectsByFile: false) + var sessions: [String: DeferredInspectionDebugSession] = [:] + let manager = DebugAdapterSessionManager(providers: [first, second]) { descriptor, _ in + let session = DeferredInspectionDebugSession() + sessions[descriptor.id] = session + return session + } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + let source = root.appendingPathComponent("main.c") + try manager.setBreakpoints([DebugSourceBreakpoint(line: 8, enabled: true)], in: source) + for descriptor in [first, second] { + _ = try manager.launchNew(for: root, rootURL: root, configuration: DebugLaunchConfiguration( + name: "example", request: .launch, arguments: [:], adapterProviderID: descriptor.id)) + #expect(sessions[descriptor.id]?.breakpointUpdates.last?.first?.line == 8) + } + try manager.setBreakpoints([DebugSourceBreakpoint(line: 12, enabled: true)], in: source) + #expect(sessions[first.id]?.breakpointUpdates.last?.first?.line == 12) + #expect(sessions[second.id]?.breakpointUpdates.last?.first?.line == 12) + manager.stop(providerID: first.id) + #expect(sessions[first.id]?.isRunning == false) + #expect(sessions[second.id]?.isRunning == true) + } + + @Test + func explicitFeatureRetryKeepsItsAdapterAndFailureCleansUpOnlyItsNewSession() throws { + let automatic = DebugProviderDescriptor(id: "c", displayName: "Default", fileExtensions: ["c"]) + let explicit = DebugProviderDescriptor(id: "dev.example.native/lldb", displayName: "LLDB", + fileExtensions: ["c"], selectsByFile: false) + var created: [(String, DeferredInspectionDebugSession)] = [] + let manager = DebugAdapterSessionManager(providers: [automatic, explicit]) { descriptor, _ in + let session = DeferredInspectionDebugSession() + if created.count == 2 { session.failNextLaunch = true } + created.append((descriptor.id, session)) + return session + } + let feature = GenericDebugFeatureModel(sessions: manager) + defer { feature.reset() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + let configuration = DebugLaunchConfiguration(name: "example", request: .launch, + arguments: [:], adapterProviderID: explicit.id) + #expect(feature.start(fileURL: root, rootURL: root, configuration: configuration)) + let original = try #require(feature.activeSessionID) + #expect(feature.providerID == explicit.id) + #expect(feature.retry()) + #expect(created.map { $0.0 } == [explicit.id, explicit.id]) + #expect(!created[0].1.isRunning) + #expect(feature.activeSessionID != original) + let current = try #require(feature.activeSessionID) + // Failed additional launch uses the same registry and must restore + // the current selection without stopping the earlier session. + let other = DebugLaunchConfiguration(name: "other", request: .launch, + arguments: [:], adapterProviderID: automatic.id) + #expect(!feature.startAdditional(fileURL: root, rootURL: root, configuration: other)) + #expect(created.count == 3) + #expect(created[2].0 == automatic.id) + #expect(!created[2].1.isRunning) + #expect(feature.activeSessionID == current) + #expect(feature.providerID == explicit.id) + #expect(created[1].1.isRunning) + #expect(manager.activeSessionIDs == [current]) + #expect(feature.retry()) + #expect(created.last?.0 == explicit.id) + } + + @Test + func failedLaunchCannotStopAReentrantReplacementOfTheSameAdapter() throws { + let descriptor = DebugProviderDescriptor(id: "dev.example.native/lldb", displayName: "LLDB", + fileExtensions: [], selectsByFile: false) + let first = DeferredInspectionDebugSession() + let replacement = DeferredInspectionDebugSession() + var created = 0 + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + created += 1 + return created == 1 ? first : replacement + } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + let configuration = DebugLaunchConfiguration(name: "example", request: .launch, + arguments: [:], adapterProviderID: descriptor.id) + // A synchronous session callback replaces the launch before the older + // launch throws. Cleanup must retain the replacement's exact identity. + first.onLaunch = { + _ = try manager.launchNew(for: root, rootURL: root, configuration: configuration) + throw DeferredDebugSessionError.launchFailed + } + defer { first.onLaunch = nil } + #expect(throws: DeferredDebugSessionError.launchFailed) { + try manager.launch(for: root, rootURL: root, configuration: configuration) + } + #expect(!first.isRunning) + #expect(replacement.isRunning) + #expect(manager.sessionSummaries.count == 1) + #expect(manager.session(providerID: descriptor.id) === replacement) + } + + @Test + func filelessFeatureSendsNonSourceBreakpointsToItsExplicitAdapter() throws { + let automatic = DebugProviderDescriptor(id: "c", displayName: "Default", fileExtensions: ["c"]) + let explicit = DebugProviderDescriptor(id: "dev.example.native/lldb", displayName: "LLDB", + fileExtensions: ["c"], selectsByFile: false) + let session = DeferredInspectionDebugSession() + let manager = DebugAdapterSessionManager(providers: [automatic, explicit]) { _, _ in session } + let feature = GenericDebugFeatureModel(sessions: manager) + defer { feature.reset() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + #expect(feature.start(fileURL: root, rootURL: root, configuration: DebugLaunchConfiguration( + name: "example", request: .launch, arguments: [:], adapterProviderID: explicit.id))) + feature.addFunctionBreakpoint(name: "main", condition: nil, hitCondition: nil) + #expect(session.functionBreakpointUpdates.last?.first?.name == "main") + try manager.setExceptionBreakpoints([], for: root, adapterProviderID: explicit.id) + try manager.setDataBreakpoints([], for: root, adapterProviderID: explicit.id) + #expect(!session.exceptionBreakpointUpdates.isEmpty) + #expect(!session.dataBreakpointUpdates.isEmpty) + #expect(feature.errorMessage == nil) + } + /// A host that registers debug adapters while it runs hands back its current /// set on each lookup, so a debugger installed later is found without /// rebuilding the manager. @@ -4778,10 +5046,14 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi private(set) var startCount = 0 private(set) var launchConfigurations: [DebugLaunchConfiguration] = [] private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = [] + private(set) var functionBreakpointUpdates: [[DebugFunctionBreakpoint]] = [] + private(set) var exceptionBreakpointUpdates: [[DebugExceptionBreakpoint]] = [] + private(set) var dataBreakpointUpdates: [[DebugDataBreakpoint]] = [] private(set) var executionCommands: [DebugExecutionCommand] = [] private(set) var steppingGranularities: [DebugSteppingGranularity] = [] private(set) var instructionBreakpointUpdates: [[DebugInstructionBreakpoint]] = [] var failNextLaunch = false + var onLaunch: (() throws -> Void)? var consoleCommands: [DebugConsoleCommand] = [] var consoleCommandPrefix: String? private(set) var completionRequests: [( @@ -4869,6 +5141,7 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi func launch(_ configuration: DebugLaunchConfiguration) throws { launchConfigurations.append(configuration) + try onLaunch?() if failNextLaunch { failNextLaunch = false throw DeferredDebugSessionError.launchFailed @@ -4891,6 +5164,15 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in _: URL) { breakpointUpdates.append(breakpoints) } + func setFunctionBreakpoints(_ breakpoints: [DebugFunctionBreakpoint]) { + functionBreakpointUpdates.append(breakpoints) + } + func setExceptionBreakpoints(_ breakpoints: [DebugExceptionBreakpoint]) { + exceptionBreakpointUpdates.append(breakpoints) + } + func setDataBreakpoints(_ breakpoints: [DebugDataBreakpoint]) { + dataBreakpointUpdates.append(breakpoints) + } func execute(_ command: DebugExecutionCommand, threadID _: Int?) { executionCommands.append(command) } diff --git a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift index 1c3e076ede..fde84e278a 100644 --- a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift @@ -1343,6 +1343,596 @@ struct BuildSystemServiceTests { }) } + @Test + func providerRunHandsValidatedPlanToNativeRunAndStopsOnDisable() async throws { + let (planner, providers, launcher, harness) = try Self.runPreparationHarness() + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + try #require(harness.service.runApplication(Self.instanceID, request: Self.runRequest, title: "Main") != nil) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(providers.sent.map(\.request.method) == ["lithe/build/prepareRun"]) + #expect(launcher.launched.count == 1) + #expect(launcher.launched.first?.arguments == ["example.Main", "a b"]) + #expect(planner.diagnosticOutputs.isEmpty) + harness.sources = [] + // Detection may already have removed the model before plugin disable + // is delivered. Process ownership must survive that disappearance. + harness.service.detect() + try #require(await harness.waitForEntries { $0.isEmpty }) + harness.service.pluginsChanged() + #expect(launcher.stopped == ["prepared-app"]) + } + + @Test + func savedRunRoutesOnlyToItsEnabledWorkspaceInstance() async throws { + let (_, providers, launcher, harness) = try Self.runPreparationHarness() + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + let reference = BuildProviderRunReference(pluginID: "dev.example", buildSystemID: "fixture", root: "app", + run: "application", projectID: "p", targetID: "t", userArguments: ["a b"]) + let configuration = RunConfiguration(id: "user:main", name: "Saved Main", kind: .process(provider: "build.provider"), + modulePath: nil, mainClass: nil, buildProvider: reference) + #expect(harness.service.runProviderConfiguration(configuration, workspace: URL(fileURLWithPath: "/other")) == nil) + try #require(harness.service.runProviderConfiguration(configuration, workspace: Self.workspace) != nil) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(launcher.lastConfiguration == configuration) + #expect(providers.sent.count == 1) + harness.sources = [] + harness.service.pluginsChanged() + try #require(await harness.waitForEntries { $0.isEmpty }) + #expect(harness.service.runProviderConfiguration(configuration, workspace: Self.workspace) == nil) + #expect(providers.sent.count == 1) + } + + @Test(arguments: [false, true]) + func aSavingApplicationCannotReplaceANewerTaskOrTest(tests: Bool) async throws { + let (planner, providers, launcher, harness) = try Self.runPreparationHarness() + let feature = BuildSystemsFeatureModel(service: harness.service) + let saving = BuildTestGate(), releaseSave = BuildTestGate(), saveReturned = BuildTestGate() + let releaseNewWork = BuildTestGate() + defer { releaseSave.open(); releaseNewWork.open(); harness.service.stop() } + feature.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + let savingRunID = feature.runApplication(Self.instanceID, request: Self.runRequest, title: "Old app", beforePreparing: { + saving.open() + defer { saveReturned.open() } + return await releaseSave.waitUntilOpen(ignoringCancellation: true) + }) + let oldID = try #require(savingRunID) + #expect(feature.isRunning) + #expect(feature.currentRunID == oldID) + try #require(await saving.waitUntilOpen()) + #expect(providers.sent.isEmpty) + + // B owns the same slot while A's save deliberately ignores cancellation. + planner.providerRequest = Self.providerRequest(method: tests ? "lithe/build/test" : "lithe/build/execute") + providers.replyGate = releaseNewWork + let newID: UUID? + if tests { newID = feature.runTests(Self.instanceID, request: BuildTestRequest(), title: "New tests") } + else { newID = feature.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "New task") } + let currentID = try #require(newID) + try #require(await providers.requestStarted.waitUntilOpen()) + providers.executionProgress?(BuildProviderProgress(output: "new work output")) + let currentOutput = feature.output + releaseSave.open() + try #require(await saveReturned.waitUntilOpen()) + #expect(feature.currentRunID == currentID) + #expect(feature.isRunning) + #expect(feature.output == currentOutput) + #expect(feature.output.contains("new work output")) + #expect(providers.sent.map(\.request.method) == [tests ? "lithe/build/test" : "lithe/build/execute"]) + #expect(launcher.launched.isEmpty) + releaseNewWork.open() + try #require(await harness.waitForRunState { $0 == .succeeded }) + } + + @Test(arguments: ["stop", "workspace", "options", "source", "disable", "reload"]) + func savingApplicationIsRevokedBeforeProviderPreparation(change: String) async throws { + let (planner, providers, launcher, harness) = try Self.runPreparationHarness() + let feature = BuildSystemsFeatureModel(service: harness.service) + let saving = BuildTestGate(), releaseSave = BuildTestGate(), saveReturned = BuildTestGate() + defer { releaseSave.open(); harness.service.stop() } + feature.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + let savingRunID = feature.runApplication(Self.instanceID, request: Self.runRequest, title: "App", beforePreparing: { + saving.open() + defer { saveReturned.open() } + return await releaseSave.waitUntilOpen(ignoringCancellation: true) + }) + let oldID = try #require(savingRunID) + try #require(await saving.waitUntilOpen()) + switch change { + case "workspace": feature.reset(workspaceURL: URL(fileURLWithPath: "/other")) + case "options": feature.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + case "source": feature.workspaceFilesChanged([Self.workspace.appendingPathComponent("app/Main.java")]) + case "disable": harness.sources = []; feature.pluginsChanged() + case "reload": + planner.providerRequest = Self.providerRequest(method: "lithe/build/model") + feature.loadModel(Self.instanceID, reason: .refresh) + default: feature.stopRun() + } + #expect(feature.currentRunID != oldID) + releaseSave.open() + try #require(await saveReturned.waitUntilOpen()) + #expect(!providers.sent.contains(where: { $0.request.method == "lithe/build/prepareRun" })) + #expect(launcher.launched.isEmpty) + } + + @Test(arguments: [false, true]) + func applicationPreparationWaitsForSuccessfulDocumentSave(saved: Bool) async throws { + let (_, providers, launcher, harness) = try Self.runPreparationHarness() + let feature = BuildSystemsFeatureModel(service: harness.service) + let saving = BuildTestGate(), releaseSave = BuildTestGate() + defer { releaseSave.open(); harness.service.stop() } + feature.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + let savingRunID = feature.runApplication(Self.instanceID, request: Self.runRequest, title: "App", beforePreparing: { + saving.open() + guard await releaseSave.waitUntilOpen() else { return false } + return saved + }) + try #require(savingRunID != nil) + try #require(await saving.waitUntilOpen()) + #expect(providers.sent.isEmpty) + #expect(launcher.launched.isEmpty) + releaseSave.open() + try #require(await harness.waitForRunState { $0 == (saved ? .succeeded : .cancelled) }) + #expect(providers.sent.count == (saved ? 1 : 0)) + #expect(launcher.launched.count == (saved ? 1 : 0)) + } + + /// A reply from a cancelled or outdated build must never start an application. + @Test(arguments: ["stop", "workspace", "options", "disable", "source", "reload"]) + func providerRunLateReplyCannotLaunch(change: String) async throws { + let (_, providers, launcher, harness) = try Self.runPreparationHarness() + let release = BuildTestGate() + providers.replyGate = release + defer { release.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runApplication(Self.instanceID, request: Self.runRequest, title: "Main") + try #require(await providers.requestStarted.waitUntilOpen()) + switch change { + case "workspace": harness.service.reset(workspaceURL: URL(fileURLWithPath: "/other")) + case "options": harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + case "disable": harness.sources = []; harness.service.pluginsChanged() + case "source": harness.service.workspaceFilesChanged([Self.workspace.appendingPathComponent("app/Main.java")]) + case "reload": harness.service.loadModel(Self.instanceID, reason: .refresh) + default: harness.service.stopRun() + } + providers.executionProgress?(BuildProviderProgress(output: "stale launch output")) + release.open() + try #require(await providers.requestFinished.waitUntilOpen()) + #expect(await launcher.didLaunch.waitUntilOpen(timeout: .milliseconds(200)) == false) + #expect(launcher.launched.isEmpty) + #expect(!harness.service.output.contains("stale launch output")) + } + + @Test + func providerRunCancellationDuringCoreDecodePreventsHandoff() async throws { + let (planner, _, launcher, harness) = try Self.runPreparationHarness() + let gate = GatedBuildDecode() + planner.runPreparationGate = gate + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runApplication(Self.instanceID, request: Self.runRequest, title: "Main") + try #require(await gate.entered.waitUntilOpen()) + harness.service.stopRun() + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + #expect(await launcher.didLaunch.waitUntilOpen(timeout: .milliseconds(200)) == false) + } + + @Test + func providerRunRejectsStaleTargetAndPreparationFailure() async throws { + let (_, providers, launcher, harness) = try Self.runPreparationHarness() + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + #expect(harness.service.runApplication(Self.instanceID, + request: BuildRunRequest(run: "application", projectID: "p", targetID: "gone"), title: "Main") == nil) + providers.failure = BuildSystemServiceTestsError(message: "build failed") + harness.service.runApplication(Self.instanceID, request: Self.runRequest, title: "Main") + try #require(await harness.waitForRunState { if case .failed = $0 { true } else { false } }) + #expect(launcher.launched.isEmpty) + #expect(harness.service.output.contains("build failed")) + } + + @Test + func providerDebugPreparationHandsOffOnlyValidatedCurrentFacts() async throws { + let (planner, providers, launcher, harness) = try Self.debugPreparationHarness() + defer { harness.service.stop() } + var launched: [BuildDebugPreparation] = [] + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + try #require(harness.service.debugApplication(Self.instanceID, request: Self.runRequest, title: "Debug") { source, plan in + #expect(source.pluginID == "dev.example") + launched.append(plan) + } != nil) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(providers.sent.map(\.request.method) == ["lithe/build/prepareDebug"]) + #expect(launched.first?.adapter == "native") + #expect(launched.first?.arguments == ["a b", "x#y"]) + #expect(launcher.launched.isEmpty) + #expect(planner.diagnosticOutputs.isEmpty) + } + + @Test(arguments: ["stop", "debug-stop", "workspace", "options", "disable", "source", "reload"]) + func providerDebugLateReplyCannotLaunch(change: String) async throws { + let (_, providers, _, harness) = try Self.debugPreparationHarness() + let release = BuildTestGate() + let launched = BuildTestGate() + providers.replyGate = release + defer { release.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.debugApplication(Self.instanceID, request: Self.runRequest, title: "Debug") { _, _ in launched.open() } + try #require(await providers.requestStarted.waitUntilOpen()) + switch change { + case "workspace": harness.service.reset(workspaceURL: URL(fileURLWithPath: "/other")) + case "options": harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + case "disable": harness.sources = []; harness.service.pluginsChanged() + case "source": harness.service.workspaceFilesChanged([Self.workspace.appendingPathComponent("app/main.c")]) + case "reload": harness.service.loadModel(Self.instanceID, reason: .refresh) + case "debug-stop": harness.service.cancelDebugPreparation() + default: harness.service.stopRun() + } + providers.executionProgress?(BuildProviderProgress(output: "stale debug output")) + release.open() + try #require(await providers.requestFinished.waitUntilOpen()) + #expect(await launched.waitUntilOpen(timeout: .milliseconds(200)) == false) + #expect(!harness.service.output.contains("stale debug output")) + } + + @Test + func providerDebugCancellationDuringDecodePreventsAdapterStart() async throws { + let (planner, _, _, harness) = try Self.debugPreparationHarness() + let gate = GatedBuildDecode() + let launched = BuildTestGate() + planner.runPreparationGate = gate + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.debugApplication(Self.instanceID, request: Self.runRequest, title: "Debug") { _, _ in launched.open() } + try #require(await gate.entered.waitUntilOpen()) + harness.service.cancelDebugPreparation() + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + #expect(await launched.waitUntilOpen(timeout: .milliseconds(200)) == false) + } + + @Test + func providerDebugRejectsMissingCapabilitiesAndReportsLaunchFailure() async throws { + let (_, providers, _, noDebug) = try Self.runPreparationHarness() + defer { noDebug.service.stop() } + noDebug.service.reset(workspaceURL: Self.workspace) + try #require(await noDebug.waitForEntries { !$0.isEmpty }) + #expect(noDebug.service.debugApplication(Self.instanceID, request: Self.runRequest, title: "Debug") { _, _ in + Issue.record("No debug adapter was declared") + } == nil) + #expect(providers.sent.isEmpty) + let (_, _, _, harness) = try Self.debugPreparationHarness() + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.debugApplication(Self.instanceID, request: Self.runRequest, title: "Debug") { _, _ in + throw BuildSystemServiceTestsError(message: "adapter unavailable") + } + try #require(await harness.waitForRunState { if case .failed = $0 { true } else { false } }) + #expect(harness.service.output.contains("adapter unavailable")) + } + + private static func debugPreparationHarness() throws -> (FakeBuildPlanner, FakeBuildProviders, FakeBuildRunLauncher, Harness) { + let (planner, providers, launcher, harness) = try runPreparationHarness() + planner.providerRequest = providerRequest(method: "lithe/build/prepareDebug") + providers.reply = try decode(ToolingJSONValue.self, + #"{"root":"app","adapter":"native","program":"app/bin/main","arguments":["a b","x#y"],"workingDirectory":"app","environment":{}}"#) + harness.sources = [fixtureSource(providerRuns: [.init(id: "application", displayName: "Run", + targetTags: ["application"], debugAdapter: "native")])] + return (planner, providers, launcher, harness) + } + + private static let runRequest = BuildRunRequest(run: "application", projectID: "p", targetID: "t") + private static func runPreparationHarness() throws -> (FakeBuildPlanner, FakeBuildProviders, FakeBuildRunLauncher, Harness) { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try instance(modelSource: "provider", model: + #"{"projects":[{"id":"p","name":"app","path":"app"}],"targets":[{"id":"t","projectID":"p","name":"Main","path":"app","tags":["application"]}]}"#)]) + planner.providerRequest = providerRequest(method: "lithe/build/prepareRun") + let providers = FakeBuildProviders() + providers.reply = try decode(ToolingJSONValue.self, + #"{"executable":{"kind":"command","value":"java"},"arguments":["example.Main","a b"],"workingDirectory":"app","environment":{}}"#) + let launcher = FakeBuildRunLauncher() + let source = fixtureSource(providerRuns: [.init(id: "application", displayName: "Run", targetTags: ["application"])]) + return (planner, providers, launcher, Harness(planner: planner, sources: [source], providers: providers, runLauncher: launcher)) + } + + @Test(arguments: [Int32(0), Int32(7)]) + func providerTaskRunsWithoutPageOrHostCommand(exitCode: Int32) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let providers = FakeBuildProviders() + providers.executionResult = BuildProviderExecutionResult(exitCode: exitCode, output: "completed") + let runner = FakeBuildRunner() + let harness = Harness(planner: planner, runner: runner, providers: providers) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + try #require(harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") != nil) + try #require(await harness.waitForRunState { $0 != .running }) + #expect(providers.sent.map(\.request.method) == ["lithe/build/execute"]) + #expect(providers.sent.first?.target.workspaceURL == Self.workspace) + #expect(runner.requests.isEmpty) + #expect(harness.service.output.contains("completed")) + if exitCode == 0 { #expect(harness.service.runState == .succeeded) } + else { if case .failed = harness.service.runState {} else { Issue.record("Expected tool failure") } } + } + + @Test(arguments: ["early\n", "a"]) + func providerTaskAppendsFinalSuffixAndDecodesTheCompleteOutput(prefix: String) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let providers = FakeBuildProviders() + // A progress chunk may end before a combining scalar in the next + // chunk; compare byte prefixes rather than normalized Characters. + let final = prefix == "a" ? "a\u{301}" : "early\nfinal\n" + providers.executionResult = BuildProviderExecutionResult(exitCode: 0, output: final) + let release = BuildTestGate() + providers.replyGate = release + let harness = Harness(planner: planner, providers: providers) + defer { release.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + try #require(await providers.requestStarted.waitUntilOpen()) + providers.executionProgress?(BuildProviderProgress(output: prefix)) + release.open() + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(Array(harness.service.output.utf8) == Array(final.utf8)) + #expect(planner.diagnosticOutputs == [final]) + } + + /// Configuration may change while the final decode is off the main actor. + @Test(arguments: [false, true]) + func providerTaskOptionsCancelDuringDiagnosticDecode(tests: Bool) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: tests ? "lithe/build/test" : "lithe/build/execute") + let stale = try Self.diagnostic("stale provider diagnostic") + planner.diagnostics = [stale] + let gate = GatedBuildDecode() + planner.diagnosticsGate = gate + let providers = FakeBuildProviders() + let harness = Harness(planner: planner, providers: providers) + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + if tests { harness.service.runTests(Self.instanceID, request: BuildTestRequest(), title: "Tests") } + else { harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") } + try #require(await gate.entered.waitUntilOpen()) + harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + #expect(harness.service.runState == .cancelled) + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + #expect(await harness.waitsForStaleDiagnostics([stale]) == false) + #expect(harness.service.diagnostics.isEmpty) + } + + /// A backend may drain after cancellation; no output or result may reach + /// the replacement workspace or the new effective configuration. + @Test(arguments: ["stop", "workspace", "options", "disable"], [false, true]) + func providerTaskLateOutputCannotCrossItsOwner(change: String, tests: Bool) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: tests ? "lithe/build/test" : "lithe/build/execute") + let providers = FakeBuildProviders() + let release = BuildTestGate() + providers.replyGate = release + let harness = Harness(planner: planner, providers: providers) + defer { release.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + if tests { harness.service.runTests(Self.instanceID, request: BuildTestRequest(), title: "Tests") } + else { harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") } + try #require(await providers.requestStarted.waitUntilOpen()) + providers.executionProgress?(BuildProviderProgress(output: "early")) + #expect(harness.service.output.contains("early")) + switch change { + case "workspace": harness.service.reset(workspaceURL: URL(fileURLWithPath: "/workspace/next")) + case "options": harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + case "disable": harness.sources = []; harness.service.pluginsChanged() + default: harness.service.stopRun() + } + providers.executionProgress?(BuildProviderProgress(output: "late")) + release.open() + try #require(await providers.requestFinished.waitUntilOpen()) + #expect(!harness.service.output.contains("late")) + #expect(!harness.service.output.contains("completed")) + #expect(harness.service.runState != .succeeded) + } + + @Test(arguments: [Int32(0), Int32(3)]) + func providerTestsDecodeReportsWithoutStartingAHostCommand(exitCode: Int32) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [ + try Self.instance(modelSource: "static", model: Self.model(name: "loaded")) + ]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/test") + planner.testPlanDeclaresResults = true + let expected = try Self.testResults("assertion failed") + planner.decodedTestResults = expected + let providers = FakeBuildProviders() + providers.executionResult = BuildProviderExecutionResult(exitCode: exitCode, output: "test output") + let runner = FakeBuildRunner() + let harness = Harness(planner: planner, runner: runner, providers: providers) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runTests(Self.instanceID, request: BuildTestRequest(projects: ["app"]), title: "Tests") + try #require(await harness.waitForRunState { $0 != .running }) + #expect(providers.sent.map(\.request.method) == ["lithe/build/test"]) + #expect(runner.requests.isEmpty) + #expect(harness.service.testResults == expected) + #expect(planner.diagnosticOutputs == ["test output"]) + if exitCode == 0 { #expect(harness.service.runState == .succeeded) } + else { if case .failed = harness.service.runState {} else { Issue.record("Expected failed test exit") } } + } + + /// The provider operation remains cancellable while its reports decode. + @Test + func providerTestOptionsCancelDuringReportDecode() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [ + try Self.instance(modelSource: "static", model: Self.model(name: "loaded")) + ]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/test") + planner.testPlanDeclaresResults = true + let stale = try Self.testResults("stale") + planner.decodedTestResults = stale + let gate = GatedBuildDecode() + planner.testResultsGate = gate + let harness = Harness(planner: planner, providers: FakeBuildProviders()) + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runTests(Self.instanceID, request: BuildTestRequest(), title: "Tests") + try #require(await gate.entered.waitUntilOpen()) + harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + #expect(harness.service.runState == .cancelled) + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + #expect(await harness.waitsForStaleTestResults(stale) == false) + #expect(harness.service.testResults == nil) + } + + @Test(arguments: ["source", "settings", "options"]) + func discoveredTestsRunExactSelectorsAndSourceEditsRevokeThem(change: String) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/discoverTests") + let discovered = try decode(BuildTestDiscovery.self, #"{"tests":[{"id":"exact","suite":"Suite","case":"case with spaces","project":{"name":"app","path":"app","tags":[],"properties":{}}}]}"#) + planner.discovery = discovered + let providers = FakeBuildProviders() + let runner = FakeBuildRunner() + var providerSettings: [String: ToolingJSONValue] = [:] + let harness = Harness(planner: planner, runner: runner, sources: [fixtureSource(testDiscovery: true)], providers: providers, + settings: { _ in providerSettings }) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.discoverTests(Self.instanceID) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(harness.service.testDiscovery(Self.instanceID) == .loaded(discovered)) + #expect(runner.requests.isEmpty) + planner.providerRequest = Self.providerRequest(method: "lithe/build/test") + try #require(harness.service.runDiscoveredTest(Self.instanceID, testID: "exact", title: "Test") != nil) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(planner.testRequests == [discovered.tests[0].request]) + #expect(providers.sent.map(\.request.method) == ["lithe/build/discoverTests", "lithe/build/test"]) + // A source edit, not just a build-file edit, revokes the cached selector. + if change == "source" { + harness.service.workspaceFilesChanged([Self.workspace.appendingPathComponent("src/Test.java")]) + } else if change == "settings" { providerSettings = ["providerHeapMB": .integer(2048)] } + else { + harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + harness.service.setOption(Self.instanceID, optionID: "release", value: nil) + } + #expect(harness.service.testDiscovery(Self.instanceID) == nil) + #expect(harness.service.runDiscoveredTest(Self.instanceID, testID: "exact", title: "Test") == nil) + } + + @Test + func queuedDiscoveryCannotPublishAfterItWasReplaced() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/discoverTests") + planner.discovery = try decode(BuildTestDiscovery.self, #"{"tests":[]}"#) + let providers = FakeBuildProviders() + let harness = Harness(planner: planner, sources: [fixtureSource(testDiscovery: true)], providers: providers) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.discoverTests(Self.instanceID) + // Both actions run on the main actor before the queued task can start. + harness.service.stopRun() + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(providers.sent.map(\.request.method) == ["lithe/build/execute"]) + #expect(harness.service.testDiscovery(Self.instanceID) == nil) + } + + @Test(arguments: [false, true]) + func discoveryFailureIsVisibleAndNeverStartsAHostCommand(hasProvider: Bool) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/discoverTests") + let providers = FakeBuildProviders() + providers.failure = BuildSystemServiceTestsError(message: "discovery failed") + let runner = FakeBuildRunner() + let harness = Harness(planner: planner, runner: runner, sources: [fixtureSource(testDiscovery: true)], + providers: hasProvider ? providers : nil) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.discoverTests(Self.instanceID) + try #require(await harness.waitForRunState { if case .failed = $0 { return true }; return false }) + guard case .failed(let message) = harness.service.testDiscovery(Self.instanceID) else { + Issue.record("Discovery failure must be visible and retryable"); return + } + if hasProvider { #expect(message.contains("discovery failed")) } + #expect(runner.requests.isEmpty) + } + + @Test(arguments: ["stop", "workspace", "options", "source", "disable"]) + func discoveryDecodeCannotPublishAfterItsOwnerChanges(change: String) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/discoverTests") + planner.discovery = try decode(BuildTestDiscovery.self, #"{"tests":[]}"#) + let gate = GatedBuildDecode() + planner.discoveryGate = gate + let harness = Harness(planner: planner, sources: [fixtureSource(testDiscovery: true)], providers: FakeBuildProviders()) + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.discoverTests(Self.instanceID) + try #require(await gate.entered.waitUntilOpen()) + switch change { + case "workspace": harness.service.reset(workspaceURL: URL(fileURLWithPath: "/workspace/next")) + case "options": harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + case "source": harness.service.workspaceFilesChanged([Self.workspace.appendingPathComponent("src/Test.java")]) + case "disable": harness.sources = []; harness.service.pluginsChanged() + default: harness.service.stopRun() + } + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + // Observe publication rather than assuming the worker's return already + // passed through the main actor. The bounded negative assertion covers it. + let published = BuildTestGate() + let observation = harness.service.objectWillChange.sink { + if case .loaded = harness.service.testDiscovery(Self.instanceID) { published.open() } + } + defer { observation.cancel() } + #expect(await published.waitUntilOpen(timeout: .milliseconds(500)) == false) + #expect(harness.service.testDiscovery(Self.instanceID) == nil) + } + // MARK: Provider @Test @@ -1633,6 +2223,183 @@ struct BuildSystemServiceTests { #expect(planner.decodedModelOutputs.isEmpty) } + + @Test(arguments: ["disable", "optionABA", "producerReload", "files", "workspace"]) + func sharedModelChangesCancelAnUncooperativeConsumer(change: String) async throws { + // Producer facts are captured before the backend starts; changing the producer + // revokes them while the reply is deliberately held, even across an option ABA. + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + let producer = try decode(BuildSystemInstance.self, """ + {"id":"source:app","pluginID":"dev.producer","buildSystemID":"source","displayName":"Source", + "root":"app","modelSource":"provider","optionFingerprint":"o", + "model":{"projects":[{"id":"p","path":"app","name":"source"}]}} + """) + planner.detection = BuildDetection(instances: [producer, try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let sources = [ + fixtureSource(pluginID: "dev.producer", buildSystemID: "source", sharesModel: true), + fixtureSource(modelInputs: [.init(pluginID: "dev.producer", buildSystemID: "source")]) + ] + let gate = BuildTestGate() + let providers = FakeBuildProviders() + providers.replyGate = gate + providers.ignoresCancellation = true + let harness = Harness(planner: planner, sources: sources, providers: providers) + defer { gate.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { $0.count == 2 }) + let operation = try #require(harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build")) + try #require(await providers.requestStarted.waitUntilOpen()) + let facts = try #require(planner.modelInputSources.last) + #expect(facts.count == 1) + #expect(facts.first?.instances.first?.buildSystemID == "source") + if change == "disable" { + harness.sources = [sources[1]] + harness.service.pluginsChanged() + } else if change == "optionABA" { + harness.service.setOption("source:app", optionID: "release", value: .bool(true)) + harness.service.setOption("source:app", optionID: "release", value: nil) + } else if change == "producerReload" { + harness.service.loadModel("source:app") + } else if change == "files" { + harness.service.workspaceFilesChanged([Self.workspace.appendingPathComponent("build.file")]) + } else { + harness.service.reset(workspaceURL: URL(fileURLWithPath: "/workspace/other")) + } + #expect(harness.service.currentRunID != operation) + gate.open() + try #require(await providers.requestFinished.waitUntilOpen()) + #expect(harness.service.runState != .succeeded) + } + + @Test + func aProducerModelLoadedAcrossOptionABARemainsStale() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/model") + let gate = BuildTestGate() + let providers = FakeBuildProviders() + providers.replyGate = gate + let harness = Harness(planner: planner, sources: [fixtureSource(sharesModel: true)], providers: providers) + defer { gate.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { $0.count == 1 }) + harness.service.loadModel(Self.instanceID) + try #require(await providers.requestStarted.waitUntilOpen()) + harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + harness.service.setOption(Self.instanceID, optionID: "release", value: nil) + gate.open() + try #require(await harness.waitForEntries { $0.first?.modelState == .loaded }) + #expect(harness.service.entry(Self.instanceID)?.isReloadRequired == true) + } + + @Test(arguments: [false, true], ["command", "provider"]) + func independentProducersPublishReadyModelsInEitherReplyOrder(reverse: Bool, source: String) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + let producers = try ["a", "b"].map { name in + try decode(BuildSystemInstance.self, """ + {"id":"\(name):app","pluginID":"dev.producer-\(name)","buildSystemID":"\(name)", + "displayName":"Producer \(name)","root":"app","modelSource":"\(source)","optionFingerprint":"o"} + """) + } + planner.detection = BuildDetection(instances: producers + [try Self.instance(modelSource: "provider")]) + let first = ControlledBuildLoad(output: "OLD"), second = ControlledBuildLoad(output: "NEW") + let providers = FakeBuildProviders() + if source == "provider" { + planner.providerRequest = Self.providerRequest(method: "lithe/build/model") + providers.requestStartedByPluginID = ["dev.producer-a": first.started, "dev.producer-b": second.started] + providers.replyGatesByPluginID = ["dev.producer-a": first.release, "dev.producer-b": second.release] + } + let sources = [ + fixtureSource(pluginID: "dev.producer-a", buildSystemID: "a", sharesModel: true), + fixtureSource(pluginID: "dev.producer-b", buildSystemID: "b", sharesModel: true), + fixtureSource(modelInputs: [.init(pluginID: "dev.producer-a", buildSystemID: "a"), .init(pluginID: "dev.producer-b", buildSystemID: "b")]) + ] + let harness = Harness(planner: planner, runner: ControlledBuildLoadRunner(loads: [first, second]), + sources: sources, providers: providers) + defer { first.release.open(); second.release.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { $0.count == 3 }) + harness.service.loadModel("a:app", reason: .refresh) + try #require(await first.started.waitUntilOpen()) + harness.service.loadModel("b:app", reason: .refresh) + try #require(await second.started.waitUntilOpen()) + // Both are in flight; no file or configuration changed. Publishing + // either one must revoke consumers without making its sibling stale. + let order = reverse ? [("b:app", second), ("a:app", first)] : [("a:app", first), ("b:app", second)] + for (id, load) in order { + load.release.open() + try #require(await harness.waitForEntries { $0.first(where: { $0.id == id })?.modelState == .loaded }) + #expect(harness.service.entry(id)?.isReloadRequired == false) + } + try #require(await harness.waitForDetectionToFinish()) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + try #require(harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Consumer") != nil) + try #require(await harness.waitForRunState { $0 == .succeeded }) + let facts = try #require(planner.modelInputSources.last) + #expect(facts.count == 2) + #expect(facts.flatMap(\.instances).count == 2) + #expect(facts.flatMap(\.instances).allSatisfy { $0.state == "ready" && $0.model != nil }) + } + + @Test(arguments: [false, true]) + func sharedInputsStayRevokedThroughBackgroundDecode(dependencies: Bool) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: dependencies ? "lithe/build/dependencies" : "lithe/build/model") + let consumer = fixtureSource(modelInputs: [.init(pluginID: "dev.producer", buildSystemID: "source")]) + let gate = GatedBuildDecode() + planner.backgroundDecodeGate = gate + let harness = Harness(planner: planner, sources: [consumer], providers: FakeBuildProviders()) + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { $0.count == 1 }) + if dependencies { harness.service.loadDependencies(Self.instanceID, project: nil) } + else { harness.service.loadModel(Self.instanceID) } + try #require(await gate.entered.waitUntilOpen()) + // Availability changes revoke even the explicit unavailable slot snapshot. + harness.service.pluginsChanged() + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + #expect(!(await harness.waitsForStaleSharedInputResult())) + #expect(harness.service.entry(Self.instanceID)?.model == nil) + #expect(harness.service.entry(Self.instanceID)?.dependencies.isEmpty == true) + } + + @Test + func sharedModelHostIncludesOnlyEnabledExactPackageFacts() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + let producer = try decode(BuildSystemInstance.self, """ + {"id":"source:app","pluginID":"dev.producer","buildSystemID":"source","displayName":"Source", + "root":"app","modelSource":"provider","optionFingerprint":"o"} + """) + planner.detection = BuildDetection(instances: [producer, try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let consumer = fixtureSource(modelInputs: [.init(pluginID: "dev.producer", buildSystemID: "source")]) + let producerSource = fixtureSource(pluginID: "dev.producer", buildSystemID: "source", sharesModel: true) + let providers = FakeBuildProviders() + let harness = Harness(planner: planner, sources: [producerSource, consumer], providers: providers) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { $0.count == 2 }) + harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + try #require(await harness.waitForRunState { $0 == .succeeded }) + let instance = try #require(planner.modelInputSources.last?.first?.instances.first) + #expect(instance.state == "stale") + #expect(instance.model == nil) + harness.sources = [consumer] + harness.service.pluginsChanged() + try #require(await harness.waitForEntries { $0.count == 1 }) + harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(planner.modelInputSources.last?.isEmpty == true) + } + private static func providerRequest(method: String) -> BuildProviderRequest { BuildProviderRequest( method: method, params: .object(["root": .string("app")]), optionFingerprint: "o", @@ -1712,14 +2479,14 @@ struct BuildSystemServiceTests { } } -private func fixtureSource(pluginID: String = "dev.example") -> BuildSystemPluginSource { +private func fixtureSource(pluginID: String = "dev.example", testDiscovery: Bool = false, providerRuns: [DeclarativeBuildSystem.ProviderRun] = [], buildSystemID: String = "fixture", sharesModel: Bool = false, modelInputs: [DeclarativeBuildSystem.ModelInput] = []) -> BuildSystemPluginSource { BuildSystemPluginSource( pluginID: pluginID, manifest: Data("{}".utf8), packageURL: URL(fileURLWithPath: "/plugins/\(pluginID)"), buildSystems: [DeclarativeBuildSystem( - id: "fixture", displayName: "Fixture", modelSource: .command, tasks: [], - testScopes: [], dependencyScopes: [] + id: buildSystemID, displayName: "Fixture", modelSource: .command, tasks: [], + testScopes: [], dependencyScopes: [], testDiscovery: testDiscovery, providerRuns: providerRuns, sharesModel: sharesModel, modelInputs: modelInputs )] ) } @@ -1747,6 +2514,7 @@ private final class Harness { runner: any BuildCommandRunning = FakeBuildRunner(), sources: [BuildSystemPluginSource] = [fixtureSource()], providers: (any BuildProviderRequesting)? = nil, + runLauncher: (any BuildRunLaunching)? = nil, settings: @escaping @MainActor (String) -> [String: ToolingJSONValue] = { _ in [:] }, waitForRedetection: @escaping @Sendable () async throws -> Void = {} ) { @@ -1759,6 +2527,7 @@ private final class Harness { stateLocator: stateLocator, runner: runner, providers: providers, + runLauncher: runLauncher, settings: settings, waitForRedetection: waitForRedetection, sources: { enabledSources.value } @@ -1780,6 +2549,15 @@ private final class Harness { /// Bounded wait for a publication a correct service never makes. Returns /// true when the stale value appears, so a regression fails locally /// instead of hanging. + func waitsForStaleSharedInputResult() async -> Bool { + let gate = BuildTestGate() + let observation = service.$entries.sink { entries in + if entries.contains(where: { $0.model != nil || $0.dependencies.values.contains(where: { if case .loaded = $0 { return true }; return false }) }) { gate.open() } + } + defer { observation.cancel() } + return await gate.waitUntilOpen(timeout: .milliseconds(500)) + } + func waitsForStaleDiagnostics(_ diagnostics: [BuildDiagnostic]) async -> Bool { let gate = BuildTestGate() let observation = service.$diagnostics.sink { if $0 == diagnostics { gate.open() } } @@ -1917,11 +2695,16 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { var providerRequest: BuildProviderRequest? var decodedDependencyOutputs: [BuildCommandOutput] = [] var diagnostics: [BuildDiagnostic] = [] + var backgroundDecodeGate: GatedBuildDecode? var diagnosticsGate: GatedBuildDecode? var testResultsGate: GatedBuildDecode? var detectGate: GatedBuildDecode? var decodedTestResults: BuildTestResults? var testPlanDeclaresResults = false + var discovery: BuildTestDiscovery? + var discoveryGate: GatedBuildDecode? + var runPreparationGate: GatedBuildDecode? + var testRequests: [BuildTestRequest] = [] var optionsResolution: BuildOptionsResolution? var loadedOptionsResolution: BuildOptionsResolution? var dynamicOptions = false @@ -1932,6 +2715,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { var decodedModelOutputs: [BuildCommandOutput] = [] var diagnosticOutputs: [String] = [] var taskPlanContexts: [BuildPlanContext] = [] + var modelInputSources: [[BuildModelInputSource]] = [] var modelPlanHints: [BuildProviderHints] = [] var dependencyPlanHints: [BuildProviderHints] = [] } @@ -1956,6 +2740,10 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { get { access { $0.providerRequest } } set { access { $0.providerRequest = newValue } } } + var runPreparationGate: GatedBuildDecode? { + get { access { $0.runPreparationGate } } + set { access { $0.runPreparationGate = newValue } } + } var decodedDependencyOutputs: [BuildCommandOutput] { access { $0.decodedDependencyOutputs } } var diagnostics: [BuildDiagnostic] { get { access { $0.diagnostics } } @@ -2002,6 +2790,15 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { get { access { $0.decodedTestResults } } set { access { $0.decodedTestResults = newValue } } } + var discovery: BuildTestDiscovery? { + get { access { $0.discovery } } + set { access { $0.discovery = newValue } } + } + var discoveryGate: GatedBuildDecode? { + get { access { $0.discoveryGate } } + set { access { $0.discoveryGate = newValue } } + } + var testRequests: [BuildTestRequest] { access { $0.testRequests } } var testPlanDeclaresResults: Bool { get { access { $0.testPlanDeclaresResults } } set { access { $0.testPlanDeclaresResults = newValue } } @@ -2103,6 +2900,33 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { """) } + var backgroundDecodeGate: GatedBuildDecode? { + get { access { $0.backgroundDecodeGate } } + set { access { $0.backgroundDecodeGate = newValue } } + } + + private func waitForBackgroundDecode() throws { + if let gate = access({ let gate = $0.backgroundDecodeGate; $0.backgroundDecodeGate = nil; return gate }) { + gate.entered.open() + defer { gate.finished.open() } + guard gate.waitForRelease() else { throw GatedDecodeError.releaseTimedOut } + } + } + + var modelInputSources: [[BuildModelInputSource]] { access { $0.modelInputSources } } + + func resolveModelInputs(_ target: BuildSystemTarget, revision: UInt64, sources: [BuildModelInputSource]) throws -> ToolingJSONValue { + access { $0.modelInputSources.append(sources) } + return .object(["version": .integer(1), "revision": .integer(Int(revision)), "inputs": .array([])]) + } + + private func withInputs(_ request: BuildProviderRequest, context: BuildPlanContext) -> BuildProviderRequest { + guard let inputs = context.modelInputs, case .object(var params) = request.params else { return request } + params["modelInputs"] = inputs + return BuildProviderRequest(method: request.method, params: .object(params), optionFingerprint: request.optionFingerprint, + executesProjectCode: request.executesProjectCode, mayUseNetwork: request.mayUseNetwork) + } + func modelPlan( _ target: BuildSystemTarget, context: BuildPlanContext, @@ -2110,25 +2934,74 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { providerHints: BuildProviderHints ) throws -> BuildOutputPlan { access { $0.modelPlanHints.append(providerHints) } - if let request = providerRequest { return .provider(request) } + if let request = providerRequest { return .provider(withInputs(request, context: context)) } return .process(plan(["model"], output: modelOutput)) } func taskPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTaskRequest) throws - -> BuildProcessPlan { + -> BuildOutputPlan { access { $0.taskPlanContexts.append(context) } - return plan([request.task, "with space"], output: nil) + if let request = providerRequest { return .provider(withInputs(request, context: context)) } + return .process(plan([request.task, "with space"], output: nil)) } func testPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTestRequest) throws -> BuildTestPlan { + access { $0.testRequests.append(request) } let declaresResults = access { $0.testPlanDeclaresResults } return BuildTestPlan( - process: plan(["test"], output: nil), + execution: providerRequest.map { .provider(withInputs($0, context: context)) } ?? .process(plan(["test"], output: nil)), results: declaresResults ? .reports(decoder: "junitXml", patterns: []) : nil ) } + func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + guard let providerRequest else { throw CancellationError() } + return withInputs(providerRequest, context: context) + } + + func prepareDebugPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + guard let providerRequest else { throw CancellationError() } + return withInputs(providerRequest, context: context) + } + + func decodeDebugPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, run: String, + result: ToolingJSONValue) throws -> BuildDebugPreparation { + if let gate = access({ let gate = $0.runPreparationGate; $0.runPreparationGate = nil; return gate }) { + gate.entered.open() + guard gate.waitForRelease() else { throw GatedDecodeError.releaseTimedOut } + gate.finished.open() + } + return try JSONDecoder().decode(BuildDebugPreparation.self, from: JSONEncoder().encode(result)) + } + + func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, + result: ToolingJSONValue) throws -> BuildRunPreparation { + if let gate = access({ let gate = $0.runPreparationGate; $0.runPreparationGate = nil; return gate }) { + gate.entered.open() + guard gate.waitForRelease() else { throw GatedDecodeError.releaseTimedOut } + gate.finished.open() + } + return try JSONDecoder().decode(BuildRunPreparation.self, from: JSONEncoder().encode(result)) + } + + func testDiscoveryPlan(_ target: BuildSystemTarget, context: BuildPlanContext) throws -> BuildProviderRequest { + guard let providerRequest else { throw CancellationError() } + return providerRequest + } + + func decodeTestDiscovery(_ target: BuildSystemTarget, context: BuildPlanContext, result: ToolingJSONValue) throws -> BuildTestDiscovery { + if let gate = access({ let gate = $0.discoveryGate; $0.discoveryGate = nil; return gate }) { + gate.entered.open() + guard gate.waitForRelease() else { throw GatedDecodeError.releaseTimedOut } + gate.finished.open() + } + guard let discovery else { throw CancellationError() } + return discovery + } + func dependencyPlan( _ target: BuildSystemTarget, context: BuildPlanContext, @@ -2137,7 +3010,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { providerHints: BuildProviderHints ) throws -> BuildOutputPlan { access { $0.dependencyPlanHints.append(providerHints) } - if let request = providerRequest { return .provider(request) } + if let request = providerRequest { return .provider(withInputs(request, context: context)) } return .process(plan(["dependencies"], output: .stdout)) } @@ -2148,6 +3021,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { output: BuildCommandOutput, options: [String: ToolingJSONValue] ) throws -> BuildModel { + try waitForBackgroundDecode() access { $0.decodedModelOutputs.append(output) } let name: String if case .stdout(let text) = output, text == "OLD" || text == "NEW" { @@ -2194,6 +3068,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { project: String?, output: BuildCommandOutput ) throws -> BuildDependencyTree { + try waitForBackgroundDecode() access { $0.decodedDependencyOutputs.append(output) } if case .stdout(let name) = output, name == "OLD" || name == "NEW" { return try decode(BuildDependencyTree.self, """ @@ -2441,6 +3316,8 @@ private final class FakeBuildProviders: BuildProviderRequesting { var reply: ToolingJSONValue = .object(["projects": .array([])]) var failure: (any Error)? var replyGate: BuildTestGate? + var replyGatesByPluginID: [String: BuildTestGate] = [:] + var requestStartedByPluginID: [String: BuildTestGate] = [:] let requestStarted = BuildTestGate() let requestFinished = BuildTestGate() private(set) var sent: [Sent] = [] @@ -2455,12 +3332,38 @@ private final class FakeBuildProviders: BuildProviderRequesting { sent.append(Sent(request: request, target: target, timeout: timeout)) running.insert(target) requestStarted.open() + requestStartedByPluginID[target.pluginID]?.open() defer { requestFinished.open() } - if let replyGate, !(await replyGate.waitUntilOpen()) { throw CancellationError() } + if let gate = replyGatesByPluginID[target.pluginID] ?? replyGate, + !(await gate.waitUntilOpen(ignoringCancellation: ignoresCancellation)) { throw CancellationError() } if let failure { throw failure } return reply } + var ignoresCancellation = false + var executionResult = BuildProviderExecutionResult(exitCode: 0, output: "completed") + var executionProgress: (@MainActor @Sendable (BuildProviderProgress) -> Void)? + + func execute( + _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void + ) async throws -> BuildProviderExecutionResult { + executionProgress = progress + _ = try await send(request, to: target, timeout: timeout) + return executionResult + } + + func prepareDebug(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + try await prepareRun(request, on: target, timeout: timeout, progress: progress) + } + + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + executionProgress = progress + return try await send(request, to: target, timeout: timeout) + } + func retainProviders(_ keep: (BuildProviderTarget) -> Bool) { for target in running where !keep(target) { running.remove(target) @@ -2473,3 +3376,18 @@ private struct BuildSystemServiceTestsError: LocalizedError { let message: String var errorDescription: String? { message } } + +@MainActor +private final class FakeBuildRunLauncher: BuildRunLaunching { + var lastConfiguration: RunConfiguration? + var launched: [BuildRunPreparation] = [] + let didLaunch = BuildTestGate() + var stopped: [String] = [] + func launchPreparedRun(_ plan: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration?) throws -> String { + lastConfiguration = configuration + launched.append(plan) + didLaunch.open() + return "prepared-app" + } + func stopPreparedRun(_ operationID: String) { stopped.append(operationID) } +} diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index 1baaf43d5f..a1d1e69577 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -8,6 +8,121 @@ import Testing @MainActor struct ExecutionModuleTests { + @Test + func providerLaunchUsesNativeProcessAndOldOwnerCannotStopReplacement() async throws { + let process = TestStreamingProcess() + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let resolver = TestExecutableResolver() + let service = RunService(runtime: TestRuntime(), process: process, processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(directories: [root.appendingPathComponent("app", isDirectory: true)]), + preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), + runConfigurationOperations: TestRunConfigurationOperations(toolchain: ProjectToolchainSelection(javaHomePath: "/fixture/jdk"), + rejectsResolutionWithoutGeneratedDocument: true), + executableResolver: resolver, + languageProviderCatalog: .compatibilityFallback, languageRunProviders: .standard(catalog: .compatibilityFallback)) + defer { service.reset() } + let launch = try JSONDecoder().decode(BuildRunPreparation.self, from: Data( + #"{"executable":{"kind":"command","value":"java"},"arguments":["Main","a b"],"workingDirectory":"app","environment":{}}"#.utf8)) + let old = try service.launchPreparedRun(launch, workspaceURL: root, title: "Provider Main") + let oldOutput = process.onOutput + let oldTermination = process.onTermination + let current = try service.launchPreparedRun(launch, workspaceURL: root, title: "Replacement") + let request = try #require(process.startRequests.last) + #expect(request.operationID == current) + #expect(request.arguments == ["Main", "a b"]) + #expect(request.workingDirectory == "/workspace/app") + #expect(service.primaryExecutionID == current) + #expect(resolver.lastOptions?.javaHomePath == "/fixture/jdk") + #expect(service.lastConfiguration == nil) + let received = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let observation = service.$output.sink { if $0.contains("replacement output") { received.continuation.yield(()) } } + defer { observation.cancel(); received.continuation.finish() } + oldOutput?("stale output") + oldTermination?(7) + process.onOutput?("replacement output") + try await awaitSignal(received.stream) + #expect(!service.output.contains("stale output")) + #expect(service.lastExitCode == nil) + service.stopPreparedRun(old) + #expect(service.isRunning) + service.stopPreparedRun(current) + #expect(!service.isRunning) + #expect(!process.isRunning) + } + + @Test(arguments: [false, true]) + func providerLaunchFailureLeavesNoRunningProcess(invalidDirectory: Bool) throws { + let process = TestStreamingProcess() + process.startFailure = ReloadTestError.failed + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let service = RunService(runtime: TestRuntime(), process: process, processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(directories: invalidDirectory ? [] : [root]), + preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), + runConfigurationOperations: TestRunConfigurationOperations(), executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, languageRunProviders: .standard(catalog: .compatibilityFallback)) + defer { service.reset() } + let launch = try JSONDecoder().decode(BuildRunPreparation.self, from: Data( + #"{"executable":{"kind":"command","value":"java"},"arguments":[],"workingDirectory":".","environment":{}}"#.utf8)) + #expect(throws: (any Error).self) { _ = try service.launchPreparedRun(launch, workspaceURL: root, title: "Main") } + #expect(!service.isRunning) + #expect(!process.isRunning) + } + + @Test + func savedProviderRunRepreparesAndStopCancelsOnlyItsPreparation() async throws { + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let process = TestStreamingProcess() + let reference = BuildProviderRunReference(pluginID: "dev.example", buildSystemID: "fixture", root: ".", + run: "application", projectID: "p", targetID: "t", userArguments: ["a b"]) + let configuration = RunConfiguration(id: "user:provider", name: "Main", kind: .process(provider: "build.provider"), + modulePath: nil, mainClass: nil, buildProvider: reference) + let service = RunService(runtime: TestRuntime(), process: process, processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(directories: [root]), preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), + runConfigurationOperations: TestRunConfigurationOperations(savedProvider: reference), executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, languageRunProviders: .standard(catalog: .compatibilityFallback)) + defer { service.reset() } + let launch = try JSONDecoder().decode(BuildRunPreparation.self, from: Data( + #"{"executable":{"kind":"command","value":"java"},"arguments":["Main"],"workingDirectory":".","environment":{}}"#.utf8)) + _ = try service.launchPreparedRun(launch, workspaceURL: root, title: "Main", configuration: configuration) + #expect(service.saveBuildProviderConfiguration()) + #expect(service.lastConfiguration?.id == "user:provider") + let feature = RunFeatureModel(service: service) + #expect(feature.configurations.first { $0.id == "user:provider" }?.buildProvider == reference) + var calls: [BuildProviderRunReference] = [] + var cancellations: [UUID] = [] + var id = UUID() + service.configureBuildProviderRuns(start: { config, workspace in + #expect(workspace == root) + if let reference = config.buildProvider { calls.append(reference) } + return id + }, cancel: { cancellations.append($0) }) + service.restart() + #expect(feature.isRunning) + #expect(service.isPreparingBuildRun) + #expect(service.primaryExecutionID == id.uuidString) + #expect(!process.isRunning) + let old = id + service.stop() + #expect(cancellations == [old]) + #expect(!feature.isRunning) + id = UUID() + service.restart() + service.finishBuildPreparation(old, message: "stale failure") + #expect(service.isPreparingBuildRun) + _ = try service.launchPreparedRun(launch, workspaceURL: root, title: "Main", configuration: configuration) + #expect(service.primaryExecutionID == id.uuidString) + #expect(!service.isPreparingBuildRun) + #expect(cancellations == [old]) + #expect(calls == [reference, reference]) + #expect(process.startRequests.count == 2) + #expect(feature.isSelectedConfigurationRunning) + await service.loadProject(at: URL(fileURLWithPath: "/other"), files: [], mavenProject: nil) + #expect(!process.isRunning) + #expect(!service.hasBuildProviderRun) + #expect(service.lastConfiguration == nil) + #expect(service.primaryExecutionID == nil) + } + @Test(arguments: ["services/alpha", "services/beta"]) func moduleRunPassesMavenContextOnlyToItsOwningReactor(reactor: String) async { let configuration = RunConfiguration( @@ -3149,6 +3264,7 @@ private final class TestRuntime: MavenRuntimePort, RunRuntimePort { private final class TestStreamingProcess: StreamingProcess, @unchecked Sendable { var isRunning = false + var startFailure: (any Error)? private(set) var startRequests: [ProcessRequest] = [] var onOutput: (@Sendable (String) -> Void)? var onTermination: (@Sendable (Int32) -> Void)? @@ -3157,6 +3273,7 @@ private final class TestStreamingProcess: StreamingProcess, @unchecked Sendable /// moment the main process launches once every pre-launch step exits zero. var onStart: (@Sendable () -> Void)? func start(_ request: ProcessRequest) throws { + if let startFailure { throw startFailure } startRequests.append(request) isRunning = true onStart?() @@ -3625,14 +3742,23 @@ private struct SingleRunConfigurationOperations: RunConfigurationOperations { @MainActor private final class TestExecutableResolver: RunExecutableResolving { + private(set) var lastOptions: RunOptions? func resolve(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) throws -> ResolvedRunExecutable { - ResolvedRunExecutable(executableURL: URL(fileURLWithPath: "/test"), environment: [:]) + lastOptions = options + return ResolvedRunExecutable(executableURL: URL(fileURLWithPath: "/test"), environment: [:]) } func refreshCandidates(projectURL: URL) async {} func candidates(projectURL: URL) -> [ProjectToolchainCandidate] { [] } } private struct TestRunConfigurationOperations: RunConfigurationOperations { + var toolchain = ProjectToolchainSelection() + var rejectsResolutionWithoutGeneratedDocument = false + var savedProvider: BuildProviderRunReference? = nil + func saveBuildProviderConfiguration(_ reference: BuildProviderRunReference, name: String, at projectURL: URL) throws -> String { + guard reference == savedProvider else { throw ReloadTestError.failed } + return "user:provider" + } var inspectionGate: ReloadScanGate? = nil func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { if let inspectionGate { @@ -3641,7 +3767,8 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations { Issue.record("Run inspection was not released before its deadline") } } - return ProjectRunConfigurationInspection(status: .missing, diagnostics: []) + return ProjectRunConfigurationInspection(status: .missing, diagnostics: [], + projectToolchain: toolchain == ProjectToolchainSelection() ? nil : toolchain) } func generate( at projectURL: URL, @@ -3652,7 +3779,14 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations { RunConfigurationGenerationResult(entryCount: 0) } func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { - RunConfigurationResolution(configurations: [], diagnostics: [], defaultConfigurationID: nil) + if rejectsResolutionWithoutGeneratedDocument { throw ReloadTestError.failed } + if let savedProvider { + let configuration = RunConfiguration(id: "user:provider", name: "Main", kind: .process(provider: "build.provider"), + modulePath: nil, mainClass: nil, buildProvider: savedProvider) + return RunConfigurationResolution(configurations: [.init(configuration: configuration, options: RunOptions())], + diagnostics: [], defaultConfigurationID: nil) + } + return RunConfigurationResolution(configurations: [], diagnostics: [], defaultConfigurationID: nil, projectToolchain: toolchain) } func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan { throw RunConfigurationOperationFailure(message: "Unavailable in lifecycle test") diff --git a/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift b/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift index e869504a84..ea4b455a57 100644 --- a/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift +++ b/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift @@ -25,6 +25,7 @@ struct OfficialPluginVerifier { let manifest = descriptor.pluginManifest guard let entry = OfficialPluginCatalog.entry(for: manifest.id), entry.signatureRequirement == manifest.vendor.signatureRequirement, + entry.bundledSignatureRequirement == manifest.vendor.bundledSignatureRequirement, packageURL.lastPathComponent == manifest.id.rawValue else { throw VerificationError.trustPolicyMismatch(manifest.id.rawValue) } @@ -100,8 +101,8 @@ struct OfficialPluginVerifier { /// The host never loads plugin code, so Mach-O files are allowed only in /// the top-level directory of a runtime the plugin declares with - /// `packagedRuntime`. Such a package ships inside the signed application, - /// whose code signature covers the runtime's binaries. + /// `packagedRuntime`. Bundled builds use the application's seal; independently + /// installed builds require a publisher signature over the final runtime. private static func rejectNativeCode(in packageURL: URL, exceptIn runtimeDirectories: [String]) throws { let machOMagics: Set = [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca] guard let enumerator = FileManager.default.enumerator( diff --git a/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift b/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift index 94e7454301..a117c8b6f2 100644 --- a/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift +++ b/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift @@ -136,6 +136,18 @@ private final class ProviderRequestRecorder: BuildProviderRequesting { return .object(["format": .string("litheBuildModel"), "version": .integer(1), "projects": .array([.object(["path": .string("."), "name": .string("fixture")])])]) } + func execute( + _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void + ) async throws -> BuildProviderExecutionResult { + _ = try await send(request, to: target, timeout: timeout) + return BuildProviderExecutionResult(exitCode: 0, output: "") + } + + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + try await send(request, to: target, timeout: timeout) + } func retainProviders(_ keep: (BuildProviderTarget) -> Bool) {} } diff --git a/macos/Tests/LitheTests/IdeCapabilitiesTests.swift b/macos/Tests/LitheTests/IdeCapabilitiesTests.swift index 5dd796356b..dba277d35b 100644 --- a/macos/Tests/LitheTests/IdeCapabilitiesTests.swift +++ b/macos/Tests/LitheTests/IdeCapabilitiesTests.swift @@ -1,11 +1,61 @@ +import AppKit +import Combine import Foundation import LitheCoreContracts import LitheExecutionModule import Testing +import SwiftUI @testable import Lithe @MainActor struct IdeCapabilitiesTests { + @Test(arguments: [false, true]) + func preparedProviderOutputRendersWithoutGeneratedConfigurations(dark: Bool) async throws { + let fixture = try ProviderCapabilityExecutionFixture() + defer { fixture.remove() } + await fixture.run.loadProject(at: fixture.root, files: [], mavenProject: nil) + let preparation = try JSONDecoder().decode(BuildRunPreparation.self, from: Data( + #"{"executable":{"kind":"command","value":"fixture"},"arguments":["Main"],"workingDirectory":".","environment":{}}"#.utf8)) + let configuration = try #require(fixture.run.configurations.first { $0.id == fixture.configurationID }) + _ = try fixture.run.launchPreparedRun(preparation, workspaceURL: fixture.root, title: "Main", configuration: configuration) + let received = TestGate() + let observation = fixture.run.$output.sink { if $0.contains("Hello from Provider") { received.open() } } + defer { observation.cancel() } + fixture.process.onOutput?("Hello from Provider\nArguments: [a b]\n") + try #require(await received.waitUntilOpen(timeout: .seconds(5))) + #expect(!FileManager.default.fileExists(atPath: fixture.root.appendingPathComponent(".lithe/run/generated.json").path)) + let suite = "provider-run-ui-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let backing = MacUserDefaultsStore(defaults: defaults) + let settings = AppSettings(store: backing) + let container = MacServiceContainer(store: backing, settings: settings) + defer { container.moduleLifecycleCoordinator.stop() } + let model = AppModel(settings: settings, services: container.services) + try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { _ in + let host = NSHostingView(rootView: RunView(feature: fixture.execution.runFeature) + .environmentObject(model).environmentObject(settings).defaultAppStorage(defaults) + .environment(\.locale, Locale(identifier: "en")).environment(\.colorScheme, dark ? .dark : .light) + .frame(width: 1100, height: 360)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1100, height: 360), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + defer { window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + await Task.yield() + host.layoutSubtreeIfNeeded() + let bitmap = try nativeSRGBCapture(host) + #expect(bitmap.pixelsWide == 2200 && bitmap.pixelsHigh == 720) + if let directory = ProcessInfo.processInfo.environment["LITHE_BUILD_CAPTURE_DIR"] { + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: directory) + .appendingPathComponent("provider-run-output-\(dark ? "dark" : "light").png")) + } + } + } + @Test func revokedAndReplacedWorkspacesCannotReachProductActions() async { let actions = CapabilityActionsFixture() @@ -45,6 +95,95 @@ struct IdeCapabilitiesTests { #expect(updated.executionID == first.executionID) #expect(replacement.executionID != first.executionID) } + + @Test + func savedProviderRunKeepsItsAgentIdentityAcrossPreparationAndLaunch() async throws { + let fixture = try ProviderCapabilityExecutionFixture() + defer { fixture.remove() } + await fixture.run.loadProject(at: fixture.root, files: [], mavenProject: nil) + var preparations: [UUID] = [] + var cancelled: [UUID] = [] + let reference = fixture.reference + let root = fixture.root + fixture.run.configureBuildProviderRuns(start: { configuration, workspace in + #expect(configuration.buildProvider == reference) + #expect(workspace == root) + let id = UUID() + preparations.append(id) + return id + }, cancel: { cancelled.append($0) }) + let actions = CapabilityActionsFixture(root: fixture.root) + actions.execution = fixture.execution + actions.startConfiguration = { fixture.run.startConfiguration($0) } + let feature = IdeCapabilitiesFeatureModel(model: actions) + feature.allowExecute = true + feature.enable() + + do { + // Exercise the public Agent entry points against a configuration read + // from disk. A primary run has no module-session fallback to hide a + // lost operation identity during the provider-to-process handoff. + let started = await feature.call("lithe_run_start", arguments: ["configurationID": fixture.configurationID]) + let firstID = try #require(started["operationID"] as? String) + #expect(firstID == "run:" + (try #require(preparations.first)).uuidString) + #expect(fixture.run.isPreparingBuildRun) + #expect(fixture.run.moduleSessions.isEmpty) + let busy = await feature.call("lithe_run_start", arguments: ["configurationID": fixture.configurationID]) + #expect((busy["error"] as? [String: String])?["code"] == "BUSY") + #expect(preparations.count == 1) + let stopped = await feature.call("lithe_operation_stop", arguments: ["operationID": firstID]) + #expect(stopped["stopRequested"] as? Bool == true) + #expect(cancelled == preparations) + #expect(!fixture.run.isPreparingBuildRun) + + let restarted = await feature.call("lithe_run_restart", arguments: ["operationID": firstID]) + let secondID = try #require(restarted["operationID"] as? String) + #expect(secondID != firstID) + #expect(preparations.count == 2) + let preparation = try JSONDecoder().decode(BuildRunPreparation.self, from: Data( + #"{"executable":{"kind":"command","value":"fixture"},"arguments":["Main"],"workingDirectory":".","environment":{}}"#.utf8)) + let configuration = try #require(fixture.run.configurations.first { $0.id == fixture.configurationID }) + _ = try fixture.run.launchPreparedRun(preparation, workspaceURL: fixture.root, title: "Main", configuration: configuration) + #expect(!fixture.run.isPreparingBuildRun) + #expect(fixture.process.isRunning) + #expect(cancelled.count == 1) + let listed = await feature.call("lithe_operations_list", arguments: [:]) + let operation = try #require((listed["operations"] as? [[String: Any]])?.first) + #expect(operation["operationID"] as? String == secondID) + #expect(operation["configurationID"] as? String == fixture.configurationID) + #expect(operation["state"] as? String == "running") + + let outputReceived = TestGate() + let finished = TestGate() + let outputObserver = fixture.run.$output.sink { if $0.contains("provider output") { outputReceived.open() } } + let runningObserver = fixture.run.$isRunning.dropFirst().sink { if !$0 { finished.open() } } + defer { outputObserver.cancel(); runningObserver.cancel() } + fixture.process.onOutput?("provider output\n") + #expect(await outputReceived.waitUntilOpen(timeout: .seconds(5))) + fixture.process.complete(exitCode: 0) + #expect(await finished.waitUntilOpen(timeout: .seconds(5))) + let completed = await feature.call("lithe_operation_output", arguments: ["operationID": secondID]) + #expect(completed["operationID"] as? String == secondID) + #expect(completed["state"] as? String == "completed") + #expect(completed["exitCode"] as? Int32 == 0) + #expect((completed["output"] as? String)?.contains("provider output") == true) + + let third = await feature.call("lithe_run_restart", arguments: ["operationID": secondID]) + let thirdID = try #require(third["operationID"] as? String) + #expect(thirdID != secondID) + let expired = await feature.call("lithe_operation_stop", arguments: ["operationID": secondID]) + #expect((expired["error"] as? [String: String])?["code"] == "OPERATION_EXPIRED") + #expect(fixture.run.isPreparingBuildRun) + #expect(cancelled.count == 1) + _ = await feature.call("lithe_operation_stop", arguments: ["operationID": thirdID]) + #expect(cancelled.map { "run:" + $0.uuidString } == [firstID, thirdID]) + } catch { + await feature.shutdown() + throw error + } + await feature.shutdown() + #expect(actions.transport.closed) + } } @MainActor @@ -57,6 +196,7 @@ private final class CapabilityTransportFixture: IdeHostTransport { func control(_ action: String, arguments: [String: Any]) throws -> [String: Any] { if action == "validate" { return validation } if action == "close" { closed = true } + if action == "output" { return arguments["snapshot"] as? [String: Any] ?? [:] } return ["requests": []] } } @@ -65,21 +205,118 @@ private final class CapabilityTransportFixture: IdeHostTransport { private final class CapabilityActionsFixture: IdeCapabilityActions { let id = UUID() let projectName = "Fixture" - let root = URL(fileURLWithPath: "/fixture/project") + let root: URL let transport = CapabilityTransportFixture() var activationCount = 0 + var execution: ExecutionFeatureAccess? + var startConfiguration: ((RunConfiguration) -> Void)? var currentWorkspaceIdentity: WorkspaceIdentity? var runtimeFeature: RuntimeSettingsFeatureModel { preconditionFailure("Authorization tests must not inspect runtimes") } var ideHostTransport: (any IdeHostTransport)? { transport } - init() { currentWorkspaceIdentity = WorkspaceIdentity(url: root, generation: 1) } + init(root: URL = URL(fileURLWithPath: "/fixture/project")) { + self.root = root + currentWorkspaceIdentity = WorkspaceIdentity(url: root, generation: 1) + } func isCurrentWorkspace(_ identity: WorkspaceIdentity) -> Bool { identity == currentWorkspaceIdentity } - func activateExecutionModule() async -> ExecutionFeatureAccess? { activationCount += 1; return nil } + func activateExecutionModule() async -> ExecutionFeatureAccess? { activationCount += 1; return execution } func ensureRunProjectReady(_ run: RunFeatureModel, for identity: WorkspaceIdentity) async -> RunProjectReadiness { .stale } func prepareProjectRuntimeSettings() async {} func persistProjectRuntimeSettings() {} func reloadMavenProject(rescan: Bool) async {} - func startAPIConfiguration(_ configuration: RunConfiguration) async {} + func startAPIConfiguration(_ configuration: RunConfiguration) async { startConfiguration?(configuration) } func registerIdeCapabilities(_ feature: IdeCapabilitiesFeatureModel) {} func unregisterIdeCapabilities() {} func copyIdeConfiguration(_ text: String) {} } + +@MainActor +private final class ProviderCapabilityExecutionFixture { + let root: URL + let reference = BuildProviderRunReference(pluginID: "dev.example", buildSystemID: "fixture", root: ".", + run: "application", projectID: "project", targetID: "main", userArguments: []) + let configurationID: String + let process = CapabilityProcessFixture() + let run: RunService + let execution: ExecutionFeatureAccess + + init() throws { + let core = RustCoreBridge() + try #require(core.isAvailable) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-agent-" + UUID().uuidString, isDirectory: true) + self.root = root + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + do { + let preferences = CapabilityPreferencesFixture() + let store = MacRunConfigurationStore(core: core, storage: MacFileStorage(), preferences: preferences) + configurationID = try store.saveBuildProviderConfiguration(reference, name: "Main", at: root) + let runtime = CapabilityRuntimeFixture() + let resolver = CapabilityExecutableFixture() + run = RunService(runtime: runtime, process: process, processFactory: { CapabilityProcessFixture() }, + fileAccess: MacRunFileAccess(storage: MacFileStorage()), preferences: preferences, + serverPortParser: CapabilityPortParserFixture(), runConfigurationOperations: store, + executableResolver: resolver, languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback)) + let maven = MavenFeatureModel(service: MavenService(runtimeService: runtime, process: CapabilityProcessFixture(), + dependencyProcess: CapabilityProcessFixture(), mavenOperations: RustJavaMavenOperations(core: core))) + let runFeature = RunFeatureModel(service: run) + execution = ExecutionFeatureAccess(mavenFeature: maven, runFeature: runFeature, + tests: LanguageTestService(executableResolver: resolver, processFactory: { CapabilityProcessFixture() }), + projectDevelopment: ProjectDevelopmentFeatureModel(mavenFeature: maven, runFeature: runFeature), buildSystems: nil) + } catch { + try? FileManager.default.removeItem(at: root) + throw error + } + } + + func remove() { + run.reset() + execution.mavenFeature.reset() + execution.tests.stop() + try? FileManager.default.removeItem(at: root) + } +} + +private final class CapabilityProcessFixture: StreamingProcess, @unchecked Sendable { + var isRunning = false + var onOutput: (@Sendable (String) -> Void)? + var onTermination: (@Sendable (Int32) -> Void)? + var onStateChange: (@Sendable (ProcessLifecycleEvent) -> Void)? + func start(_ request: ProcessRequest) throws { isRunning = true } + func send(_ input: Data) throws {} + func stop() { isRunning = false } + func complete(exitCode: Int32) { isRunning = false; onTermination?(exitCode) } +} + +@MainActor +private final class CapabilityRuntimeFixture: RunRuntimePort, MavenRuntimePort { + func mavenExecutable(for project: MavenProject, overridePath: String?) -> URL? { nil } + func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] { [:] } + func setActiveServiceJavaHomePath(_ path: String) {} + func javaHomeURL(overridePath: String?) -> URL? { nil } + func mavenJavaHomeURL(overridePath: String?) -> URL? { nil } + func runConfigurationToolchainCandidates(for project: MavenProject?, projectRoot: URL?, + javaHomeOverride: String?, mavenExecutableOverride: String?) -> [ProjectToolchainCandidate] { [] } + func loadRunConfigurationToolchainCandidates(for project: MavenProject?, projectRoot: URL?, + javaHomeOverride: String?, mavenExecutableOverride: String?) async throws -> [ProjectToolchainCandidate] { [] } +} + +@MainActor +private final class CapabilityExecutableFixture: RunExecutableResolving { + func resolve(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) throws -> ResolvedRunExecutable { + ResolvedRunExecutable(executableURL: URL(fileURLWithPath: "/fixture/launcher"), environment: [:]) + } +} + +private final class CapabilityPreferencesFixture: KeyValueStore, RunPreferenceStore, @unchecked Sendable { + nonisolated func data(forKey key: String) -> Data? { nil } + nonisolated func object(forKey key: String) -> Any? { nil } + nonisolated func string(forKey key: String) -> String? { nil } + nonisolated func stringArray(forKey key: String) -> [String]? { nil } + nonisolated func set(_ value: Any?, forKey key: String) {} + nonisolated func setData(_ data: Data, forKey key: String) {} + nonisolated func setString(_ value: String, forKey key: String) {} +} + +private struct CapabilityPortParserFixture: RunServerPortParsing { + func serverPort(content: String, fileExtension: String) -> Int? { nil } +} diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index 26417df51f..7ba65b05c2 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -9,6 +9,57 @@ import Testing /// process a request reuses, and when the host stops or restarts it. @MainActor struct MacBuildProviderSessionsTests { + /// Native execution and model requests share the same process; no page + /// or plugin-function negotiation participates. + @Test(arguments: ["lithe/build/execute", "lithe/build/test"]) + func nativeExecutionReusesTheWorkspaceProviderAndDeliversProgress(method: String) async throws { + let fixture = try ProviderFixture() + defer { fixture.remove() } + let packageBefore = try fixture.packageSnapshot() + _ = try await fixture.load() + let received = BuildProgressRecorder() + let result = try await fixture.sessions.execute(BuildProviderRequest( + method: method, params: .object([:]), optionFingerprint: "", + executesProjectCode: true, mayUseNetwork: true + ), on: fixture.target, timeout: .seconds(5)) { received.values.append($0) } + #expect(result == BuildProviderExecutionResult(exitCode: 0, output: "built")) + #expect(received.values == [BuildProviderProgress(percentage: 50, output: "building")]) + #expect(fixture.core.startCalls.count == 1) + #expect(fixture.core.buildRequests == ["lithe/build/model", method]) + let otherRoot = fixture.root.appendingPathComponent("other-workspace") + try FileManager.default.createDirectory(at: otherRoot, withIntermediateDirectories: true) + let other = BuildProviderTarget( + pluginID: ProviderFixture.pluginID, manifest: fixture.manifest, packageURL: fixture.packageURL, + buildSystemID: "gradle", workspaceURL: otherRoot + ) + _ = try await fixture.sessions.execute(BuildProviderRequest( + method: method, params: .object([:]), optionFingerprint: "", + executesProjectCode: true, mayUseNetwork: true + ), on: other, timeout: .seconds(5)) { _ in } + #expect(fixture.core.startCalls.count == 2) + #expect(try fixture.packageSnapshot() == packageBefore) + fixture.sessions.stopProviders(ofPlugin: ProviderFixture.pluginID) + #expect(fixture.core.stopCalls.count == 2) + } + + @Test + func nativeRunPreparationReusesProviderAndKeepsPackageReadOnly() async throws { + let fixture = try ProviderFixture() + defer { fixture.remove() } + let before = try fixture.packageSnapshot() + _ = try await fixture.load() + let progress = BuildProgressRecorder() + let result = try await fixture.sessions.prepareRun(BuildProviderRequest( + method: "lithe/build/prepareRun", params: .object([:]), optionFingerprint: "", + executesProjectCode: true, mayUseNetwork: true), on: fixture.target, timeout: .seconds(5)) { progress.values.append($0) } + #expect(result == .object(["executable": .object(["kind": .string("command"), "value": .string("java")]), + "arguments": .array([]), "workingDirectory": .string("."), "environment": .object([:])])) + #expect(progress.values == [BuildProviderProgress(percentage: 50, output: "building")]) + #expect(fixture.core.startCalls.count == 1) + #expect(fixture.core.buildRequests == ["lithe/build/model", "lithe/build/prepareRun"]) + #expect(try fixture.packageSnapshot() == before) + } + /// Cancellation reaches Core for the exact workspace command, while the /// shared language server stays available for editing and later sessions. @Test(arguments: [false, true]) @@ -466,6 +517,28 @@ private struct ProviderFixture { ) } + /// Compare both the complete listing and bytes, including added empty + /// directories; runtime state must not appear next to packaged inputs. + func packageSnapshot() throws -> [String: Data] { + let keys: Set = [.isDirectoryKey, .isSymbolicLinkKey] + let files = try #require(FileManager.default.enumerator( + at: packageURL, includingPropertiesForKeys: Array(keys) + )) + var snapshot: [String: Data] = [:] + for case let url as URL in files { + let relative = String(url.path.dropFirst(packageURL.path.count)) + let values = try url.resourceValues(forKeys: keys) + if values.isSymbolicLink == true { + snapshot["link:" + relative] = Data(try FileManager.default.destinationOfSymbolicLink(atPath: url.path).utf8) + } else if values.isDirectory == true { + snapshot["directory:" + relative] = Data() + } else { + snapshot["file:" + relative] = try Data(contentsOf: url) + } + } + return snapshot + } + func remove() { sessions.retainProviders { _ in false } try? FileManager.default.removeItem(at: root) @@ -663,10 +736,20 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked _buildRequests.append(method) _buildParams.append(params) let operationID = "\(sessionID)-\(_buildRequests.count)" + if ["lithe/build/execute", "lithe/build/test", "lithe/build/prepareRun"].contains(method) { + events[sessionID, default: []].append(LanguageServerRuntimeEvent( + type: "requestProgress", operationID: operationID, + result: .object(["percentage": .integer(50), "output": .string("building")]) + )) + } + let launch: ToolingJSONValue = .object(["executable": .object(["kind": .string("command"), "value": .string("java")]), + "arguments": .array([]), "workingDirectory": .string("."), "environment": .object([:])]) + let value: ToolingJSONValue = method == "lithe/build/prepareRun" ? launch : ["lithe/build/execute", "lithe/build/test"].contains(method) + ? .object(["exitCode": .integer(0), "output": .string("built")]) + : .object(["session": .string(sessionID)]) events[sessionID, default: []].append(LanguageServerRuntimeEvent( - type: "requestCompleted", - operationID: operationID, - result: .object(["value": .object(["session": .string(sessionID)])]) + type: "requestCompleted", operationID: operationID, + result: .object(["value": value]) )) return .success(LanguageServerRuntimeOperation(operationID: operationID)) } @@ -740,3 +823,6 @@ private final class EmptyToolRuntime: LanguageToolRuntimePort { func languageToolProcessEnvironment(runtimeTools _: [String]) -> [String: String] { [:] } func missingLanguageToolMessage(_ name: String) -> String { name } } + +@MainActor +private final class BuildProgressRecorder { var values: [BuildProviderProgress] = [] } diff --git a/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift b/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift new file mode 100644 index 0000000000..479d175ba6 --- /dev/null +++ b/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift @@ -0,0 +1,135 @@ +import CryptoKit +import Foundation +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import Testing +@testable import Lithe + +/// Explicit tool integration: the production macOS session table, linked Core, +/// real Java SDK backend and Maven reactor. No page or scripted runtime Core. +@Suite("Native macOS build Provider integration", .serialized) +@MainActor +struct MacNativeBuildProviderIntegrationTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_C_MAVEN_PACKAGE"] != nil, + "Requires an explicit development Maven package and tool paths")) + func realMavenProviderBuildsAndTestsWithoutAPluginPage() async throws { + let environment = ProcessInfo.processInfo.environment + let package = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_C_MAVEN_PACKAGE"])) + let fixture = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_C_MAVEN_FIXTURE"])) + let java = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_C_JAVA"])) + let maven = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_C_MAVEN"])) + try #require(RustCoreBridge().isAvailable) + let files = FileManager.default + let scratch = files.temporaryDirectory.appendingPathComponent("lithe-native-provider-\(UUID().uuidString)") + try files.createDirectory(at: scratch, withIntermediateDirectories: true) + defer { try? files.removeItem(at: scratch) } + let workspace = scratch.appendingPathComponent("workspace") + try files.createDirectory(at: workspace, withIntermediateDirectories: true) + try files.copyItem(at: fixture, to: workspace.appendingPathComponent("nested")) + let before = try Self.inventory(package) + let core = RustCoreBridge() + let operations = RustBuildSystemOperations(core: core) + let declarations = RustDeclarativePluginOperations(core: core) + let descriptor = try declarations.validate(manifest: Data(contentsOf: package.appendingPathComponent("plugin.json"))) + let target = BuildSystemTarget(manifest: descriptor.normalizedManifest, buildSystemID: "maven") + let provider = BuildProviderTarget(pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, + packageURL: package, buildSystemID: "maven", workspaceURL: workspace) + let released = TestGate() + let selectedTools = ExplicitProviderRuntime(java: java, environment: environment) + let sessions = MacBuildProviderSessions( + launchResolver: MacPluginLanguageServerLaunchResolver(planner: declarations, + directories: MacPluginStateDirectories(rootURL: scratch.appendingPathComponent("state")), settings: InMemoryPluginSettings()), + tools: selectedTools, versionChecker: declarations, runtime: selectedTools, core: core, processRegistry: nil, + endBackendProcesses: { token, _ in + MacPluginBackendProcesses.end(token: token, gracePeriod: 0) + released.open() + }) + let context = BuildPlanContext(root: "nested", options: ["profiles": .array([.string("ci")])], + pluginDirectory: package, workspaceStateDirectory: scratch.appendingPathComponent("workspace-state")) + let outcome: Result + do { + let plan = try operations.modelPlan(target, context: context, outputFile: nil, + providerHints: BuildProviderHints(toolExecutable: maven)) + guard case .provider(let request) = plan else { throw NativeProviderIntegrationError.unexpectedCommand } + let raw = try await sessions.send(request, to: provider, timeout: .seconds(20)) + let model = try operations.decodeModel(target, workspaceURL: workspace, root: context.root, + output: .providerResult(raw), options: context.options) + #expect(Set(model.projects.map(\.name)) == ["reactor", "core", "app"]) + let project = try #require(model.projects.first { $0.name == "core" }) + let taskPlan = try operations.taskPlan(target, context: context, + request: BuildTaskRequest(task: "compile", project: BuildSelection(project: project))) + guard case .provider(let build) = taskPlan else { throw NativeProviderIntegrationError.unexpectedCommand } + let progress = NativeProviderProgress() + let built = try await sessions.execute(Self.withTool(build, maven: maven), on: provider, timeout: .seconds(20)) { + progress.output += $0.output ?? "" + } + #expect(built.exitCode == 0) + #expect(built.output == progress.output) + #expect(files.fileExists(atPath: workspace.appendingPathComponent("nested/core/target/classes/demo/core/Greeter.class").path)) + let testPlan = try operations.testPlan(target, context: context, + request: BuildTestRequest(project: BuildSelection(project: project), suite: "demo.core.GreeterTest", testCase: "greetsByName")) + guard case .provider(let test) = testPlan.execution else { throw NativeProviderIntegrationError.unexpectedCommand } + let tested = try await sessions.execute(Self.withTool(test, maven: maven), on: provider, timeout: .seconds(20)) { _ in } + #expect(tested.exitCode == 0) + let results = try operations.decodeTestResults(target, request: BuildTestResultsRequest(workspaceURL: workspace, + root: context.root, project: project.path, projects: model.projects.map(\.path), output: tested.output, + notBefore: nil, suite: "demo.core.GreeterTest")) + #expect(results.summary.total == 1) + #expect(results.summary.failed == 0) + #expect(results.tests.first?.name == "greetsByName") + #expect(try await sessions.pluginFunctions(of: provider).isEmpty) + outcome = .success(()) + } catch { outcome = .failure(error) } + // Shutdown is observed through the production session's release callback, + // including after failed assertions/requests; the scratch workspace outlives it. + sessions.retainProviders { _ in false } + #expect(await released.waitUntilOpen(timeout: .seconds(5))) + #expect(try Self.inventory(package) == before) + try outcome.get() + } + + private static func withTool(_ request: BuildProviderRequest, maven: URL) -> BuildProviderRequest { + guard case .object(var params) = request.params else { return request } + params["tool"] = .object(["executable": .string(maven.path)]) + return BuildProviderRequest(method: request.method, params: .object(params), optionFingerprint: request.optionFingerprint, + executesProjectCode: request.executesProjectCode, mayUseNetwork: request.mayUseNetwork) + } + + static func inventory(_ root: URL) throws -> [String: String] { + let files = FileManager.default + let paths = try files.subpathsOfDirectory(atPath: root.path).sorted() + return try Dictionary(uniqueKeysWithValues: paths.map { relative in + let path = root.appendingPathComponent(relative) + let attributes = try files.attributesOfItem(atPath: path.path) + let type = attributes[.type] as? FileAttributeType + let digest = type == .typeRegular ? SHA256.hash(data: try Data(contentsOf: path)).map { String(format: "%02x", $0) }.joined() : "directory" + return (relative, "\(attributes[.posixPermissions] ?? ""):\(digest)") + }) + } +} + +private enum NativeProviderIntegrationError: Error { case unexpectedCommand } +@MainActor private final class NativeProviderProgress { var output = "" } + +/// Explicit test-selected executable paths. All process/RPC execution is real; +/// this port avoids reading or modifying the user's runtime preferences. +@MainActor +private final class ExplicitProviderRuntime: BuildToolResolving, LanguageToolRuntimePort { + let java: URL + let environment: [String: String] + init(java: URL, environment: [String: String]) { + self.java = java + var values = environment + values["JAVA_HOME"] = java.deletingLastPathComponent().deletingLastPathComponent().path + self.environment = values + } + func resolve(_ invocation: BuildInvocation, displayName: String, workspaceURL: URL, + checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck) async throws -> ResolvedBuildTool { + ResolvedBuildTool(executableURL: java, environment: environment, executesProjectCode: false, version: nil) + } + func executableOnPath(_ name: String) -> URL? { name == "java" ? java : nil } + func executableURL(at path: String) -> URL? { path == java.path ? java : nil } + func executableCandidates(_ command: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment(runtimeTools: [String]) -> [String: String] { environment } + func missingLanguageToolMessage(_ name: String) -> String { name } +} diff --git a/macos/Tests/LitheTests/MacNativeProviderDebugIntegrationTests.swift b/macos/Tests/LitheTests/MacNativeProviderDebugIntegrationTests.swift new file mode 100644 index 0000000000..5032bd3615 --- /dev/null +++ b/macos/Tests/LitheTests/MacNativeProviderDebugIntegrationTests.swift @@ -0,0 +1,251 @@ +import Foundation +import LitheCoreContracts +import LitheDebugModule +import LitheLanguageIntelligenceModule +import LitheModuleAPI +import Testing +@testable import Lithe + +/// Explicit integration: the public Node SDK builds a native program, then +/// the production Registry and transport hand it to a real LLDB DAP adapter. +@Suite("Native Provider LLDB integration", .serialized) +@MainActor +struct MacNativeProviderDebugIntegrationTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_C_NODE_SDK"] != nil, + "Requires the Node SDK source, Node, Clang and LLDB DAP paths")) + func providerBuildsThenDebugsANativeProgramThroughTheRegistry() async throws { + let environment = ProcessInfo.processInfo.environment + func selected(_ key: String) throws -> URL { + URL(fileURLWithPath: try #require(environment[key])) + } + let sdk = try selected("LITHE_STAGE_C_NODE_SDK") + let node = try selected("LITHE_STAGE_C_NODE") + let clang = try selected("LITHE_STAGE_C_CLANG") + let lldb = try selected("LITHE_STAGE_C_LLDB_DAP") + let files = FileManager.default + let scratch = files.temporaryDirectory.appendingPathComponent("provider-lldb-\(UUID().uuidString)") + try files.createDirectory(at: scratch, withIntermediateDirectories: true) + defer { try? files.removeItem(at: scratch) } + // Preserve the example's relative SDK import. All mutations are test + // inputs in this private copy, never the SDK checkout or an installation. + let copiedSDK = scratch.appendingPathComponent("sdk") + let package = copiedSDK.appendingPathComponent("examples/sample-native-build") + try files.createDirectory(at: package.deletingLastPathComponent(), withIntermediateDirectories: true) + try files.copyItem(at: sdk.appendingPathComponent("src"), to: copiedSDK.appendingPathComponent("src")) + try files.copyItem(at: sdk.appendingPathComponent("examples/sample-native-build"), to: package) + let manifestFile = package.appendingPathComponent("plugin.json") + var manifest = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: manifestFile)) as? [String: Any]) + var adapters = try #require(manifest["debugAdapters"] as? [[String: Any]]) + adapters[0]["launch"] = ["process": ["launch": ["kind": "systemCommand", "commands": ["lldb-dap"]]], "transport": "stdio"] + adapters[0]["configurations"] = ["launch": [ + "arguments": ["disableASLR": false], + "targetBindings": ["program": "target.path", "cwd": "target.workingDirectory", "args": "target.arguments", "env": "target.environment"]]] + manifest["debugAdapters"] = adapters + try JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys]).write(to: manifestFile) + let before = try MacNativeBuildProviderIntegrationTests.inventory(copiedSDK) + let workspace = scratch.appendingPathComponent("workspace") + let app = workspace.appendingPathComponent("nested/app") + try files.createDirectory(at: app, withIntermediateDirectories: true) + let sourceFile = app.appendingPathComponent("main.c") + try Data(""" + #include + int main(int argc, char **argv) { + int value = 40; + value += 2; + printf("%s:%d\\n", argc > 1 ? argv[1] : "missing", value); + return 0; + } + + """.utf8).write(to: sourceFile) + // Explicit source paths keep the compiler's DWARF file identity and + // DAP source identity equal across macOS's /var -> /private/var alias. + let config: [String: Any] = ["projects": [["name": "app", "path": "app", + "build": [clang.path, "-g", "-O0", sourceFile.path, "-o", "native-program"], + "run": ["command": "node", "arguments": [], "debugProgram": "native-program"]]]] + try JSONSerialization.data(withJSONObject: config).write(to: workspace.appendingPathComponent("nested/lithe-sample.json")) + + let core = RustCoreBridge() + try #require(core.isAvailable) + let declarations = RustDeclarativePluginOperations(core: core) + let operations = RustBuildSystemOperations(core: core) + let descriptor = try declarations.validate(manifest: Data(contentsOf: manifestFile)) + let installed = InstalledPluginPackage(descriptor: descriptor, installation: PluginInstallationRecord( + pluginID: descriptor.id, activeVersion: descriptor.pluginManifest.version, origin: .marketplace, status: .installed), packageURL: package) + let entry = try #require(PluginDebugAdapterEntry.qualifiedIndex(of: [installed]).values.first) + let target = BuildSystemTarget(manifest: descriptor.normalizedManifest, buildSystemID: "sample") + let provider = BuildProviderTarget(pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, + packageURL: package, buildSystemID: "sample", workspaceURL: workspace) + let pluginSource = BuildSystemPluginSource(pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, + packageURL: package, buildSystems: descriptor.buildSystems) + let resolver = MacPluginLanguageServerLaunchResolver(planner: declarations, + directories: MacPluginStateDirectories(rootURL: scratch.appendingPathComponent("state")), settings: InMemoryPluginSettings()) + let runtime = NativeDebugNodeRuntime(node: node, environment: environment) + let released = TestGate() + let sessions = MacBuildProviderSessions(launchResolver: resolver, tools: runtime, versionChecker: declarations, + runtime: runtime, core: core, processRegistry: nil, endBackendProcesses: { token, _ in + MacPluginBackendProcesses.end(token: token, gracePeriod: 0) + released.open() + }) + let trace = NativeDebugTrace() + let processScope = "native-debug-test-\(UUID().uuidString)" + let manager = DebugAdapterSessionManager(providers: [entry.provider(languages: [])]) { _, _ in + CoreDebugAdapterProtocolSession(adapterID: entry.adapter.id, + transport: NativeDebugRecordingTransport(wrapping: MacPluginDebugAdapterTransport(adapterID: entry.adapter.id, resolve: { + try resolver.resolveDebugAdapter(pluginID: descriptor.id.rawValue, adapterID: entry.adapter.id, + manifest: descriptor.normalizedManifest, packageURL: package, workspaceRootURL: $0) + }, makeProcess: { NativeDebugScopedProcess(scope: processScope) }, core: core, findCommand: { commands in + #expect(commands == ["lldb-dap"]) + return lldb + }), trace: trace), core: core, deadlineScheduler: MacDebugOperationDeadlineScheduler()) + } + manager.onSessionStateChange = { _, _, state in if state == .ready { trace.ready.open() } } + manager.onSessionEvent = { _, _, event in trace.record(event) } + let outcome: Result + do { + let context = BuildPlanContext(root: "nested", options: [:], pluginDirectory: package, + workspaceStateDirectory: scratch.appendingPathComponent("workspace-state")) + guard case .provider(let modelRequest) = try operations.modelPlan(target, context: context, outputFile: nil, providerHints: BuildProviderHints()) else { + throw NativeDebugIntegrationError.unexpectedPlan + } + let rawModel = try await sessions.send(modelRequest, to: provider, timeout: .seconds(15)) + let model = try operations.decodeModel(target, workspaceURL: workspace, root: context.root, + output: .providerResult(rawModel), options: [:]) + let project = try #require(model.projects.first) + let application = try #require(model.targets.first) + let request = try operations.prepareDebugPlan(target, context: context, run: "application", + project: BuildSelection(project: project), selectedTarget: BuildSelection(target: application), userArguments: ["two words"]) + let prepared = try await sessions.prepareDebug(request, on: provider, timeout: .seconds(15)) { trace.buildOutput += $0.output ?? "" } + let preparation = try operations.decodeDebugPreparation(target, workspaceURL: workspace, root: context.root, + run: "application", result: prepared) + let launch = try MacBuildDebugLaunchResolver(planner: declarations, entry: { _ in entry }, generation: { 1 }) + .resolve(source: pluginSource, preparation: preparation, workspaceURL: workspace, title: "Native integration") + #expect(manager.provider(for: sourceFile) == nil, "An executable-only adapter requires explicit Registry selection") + try manager.setBreakpoints([DebugSourceBreakpoint(line: 4)], in: sourceFile) + let binary = workspace.appendingPathComponent(preparation.program) + _ = try manager.activate(for: binary, rootURL: workspace, adapterProviderID: launch.adapterProviderID) + try #require(await trace.ready.waitUntilOpen(timeout: .seconds(10)), "LLDB initialize timed out: \(trace.output)") + let debugging = try manager.launch(for: binary, rootURL: workspace, configuration: launch) + try #require(await trace.stopped.waitUntilOpen(timeout: .seconds(10)), "Native breakpoint timed out: \(trace.output)\n\(trace.protocolLog)") + let thread = try #require(trace.thread) + let inspected = TestGate() + var stack: Result<[DebugStackFrame], Error>? + debugging.requestStackTrace(threadID: thread) { result in stack = result; inspected.open() } + try #require(await inspected.waitUntilOpen(timeout: .seconds(10)), "Native stack request timed out") + let frames = try #require(stack).get() + #expect(frames.first?.name.contains("main") == true) + #expect(frames.first?.line == 4) + #expect(trace.verifiedBreakpoint) + debugging.execute(DebugExecutionCommand.continueExecution, threadID: thread) + try #require(await trace.terminated.waitUntilOpen(timeout: .seconds(10)), "Native program did not terminate: \(trace.output)") + #expect(trace.exitCode == 0) + #expect(trace.output.contains("two words:42")) + #expect(try await sessions.pluginFunctions(of: provider).isEmpty) + outcome = .success(()) + } catch { outcome = .failure(error) } + // Pin descendants while LLDB still owns them: debugserver can create + // a new session and discard inherited environment variables. + let adapterRoots = MacProcessScopeScanner.processes(in: processScope).map(\.info.identity) + let owned = MacProcessScopeScanner.retainedIdentities(adapterRoots, snapshot: MacProcessTable.snapshot()) + manager.stopAll() + // The native adapter's bounded OS cleanup runs on its worker queue, + // never on the test's cooperative executor or the main actor. + let remaining = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + continuation.resume(returning: MacProcessScopeScanner.terminate(Array(owned), gracePeriod: 0)) + } + } + #expect(remaining.isEmpty) + sessions.retainProviders { _ in false } + #expect(await released.waitUntilOpen(timeout: .seconds(5))) + #expect(manager.activeSessionIDs.isEmpty) + #expect(try MacNativeBuildProviderIntegrationTests.inventory(copiedSDK) == before) + try outcome.get() + } +} + +private enum NativeDebugIntegrationError: Error { case unexpectedPlan } + +@MainActor +private final class NativeDebugTrace { + let ready = TestGate(), stopped = TestGate(), terminated = TestGate() + var thread: Int? + var exitCode: Int? + var verifiedBreakpoint = false + var output = "" + var buildOutput = "" + var protocolLog = "" + func record(_ event: DebugAdapterEvent) { + switch event { + case .stopped(_, let threadID, _): thread = threadID; stopped.open() + case .terminated(let code): exitCode = code; terminated.open() + case .breakpoint(let breakpoint): verifiedBreakpoint = verifiedBreakpoint || breakpoint.verified + case .output(_, let text): output += text + default: break + } + } +} + +@MainActor +private final class NativeDebugRecordingTransport: DebugAdapterTransport { + let wrapped: any DebugAdapterTransport + let trace: NativeDebugTrace + var onData: ((Data) -> Void)? + var onErrorOutput: ((Data) -> Void)? + var onTermination: ((Int) -> Void)? + var isRunning: Bool { wrapped.isRunning } + init(wrapping wrapped: any DebugAdapterTransport, trace: NativeDebugTrace) { + self.wrapped = wrapped; self.trace = trace + wrapped.onData = { [weak self] data in + self?.trace.protocolLog += "<- " + String(decoding: data, as: UTF8.self) + "\n" + self?.onData?(data) + } + wrapped.onErrorOutput = { [weak self] data in self?.onErrorOutput?(data) } + wrapped.onTermination = { [weak self] code in self?.onTermination?(code) } + } + func start(rootURL: URL) throws { try wrapped.start(rootURL: rootURL) } + func send(_ data: Data) throws { + trace.protocolLog += "-> " + String(decoding: data, as: UTF8.self) + "\n" + try wrapped.send(data) + } + func stop() { wrapped.stop() } +} + +/// Adds a private cleanup scope to the real adapter process without mutating +/// the test runner's environment or changing process execution behavior. +private final class NativeDebugScopedProcess: RawProcessSession, @unchecked Sendable { + let wrapped = MacRawProcessSession() + let scope: String + init(scope: String) { self.scope = scope } + var isRunning: Bool { wrapped.isRunning } + var onOutput: (@Sendable (Data) -> Void)? { get { wrapped.onOutput } set { wrapped.onOutput = newValue } } + var onError: (@Sendable (Data) -> Void)? { get { wrapped.onError } set { wrapped.onError = newValue } } + var onTermination: (@Sendable (Int32) -> Void)? { get { wrapped.onTermination } set { wrapped.onTermination = newValue } } + var onStateChange: (@Sendable (ProcessLifecycleEvent) -> Void)? { get { wrapped.onStateChange } set { wrapped.onStateChange = newValue } } + func start(_ request: ProcessRequest) throws { + var environment = request.environment ?? [:] + environment[MacProcessScope.environmentKey] = scope + try wrapped.start(ProcessRequest(operationID: request.operationID, executablePath: request.executablePath, + arguments: request.arguments, workingDirectory: request.workingDirectory, environment: environment, + standardInput: request.standardInput, keepsStandardInputOpen: request.keepsStandardInputOpen, + timeoutMilliseconds: request.timeoutMilliseconds)) + } + func send(_ input: Data) throws { try wrapped.send(input) } + func stop() { wrapped.stop() } +} + +/// The caller selects Node explicitly; every process and protocol request is real. +@MainActor +private final class NativeDebugNodeRuntime: BuildToolResolving, LanguageToolRuntimePort { + let node: URL + let environment: [String: String] + init(node: URL, environment: [String: String]) { self.node = node; self.environment = environment } + func resolve(_ invocation: BuildInvocation, displayName: String, workspaceURL: URL, + checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck) async throws -> ResolvedBuildTool { + ResolvedBuildTool(executableURL: node, environment: environment, executesProjectCode: false, version: nil) + } + func executableOnPath(_ name: String) -> URL? { name == "node" ? node : nil } + func executableURL(at path: String) -> URL? { path == node.path ? node : nil } + func executableCandidates(_ command: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment(runtimeTools: [String]) -> [String: String] { environment } + func missingLanguageToolMessage(_ name: String) -> String { name } +} diff --git a/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift b/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift new file mode 100644 index 0000000000..932766d8e3 --- /dev/null +++ b/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift @@ -0,0 +1,378 @@ +import CryptoKit +import Foundation +@testable import Lithe +import LitheCoreContracts +import LitheApplicationKernel +import LitheModuleAPI +import LithePluginPackageSigning +import Testing + +/// Runs production validation, signature verification and package storage. +/// No fixture process executes, and all files belong to this test's root. +@MainActor +struct PluginBundledPublisherTrustTests { + @Test(arguments: [true, false], ["direct", "marketplace", "disk"]) + func installingOverBundledCopyPreservesDefaultOrDisabledChoice(enabled: Bool, source: String) async throws { + let fixture = try Fixture() + defer { fixture.remove() } + let preferences = MigrationPreferences() + let configuration = MacModuleConfigurationStore(store: preferences) + let store = fixture.store() + let initial = try store.scanInstalledPlugins().packages + let moduleID = try #require(initial.first?.manifest.modules.first?.manifest.id) + if !enabled { configuration.setEnabledState(false, for: moduleID) } + let settings = InMemoryPluginSettings([OfficialPluginCatalog.javaPluginID.rawValue: ["saved": .string("retained")]]) + let manager = fixture.manager(store: store, configuration: configuration, packages: initial, settings: settings) + let original = try fixture.digest(of: fixture.bundled) + try fixture.sign(fixture.download) + + // All production entry points now share live-activation routing. Each + // must still migrate the sealed copy's choice only after validation. + let digest = try fixture.digest(of: fixture.download) + switch source { + case "marketplace": + try manager.installMarketplacePackage(at: fixture.download, origin: .marketplace, + provenance: PluginMarketplaceProvenance(sourceID: "official", sourceName: "Official", + repository: "https://github.com/example/java-support", archiveSHA256: String(repeating: "0", count: 64), + contentDigest: digest, approvedPrograms: [])) + case "disk": + // Signed disk imports retain publisher trust. The community-only + // installDiskPackage entry point must never accept this official ID. + let transport = PluginMarketplaceTestSupport.LocalFileTransport(files: [:]) + let service = MacPluginMarketplaceService( + evaluator: PluginMarketplaceTestSupport.evaluator, + packageStore: store, + downloader: MacPluginPackageDownloader(transport: transport, temporaryDirectory: fixture.root.appendingPathComponent("tmp")), + transport: transport, + applicationSupportDirectory: fixture.root.appendingPathComponent("support"), + cacheDirectory: fixture.root.appendingPathComponent("cache"), + bundledOfficialIndexURL: nil, + diskImporter: MacPluginDiskPackageImporter(temporaryDirectory: fixture.root.appendingPathComponent("tmp")), + allowsLocalDevelopmentPackages: false + ) + let marketplace = PluginMarketplaceFeatureModel(service: service, pluginManager: manager) + await marketplace.installFromDisk(at: fixture.download) + #expect(marketplace.diskInstallError == nil) + #expect(marketplace.pendingConfirmation == nil) + default: + try manager.installPackage(at: fixture.download) + } + + #expect(configuration.enabledState(for: moduleID) == enabled) + #expect(manager.snapshots.first?.isEnabled == enabled) + #expect(manager.snapshots.first?.requiresRestart == true) + #expect(settings.values(forPlugin: OfficialPluginCatalog.javaPluginID.rawValue)["saved"] == .string("retained")) + try store.prepareForLaunch() + let restarted = try store.scanInstalledPlugins() + #expect(restarted.packages.count == 1) + #expect(restarted.packages.first?.installation.origin == .marketplace) + #expect(restarted.packages.first?.installation.status == .installed) + #expect(try fixture.digest(of: fixture.bundled) == original) + } + + @Test + func migrationDoesNotEnableModulesThePreviousPackageDidNotOwn() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let store = fixture.store() + try store.setBundledPluginRemoved(OfficialPluginCatalog.javaPluginID, removed: true) + let configuration = MacModuleConfigurationStore(store: MigrationPreferences()) + let manager = fixture.manager(store: store, configuration: configuration, packages: try store.scanInstalledPlugins().packages) + var object = fixture.manifest + var languages = try #require(object["languages"] as? [[String: Any]]) + languages.append(["id": "future", "displayName": "Future", "fileExtensions": ["future"]]) + object["languages"] = languages + object["activationEvents"] = ["onLanguage:fixture", "onLanguage:future"] + var servers = try #require(object["languageServers"] as? [[String: Any]]) + var added = try #require(servers.first) + added["languageID"] = "future" + servers.append(added) + object["languageServers"] = servers + try JSONSerialization.data(withJSONObject: object).write(to: fixture.download.appendingPathComponent("plugin.json")) + try fixture.sign(fixture.download) + + try manager.installPackage(at: fixture.download) + + #expect(configuration.enabledState(for: .languageServerExtension("fixture")) == true) + #expect(configuration.enabledState(for: .languageServerExtension("future")) == nil) + } + + @Test + func directUpgradeWithoutBundledCopyKeepsDisabledPreferenceAndRemovedState() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let store = fixture.store(includeBundled: false) + try store.setBundledPluginRemoved(OfficialPluginCatalog.javaPluginID, removed: true) + try store.prepareForLaunch() + #expect(try store.scanInstalledPlugins().packages.isEmpty) + let configuration = MacModuleConfigurationStore(store: MigrationPreferences()) + let moduleID = try #require(DeclarativePluginTestSupport.operations.validate( + manifest: Data(contentsOf: fixture.download.appendingPathComponent("plugin.json")) + ).pluginManifest.modules.first?.manifest.id) + configuration.setEnabledState(false, for: moduleID) + let manager = fixture.manager(store: store, configuration: configuration, packages: []) + try fixture.sign(fixture.download) + try manager.installPackage(at: fixture.download) + // An explicit disk install is allowed, but does not turn a disabled + // module back on or let the old removal marker delete its state. + let state = fixture.stateDirectories.pluginDirectory(pluginID: OfficialPluginCatalog.javaPluginID.rawValue) + try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true) + let saved = state.appendingPathComponent("saved.json") + try Data("user state".utf8).write(to: saved) + try store.prepareForLaunch() + #expect(configuration.enabledState(for: moduleID) == false) + #expect(try Data(contentsOf: saved) == Data("user state".utf8)) + #expect(try store.scanInstalledPlugins().packages.count == 1) + } + + @Test + func uninstallingInstalledUpdateDoesNotResurrectBundledJava() async throws { + let fixture = try Fixture() + defer { fixture.remove() } + let store = fixture.store() + try fixture.sign(fixture.download) + _ = try store.installPackage(from: fixture.download) + let configuration = MacModuleConfigurationStore(store: MigrationPreferences()) + let manager = fixture.manager(store: store, configuration: configuration, packages: try store.scanInstalledPlugins().packages) + try await manager.uninstall(OfficialPluginCatalog.javaPluginID) + try store.prepareForLaunch() + let packages = try store.scanInstalledPlugins().packages + #expect(packages.count == 1) + #expect(packages.first?.installation.origin == .bundled) + #expect(packages.first?.installation.status == .removed) + let restarted = fixture.manager(store: store, configuration: configuration, packages: packages) + #expect(restarted.snapshots.first?.installationStatus == .removed) + for file in ["Main.java", "pom.xml"] { + #expect(JavaPluginTransition.shouldSuggest(files: [fixture.root.appendingPathComponent(file)], + plugins: restarted.snapshots)) + } + } + + @Test + func failedInstallPreservesBundledEnablementAndImmutableResources() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let configuration = MacModuleConfigurationStore(store: MigrationPreferences()) + let store = fixture.store() + let packages = try store.scanInstalledPlugins().packages + let manager = fixture.manager(store: store, configuration: configuration, packages: packages) + let moduleID = try #require(packages.first?.manifest.modules.first?.manifest.id) + let before = try fixture.digest(of: fixture.bundled) + #expect(throws: (any Error).self) { try manager.installPackage(at: fixture.download) } + #expect(configuration.enabledState(for: moduleID) == nil) + #expect(manager.snapshots.first?.isEnabled == true) + #expect(manager.snapshots.first?.requiresRestart == false) + #expect(try fixture.digest(of: fixture.bundled) == before) + } + + @Test + func missingJavaIsSuggestedOnlyWhenNeededAndNoPackageExists() { + for name in ["Main.java", "pom.xml", "build.gradle", "build.gradle.kts"] { + let files = [URL(fileURLWithPath: "/fixture/\(name)")] + #expect(JavaPluginTransition.shouldSuggest(files: files, plugins: [])) + } + #expect(!JavaPluginTransition.shouldSuggest(files: [URL(fileURLWithPath: "/fixture/README.md")], plugins: [])) + } + + @Test(arguments: [PluginInstallationStatus.installed, .updateStaged, .uninstallPending, .removed], [false, true]) + func installationStateControlsTheJavaSuggestion(status: PluginInstallationStatus, enabled: Bool) throws { + let manifest = try DeclarativePluginTestSupport.officialDescriptor("JavaSupport").pluginManifest + var snapshot = PluginManagementSnapshot(manifest: manifest, origin: .bundled, installationStatus: status, + isEnabled: enabled, isRequired: false, isRunning: false, isQuarantined: false, + isSuppressedBySafeMode: false, requiresRestart: status != .installed, canRollback: false, + statusMessage: "fixture", marketplace: nil) + for name in ["Main.java", "pom.xml", "build.gradle", "build.gradle.kts"] { + #expect(JavaPluginTransition.shouldSuggest(files: [URL(fileURLWithPath: "/fixture/\(name)")], + plugins: [snapshot]) == (status == .removed)) + } + #expect(!JavaPluginTransition.shouldSuggest(files: [URL(fileURLWithPath: "/fixture/README.md")], plugins: [snapshot])) + // A removed sealed copy can still be registered until this session + // restarts; only the next launch should offer to restore it. + snapshot.hasChangesPendingRestart = true + #expect(!JavaPluginTransition.shouldSuggest(files: [URL(fileURLWithPath: "/fixture/Main.java")], plugins: [snapshot])) + } + @Test + func hostSealedCopyAndSignedInstalledUpdateUseOnePluginIdentity() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let before = try fixture.digest(of: fixture.bundled) + let initial = try fixture.store().scanInstalledPlugins() + #expect(initial.issues.isEmpty) + #expect(initial.packages.map(\.installation.origin) == [.bundled]) + + try fixture.sign(fixture.download) + let installed = try fixture.store().installPackage(from: fixture.download) + let restarted = try fixture.store().scanInstalledPlugins() + #expect(restarted.issues.isEmpty) + #expect(restarted.packages.count == 1) + #expect(restarted.packages.first?.packageURL.resolvingSymlinksInPath() == installed.packageURL.resolvingSymlinksInPath()) + #expect(restarted.packages.first?.installation.origin == .marketplace) + #expect(try fixture.digest(of: fixture.bundled) == before) + #expect(try fixture.digest(of: installed.packageURL) == fixture.digest(of: fixture.download)) + } + + @Test + func unsignedOutsideCopyCannotClaimTheBundledTrustException() throws { + let fixture = try Fixture() + defer { fixture.remove() } + for origin: PluginInstallationOrigin in [.marketplace, .bundled, .localDevelopment] { + #expect(throws: (any Error).self) { + _ = try fixture.store().verifyCandidatePackage( + at: fixture.download, origin: origin, marketplace: nil + ) + } + } + #expect(try fixture.store().scanInstalledPlugins().packages.first?.installation.origin == .bundled) + } + + @Test + func presentInvalidSignatureIsRejectedEvenInsideTheHostSeal() throws { + let fixture = try Fixture() + defer { fixture.remove() } + try fixture.sign(fixture.bundled) + try Data("tampered".utf8).write(to: fixture.bundled.appendingPathComponent("server.jar")) + let scan = try fixture.store().scanInstalledPlugins() + #expect(scan.packages.isEmpty) + #expect(scan.issues.count == 1) + } + + @Test + func installedRuntimeTamperingCannotFallBackToHostSeal() throws { + let fixture = try Fixture() + defer { fixture.remove() } + try fixture.sign(fixture.download) + let installed = try fixture.store().installPackage(from: fixture.download) + try Data("tampered".utf8).write(to: installed.packageURL.appendingPathComponent("runtime/arm64/bin/java")) + let scan = try fixture.store().scanInstalledPlugins() + #expect(scan.issues.count == 1) + // The damaged update is isolated; the immutable bundled source remains. + #expect(scan.packages.map(\.installation.origin) == [.bundled]) + } + + @Test + func officialPackageCannotChangeItsPinnedBundledTrust() throws { + let fixture = try Fixture() + defer { fixture.remove() } + var manifest = fixture.manifest + var vendor = try #require(manifest["vendor"] as? [String: Any]) + vendor.removeValue(forKey: "bundledSignatureRequirement") + manifest["vendor"] = vendor + var requires = try #require(manifest["requires"] as? [String: Int]) + requires.removeValue(forKey: "packageTrust") + manifest["requires"] = requires + try JSONSerialization.data(withJSONObject: manifest).write(to: fixture.download.appendingPathComponent("plugin.json")) + try fixture.sign(fixture.download) + #expect(throws: PluginPackageStoreError.officialSignatureRequirementMismatch(OfficialPluginCatalog.javaPluginID)) { + _ = try fixture.store().installPackage(from: fixture.download) + } + } + + @Test + func signedPackageMissingTheHostArchitectureRuntimeIsRejected() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let arch = MacPluginLanguageServerLaunchResolver.hostArchitecture + try FileManager.default.removeItem(at: fixture.download.appendingPathComponent("runtime/\(arch)/bin/java")) + try fixture.sign(fixture.download) + #expect(throws: (any Error).self) { + _ = try fixture.store().installPackage(from: fixture.download) + } + #expect(try fixture.store().scanInstalledPlugins().packages.map(\.installation.origin) == [.bundled]) + } + + private struct Fixture { + let root: URL + let host: URL + let bundled: URL + let download: URL + let manifest: [String: Any] + let privateKey: Curve25519.Signing.PrivateKey + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-publisher-bundled-\(UUID().uuidString)") + host = root.appendingPathComponent("Lithe.app") + bundled = host.appendingPathComponent("Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support") + download = root.appendingPathComponent("download/dev.lithe.plugin.java-support") + privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 9, count: 32)) + let url = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json") + var object = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + object["id"] = OfficialPluginCatalog.javaPluginID.rawValue + manifest = object + do { + for package in [bundled, download] { + try DeclarativePluginTestSupport.writePackage(at: package, manifest: manifest) + for arch in ["arm64", "x86_64"] { + let java = package.appendingPathComponent("runtime/\(arch)/bin/java") + try FileManager.default.createDirectory(at: java.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("fixture runtime".utf8).write(to: java) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: java.path) + } + try Data("fixture server".utf8).write(to: package.appendingPathComponent("server.jar")) + } + } catch { + try? FileManager.default.removeItem(at: root) + throw error + } + } + + var stateDirectories: MacPluginStateDirectories { MacPluginStateDirectories(rootURL: root.appendingPathComponent("state")) } + + func store(includeBundled: Bool = true) -> MacPluginPackageStore { + MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed"), + bundledRootURL: includeBundled ? bundled.deletingLastPathComponent() : nil, + stateDirectories: stateDirectories, + validator: DeclarativePluginTestSupport.operations, + verifier: MacOfficialPluginSignatureVerifier( + trustedPublisherKeys: [PluginPackageSignature.keyID: privateKey.publicKey], + allowsLocalDevelopmentPackages: false, + hostBundleURL: host + ) + ) + } + + @MainActor + func manager( + store: MacPluginPackageStore, + configuration: MacModuleConfigurationStore, + packages: [InstalledPluginPackage], + settings: any PluginSettingsStoring = InMemoryPluginSettings() + ) -> MacPluginManager { + MacPluginManager( + packageStore: store, + moduleRuntime: ModuleRuntime(configurationStore: configuration, recoveryStore: configuration), + configurationStore: configuration, + settings: settings, + launchMode: .normal, + startup: MacPluginStartupResult(installedPlugins: packages, registeredPlugins: [], issues: []), + allowsLocalDevelopmentPackages: false + ) + } + + func sign(_ package: URL) throws { + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: Data(contentsOf: package.appendingPathComponent("plugin.json"))) + let document = try PluginPackageSignature.makeDocument( + packageAt: package, + pluginID: descriptor.id.rawValue, + pluginVersion: descriptor.pluginManifest.version.description, + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: package) + } + + func digest(of package: URL) throws -> String { + try PluginPackageSignature.contentDigest(packageAt: package, fileManager: FileManager.default) + } + + func remove() { try? FileManager.default.removeItem(at: root) } + } +} + +private final class MigrationPreferences: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift index 7f0baf5bbc..b435f33319 100644 --- a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift +++ b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift @@ -1,6 +1,7 @@ import Foundation import LitheApplicationKernel import LitheCoreContracts +import LitheModuleAPI import LitheDebugModule import LitheLanguageIntelligenceModule import LitheModuleAPI @@ -12,6 +13,94 @@ import Testing struct PluginDebugAdapterTests { private struct ResolveFailure: Error, Equatable {} + @Test(arguments: ["valid", "not-executable", "withdrawn", "replaced", "program-alias", "instance-alias"]) + func providerDebugHandoffRechecksFilesAndCurrentPackage(change: String) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-debug-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("workspace", isDirectory: true) + let instance = workspace.appendingPathComponent("app", isDirectory: true) + try FileManager.default.createDirectory(at: instance, withIntermediateDirectories: true) + let program = instance.appendingPathComponent("main") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: program) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: program.path) + let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json") + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: Data(contentsOf: fixture)) + let package = InstalledPluginPackage(descriptor: descriptor, installation: PluginInstallationRecord( + pluginID: descriptor.id, activeVersion: descriptor.pluginManifest.version, origin: .marketplace, status: .installed), + packageURL: root.appendingPathComponent("package", isDirectory: true)) + let entry = try #require(PluginDebugAdapterEntry.qualifiedIndex(of: [package]).values.first) + let source = BuildSystemPluginSource(pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, packageURL: package.packageURL, buildSystems: descriptor.buildSystems) + let plan = try JSONDecoder().decode(BuildDebugPreparation.self, from: Data( + #"{"root":"app","adapter":"native","program":"app/main","arguments":["a b","x#y"],"workingDirectory":"app","environment":{"MODE":"debug"}}"#.utf8)) + let lookup = entry.provider(languages: []) + #expect(lookup.matches(URL(fileURLWithPath: "/workspace/main.c"))) + #expect(!lookup.selectsByFile) + var current: PluginDebugAdapterEntry? = entry + switch change { + case "not-executable": try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: program.path) + case "withdrawn": current = nil + case "replaced": + let other = InstalledPluginPackage(descriptor: descriptor, installation: package.installation, + packageURL: root.appendingPathComponent("replacement", isDirectory: true)) + current = PluginDebugAdapterEntry(plugin: other, adapter: entry.adapter) + case "program-alias", "instance-alias": + let other = workspace.appendingPathComponent("other", isDirectory: true) + try FileManager.default.createDirectory(at: other, withIntermediateDirectories: true) + let outside = other.appendingPathComponent("main") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: outside) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: outside.path) + if change == "program-alias" { + try FileManager.default.removeItem(at: program) + try FileManager.default.createSymbolicLink(at: program, withDestinationURL: outside) + } else { + let external = root.appendingPathComponent("external", isDirectory: true) + try FileManager.default.moveItem(at: instance, to: external) + try FileManager.default.createSymbolicLink(at: instance, withDestinationURL: external) + } + default: break + } + let resolver = MacBuildDebugLaunchResolver(planner: DeclarativePluginTestSupport.operations, + entry: { _ in current }, generation: { 7 }) + if change == "valid" { + let launch = try resolver.resolve(source: source, preparation: plan, workspaceURL: workspace, title: "Main") + #expect(launch.adapterProviderID == entry.providerID) + #expect(launch.arguments["args"] == .array([.string("a b"), .string("x#y")])) + #expect(launch.arguments["env"] == .object(["MODE": .string("debug")])) + #expect(launch.arguments["program"] == .string(program.resolvingSymlinksInPath().path)) + } else { + #expect(throws: DebugLaunchConfigurationResolutionError.providerDebugUnavailable) { + try resolver.resolve(source: source, preparation: plan, workspaceURL: workspace, title: "Main") + } + } + } + + + @Test + func explicitHostIdentityCrossesTheRealCoreBridgeWithoutBecomingADAPArgument() throws { + let core = RustCoreBridge() + let id = "explicit-adapter-\(UUID().uuidString)" + _ = try core.createDebugSession(sessionID: id, adapterID: "example-dap", rootPath: "/workspace/example", + supportsRunInTerminalRequest: false, supportsStartDebuggingRequest: false, + disabledCapabilities: [], parentSessionID: nil) + defer { core.destroyDebugSession(sessionID: id) } + let body = Data(#"{"seq":1,"type":"response","request_seq":1,"command":"initialize","success":true,"body":{}}"#.utf8) + var reply = Data("Content-Length: \(body.count)\r\n\r\n".utf8) + reply.append(body) + _ = try core.receiveDebugData(sessionID: id, data: reply) + let launch = try core.launchDebugSession(sessionID: id, operationID: "launch", configuration: + DebugLaunchConfiguration(name: "example", request: .launch, + arguments: ["program": .string("bin/example")], adapterProviderID: "dev.example.native/lldb")) + let frame = try #require(launch.outboundFrames.first) + let bytes = try #require(Data(base64Encoded: frame)) + let separator = try #require(bytes.range(of: Data("\r\n\r\n".utf8))) + let message = try #require(JSONSerialization.jsonObject(with: bytes[separator.upperBound...]) as? [String: Any]) + let arguments = try #require(message["arguments"] as? [String: Any]) + #expect(message["command"] as? String == "launch") + #expect(arguments["program"] as? String == "bin/example") + #expect(arguments["adapterProviderID"] == nil) + #expect(message["adapterProviderID"] == nil) + } + private static func process(_ executable: MacPluginLanguageServerLaunchResolver.BuildProviderProcess.Executable) -> MacPluginLanguageServerLaunchResolver.DebugAdapterLaunch { diff --git a/macos/Tests/LitheTests/PluginInstallationGuidancePresentationTests.swift b/macos/Tests/LitheTests/PluginInstallationGuidancePresentationTests.swift new file mode 100644 index 0000000000..e3f11a0d8f --- /dev/null +++ b/macos/Tests/LitheTests/PluginInstallationGuidancePresentationTests.swift @@ -0,0 +1,131 @@ +import AppKit +import Foundation +import SwiftUI +import Testing +import LitheCoreContracts +import LitheModuleAPI +@testable import Lithe + +@MainActor +@Suite("Java installation guidance presentation", .serialized) +struct PluginInstallationGuidancePresentationTests { + @Test(arguments: [false, true]) + func missingJavaNotificationOffersAnExplicitMarketplaceAction(dark: Bool) async throws { + let language = InterfaceLanguage.locale.identifier + InterfaceLanguage.select("en") + defer { InterfaceLanguage.select(language) } + let feature = WorkbenchNotificationFeatureModel() + feature.setApplicationActive(false) + defer { feature.clear() } + var searches: [String] = [] + feature.show("Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept.", + arguments: [], action: WorkbenchNotification.Action(title: "Open Marketplace") { searches.append("Java Support") }) + let notification = try #require(feature.activeNotifications.first) + let view = WorkbenchNotificationBanner(message: notification.message, occurrenceCount: 1, + actionTitle: notification.action?.title, performAction: { feature.perform(notification.id) }, + dismiss: { feature.dismiss(notification.id) }) + .padding(20).frame(width: 400, height: 260, alignment: .top) + .background(LitheTheme.editor) + try await withHost(AnyView(view), size: NSSize(width: 400, height: 260), dark: dark) { host in + let expand = try #require(await button("Expand notification", in: host)) + try #require(expand.accessibilityPerformPress?() == true) + try #require(await button("Collapse notification", in: host) != nil) + let action = try #require(await button("Open Marketplace", in: host)) + try capture(host, name: "java-missing-notification-" + (dark ? "dark" : "light")) + #expect(searches.isEmpty) + #expect(action.accessibilityPerformPress?() == true) + #expect(searches == ["Java Support"]) + #expect(feature.activeNotifications.isEmpty) + } + } + + @Test(arguments: [false, true]) + func newProjectOffersJavaMavenAndSpringTemplatesWhenJavaIsMissing(dark: Bool) async throws { + let language = InterfaceLanguage.locale.identifier + InterfaceLanguage.select("en") + defer { InterfaceLanguage.select(language) } + let suite = "java-template-presentation-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let planner = RustProjectGeneratorOperations(core: RustCoreBridge()) + let service = ProjectGenerationService(planner: planner, + platform: MacProjectGenerationPlatform(environment: { $0 }, executable: { _ in nil }, runner: GuidanceCommandRunner()), + initializeGitRepository: { _ in nil }, recordJavaHome: { _, _ in }) + var searches: [String] = [] + let feature = NewProjectFeatureModel(environment: NewProjectEnvironment( + sources: { [] }, iconResources: [:], javaRuntimes: { [] }, service: service, + setPluginEnabled: { _ in Issue.record("A marketplace action must not enable a plugin") }, + openMarketplace: { searches.append($0) }, openProject: { _ in Issue.record("No project should be created") }, + store: MacUserDefaultsStore(defaults: defaults))) + defer { feature.cancelCreation() } + feature.location = "/fixture/projects" + feature.reloadCatalog() + #expect(feature.isJavaSupportMissing) + try await withHost(AnyView(NewProjectView(feature: feature).defaultAppStorage(defaults)), + size: NSSize(width: 880, height: 660), dark: dark) { host in + let action = try #require(await button("Install Java Support for Java, Maven and Spring templates…", in: host)) + try capture(host, name: "java-new-project-" + (dark ? "dark" : "light")) + #expect(searches.isEmpty) + #expect(action.accessibilityPerformPress?() == true) + #expect(searches == ["Java Support"]) + #expect(!feature.isCreating) + } + } + + private func withHost(_ view: AnyView, size: NSSize, dark: Bool, + body: (NSView) async throws -> Void) async throws { + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (application.accessibilityAttributeValue(attribute) as? Bool) ?? false + application.accessibilitySetValue(true, forAttribute: attribute) + defer { application.accessibilitySetValue(previous, forAttribute: attribute) } + MacBundledFontRegistry.registerFonts() + let host = NSHostingView(rootView: view.environment(\.locale, Locale(identifier: "en")) + .environment(\.colorScheme, dark ? .dark : .light)) + let window = NSWindow(contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + window.orderFront(nil) + defer { window.contentView = nil; window.close() } + try await body(host) + } + + private func buttons(in value: Any) -> [AnyObject] { + let item = value as AnyObject + return (item.accessibilityRole?() == .button ? [item] : []) + + (item.accessibilityChildren?() ?? []).flatMap { buttons(in: $0) } + } + + private func button(_ title: String, in host: NSView) async -> AnyObject? { + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if let result = buttons(in: host).first(where: { ($0.accessibilityLabel?() ?? $0.accessibilityTitle?()) == title }) { + return result + } + await Task.yield() + } while clock.now < deadline + return nil + } + + private func capture(_ host: NSView, name: String) throws { + host.layoutSubtreeIfNeeded() + let bitmap = try nativeSRGBCapture(host) + #expect(bitmap.pixelsWide == Int(host.bounds.width * 2)) + #expect(bitmap.pixelsHigh == Int(host.bounds.height * 2)) + if let directory = ProcessInfo.processInfo.environment["LITHE_JAVA_GUIDANCE_CAPTURE_DIR"] { + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: + URL(fileURLWithPath: directory).appendingPathComponent(name + ".png")) + } + } +} + +private struct GuidanceCommandRunner: BuildCommandRunning { + func run(_ request: BuildCommandRequest, onOutput: @escaping @Sendable (String) -> Void) async throws -> BuildCommandResult { + Issue.record("Installation guidance must not execute a tool") + throw CancellationError() + } +} diff --git a/macos/Tests/LitheTests/PluginLiveActivationTests.swift b/macos/Tests/LitheTests/PluginLiveActivationTests.swift index 3e253c2606..4344c93a6f 100644 --- a/macos/Tests/LitheTests/PluginLiveActivationTests.swift +++ b/macos/Tests/LitheTests/PluginLiveActivationTests.swift @@ -79,6 +79,46 @@ struct PluginLiveActivationTests { // MARK: - Registry content + @Test + func qualifiedAdaptersKeepEveryPackageAndNeverBecomeFileDefaults() throws { + let fixture = try LiveFixture() + defer { fixture.cleanUp() } + let first = try fixture.package(Self.debugManifest(id: "dev.example.first")) + let second = try fixture.package(Self.debugManifest(id: "dev.example.second")) + let attach = try fixture.package(Self.debugManifest(id: "dev.example.attach", + languageIDs: ["java"], requests: ["attach"], targetKinds: ["process"])) + let packages = [second, attach, first] + let qualified = PluginDebugAdapterEntry.qualifiedIndex(of: packages) + #expect(Set(qualified.keys) == ["dev.example.first/lldb", "dev.example.second/lldb", "dev.example.attach/lldb"]) + let automatic = PluginDebugAdapterEntry.index(of: packages) + #expect(automatic["rust"]?.plugin.descriptor.id == first.descriptor.id) + #expect(automatic["java"] == nil) + let languages = [DebugProviderDescriptor(id: "java", displayName: "Java", fileExtensions: ["java"])] + let descriptor = try #require(qualified["dev.example.attach/lldb"]?.provider(languages: languages)) + #expect(descriptor.requests == [.attach]) + #expect(!descriptor.selectsByFile) + #expect(descriptor.matches(URL(fileURLWithPath: "/workspace/Main.java"))) + } + + @Test + func qualifiedAdapterLookupFollowsLiveEnablementAndReplacement() throws { + let fixture = try LiveFixture() + defer { fixture.cleanUp() } + let original = try fixture.package(Self.debugManifest()) + fixture.registry.apply([original]) + let key = "\(Self.pluginID.rawValue)/lldb" + #expect(PluginDebugAdapterEntry.qualifiedIndex(of: fixture.registry.snapshot.activePackages)[key] != nil) + fixture.isEnabled = false + fixture.registry.apply([original]) + #expect(PluginDebugAdapterEntry.qualifiedIndex(of: fixture.registry.snapshot.activePackages)[key] == nil) + fixture.isEnabled = true + let replacement = try fixture.package(Self.debugManifest(adapterID: "replacement")) + fixture.registry.apply([replacement]) + let entries = PluginDebugAdapterEntry.qualifiedIndex(of: fixture.registry.snapshot.activePackages) + #expect(entries[key] == nil) + #expect(entries["\(Self.pluginID.rawValue)/replacement"] != nil) + } + @Test func onlyInstalledPackagesThatQualifyAreAdopted() throws { let fixture = try LiveFixture() @@ -185,7 +225,16 @@ struct PluginLiveActivationTests { func anAdapterWhoseSelectorSkipsFileTargetsIsNotChosenToLaunchAFile(_ targetKinds: [String]) throws { let fixture = try LiveFixture() defer { fixture.cleanUp() } - let package = try fixture.package(Self.debugManifest(targetKinds: targetKinds)) + let connectionOnly = targetKinds == ["connection"] + var manifest = Self.debugManifest(requests: connectionOnly ? ["attach"] : ["launch"], targetKinds: targetKinds) + if connectionOnly { + // Connection selectors require an actual attach binding. Keep the + // fixture valid while checking that it never owns a file launch. + var adapters = try #require(manifest["debugAdapters"] as? [[String: Any]]) + adapters[0]["configurations"] = ["attach": ["targetBindings": ["connection": ["host": "hostName", "port": "port"]]]] + manifest["debugAdapters"] = adapters + } + let package = try fixture.package(manifest) fixture.registry.apply([package]) diff --git a/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift b/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift index 314e47fc29..5b94bf1317 100644 --- a/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift +++ b/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift @@ -19,6 +19,36 @@ struct PluginMarketplaceFeatureModelTests { isEnabled: true ) + @Test + func bundledJavaCanMoveToTheOfficialDownloadAtTheSameVersion() async throws { + let id = OfficialPluginCatalog.javaPluginID + let (feature, service, manager) = try makeModel(staleOfficialIDs: [id.rawValue]) + var object = try #require(JSONSerialization.jsonObject(with: + DeclarativePluginTestSupport.officialDescriptor("JavaSupport").normalizedManifest + ) as? [String: Any]) + object["version"] = "1.0.0" + let manifest = try DeclarativePluginTestSupport.descriptor(object).pluginManifest + func snapshot(_ status: PluginInstallationStatus) -> PluginManagementSnapshot { + PluginManagementSnapshot( + manifest: manifest, origin: .bundled, installationStatus: status, + isEnabled: false, isRequired: false, isRunning: false, isQuarantined: false, + isSuppressedBySafeMode: false, requiresRestart: false, canRollback: false, statusMessage: "Disabled", marketplace: nil + ) + } + manager.snapshots = [snapshot(.installed)] + let listing = try #require(feature.listings.first { $0.pluginID == id }) + let offer = try #require(feature.offer(for: listing)) + #expect(feature.installedSourceID(for: id) == PluginMarketplaceSource.officialID) + #expect(feature.installState(for: offer) == .updateAvailable(installed: "1.0.0", available: "1.0.0")) + #expect(feature.availableUpdate(for: id)?.entry.id == id.rawValue) + service.origin = .marketplace + await feature.install(offer) + #expect(manager.installed.count == 1) + #expect(feature.pendingConfirmation == nil) + manager.snapshots = [snapshot(.removed)] + #expect(feature.installState(for: offer) == .installable) + } + /// Rows and the detail pane ask for one URL at the same time; they share /// one request, and a rejected icon is not requested again per row. @Test diff --git a/macos/Tests/LitheTests/PluginUIIconTests.swift b/macos/Tests/LitheTests/PluginUIIconTests.swift index 9c1100ae66..e4f928242d 100644 --- a/macos/Tests/LitheTests/PluginUIIconTests.swift +++ b/macos/Tests/LitheTests/PluginUIIconTests.swift @@ -100,7 +100,9 @@ struct PluginUIIconTests { for dark in [false, true] { let view = WorkbenchContributionIcon(asset: asset, role: .action).foregroundStyle(Color(red: 0, green: 1, blue: 0)) let bitmap = try snapshot(view, dark: dark) - let center = try #require(bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)?.usingColorSpace(.deviceRGB)) + // Decode the raw channels with their bitmap's ICC profile, + // rather than treating display-dependent samples as device RGB. + let center = try nativeSRGBPixel(bitmap, x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2) if mode == .template { #expect(center.greenComponent > 0.8 && center.redComponent < 0.1 && center.blueComponent < 0.1) } else if dark { #expect(center.blueComponent > 0.8 && center.redComponent < 0.1) } else { #expect(center.redComponent > 0.8 && center.blueComponent < 0.1) } diff --git a/macos/Tests/LitheTests/PluginWebBridgeTests.swift b/macos/Tests/LitheTests/PluginWebBridgeTests.swift index 5829c883f0..7d863bd71c 100644 --- a/macos/Tests/LitheTests/PluginWebBridgeTests.swift +++ b/macos/Tests/LitheTests/PluginWebBridgeTests.swift @@ -465,6 +465,7 @@ struct PluginWebBridgeTests { ) #expect(request.project == BuildSelection(project: project)) #expect(request.target == BuildSelection(target: example)) + guard case .process(let plan) = plan else { Issue.record("Expected a command plan"); return } #expect(plan.arguments == ["run", "--example", "demo"]) #expect(plan.workingDirectory == instance.root) #expect(plan.execution == .application) diff --git a/macos/Tests/LitheTests/ProjectGenerationTests.swift b/macos/Tests/LitheTests/ProjectGenerationTests.swift index aa0fcd935d..e6a7a443e3 100644 --- a/macos/Tests/LitheTests/ProjectGenerationTests.swift +++ b/macos/Tests/LitheTests/ProjectGenerationTests.swift @@ -250,6 +250,18 @@ struct ProjectGenerationTests { // MARK: Window state + @Test + func missingJavaTemplatesOfferAnInstallationEntry() throws { + let missing = NewProjectFeatureModel(environment: try Self.environment(plugins: PluginStates(enabled: []), sources: [])) + missing.reloadCatalog() + #expect(missing.isJavaSupportMissing) + let installed = NewProjectFeatureModel(environment: try Self.environment( + plugins: PluginStates(enabled: []), sources: [try Self.source(Self.javaSupportDirectory, enabled: false)] + )) + installed.reloadCatalog() + #expect(!installed.isJavaSupportMissing) + } + @Test func offersToEnableDisabledProvidersAndFindsMissingOnes() async throws { let plugins = PluginStates(enabled: [Self.templatesPluginID]) @@ -342,6 +354,11 @@ struct ProjectGenerationTests { templates["displayName"] = "Java Templates" templates["permissions"] = ["project.generate"] templates["requires"] = ["projectGenerators": 1] + // This separate template fixture is not the host-pinned bundled Java + // package, so it does not inherit that package's origin trust. + var templateVendor = manifest["vendor"] as? [String: Any] ?? [:] + templateVendor["bundledSignatureRequirement"] = nil + templates["vendor"] = templateVendor templates["activationEvents"] = [String]() func plugin(_ object: [String: Any], id: PluginID) throws -> ProjectGeneratorPluginSource { ProjectGeneratorPluginSource( diff --git a/macos/Tests/LitheTests/ProviderWorkflowPresentationTests.swift b/macos/Tests/LitheTests/ProviderWorkflowPresentationTests.swift new file mode 100644 index 0000000000..73688c7197 --- /dev/null +++ b/macos/Tests/LitheTests/ProviderWorkflowPresentationTests.swift @@ -0,0 +1,170 @@ +import AppKit +import Combine +import Foundation +import SwiftUI +import Testing +import LitheCoreContracts +import LitheExecutionModule +import LitheModuleAPI +@testable import Lithe + +/// Native presentation evidence over real Core plans and controlled Providers. +/// No build tool, backend process, or application process is started. +@MainActor +@Suite("Provider workflow presentation", .serialized) +struct ProviderWorkflowPresentationTests { + @Test(arguments: [false, true]) + func nativeBuildDiscoveryAndDebugEntriesRemainVisible(dark: Bool) async throws { + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previousAccessibility = (application.accessibilityAttributeValue(attribute) as? Bool) ?? false + application.accessibilitySetValue(true, forAttribute: attribute) + defer { application.accessibilitySetValue(previousAccessibility, forAttribute: attribute) } + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-ui-" + UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("// fixture".utf8).write(to: root.appendingPathComponent("build.gradle")) + let suite = "provider-ui-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let backing = MacUserDefaultsStore(defaults: defaults) + let settings = AppSettings(store: backing) + let container = MacServiceContainer(store: backing, settings: settings) + defer { container.moduleLifecycleCoordinator.stop() } + let model = AppModel(settings: settings, services: container.services) + let core = RustCoreBridge() + let descriptor = try RustDeclarativePluginOperations(core: core).validate(manifest: Data(contentsOf: + DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json"))) + let source = BuildSystemPluginSource(pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, + packageURL: root, buildSystems: descriptor.buildSystems) + let service = BuildSystemService(planner: RustBuildSystemOperations(core: core), tools: PresentationBuildTools(), + optionsStore: MacBuildOptionsStore(store: backing), + stateLocator: MacBuildSystemStateLocator(directories: MacPluginStateDirectories(rootURL: root.appendingPathComponent("state")), fileStorage: MacFileStorage()), + runner: PresentationBuildRunner(), providers: PresentationBuildProvider(), sources: { [source] }) + defer { service.stop() } + let feature = BuildSystemsFeatureModel(service: service) + try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { _ in + feature.reset(workspaceURL: root) + _ = try await service.prepareLanguageImport(workspaceURL: root) + let entryID = try #require(feature.entries.first?.id) + let loaded = TestGate() + let loadedObservation = service.$entries.sink { if $0.first?.modelState == .loaded { loaded.open() } } + defer { loadedObservation.cancel() } + feature.loadModel(entryID) + try #require(await loaded.waitUntilOpen(timeout: .seconds(5))) + let discovered = TestGate() + let discoveryObservation = service.runEnded.sink { if $0.state == .succeeded { discovered.open() } } + defer { discoveryObservation.cancel() } + feature.discoverTests(entryID) + try #require(await discovered.waitUntilOpen(timeout: .seconds(5))) + guard case .loaded(let discovery)? = feature.testDiscovery(entryID) else { + Issue.record("Expected discovered tests before rendering") + return + } + #expect(discovery.tests.count == 2) + let windowID = "provider-ui" + let icons = BuildSystemToolWindowIcons(windowID: windowID, + window: WorkbenchContributionIcon.Asset(nil, resources: nil), resources: [:]) + let view = HStack(spacing: 1) { + BuildSystemToolWindowView(feature: feature, title: "Gradle", toolWindow: .init(rawValue: windowID), + pluginID: descriptor.id.rawValue, buildSystemID: "gradle", icons: icons).frame(width: 460) + BuildOutputView(feature: feature) + }.environmentObject(model).environmentObject(settings).defaultAppStorage(defaults) + .environment(\.locale, Locale(identifier: "en")).environment(\.colorScheme, dark ? .dark : .light) + let host = NSHostingView(rootView: view.frame(width: 1100, height: 540)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1100, height: 540), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + await Task.yield() + host.layoutSubtreeIfNeeded() + try capture(host, name: "build-collapsed-" + (dark ? "dark" : "light")) + // Use the shared tree's public accessibility actions, then wait for + // visible children. A screenshot of collapsed rows is insufficient. + for (branch, child) in [("Discovered Tests", "greetsByName"), ("Main", "Debug")] { + let row = try #require(await element(in: host, containing: branch, role: .button)) + let expand = try #require(accessibilityElements(row).first { + $0.accessibilityRole?() == .button && $0.accessibilityLabel?() == "Expand" + }) + try #require(expand.accessibilityPerformPress?() == true) + try #require(await element(in: host, containing: child) != nil) + } + try capture(host, name: "build-discovery-debug-" + (dark ? "dark" : "light")) + } + } + + private func accessibilityElements(_ value: Any) -> [AnyObject] { + let item = value as AnyObject + return [item] + (item.accessibilityChildren?() ?? []).flatMap(accessibilityElements) + } + + private func contains(_ item: AnyObject, text: String) -> Bool { + let value: String? = item.accessibilityValue?() + return (value ?? "").contains(text) || (item.accessibilityLabel?() ?? "").contains(text) + || (item.accessibilityChildren?() ?? []).contains { contains($0 as AnyObject, text: text) } + } + + private func element(in view: NSView, containing text: String, role: NSAccessibility.Role? = nil) async -> AnyObject? { + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) + repeat { + view.layoutSubtreeIfNeeded() + if let match = accessibilityElements(view).first(where: { + (role == nil || $0.accessibilityRole?() == role) && contains($0, text: text) + }) { return match } + await Task.yield() + } while clock.now < deadline + return nil + } + private func capture(_ view: NSView, name: String) throws { + view.layoutSubtreeIfNeeded() + let bitmap = try nativeSRGBCapture(view) + #expect(bitmap.pixelsWide == 2200 && bitmap.pixelsHigh == 1080) + if let directory = ProcessInfo.processInfo.environment["LITHE_BUILD_CAPTURE_DIR"] { + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: directory).appendingPathComponent(name + ".png")) + } + } +} + +@MainActor +private final class PresentationBuildTools: BuildToolResolving { + func resolve(_ invocation: BuildInvocation, displayName: String, workspaceURL: URL, + checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck) async throws -> ResolvedBuildTool { + ResolvedBuildTool(executableURL: URL(fileURLWithPath: "/fixture/bin/gradle"), environment: [:], executesProjectCode: false, version: "8.0") + } +} + +private struct PresentationBuildRunner: BuildCommandRunning { + func run(_ request: BuildCommandRequest, onOutput: @escaping @Sendable (String) -> Void) async throws -> BuildCommandResult { + Issue.record("A presentation fixture must never execute a command") + throw CancellationError() + } +} + +@MainActor +private final class PresentationBuildProvider: BuildProviderRequesting { + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + Issue.record("A discovery presentation fixture must not prepare an application") + throw CancellationError() + } + func execute(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> BuildProviderExecutionResult { + Issue.record("A discovery presentation fixture must not execute a task") + throw CancellationError() + } + func send(_ request: BuildProviderRequest, to target: BuildProviderTarget, timeout: Duration) async throws -> ToolingJSONValue { + let json: String + if request.method == "lithe/build/discoverTests" { + json = #"{"format":"litheTestDiscovery","version":1,"tests":[{"suite":"DemoTest","case":"greetsByName","project":{"name":"demo","path":"."}},{"suite":"DemoTest","case":"keepsArguments","project":{"name":"demo","path":"."}}]}"# + } else { + json = #"{"format":"litheBuildModel","version":1,"projects":[{"id":"demo","name":"demo","path":"."}],"targets":[{"id":"main","projectID":"demo","name":"Main","path":"src/Main.java","tags":["application"]}]}"# + } + return try JSONDecoder().decode(ToolingJSONValue.self, from: Data(json.utf8)) + } + func retainProviders(_ keep: (BuildProviderTarget) -> Bool) {} +} diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 3a6a50a234..e14546d5a3 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -9,6 +9,33 @@ import Testing @Suite("Run configuration integration") @MainActor struct RunConfigurationIntegrationTests { + @Test + func savedBuildProviderReferenceWorksWithoutGeneratedConfiguration() throws { + let core = RustCoreBridge() + try #require(core.isAvailable) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-config-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root.appendingPathComponent("app"), withIntermediateDirectories: true) + let store = MacRunConfigurationStore(core: core, storage: MacFileStorage(), preferences: RunTestKeyValueStore()) + let reference = BuildProviderRunReference(pluginID: "dev.example.gradle", buildSystemID: "gradle", root: "app", + run: "application", projectID: "p", targetID: "t", userArguments: ["a b", "--x=one two"]) + let id = try store.saveBuildProviderConfiguration(reference, name: "Main", at: root) + #expect(store.inspect(at: root).status == .ready) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe/run/generated.json").path)) + let resolved = try store.resolve(at: root, toolchainCandidates: []) + #expect(resolved.configurations.first?.configuration.buildProvider == reference) + #expect(resolved.configurations.first?.configuration.id == id) + #expect(try store.saveBuildProviderConfiguration(reference, name: "Renamed", at: root) == id) + #expect(try store.resolve(at: root, toolchainCandidates: []).configurations.count == 1) + let data = try Data(contentsOf: root.appendingPathComponent(".lithe/run/local.json")) + let document = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let entry = try #require((document["configurations"] as? [[String: Any]])?.first) + #expect(entry["command"] == nil) + #expect(entry["args"] == nil) + let ignore = try String(contentsOf: root.appendingPathComponent(".lithe/.gitignore"), encoding: .utf8) + #expect(ignore.contains("run/local.json")) + } + @Test func projectEnvironmentSavesWithoutGeneratedConfigurationsAndPreservesOverrides() throws { // The unit lane does not link Rust Core; the Rust test with the same diff --git a/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift b/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift index 1e12c6236b..b5f91a28f7 100644 --- a/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift +++ b/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift @@ -11,6 +11,35 @@ import Testing struct RunLaunchEnvironmentTests { private let projectURL = URL(fileURLWithPath: "/fixture-project", isDirectory: true) + @Test + func sharedWorkspaceChangeFilterCrossesTheNativeBridge() throws { + struct Fixture: Decodable { let paths: [String]; let expected: [String] } + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: + root.appendingPathComponent("shared/fixtures/workspace/change-filter-v1.json"))) + let actual: [String] = try RustCoreBridge().executeResult( + command: "workspace.filterChanges", payload: fixture.paths + ).get() + #expect(actual == fixture.expected) + } + + @Test + func sharedCoreLaunchEnvironmentRetainsSelectionAndExplicitOverrides() throws { + let core = RustCoreBridge() + let selected = try core.resolveLaunchEnvironment( + toolchain: "project-maven", selectedHome: "/fixture/jdk21", + inherited: ["JAVA_HOME": "/fixture/jdk8", "PATH": "/fixture/bin"], overrides: [:] + ) + #expect(selected["JAVA_HOME"] == "/fixture/jdk21") + #expect(selected["PATH"] == "/fixture/bin") + let overridden = try core.resolveLaunchEnvironment( + toolchain: "project-maven", selectedHome: "/fixture/jdk21", + inherited: selected, overrides: ["JAVA_HOME": "/fixture/explicit"] + ) + #expect(overridden["JAVA_HOME"] == "/fixture/explicit") + } + @Test func guiLaunchPathReachesTheDiscoveredNodeInterpreter() { let environment = RunLaunchEnvironment.augmenting( diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index e52a9b53d3..97137c4024 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -99,6 +99,121 @@ struct RustBuildSystemOperationsTests { #expect(goVersion.satisfied) } + @Test + func providerTaskPlanCarriesSelectionWithoutHostArguments() throws { + let target = try Self.target("build-provider-execution", "gradle") + let plan = try operations.taskPlan( + target, context: BuildPlanContext(root: ".", options: ["offline": .bool(true)]), + request: BuildTaskRequest(task: "build", project: BuildSelection(name: "app", path: "app")) + ) + guard case .provider(let request) = plan, case .object(let params) = request.params else { + Issue.record("Expected a typed provider execution plan") + return + } + #expect(request.method == "lithe/build/execute") + #expect(params["task"] == .string("build")) + #expect(params["project"] == .object([ + "name": .string("app"), "path": .string("app"), "tags": .array([]), "properties": .object([:]) + ])) + #expect(params["options"] == .object(["offline": .bool(true)])) + #expect(request.executesProjectCode) + #expect(request.mayUseNetwork) + } + + @Test + func providerRunPreparationRoundTripsThroughRealCore() throws { + let descriptor = try Self.descriptor("build-provider-run-preparation") + #expect(descriptor.buildSystems.first?.providerRuns.map(\.id) == ["application"]) + let target = try Self.target("build-provider-run-preparation", "gradle") + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: workspace) } + try FileManager.default.createDirectory(at: workspace.appendingPathComponent("app"), withIntermediateDirectories: true) + let plan = try operations.prepareRunPlan(target, context: BuildPlanContext(root: ".", options: ["offline": .bool(true)]), + run: "application", project: BuildSelection(name: "app", path: "app"), + selectedTarget: BuildSelection(name: "Main", path: "app", tags: ["application"]), userArguments: ["a b"]) + #expect(plan.method == "lithe/build/prepareRun") + guard case .object(let params) = plan.params else { Issue.record("Missing provider params"); return } + #expect(params["userArguments"] == .array([.string("a b")])) + let result: ToolingJSONValue = .object(["executable": .object(["kind": .string("toolchain"), "value": .string("project-jdk")]), + "arguments": .array([.string("Main"), .string("a b")]), "workingDirectory": .string("app"), "environment": .object([:])]) + let launch = try operations.decodeRunPreparation(target, workspaceURL: workspace, root: ".", result: result) + #expect(launch.executable.kind == .toolchain) + #expect(launch.executable.value == "project-jdk") + #expect(launch.arguments == ["Main", "a b"]) + #expect(launch.workingDirectory == "app") + } + + @Test + func providerDebugPreparationRoundTripsThroughRealCore() throws { + let descriptor = try Self.descriptor("build-provider-debug-preparation") + #expect(descriptor.buildSystems.first?.providerRuns.first?.debugAdapter == "native") + let target = try Self.target("build-provider-debug-preparation", "gradle") + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: workspace) } + let directory = workspace.appendingPathComponent("app", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("native program".utf8).write(to: directory.appendingPathComponent("main")) + let plan = try operations.prepareDebugPlan(target, context: BuildPlanContext(root: ".", options: ["offline": .bool(true)]), + run: "application", project: BuildSelection(name: "app", path: "app"), + selectedTarget: BuildSelection(name: "Main", path: "app", tags: ["application"]), userArguments: ["a b"]) + #expect(plan.method == "lithe/build/prepareDebug") + guard case .object(let params) = plan.params else { Issue.record("Missing provider params"); return } + #expect(params["userArguments"] == .array([.string("a b")])) + let result: ToolingJSONValue = .object(["program": .string("app/main"), "arguments": .array([.string("a b")]), + "workingDirectory": .string("app"), "environment": .object(["MODE": .string("debug")])]) + let prepared = try operations.decodeDebugPreparation(target, workspaceURL: workspace, root: "app", run: "application", result: result) + #expect(prepared.adapter == "native") + #expect(prepared.root == "app") + #expect(prepared.program == "app/main") + #expect(prepared.arguments == ["a b"]) + #expect(prepared.environment == ["MODE": "debug"]) + } + + @Test + func providerTestPlanKeepsTypedFiltersAndReportPatterns() throws { + let target = try Self.target("build-provider-tests", "gradle") + let plan = try operations.testPlan( + target, context: BuildPlanContext(root: ".", options: ["offline": .bool(true)]), + request: BuildTestRequest(project: BuildSelection(name: "app", path: "app"), + suite: "Suite", testCase: "case with spaces", userArguments: ["--rerun-tasks"]) + ) + guard case .provider(let request) = plan.execution, case .object(let params) = request.params else { + Issue.record("Expected a provider test plan"); return + } + #expect(request.method == "lithe/build/test") + #expect(params["suite"] == .string("Suite")) + #expect(params["case"] == .string("case with spaces")) + #expect(params["userArguments"] == .array([.string("--rerun-tasks")])) + #expect(plan.results == .reports(decoder: "junitXml", patterns: ["app/build/test-results/**/*.xml"])) + } + + @Test + func discoveredSelectorsRoundTripThroughTheRealCore() throws { + let descriptor = try Self.descriptor("build-provider-tests") + #expect(descriptor.buildSystems.first?.testDiscovery == true) + #expect(try Self.descriptor("build-static-file").buildSystems.first?.testDiscovery == false) + let target = try Self.target("build-provider-tests", "gradle") + let context = BuildPlanContext(root: ".", options: ["offline": .bool(true)]) + let plan = try operations.testDiscoveryPlan(target, context: context) + #expect(plan.method == "lithe/build/discoverTests") + let fixture = try JSONDecoder().decode(ToolingJSONValue.self, from: Data(contentsOf: + DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-test-discovery.json"))) + guard case .object(let object) = fixture, let result = object["result"] else { + Issue.record("Missing discovery result"); return + } + let discovery = try operations.decodeTestDiscovery(target, context: context, result: result) + #expect(discovery.tests.count == 2) + #expect(Set(discovery.tests.map(\.id)).count == 2) + for test in discovery.tests { + let execution = try operations.testPlan(target, context: context, request: test.request) + guard case .provider(let request) = execution.execution, case .object(let params) = request.params else { + Issue.record("Discovery selector must remain a provider test"); return + } + #expect(params["suite"] == .string(test.suite)) + #expect(params["case"] == .string(test.testCase)) + } + } + @Test func taskAndTestPlansCarryOptionsAndSelection() throws { let target = try Self.target("build-static-file", "cargo") @@ -110,6 +225,7 @@ struct RustBuildSystemOperationsTests { context: context, request: BuildTaskRequest(task: "build", project: BuildSelection(project: project)) ) + guard case .process(let build) = build else { Issue.record("Expected a command plan"); return } #expect(build.arguments == ["build", "--message-format=short", "--release"]) #expect(build.workingDirectory == "crates/demo") #expect(build.output == nil) @@ -123,7 +239,10 @@ struct RustBuildSystemOperationsTests { projects: ["crates/demo"] ) ) - #expect(tests.process.arguments == ["test", "--release", "--test", "integration"]) + guard case .process(let process) = tests.execution else { + Issue.record("Expected a command test plan"); return + } + #expect(process.arguments == ["test", "--release", "--test", "integration"]) #expect(throws: DeclarativePluginRejection.self) { try operations.taskPlan(target, context: context, request: BuildTaskRequest(task: "missing")) @@ -263,6 +382,44 @@ struct RustBuildSystemOperationsTests { ) == nil) } + @Test + func modelInputsCrossTheRealCoreBridgeWithoutPrivatePlans() throws { + let producer = try Self.source("build-model-producer") + let consumer = try Self.source("build-model-consumer") + #expect(producer.buildSystems.first?.sharesModel == true) + #expect(consumer.buildSystems.first?.modelInputs == [.init(pluginID: producer.pluginID, buildSystemID: "source")]) + let model = try JSONDecoder().decode(BuildModel.self, from: Data(""" + {"projects":[{"id":"source:app/demo","path":"app","name":"demo","properties":{"group":"org.example"}}], + "targets":[], "contentRoots":[], "tasks":[], "run":[{"command":"private"}], "optionValues":{}} + """.utf8)) + let target = BuildSystemTarget(manifest: consumer.manifest, buildSystemID: "gradle") + let snapshot = try operations.resolveModelInputs(target, revision: 9, sources: [ + BuildModelInputSource(manifest: producer.manifest, instances: [ + .init(buildSystemID: "source", root: "app", state: "ready", model: model, options: ["offline": .bool(true)]) + ]) + ]) + let context = BuildPlanContext(root: "app", options: [:], modelInputs: snapshot) + guard case .provider(let request) = try operations.taskPlan(target, context: context, request: BuildTaskRequest(task: "build")), + case .object(let params) = request.params, case .object(let wire)? = params["modelInputs"], + case .array(let slots)? = wire["inputs"], case .object(let slot)? = slots.first, + case .array(let instances)? = slot["instances"], case .object(let instance)? = instances.first, + case .object(let shared)? = instance["model"] else { + Issue.record("Expected the shared snapshot in the typed provider request"); return + } + #expect(params["modelInputs"] == snapshot) + #expect(instance["options"] == .object(["offline": .bool(true)])) + #expect(Set(shared.keys) == ["projects", "targets", "contentRoots"]) + #expect(shared["run"] == nil) + #expect(throws: (any Error).self) { + try operations.taskPlan(target, context: BuildPlanContext(root: "app", options: [:]), request: BuildTaskRequest(task: "build")) + } + let unavailable = try operations.resolveModelInputs(target, revision: 10, sources: []) + guard case .object(let value) = unavailable, case .array(let slots)? = value["inputs"], + case .object(let missing)? = slots.first else { Issue.record("Missing slot"); return } + #expect(missing["state"] == .string("unavailable")) + #expect(missing["instances"] == .array([])) + } + // MARK: Fixtures private static func fixtureData(_ name: String) throws -> Data { diff --git a/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift b/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift index 84bfce1b1e..72a9b62987 100644 --- a/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift +++ b/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift @@ -710,8 +710,8 @@ struct SearchEverywhereBehaviorTests { timestamp: 0, windowNumber: popup.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1))) } deadline = clock.now.advanced(by: .seconds(1)) - while popup.childWindows?.first == nil, clock.now < deadline { await Task.yield() } - var filter = try #require(popup.childWindows?.first) + while popup.childWindows?.first(where: { !$0.styleMask.contains(.titled) }) == nil, clock.now < deadline { await Task.yield() } + var filter = try #require(popup.childWindows?.first(where: { !$0.styleMask.contains(.titled) })) session.enabledEverywhereProviders = [.actions] deadline = clock.now.advanced(by: .seconds(1)) while !filteredProviderWasRead, clock.now < deadline { await Task.yield() } @@ -842,11 +842,16 @@ struct SearchEverywhereBehaviorTests { editor.selectAll(nil) editor.insertText("discarded", replacementRange: editor.selectedRange()) try await clickControl("search-exclusions-cancel", in: dialog) + // The SwiftUI binding closes the native dialog on its next + // update. Do not send the next click through that closing panel. + deadline = clock.now.advanced(by: .seconds(1)) + while dialog.parent != nil || dialog.isVisible, clock.now < deadline { await Task.yield() } + try #require(dialog.parent == nil && !dialog.isVisible) #expect(settings.searchExcludedPathPatterns.isEmpty && session.isSearchEverywhereVisible) try await clickControl("search-everywhere-filter", in: popup) deadline = clock.now.advanced(by: .seconds(1)) - while popup.childWindows?.first == nil, clock.now < deadline { await Task.yield() } - filter = try #require(popup.childWindows?.first) + while popup.childWindows?.first(where: { !$0.styleMask.contains(.titled) }) == nil, clock.now < deadline { await Task.yield() } + filter = try #require(popup.childWindows?.first(where: { !$0.styleMask.contains(.titled) })) dialog = try await openEditor() editor = try #require(dialog.contentView.flatMap { descendants($0).compactMap { $0 as? NSTextView }.first }) } @@ -855,6 +860,9 @@ struct SearchEverywhereBehaviorTests { editor.insertText(draft, replacementRange: editor.selectedRange()) searchedPatterns = [] try await clickControl("search-exclusions-apply", in: dialog) + deadline = clock.now.advanced(by: .seconds(1)) + while dialog.parent != nil || dialog.isVisible, clock.now < deadline { await Task.yield() } + try #require(dialog.parent == nil && !dialog.isVisible) saved = draft.isEmpty ? [] : ["*.log", "**/.*/"] deadline = clock.now.advanced(by: .seconds(2)) while !searchedPatterns.contains(saved), clock.now < deadline { await Task.yield() } @@ -866,9 +874,13 @@ struct SearchEverywhereBehaviorTests { #expect(settings.searchEverywhereVisibilityRules == settings.fileVisibilityRules) try await clickControl("search-everywhere-filter", in: popup) deadline = clock.now.advanced(by: .seconds(1)) - while popup.childWindows?.first == nil, clock.now < deadline { await Task.yield() } - filter = try #require(popup.childWindows?.first) + while popup.childWindows?.first(where: { !$0.styleMask.contains(.titled) }) == nil, clock.now < deadline { await Task.yield() } + filter = try #require(popup.childWindows?.first(where: { !$0.styleMask.contains(.titled) })) try await clickControl("search-exclusions-toggle", in: filter) + // Native input delivery may finish after sendEvent returns. + // Observe its effect once, without sending a second toggle. + deadline = clock.now.advanced(by: .seconds(1)) + while !settings.searchExclusionsEnabled, clock.now < deadline { await Task.yield() } #expect(settings.searchExclusionsEnabled) #expect(settings.searchEverywhereVisibilityRules.hiddenFilePatterns.contains("*.log")) } @@ -882,8 +894,8 @@ struct SearchEverywhereBehaviorTests { popup = try #require(parent.childWindows?.first) try await clickControl("search-everywhere-filter", in: popup) deadline = clock.now.advanced(by: .seconds(1)) - while popup.childWindows?.first == nil, clock.now < deadline { await Task.yield() } - filter = try #require(popup.childWindows?.first) + while popup.childWindows?.first(where: { !$0.styleMask.contains(.titled) }) == nil, clock.now < deadline { await Task.yield() } + filter = try #require(popup.childWindows?.first(where: { !$0.styleMask.contains(.titled) })) } let finalDialog = try await openEditor() let finalEditor = try #require(finalDialog.contentView.flatMap { descendants($0).compactMap { $0 as? NSTextView }.first }) diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index e8d7181236..7d25125415 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -16,6 +16,7 @@ use super::outputs::{ DecodeTestResultsRequest, SyncFingerprintRequest, }; use super::plans::{self, OutputPlanRequest, TaskPlanRequest, TestPlanRequest}; +use super::test_discovery::{self, DecodeTestDiscoveryRequest}; use crate::plugins::declarative::{ decode_payload, outcome, validate_manifest_value, DeclarativePluginManifest, PluginRejection, Validation, @@ -170,6 +171,54 @@ pub(crate) fn build_test_plan_command(payload: Value) -> Result Result { + with_build_system( + payload, + "build.testDiscoveryPlan", + "plan", + |build_system, request: &OutputPlanRequest| { + plans::test_discovery_plan(build_system, request) + }, + ) +} + +/// `build.decodeTestDiscovery`: validated, deterministic selectors for native UI. +pub(crate) fn build_decode_test_discovery_command(payload: Value) -> Result { + with_build_system( + payload, + "build.decodeTestDiscovery", + "discovery", + |build_system, request: &DecodeTestDiscoveryRequest| { + test_discovery::decode(build_system, request) + }, + ) +} + +/// `build.prepareRunPlan`: validates a provider-owned target launch request. +pub(crate) fn build_prepare_run_plan_command(payload: Value) -> Result { + with_build_system( + payload, + "build.prepareRunPlan", + "plan", + |build_system, request: &super::run_preparation::PrepareRunRequest| { + super::run_preparation::plan(build_system, request) + }, + ) +} + +/// `build.decodeRunPreparation`: validates portable process launch data. +pub(crate) fn build_decode_run_preparation_command(payload: Value) -> Result { + with_build_system( + payload, + "build.decodeRunPreparation", + "launch", + |build_system, request: &super::run_preparation::DecodeRunPreparationRequest| { + super::run_preparation::decode(build_system, request) + }, + ) +} + /// `build.dependencyPlan`: the command that prints a project's dependencies. pub(crate) fn build_dependency_plan_command(payload: Value) -> Result { with_build_system( @@ -250,3 +299,39 @@ pub(crate) fn build_language_import_command(payload: Value) -> Result Result { + with_build_system( + payload, + "build.prepareDebugPlan", + "plan", + |build, request: &super::run_preparation::PrepareRunRequest| { + super::debug_preparation::plan(build, request) + }, + ) +} + +/// `build.decodeDebugPreparation`: validates portable native debug target facts. +pub(crate) fn build_decode_debug_preparation_command(payload: Value) -> Result { + with_build_system( + payload, + "build.decodeDebugPreparation", + "preparation", + |build, request: &super::debug_preparation::DecodeDebugPreparationRequest| { + super::debug_preparation::decode(build, request) + }, + ) +} + +/// `build.resolveModelInputs`: filters optional public models for one consumer. +pub(crate) fn build_resolve_model_inputs_command(payload: Value) -> Result { + with_build_system( + payload, + "build.resolveModelInputs", + "modelInputs", + |build, request: &super::model_inputs::ResolveRequest| { + super::model_inputs::resolve(build, request) + }, + ) +} diff --git a/rust/lithe-core/src/build/debug_preparation.rs b/rust/lithe-core/src/build/debug_preparation.rs new file mode 100644 index 0000000000..1fa36873cc --- /dev/null +++ b/rust/lithe-core/src/build/debug_preparation.rs @@ -0,0 +1,120 @@ +//! Native executable preparation uses a same-package DebugAdapter Registry entry. +//! Providers own build facts; Core validates portable data and declared bindings. + +use super::decode::{self, Workspace}; +use super::manifest::{BuildSystemDeclaration, RunDeclaration, TaskSource}; +use super::plans; +use super::run_preparation::{self, DecodeRunPreparationRequest, PrepareRunRequest}; +use crate::debug::adapters::{DebugAdapterDeclaration, DebugRequest}; +use crate::plugins::declarative::Validation; +use serde::Deserialize; +use serde_json::{json, Value}; + +/// No backend-selected adapter, process, endpoint, or arbitrary DAP arguments. +pub(crate) fn adapter<'a>( + run: &RunDeclaration, + adapters: &'a [DebugAdapterDeclaration], +) -> Validation<&'a DebugAdapterDeclaration> { + let invalid = || { + plans::invalid_selection("Native debug preparation needs a same-plugin launch adapter with program, cwd, args and env target bindings, without overriding defaults.") + }; + let id = &run.debug.as_ref().ok_or_else(invalid)?.adapter; + let adapter = adapters + .iter() + .find(|adapter| &adapter.id == id) + .ok_or_else(invalid)?; + let configuration = adapter + .configurations + .as_ref() + .and_then(|configs| configs.launch.as_ref()) + .ok_or_else(invalid)?; + let bindings = configuration.target_bindings.as_ref().ok_or_else(invalid)?; + if run.source != TaskSource::Provider + || !adapter.requests.contains(&DebugRequest::Launch) + || bindings.program.as_deref() != Some("target.path") + || bindings.cwd.as_deref() != Some("target.workingDirectory") + || bindings.args.as_deref() != Some("target.arguments") + || bindings.env.as_deref() != Some("target.environment") + || bindings.process_id.is_some() + || configuration.arguments.as_ref().is_some_and(|args| { + ["program", "cwd", "args", "env"] + .iter() + .any(|key| args.contains_key(*key)) + }) + || !adapter + .selectors + .iter() + .any(|selector| selector.target_kinds.contains(&"executable".to_string())) + { + return Err(invalid()); + } + Ok(adapter) +} + +/// Validates the ordinary run selector before changing its method and admission. +pub(crate) fn plan( + build: &BuildSystemDeclaration, + request: &PrepareRunRequest, +) -> Validation { + let mut value = run_preparation::plan(build, request)?; + if !build + .run + .iter() + .any(|run| run.id == request.run && run.debug.is_some()) + { + return Err(plans::unavailable(build, "debug preparation")); + } + value["provider"]["method"] = json!("lithe/build/prepareDebug"); + value["decoder"] = json!("litheDebugPreparation"); + Ok(value) +} + +/// The host keeps the selected run separate from the untrusted provider result. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DecodeDebugPreparationRequest { + #[serde(flatten)] + pub(crate) base: DecodeRunPreparationRequest, + pub(crate) run: String, +} + +/// Reuses bounded argv/environment/cwd validation without accepting an executable command. +pub(crate) fn decode( + build: &BuildSystemDeclaration, + request: &DecodeDebugPreparationRequest, +) -> Validation { + let run = build + .run + .iter() + .find(|run| run.id == request.run && run.debug.is_some()) + .ok_or_else(|| plans::unavailable(build, "debug preparation"))?; + let invalid = || decode::invalid_output("Invalid provider native debug preparation result."); + let object = request.base.result.as_object().ok_or_else(invalid)?; + if object.len() != 4 + || !["program", "arguments", "workingDirectory", "environment"] + .iter() + .all(|key| object.contains_key(*key)) + { + return Err(invalid()); + } + let program = decode::request_path(object["program"].as_str().ok_or_else(invalid)?, "program") + .map_err(|_| invalid())?; + let root = decode::request_path(&request.base.root, "root")?; + let workspace = Workspace::new(&request.base.workspace_root)?; + let instance = workspace + .read_path(&root) + .filter(|path| path.is_dir()) + .ok_or_else(invalid)?; + if !decode::contains(&root, &program) + || !workspace + .read_path(&program) + .is_some_and(|path| path.is_file() && path.starts_with(&instance)) + { + return Err(invalid()); + } + let validated = run_preparation::process_fields(&request.base)?; + Ok( + json!({"adapter":run.debug.as_ref().expect("selected debug declaration").adapter, + "root":root,"program":program,"arguments":validated["arguments"],"environment":validated["environment"],"workingDirectory":validated["workingDirectory"]}), + ) +} diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index 5a9bd8760d..ae32e7ba47 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -49,6 +49,12 @@ pub(crate) const MAX_CATEGORY_DEPTH: usize = 4; pub(crate) struct BuildSystemDeclaration { pub(crate) id: String, pub(crate) display_name: String, + /// Optional, explicit read-only publication of the normalized model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) model_sharing: Option, + /// Exact optional producers; these are not installation dependencies. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) model_inputs: Vec, /// Icon of the build system's tool window; display only. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) icon: Option, @@ -602,9 +608,27 @@ pub(crate) struct TaskDeclaration { /// template runs. Display only; empty files the task by `group`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) category: Vec, + #[serde(default, skip_serializing_if = "TaskSource::is_command")] + pub(crate) source: TaskSource, pub(crate) arguments: Vec, } +/// Whether Core expands a command or a workspace provider executes the task. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum TaskSource { + #[default] + #[serde(skip_deserializing)] + Command, + Provider, +} + +impl TaskSource { + fn is_command(&self) -> bool { + *self == Self::Command + } +} + fn task_execution() -> BuildExecution { BuildExecution::Task } @@ -613,7 +637,13 @@ fn task_execution() -> BuildExecution { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct RunDeclaration { + /// A same-package adapter for native executable debug preparation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) debug: Option, pub(crate) id: String, + /// Provider runs prepare a launch plan; they never become empty argv entries. + #[serde(default, skip_serializing_if = "TaskSource::is_command")] + pub(crate) source: TaskSource, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) from_targets: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -633,6 +663,13 @@ pub(crate) struct RunDeclaration { pub(crate) extension: BTreeMap, } +/// Explicit adapter reference; it never grants cross-package access. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RunDebug { + pub(crate) adapter: String, +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct FromTargets { @@ -666,6 +703,12 @@ pub(crate) struct TestDeclaration { pub(crate) target_tags: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) filters: Vec, + /// Omitted for argv templates; provider sends a typed test request. + #[serde(default, skip_serializing_if = "TaskSource::is_command")] + pub(crate) source: TaskSource, + /// The provider can enumerate exact selectors for the native test tree. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) discovery: bool, pub(crate) arguments: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) results: Option, @@ -901,8 +944,12 @@ fn check_target_tags(tags: &mut Vec, scopes: &[Scope], field: &str) -> V /// Facts of the rest of the manifest a build-system declaration is checked /// against. pub(crate) struct DeclarationContext<'a> { + pub(crate) debug_adapters: &'a [crate::debug::adapters::DebugAdapterDeclaration], pub(crate) plugin_api_version: u32, pub(crate) build_systems_version: u32, + /// Admission version for provider execution; independent of wire negotiation. + pub(crate) build_providers_version: u32, + pub(crate) model_inputs_version: u32, pub(crate) official: bool, /// Setting IDs a provider's launch templates may name. pub(crate) declared_settings: &'a crate::plugins::settings::DeclaredSettings, @@ -987,7 +1034,13 @@ fn program(build_system: &BuildSystemDeclaration) -> Value { let tasks: Vec = build_system .tasks .iter() - .map(|task| json!({ "id": task.id, "arguments": task.arguments })) + .map(|task| { + let mut review = json!({ "id": task.id, "arguments": task.arguments }); + if task.source == TaskSource::Provider { + review["source"] = json!("provider"); + } + review + }) .collect(); let model = match &build_system.model { Some(ModelSource::Command { arguments, .. }) => json!({ "arguments": arguments }), @@ -1004,7 +1057,14 @@ fn program(build_system: &BuildSystemDeclaration) -> Value { "options": options, "tasks": tasks, "run": build_system.run, - "test": build_system.test.as_ref().map(|test| json!({ "arguments": test.arguments })), + "test": build_system.test.as_ref().map(|test| { + let mut review = json!({ "arguments": test.arguments }); + if test.source == TaskSource::Provider { + review["source"] = json!("provider"); + } + if test.discovery { review["discovery"] = json!(true); } + review + }), "model": model, "dependencies": dependencies, }); @@ -1021,7 +1081,7 @@ fn program(build_system: &BuildSystemDeclaration) -> Value { /// A build-system or run ID: also a run-configuration provider segment, so /// it starts with a letter. -fn is_entry_identifier(value: &str) -> bool { +pub(super) fn is_entry_identifier(value: &str) -> bool { is_identifier(value) && value.len() <= MAX_IDENTIFIER && value @@ -1180,6 +1240,11 @@ impl BuildSystemDeclaration { self.validate_provider(field, context)?; self.validate_detection(field)?; let option_ids = self.validate_options(field)?; + super::model_inputs::validate_declaration( + self, + context.model_inputs_version, + context.build_providers_version, + )?; self.validate_invocation(field, &option_ids, context)?; let model_templates = self.validate_tasks(field, &option_ids)?; self.validate_model(field, &option_ids)?; @@ -1271,8 +1336,22 @@ impl BuildSystemDeclaration { } fn validate_provider(&self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { + let provider_runs = self + .run + .iter() + .any(|run| run.source == TaskSource::Provider); + let provider_tests = self + .test + .as_ref() + .is_some_and(|test| test.source == TaskSource::Provider); let uses = matches!(self.model, Some(ModelSource::Provider)) - || matches!(self.dependencies, Some(DependenciesSource::Provider { .. })); + || provider_runs + || matches!(self.dependencies, Some(DependenciesSource::Provider { .. })) + || provider_tests + || self + .tasks + .iter() + .any(|task| task.source == TaskSource::Provider); let field = format!("{field}.provider"); let Some(provider) = &self.provider else { if uses { @@ -1283,9 +1362,46 @@ impl BuildSystemDeclaration { if !uses { return Err(invalid( field, - "No model or dependencies source uses the provider.", + "No model, dependencies, task or test source uses the provider.", + )); + } + for run in &self.run { + if run.debug.is_some() { + if context.build_providers_version < 5 { + return Err(invalid( + &field, + "Provider debug preparation requires buildProviders 5.", + )); + } + super::debug_preparation::adapter(run, context.debug_adapters)?; + } + } + if provider_tests && context.build_providers_version < 3 { + return Err(invalid(&field, "Provider tests require buildProviders 3.")); + } + if provider_runs && context.build_providers_version < 4 { + return Err(invalid( + &field, + "Provider run preparation requires buildProviders 4.", )); } + if provider_runs + || provider_tests + || self + .tasks + .iter() + .any(|task| task.source == TaskSource::Provider) + { + if context.build_providers_version < 2 { + return Err(invalid(&field, "Provider tasks require buildProviders 2.")); + } + if !matches!(provider, ProviderDeclaration::Process(_)) { + return Err(invalid( + &field, + "Provider execution requires a plugin-owned provider process.", + )); + } + } match provider { ProviderDeclaration::LanguageServer(shared) => { if !context.language_servers.contains(&shared.language_id) { @@ -1588,6 +1704,15 @@ impl BuildSystemDeclaration { ..Allowed::none() } }; + if task.source == TaskSource::Provider { + if !task.arguments.is_empty() || task.execution != BuildExecution::Task { + return Err(invalid( + &field, + "A provider task has empty arguments and task execution.", + )); + } + continue; + } check_argument_count(&task.arguments, &format!("{field}.arguments"))?; template::validate_arguments(&task.arguments, &allowed, &format!("{field}.arguments"))?; if task.requires_user_arguments @@ -1699,6 +1824,35 @@ impl BuildSystemDeclaration { "Run IDs are unique identifiers that start with a letter.", )); } + if run.debug.is_some() && run.source != TaskSource::Provider { + return Err(invalid( + &field, + "Debug preparation requires a provider run.", + )); + } + if run.source == TaskSource::Provider { + let valid_targets = run.from_targets.as_ref().is_some_and(|from| { + !from.tags.is_empty() + && from.tags.len() <= MAX_LIST + && from.tags.iter().all(|tag| is_tag(tag)) + }); + if !valid_targets + || run.from_tasks.is_some() + || !run.arguments.is_empty() + || !run.extension.is_empty() + || run + .execution + .is_some_and(|execution| execution != BuildExecution::Application) + || run.name.as_ref().is_some_and(|name| { + name.trim().is_empty() || name.len() > MAX_TEXT || name.contains("${") + }) + { + return Err(invalid(&field, "Provider runs require target tags, application execution, and no host command templates.")); + } + let from = run.from_targets.as_mut().expect("validated target source"); + from.tags = sorted_unique(std::mem::take(&mut from.tags)); + continue; + } let from_tasks = match (&mut run.from_targets, &mut run.from_tasks) { (Some(targets), None) => { if targets.tags.is_empty() || targets.tags.iter().any(|tag| !is_tag(tag)) { @@ -1731,6 +1885,14 @@ impl BuildSystemDeclaration { format!("{unknown} is not a fromModel task."), )); } + if self.tasks.iter().any(|task| { + task.source == TaskSource::Provider + && task.from_model + && (tasks.templates.is_empty() || tasks.templates.contains(&task.id)) + }) { + return Err(invalid(format!("{field}.fromTasks"), + "Provider tasks need a typed run capability and cannot become command run entries.")); + } tasks.templates = sorted_unique(std::mem::take(&mut tasks.templates)); tasks.executions.sort(); tasks.executions.dedup(); @@ -1804,21 +1966,42 @@ impl BuildSystemDeclaration { check_target_tags(&mut test.target_tags, &test.scopes, &field)?; test.filters.sort(); test.filters.dedup(); - check_argument_count(&test.arguments, &format!("{field}.arguments"))?; - template::validate_arguments( - &test.arguments, - &Allowed { - root: true, - project: availability(&test.scopes, Scope::Project), - target: availability(&test.scopes, Scope::Target), - test: true, - user_arguments: true, - conditions: true, - options: Some(options), - ..Allowed::none() - }, - &format!("{field}.arguments"), - )?; + if test.discovery + && (test.source != TaskSource::Provider + || !test.filters.contains(&TestFilter::Suite) + || !test.filters.contains(&TestFilter::Case)) + { + return Err(invalid( + &field, + "Test discovery requires a provider and suite/case filters.", + )); + } + if test.source == TaskSource::Provider { + if !test.arguments.is_empty() + || matches!(test.results, Some(TestResultsSource::HostIntegration)) + { + return Err(invalid( + &field, + "Provider tests require empty arguments and plugin-owned reports.", + )); + } + } else { + check_argument_count(&test.arguments, &format!("{field}.arguments"))?; + template::validate_arguments( + &test.arguments, + &Allowed { + root: true, + project: availability(&test.scopes, Scope::Project), + target: availability(&test.scopes, Scope::Target), + test: true, + user_arguments: true, + conditions: true, + options: Some(options), + ..Allowed::none() + }, + &format!("{field}.arguments"), + )?; + } match &mut test.results { Some(TestResultsSource::JunitXml { reports }) | Some(TestResultsSource::LitheTestResults { reports }) => { diff --git a/rust/lithe-core/src/build/mod.rs b/rust/lithe-core/src/build/mod.rs index bb5f284928..7981621b8e 100644 --- a/rust/lithe-core/src/build/mod.rs +++ b/rust/lithe-core/src/build/mod.rs @@ -16,6 +16,7 @@ //! Note: the contract is recorded in .agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md mod commands; +mod debug_preparation; mod decode; mod detect; mod integrations; @@ -23,11 +24,14 @@ mod invocation; mod language_import; mod manifest; mod model; +mod model_inputs; mod options; mod outputs; mod plans; +mod run_preparation; mod select; mod template; +mod test_discovery; pub(crate) use commands::*; pub(crate) use invocation::host_runtime_version_arguments; diff --git a/rust/lithe-core/src/build/model.rs b/rust/lithe-core/src/build/model.rs index 8e4fc2074a..2e82281a2c 100644 --- a/rust/lithe-core/src/build/model.rs +++ b/rust/lithe-core/src/build/model.rs @@ -42,7 +42,7 @@ pub(crate) struct BuildModel { pub(crate) truncated: bool, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ModelProject { pub(crate) id: String, @@ -56,7 +56,7 @@ pub(crate) struct ModelProject { pub(crate) properties: BTreeMap, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ModelTarget { pub(crate) id: String, @@ -92,7 +92,7 @@ pub(crate) struct ModelTask { pub(crate) properties: BTreeMap, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ModelContentRoot { #[serde(rename = "projectID")] diff --git a/rust/lithe-core/src/build/model_inputs.rs b/rust/lithe-core/src/build/model_inputs.rs new file mode 100644 index 0000000000..9df8292d85 --- /dev/null +++ b/rust/lithe-core/src/build/model_inputs.rs @@ -0,0 +1,398 @@ +//! Optional read-only model inputs between already enabled workspace providers. +//! Sharing grants no installation, process, resource or private-function authority. + +use super::{ + decode, + manifest::{is_entry_identifier, BuildSystemDeclaration}, + model::{ModelContentRoot, ModelProject, ModelTarget}, + options, plans, +}; +use crate::plugins::declarative::{is_plugin_id, validate_manifest_value, Validation}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +const MAX_INPUTS: usize = 8; +const MAX_INSTANCES: usize = 64; +const MAX_BYTES: usize = 8 * 1024 * 1024; + +/// Producer opt-in; only these ordinary build options leave their owner. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ModelSharing { + #[serde(default)] + pub(crate) options: Vec, +} + +/// Exact optional producer identity; package versions and permissions are not inherited. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ModelInput { + #[serde(rename = "pluginID")] + pub(crate) plugin_id: String, + #[serde(rename = "buildSystemID")] + pub(crate) build_system_id: String, +} + +/// Enforces explicit publication/consumption and independent wire admission. +pub(crate) fn validate_declaration( + build: &mut BuildSystemDeclaration, + version: u32, + provider_version: u32, +) -> Validation<()> { + let invalid = || { + plans::invalid_selection("Model sharing requires buildModelInputs 1; consumers need a provider and buildProviders 6, exact unique producers, and declared shared option IDs; producers cannot also consume inputs.") + }; + if build.model_sharing.is_none() && build.model_inputs.is_empty() { + return Ok(()); + } + if version != 1 + || (build.model_sharing.is_some() && !build.model_inputs.is_empty()) + || build.model.is_none() + || build.model_inputs.len() > MAX_INPUTS + { + return Err(invalid()); + } + if !build.model_inputs.is_empty() && (build.provider.is_none() || provider_version < 6) { + return Err(invalid()); + } + let mut seen = BTreeSet::new(); + for input in &build.model_inputs { + if !is_plugin_id(&input.plugin_id) + || !is_entry_identifier(&input.build_system_id) + || !seen.insert(input.clone()) + { + return Err(invalid()); + } + } + build.model_inputs.sort(); + if let Some(sharing) = &mut build.model_sharing { + if sharing.options.len() > 32 + || sharing + .options + .iter() + .any(|id| !build.options.iter().any(|option| option.id == *id)) + { + return Err(invalid()); + } + let unique: BTreeSet<_> = sharing.options.iter().collect(); + if unique.len() != sharing.options.len() { + return Err(invalid()); + } + sharing.options.sort(); + } + Ok(()) +} + +/// Installed/enabled source facts supplied by the native host, not by another backend. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Source { + pub(crate) manifest: Value, + pub(crate) instances: Vec, +} + +/// Host model state; old or incomplete models never accompany an unavailable state. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Instance { + #[serde(rename = "buildSystemID")] + pub(crate) build_system_id: String, + pub(crate) root: String, + pub(crate) state: State, + #[serde(default)] + pub(crate) model: Option, + #[serde(default)] + pub(crate) options: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum State { + Ready, + Loading, + Stale, + Failed, +} + +/// A bounded snapshot revision belongs to this workspace activation only. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ResolveRequest { + pub(crate) revision: u64, + pub(crate) sources: Vec, +} + +fn invalid() -> crate::plugins::declarative::PluginRejection { + plans::invalid_selection("Invalid or oversized shared build model input.") +} + +/// Selects exact public producers and strips tasks, run plans, settings and unshared options. +pub(crate) fn resolve( + consumer: &BuildSystemDeclaration, + request: &ResolveRequest, +) -> Validation { + if consumer.model_inputs.is_empty() + || request.sources.len() > MAX_INPUTS + || request.revision > 9_007_199_254_740_991 + { + return Err(invalid()); + } + let mut sources = BTreeMap::new(); + for source in &request.sources { + let manifest = validate_manifest_value(source.manifest.clone())?; + if source.instances.len() > MAX_INSTANCES + || sources + .insert(manifest.id().to_string(), (manifest, source)) + .is_some() + { + return Err(invalid()); + } + } + let mut inputs = Vec::new(); + for input in &consumer.model_inputs { + let mut value = json!({"pluginID":input.plugin_id,"buildSystemID":input.build_system_id,"state":"unavailable","instances":[]}); + if let Some((manifest, source)) = sources.get(&input.plugin_id) { + if let Some(producer) = manifest + .build_systems() + .iter() + .find(|build| build.id == input.build_system_id && build.model_sharing.is_some()) + { + let mut instances = BTreeMap::new(); + for instance in source + .instances + .iter() + .filter(|instance| instance.build_system_id == producer.id) + { + let root = decode::request_path(&instance.root, "root")?; + let mut shared = json!({"root":root,"state":instance.state}); + if instance.state == State::Ready { + let model = instance.model.as_ref().ok_or_else(invalid)?; + let truncated = match model.get("truncated") { + Some(value) => value.as_bool().ok_or_else(invalid)?, + None => false, + }; + if truncated { + shared["state"] = json!("stale"); + } else { + shared["model"] = public_model(model, &root)?; + let values: BTreeMap> = serde_json::from_value( + model + .get("optionValues") + .cloned() + .unwrap_or_else(|| json!({})), + ) + .map_err(|_| invalid())?; + let resolved = + options::resolve(producer, &instance.options, Some(&values)); + shared["configurationFingerprint"] = + json!(options::fingerprint(producer, &resolved)); + let shared_values: BTreeMap<_, _> = producer + .model_sharing + .as_ref() + .expect("selected public model") + .options + .iter() + .filter_map(|id| resolved.values.get(id).map(|value| (id, value))) + .collect(); + shared["options"] = json!(shared_values); + } + } + if instances.insert(root, shared).is_some() { + return Err(invalid()); + } + } + value["state"] = json!("available"); + value["instances"] = json!(instances.into_values().collect::>()); + } + } + inputs.push(value); + } + let value = json!({"version":1,"revision":request.revision,"inputs":inputs}); + if serde_json::to_vec(&value).map_or(usize::MAX, |bytes| bytes.len()) > MAX_BYTES { + return Err(invalid()); + } + check_snapshot(consumer, &value)?; + Ok(value) +} + +/// Public normalized facts only; executable tasks and run plans are never decoded here. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PublicModel { + projects: Vec, + targets: Vec, + content_roots: Vec, +} + +fn text(value: &str, limit: usize) -> bool { + !value.is_empty() && value.len() <= limit && !value.chars().any(char::is_control) +} + +fn properties(values: &BTreeMap) -> bool { + values.len() <= 256 + && values.iter().all(|(key, value)| { + text(key, 256) && value.len() <= 1024 && !value.chars().any(char::is_control) + }) +} + +/// Rechecks IDs/ownership and lexical containment without inventing project facts. +fn public_model(model: &Value, root: &str) -> Validation { + let mut model: PublicModel = serde_json::from_value(model.clone()).map_err(|_| invalid())?; + if model.projects.len() > 2000 + || model.targets.len() > 10000 + || model.content_roots.len() > 10000 + { + return Err(invalid()); + } + let projects: BTreeMap<_, _> = model + .projects + .iter() + .map(|project| (project.id.as_str(), project)) + .collect(); + if projects.len() != model.projects.len() { + return Err(invalid()); + } + for project in &model.projects { + if !text(&project.id, 2048) + || !text(&project.name, 256) + || project + .display_name + .as_ref() + .is_some_and(|name| !text(name, 256)) + || !properties(&project.properties) + || decode::request_path(&project.path, "path")? != project.path + || !decode::contains(root, &project.path) + { + return Err(invalid()); + } + let mut parents = BTreeSet::from([project.id.as_str()]); + let mut parent = project.parent_id.as_deref(); + while let Some(id) = parent { + if !parents.insert(id) { + return Err(invalid()); + } + let ancestor = projects.get(id).ok_or_else(invalid)?; + if !decode::contains(&ancestor.path, &project.path) { + return Err(invalid()); + } + parent = ancestor.parent_id.as_deref(); + } + } + let mut targets = BTreeSet::new(); + for target in &model.targets { + let project = projects + .get(target.project_id.as_str()) + .ok_or_else(invalid)?; + if !text(&target.id, 2048) + || !targets.insert(&target.id) + || !text(&target.name, 256) + || !properties(&target.properties) + || target.tags.len() > 32 + || target.tags.iter().any(|tag| !text(tag, 256)) + || decode::request_path(&target.path, "path")? != target.path + || !decode::contains(&project.path, &target.path) + { + return Err(invalid()); + } + } + let mut roots = BTreeSet::new(); + for content in &model.content_roots { + let project = projects + .get(content.project_id.as_str()) + .ok_or_else(invalid)?; + if !roots.insert(content) + || decode::request_path(&content.path, "path")? != content.path + || !decode::contains(&project.path, &content.path) + { + return Err(invalid()); + } + } + model.projects.sort_by(|a, b| a.id.cmp(&b.id)); + model.targets.sort_by(|a, b| a.id.cmp(&b.id)); + model.content_roots.sort(); + serde_json::to_value(model).map_err(|_| invalid()) +} + +/// Revalidates the resolved snapshot instead of accepting arbitrary opaque backend context. +pub(crate) fn check_snapshot(consumer: &BuildSystemDeclaration, value: &Value) -> Validation<()> { + fn keys(value: &Value, allowed: &[&str]) -> bool { + value + .as_object() + .is_some_and(|object| object.keys().all(|key| allowed.contains(&key.as_str()))) + } + if !keys(value, &["version", "revision", "inputs"]) + || value["version"] != 1 + || value["revision"] + .as_u64() + .is_none_or(|revision| revision > 9_007_199_254_740_991) + || serde_json::to_vec(value).map_or(usize::MAX, |bytes| bytes.len()) > MAX_BYTES + { + return Err(invalid()); + } + let inputs = value["inputs"].as_array().ok_or_else(invalid)?; + if inputs.len() != consumer.model_inputs.len() { + return Err(invalid()); + } + for (value, slot) in inputs.iter().zip(&consumer.model_inputs) { + if !keys(value, &["pluginID", "buildSystemID", "state", "instances"]) + || value["pluginID"] != slot.plugin_id + || value["buildSystemID"] != slot.build_system_id + { + return Err(invalid()); + } + let instances = value["instances"].as_array().ok_or_else(invalid)?; + if instances.len() > MAX_INSTANCES + || !matches!(value["state"].as_str(), Some("available" | "unavailable")) + || value["state"] == "unavailable" && !instances.is_empty() + { + return Err(invalid()); + } + let mut roots = BTreeSet::new(); + for instance in instances { + let root = + decode::request_path(instance["root"].as_str().ok_or_else(invalid)?, "root")?; + if instance["root"] != root || !roots.insert(root.clone()) { + return Err(invalid()); + } + if instance["state"] == "ready" { + if !keys( + instance, + &[ + "root", + "state", + "model", + "configurationFingerprint", + "options", + ], + ) || public_model(&instance["model"], &root)? != instance["model"] + || !instance["configurationFingerprint"] + .as_str() + .is_some_and(|value| { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + || !instance["options"].as_object().is_some_and(|options| { + options.len() <= 32 + && options.values().all(|value| { + value.is_boolean() + || value.is_string() + || value + .as_array() + .is_some_and(|items| items.iter().all(Value::is_string)) + }) + }) + { + return Err(invalid()); + } + } else if !keys(instance, &["root", "state"]) + || !matches!( + instance["state"].as_str(), + Some("loading" | "stale" | "failed") + ) + { + return Err(invalid()); + } + } + } + Ok(()) +} diff --git a/rust/lithe-core/src/build/plans.rs b/rust/lithe-core/src/build/plans.rs index 15e4244980..8f8c4a4edd 100644 --- a/rust/lithe-core/src/build/plans.rs +++ b/rust/lithe-core/src/build/plans.rs @@ -12,8 +12,8 @@ use super::decode::{self, join_relative}; use super::integrations; use super::manifest::{ BuildExecution, BuildSystemDeclaration, DependenciesSource, ExecutableCandidate, ModelSource, - OutputChannel, ProviderDeclaration, RunDeclaration, Scope, TestFilter, TestResultsSource, - WorkingDirectory, + OutputChannel, ProviderDeclaration, RunDeclaration, Scope, TaskSource, TestFilter, + TestResultsSource, WorkingDirectory, }; use super::model::{entry_id, BuildModel, ModelProject}; use super::options::{self, OptionContribution}; @@ -68,6 +68,9 @@ pub(crate) struct SelectedEntity { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PlanBase { + /// Host-resolved public input snapshot, validated against declared slots. + #[serde(default)] + pub(crate) model_inputs: Option, /// Instance root, workspace-relative. pub(crate) root: String, /// Stored option values by option ID. @@ -154,7 +157,7 @@ pub(crate) struct ProviderTool { /// Validates what the host adds to a provider request and returns the /// params it contributes: `refresh` only when set and `tool` only when /// resolved, so a request without them matches a host that predates both. -fn provider_hints(request: &OutputPlanRequest) -> Validation> { +pub(crate) fn provider_hints(request: &OutputPlanRequest) -> Validation> { let mut params = Map::new(); if request.refresh { params.insert("refresh".to_string(), json!(true)); @@ -248,6 +251,7 @@ pub(crate) struct PlanContext<'a> { pub(crate) fingerprint: String, /// Effective option values by option ID. option_values: BTreeMap, + model_inputs: Option, plugin_directory: Option, workspace_state_directory: Option, } @@ -273,6 +277,15 @@ impl<'a> PlanContext<'a> { build_system: &'a BuildSystemDeclaration, base: &PlanBase, ) -> Validation { + match (&base.model_inputs, build_system.model_inputs.is_empty()) { + (None, true) => {} + (Some(value), false) => super::model_inputs::check_snapshot(build_system, value)?, + _ => { + return Err(invalid_selection( + "The provider needs exactly its declared shared model inputs.", + )) + } + } Self::with_model_options(build_system, base, None) } @@ -286,6 +299,7 @@ impl<'a> PlanContext<'a> { let fingerprint = options::fingerprint(build_system, &resolved); Ok(Self { build_system, + model_inputs: base.model_inputs.clone(), root: decode::request_path(&base.root, "root")?, contribution, fingerprint, @@ -349,7 +363,7 @@ impl<'a> PlanContext<'a> { /// would contribute to a command line, so the provider can hand either /// to the tool. `params` already holds the source's own fields and the /// host's `refresh` and `tool` hints. - fn provider_plan( + pub(crate) fn provider_plan( &self, provider: &ProviderDeclaration, method: &str, @@ -370,6 +384,9 @@ impl<'a> PlanContext<'a> { "environment".to_string(), json!(self.contribution.environment), ); + if let Some(inputs) = &self.model_inputs { + params.insert("modelInputs".into(), inputs.clone()); + } json!({ "buildSystemID": self.build_system.id, "root": self.root, @@ -397,6 +414,11 @@ fn check_user_arguments(arguments: &[String], declared: &[BuildArgument]) -> Val if !template::uses(declared, &template::Variable::UserArguments) { return Err(invalid_selection("This command takes no extra arguments.")); } + check_argument_limits(arguments) +} + +/// Provider calls accept raw argument values without a CLI placeholder. +pub(crate) fn check_argument_limits(arguments: &[String]) -> Validation<()> { if arguments.len() > MAX_USER_ARGUMENTS || arguments .iter() @@ -463,7 +485,11 @@ pub(crate) fn task_plan( build_system.display_name, request.task )) })?; - check_user_arguments(&request.user_arguments, &declaration.arguments)?; + if declaration.source == TaskSource::Provider { + check_argument_limits(&request.user_arguments)?; + } else { + check_user_arguments(&request.user_arguments, &declaration.arguments)?; + } let project = request .project .as_ref() @@ -523,6 +549,39 @@ pub(crate) fn task_plan( values.project = project.as_ref().map(Selection::selected); values.target = target.as_ref().map(Selection::selected); values.task = task.as_ref().map(Selection::selected); + if declaration.source == TaskSource::Provider { + // A model may override the template execution; do not turn an + // unbounded application/service into a single bounded RPC task. + if execution != BuildExecution::Task { + return Err(invalid_selection( + "A provider task supports task execution only.", + )); + } + let mut params = Map::new(); + params.insert("task".to_string(), json!(request.task)); + params.insert("userArguments".to_string(), json!(request.user_arguments)); + for (name, selected) in [ + ("project", &project), + ("target", &target), + ("modelTask", &task), + ] { + if let Some(selected) = selected { + params.insert( + name.to_string(), + json!({ + "name": selected.name, "path": selected.path, + "tags": selected.tags, "properties": selected.properties + }), + ); + } + } + return Ok(context.provider_plan( + declared_provider(build_system)?, + "lithe/build/execute", + "execution", + params, + )); + } context.plan(&declaration.arguments, &values, execution, Map::new()) } @@ -533,6 +592,32 @@ fn check_test_text(value: &Option, what: &str) -> Validation<()> { Ok(()) } +/// Discovers the instance's tests; individual selectors are validated on decode. +pub(crate) fn test_discovery_plan( + build_system: &BuildSystemDeclaration, + request: &OutputPlanRequest, +) -> Validation { + if !build_system + .test + .as_ref() + .is_some_and(|test| test.discovery) + { + return Err(unavailable(build_system, "test discovery")); + } + if request.project.is_some() || request.output_file.is_some() { + return Err(invalid_selection( + "Test discovery addresses the whole instance.", + )); + } + let context = PlanContext::new(build_system, &request.base)?; + Ok(context.provider_plan( + declared_provider(build_system)?, + "lithe/build/discoverTests", + "litheTestDiscovery", + provider_hints(request)?, + )) +} + /// `build.testPlan`: the command and where its results will be. /// Whether a selected target carries one of the declared tags; no tags /// accept every target. @@ -549,7 +634,11 @@ pub(crate) fn test_plan( .as_ref() .ok_or_else(|| unavailable(build_system, "tests"))?; let context = PlanContext::new(build_system, &request.base)?; - check_user_arguments(&request.user_arguments, &test.arguments)?; + if test.source == TaskSource::Provider { + check_argument_limits(&request.user_arguments)?; + } else { + check_user_arguments(&request.user_arguments, &test.arguments)?; + } check_test_text(&request.suite, "test suite")?; check_test_text(&request.case, "test case")?; for (requested, filter) in [ @@ -612,7 +701,10 @@ pub(crate) fn test_plan( "this test case without its suite", ), ] { - if requested && !template::applies_any(&test.arguments, &values, variables) { + if test.source != TaskSource::Provider + && requested + && !template::applies_any(&test.arguments, &values, variables) + { return Err(invalid_selection(format!( "{} cannot select {what}.", build_system.display_name @@ -652,6 +744,38 @@ pub(crate) fn test_plan( }; let mut extra = Map::new(); extra.insert("results".to_string(), results); + if test.source == TaskSource::Provider { + let mut params = Map::new(); + params.insert("userArguments".to_string(), json!(request.user_arguments)); + // Filters stay separate values; the owning backend interprets them + // using its upstream test engine, never a host-generated CLI string. + for (name, value) in [("suite", &request.suite), ("case", &request.case)] { + if let Some(value) = value { + params.insert(name.to_string(), json!(value)); + } + } + for (name, selected) in [("project", &project), ("target", &target)] { + if let Some(selected) = selected { + params.insert( + name.to_string(), + json!({ + "name": selected.name, "path": selected.path, + "tags": selected.tags, "properties": selected.properties + }), + ); + } + } + let mut plan = context.provider_plan( + declared_provider(build_system)?, + "lithe/build/test", + "execution", + params, + ); + plan.as_object_mut() + .expect("provider plan is an object") + .extend(extra); + return Ok(plan); + } context.plan(&test.arguments, &values, BuildExecution::Task, extra) } @@ -859,6 +983,7 @@ pub(crate) fn run_entries( return Ok(Vec::new()); } let base = PlanBase { + model_inputs: None, root: root.to_string(), options: stored_options.clone(), plugin_directory: None, @@ -872,6 +997,11 @@ pub(crate) fn run_entries( .collect(); let mut parts = Vec::new(); for run in &build_system.run { + // A provider-owned launch is prepared on demand; it must not be + // persisted as a command or emitted with an empty argument list. + if run.source == TaskSource::Provider { + continue; + } if let Some(from) = &run.from_targets { for target in model .targets diff --git a/rust/lithe-core/src/build/run_preparation.rs b/rust/lithe-core/src/build/run_preparation.rs new file mode 100644 index 0000000000..c51158f3b4 --- /dev/null +++ b/rust/lithe-core/src/build/run_preparation.rs @@ -0,0 +1,181 @@ +//! Provider-owned run preparation and validation of portable launch plans. +//! Upstream providers own build/classpath facts; Core only validates selectors, +//! bounded arguments, executable references, and workspace path containment. + +use super::decode::{self, Workspace}; +use super::manifest::{BuildSystemDeclaration, TaskSource}; +use super::plans::{self, OutputPlanRequest, SelectedEntity}; +use crate::plugins::declarative::{is_bare_command, is_identifier, Validation}; +use serde::Deserialize; +use serde_json::{json, Value}; + +const MAX_ARGUMENTS: usize = 1024; +const MAX_ARGUMENT_BYTES: usize = 65536; +const MAX_ENVIRONMENT: usize = 64; + +/// Host-selected model target and the run declaration that can prepare it. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PrepareRunRequest { + #[serde(flatten)] + pub(crate) base: OutputPlanRequest, + pub(crate) run: String, + pub(crate) target: SelectedEntity, + #[serde(default)] + pub(crate) user_arguments: Vec, +} + +/// Builds the public request without inferring language-specific launch facts. +pub(crate) fn plan( + build_system: &BuildSystemDeclaration, + request: &PrepareRunRequest, +) -> Validation { + let declaration = build_system + .run + .iter() + .find(|run| run.id == request.run && run.source == TaskSource::Provider) + .ok_or_else(|| plans::unavailable(build_system, "this provider run"))?; + if request.base.output_file.is_some() { + return Err(plans::invalid_selection( + "Provider run preparation does not use an output file.", + )); + } + let context = plans::PlanContext::new(build_system, &request.base.base)?; + let project = request + .base + .project + .as_ref() + .ok_or_else(|| plans::invalid_selection("A run target needs its project."))?; + let project = plans::selection(project, &context.root, None, "project")?; + let target = plans::selection( + &request.target, + &context.root, + Some(&project.path), + "target", + )?; + if !declaration + .from_targets + .as_ref() + .is_some_and(|from| target.tags.iter().any(|tag| from.tags.contains(tag))) + { + return Err(plans::invalid_selection( + "This run does not apply to the selected target.", + )); + } + plans::check_argument_limits(&request.user_arguments)?; + let mut params = plans::provider_hints(&request.base)?; + params.insert("run".to_string(), json!(declaration.id)); + params.insert("userArguments".to_string(), json!(request.user_arguments)); + for (name, selected) in [("project", project), ("target", target)] { + params.insert( + name.to_string(), + json!({"name":selected.name,"path":selected.path, + "tags":selected.tags,"properties":selected.properties}), + ); + } + let provider = build_system + .provider + .as_ref() + .ok_or_else(|| plans::unavailable(build_system, "a provider"))?; + Ok(context.provider_plan( + provider, + "lithe/build/prepareRun", + "litheRunPreparation", + params, + )) +} + +/// The platform supplies its writable workspace root, never an installation path. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DecodeRunPreparationRequest { + pub(crate) workspace_root: String, + pub(crate) root: String, + pub(crate) result: Value, +} + +/// Validates all launch data before a platform executable resolver sees it. +pub(crate) fn decode( + build_system: &BuildSystemDeclaration, + request: &DecodeRunPreparationRequest, +) -> Validation { + if !build_system + .run + .iter() + .any(|run| run.source == TaskSource::Provider) + { + return Err(plans::unavailable(build_system, "run preparation")); + } + let invalid = || decode::invalid_output("Invalid provider run preparation result."); + let value = &request.result; + if serde_json::to_vec(value).map_or(usize::MAX, |bytes| bytes.len()) > decode::MAX_OUTPUT_BYTES + { + return Err(invalid()); + } + let executable = value.get("executable").ok_or_else(invalid)?; + let kind = executable["kind"].as_str().ok_or_else(invalid)?; + let name = executable["value"].as_str().ok_or_else(invalid)?; + if name.len() > 120 + || !(kind == "command" && is_bare_command(name) + || kind == "toolchain" && is_identifier(name)) + { + return Err(invalid()); + } + let mut fields = process_fields(request)?; + fields["executable"] = json!({"kind":kind,"value":name}); + Ok(fields) +} + +/// Common bounded arguments, environment and workspace-contained directory. +pub(super) fn process_fields(request: &DecodeRunPreparationRequest) -> Validation { + let invalid = || decode::invalid_output("Invalid provider process preparation result."); + let value = &request.result; + if serde_json::to_vec(value).map_or(usize::MAX, |bytes| bytes.len()) > decode::MAX_OUTPUT_BYTES + { + return Err(invalid()); + } + let args = value["arguments"] + .as_array() + .filter(|args| args.len() <= MAX_ARGUMENTS) + .ok_or_else(invalid)?; + if args.iter().any(|arg| { + !arg.as_str() + .is_some_and(|text| text.len() <= MAX_ARGUMENT_BYTES && !text.contains('\0')) + }) { + return Err(invalid()); + } + let root = decode::request_path(&request.root, "root")?; + let cwd = value["workingDirectory"].as_str().ok_or_else(invalid)?; + let cwd = decode::request_path(cwd, "workingDirectory").map_err(|_| invalid())?; + let workspace = Workspace::new(&request.workspace_root)?; + let instance = workspace + .read_path(&root) + .filter(|path| path.is_dir()) + .ok_or_else(invalid)?; + if !decode::contains(&root, &cwd) + || !workspace + .read_path(&cwd) + .is_some_and(|path| path.is_dir() && path.starts_with(&instance)) + { + return Err(invalid()); + } + let environment = value["environment"] + .as_object() + .filter(|env| env.len() <= MAX_ENVIRONMENT) + .ok_or_else(invalid)?; + for (key, value) in environment { + let valid_key = !key.is_empty() + && key.len() <= 128 + && key.bytes().enumerate().all(|(index, byte)| { + byte == b'_' || byte.is_ascii_alphabetic() || index > 0 && byte.is_ascii_digit() + }); + if !valid_key + || !value + .as_str() + .is_some_and(|text| text.len() <= MAX_ARGUMENT_BYTES && !text.contains('\0')) + { + return Err(invalid()); + } + } + Ok(json!({"arguments":args,"workingDirectory":cwd,"environment":environment})) +} diff --git a/rust/lithe-core/src/build/test_discovery.rs b/rust/lithe-core/src/build/test_discovery.rs new file mode 100644 index 0000000000..d3b9d19f28 --- /dev/null +++ b/rust/lithe-core/src/build/test_discovery.rs @@ -0,0 +1,91 @@ +//! Normalizes provider-owned test selectors without inferring test semantics. +//! Every item must round-trip through the ordinary typed test plan, so discovery +//! cannot expose an item the declaration is unable to run exactly. + +use super::decode::{invalid_output, MAX_OUTPUT_BYTES}; +use super::manifest::BuildSystemDeclaration; +use super::plans::{self, PlanBase, SelectedEntity, TestPlanRequest}; +use crate::plugins::declarative::Validation; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +const MAX_TESTS: usize = 10_000; + +/// The plan's configuration and a provider's canonical discovery result. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DecodeTestDiscoveryRequest { + #[serde(flatten)] + pub(crate) base: PlanBase, + pub(crate) result: Value, +} + +/// Selectors have no host-generated command or cached upstream project model. +#[derive(Deserialize)] +struct Selector { + suite: String, + case: String, + project: Option, + target: Option, +} + +/// Rejects the entire malformed result rather than publishing a partial list +/// whose apparent completeness could hide tests or widen a selected run. +pub(crate) fn decode( + build_system: &BuildSystemDeclaration, + request: &DecodeTestDiscoveryRequest, +) -> Validation { + if !build_system + .test + .as_ref() + .is_some_and(|test| test.discovery) + { + return Err(plans::unavailable(build_system, "test discovery")); + } + // Validate host context even for a valid empty discovery result. + plans::PlanContext::new(build_system, &request.base)?; + let result = &request.result; + if serde_json::to_vec(result).map_or(usize::MAX, |bytes| bytes.len()) > MAX_OUTPUT_BYTES + || result["format"] != "litheTestDiscovery" + || result["version"] != 1 + { + return Err(invalid_output("Invalid test discovery document.")); + } + let tests = result["tests"] + .as_array() + .filter(|tests| tests.len() <= MAX_TESTS) + .ok_or_else(|| invalid_output("Test discovery requires at most 10000 selectors."))?; + let mut normalized = BTreeMap::new(); + for test in tests { + let selector: Selector = serde_json::from_value(test.clone()) + .map_err(|_| invalid_output("Invalid test selector."))?; + let plan = plans::test_plan( + build_system, + &TestPlanRequest { + base: request.base.clone(), + project: selector.project, + target: selector.target, + suite: Some(selector.suite), + case: Some(selector.case), + user_arguments: vec![], + projects: vec![], + }, + ) + .map_err(|_| invalid_output("A discovered selector cannot be run by this declaration."))?; + let params = &plan["provider"]["params"]; + let mut selected = json!({"suite":params["suite"],"case":params["case"]}); + for field in ["project", "target"] { + if let Some(value) = params.get(field) { + selected[field] = value.clone(); + } + } + let key = serde_json::to_string(&selected).expect("selector is JSON"); + // Identity includes project and target, so equal suite/case names in + // sibling modules remain independently runnable. Order is deterministic. + selected["id"] = json!(format!("test:{:x}", Sha256::digest(key.as_bytes()))); + normalized.insert(key, selected); + } + Ok(json!({"tests":normalized.into_values().collect::>()})) +} diff --git a/rust/lithe-core/src/debug/adapters.rs b/rust/lithe-core/src/debug/adapters.rs index 39e7633a17..058633a166 100644 --- a/rust/lithe-core/src/debug/adapters.rs +++ b/rust/lithe-core/src/debug/adapters.rs @@ -260,6 +260,10 @@ pub(crate) struct DebugExecutable { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DebugTargetBindings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) args: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) env: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) program: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -903,6 +907,8 @@ impl DebugConfiguration { if bindings.program.is_none() && bindings.process_id.is_none() && bindings.cwd.is_none() + && bindings.args.is_none() + && bindings.env.is_none() && bindings.connection.is_none() { return Err(invalid( @@ -918,6 +924,8 @@ impl DebugConfiguration { ), ("processId", &bindings.process_id, &["target.processID"][..]), ("cwd", &bindings.cwd, &["target.workingDirectory"][..]), + ("args", &bindings.args, &["target.arguments"][..]), + ("env", &bindings.env, &["target.environment"][..]), ] { if source .as_deref() @@ -1468,6 +1476,11 @@ impl Generation { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct TargetFacts { + /// Host-validated process arguments and environment; absent differs from empty. + #[serde(default)] + pub(crate) arguments: Option>, + #[serde(default)] + pub(crate) environment: Option>, // Selection by kind happens before a start; the host still states it so a // start names what it was prepared for. A `connection` target is also // checked against the adapter, see `plan`. @@ -1575,6 +1588,16 @@ pub(crate) fn start_arguments( &bindings.cwd, target.working_directory.clone().map(Value::from), ); + bind( + "args", + &bindings.args, + target.arguments.as_ref().map(|args| json!(args)), + ); + bind( + "env", + &bindings.env, + target.environment.as_ref().map(|env| json!(env)), + ); let connection = match &bindings.connection { Some(binding) => Some(bind_connection(binding, target, &mut arguments)?), None => None, @@ -1943,6 +1966,34 @@ pub(crate) fn plan( } _ => {} } + if bindings.args.is_some() + && target.arguments.as_ref().map_or(true, |args| { + args.len() > 1024 + || args + .iter() + .any(|arg| arg.len() > 65536 || arg.contains('\0')) + }) + { + return Err(missing("target.arguments", "bounded arguments")); + } + if bindings.env.is_some() + && target.environment.as_ref().map_or(true, |env| { + env.len() > 64 + || env.iter().any(|(key, value)| { + key.is_empty() + || key.len() > 128 + || !key.bytes().enumerate().all(|(index, byte)| { + byte == b'_' + || byte.is_ascii_alphabetic() + || index > 0 && byte.is_ascii_digit() + }) + || value.len() > 65536 + || value.contains('\0') + }) + }) + { + return Err(missing("target.environment", "bounded environment")); + } if bindings.process_id.is_some() && target.process_id.is_none() { return Err(missing("target.processID", "process ID")); } @@ -2069,3 +2120,17 @@ fn sort_unique(values: &mut Vec, field: &str) -> Validation<()> { Err(invalid(field, "An entry is listed more than once.")) } } + +/// New bindings have their own manifest admission version. +pub(crate) fn uses_target_arguments(adapters: &[DebugAdapterDeclaration]) -> bool { + adapters + .iter() + .filter_map(|adapter| adapter.configurations.as_ref()) + .any(|configs| { + [configs.launch.as_ref(), configs.attach.as_ref()] + .into_iter() + .flatten() + .filter_map(|config| config.target_bindings.as_ref()) + .any(|bindings| bindings.args.is_some() || bindings.env.is_some()) + }) +} diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 87910c5954..40c68dd656 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -14,7 +14,7 @@ const VERSION: u32 = 2; const LEGACY_VERSION: u32 = 1; // Bumped when generation changes what a workspace should contain: existing // workspaces regenerate instead of keeping a stale `generated.json`. -const GENERATOR_REVISION: &str = "10"; +const GENERATOR_REVISION: &str = "11"; /// Toolchain requirements and `project.json` are separate documents that happen /// to live under `.lithe`. Their schema did not change with run-config v2, so /// they keep their own version and must not be validated against `VERSION`. @@ -191,6 +191,62 @@ pub struct CreateUserConfigurationRequest { pub module: String, #[serde(default)] pub main_class: String, + /// A typed selector, never a cached executable or classpath. + #[serde(default)] + pub build: Option, +} + +/// Persisted workspace identity for re-preparing a build-provider application. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BuildProviderRunReference { + #[serde(rename = "pluginID")] + pub plugin_id: String, + #[serde(rename = "buildSystemID")] + pub build_system_id: String, + pub root: String, + pub run: String, + #[serde(rename = "projectID")] + pub project_id: String, + #[serde(rename = "targetID")] + pub target_id: String, + #[serde(default)] + pub user_arguments: Vec, +} + +impl BuildProviderRunReference { + /// Persistence validates identities only; current model membership is checked at launch. + fn validate(&self) -> Result<(), CoreError> { + let identifier = |value: &str| { + !value.is_empty() + && value.len() <= 200 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + }; + if !identifier(&self.plugin_id) + || !identifier(&self.build_system_id) + || !identifier(&self.run) + || [&self.project_id, &self.target_id] + .iter() + .any(|id| id.is_empty() || id.len() > 2048 || id.contains('\0')) + || self.user_arguments.len() > 64 + || self + .user_arguments + .iter() + .any(|arg| arg.len() > 4096 || arg.contains('\0')) + || invalid_relative_path(&self.root) + || self.root.is_empty() + || self.root.contains('\\') + || self.root.contains('\0') + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid build provider run reference", + )); + } + Ok(()) + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -284,6 +340,9 @@ fn migrate_configuration_value(item: &mut Value) { /// or to `path` for a Java source that only counts by presence. pub struct GeneratorMetadata { pub fingerprint: String, + /// Unreadable inputs retained as unknown; a partial baseline cannot prove freshness. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub unreadable_paths: BTreeSet, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub inputs: BTreeMap, } @@ -437,6 +496,16 @@ pub fn inspect(request: InspectRequest) -> Result { validate_sidecar_version(document.version)?; } let mut diagnostics = Vec::new(); + if request.check_fingerprint == Some(false) { + if let Some(metadata) = generated + .as_ref() + .and_then(|document| document.generator.as_ref()) + { + if !metadata.unreadable_paths.is_empty() { + diagnostics.push(incomplete_scan_diagnostic(&metadata.unreadable_paths)); + } + } + } let local = local_layer_document(&root, request.local_document.clone())?; if let Some((document, metadata)) = generated .as_ref() @@ -444,7 +513,14 @@ pub fn inspect(request: InspectRequest) -> Result { .filter(|_| request.check_fingerprint != Some(false)) { let current_inputs = project_inputs(&root, &java_entry_sources(&document.configurations))?; - if metadata.fingerprint != fingerprint_from_inputs(¤t_inputs) { + if !current_inputs.unreadable_paths.is_empty() || !metadata.unreadable_paths.is_empty() { + let unknown = current_inputs + .unreadable_paths + .union(&metadata.unreadable_paths) + .cloned() + .collect(); + diagnostics.push(incomplete_scan_diagnostic(&unknown)); + } else if metadata.fingerprint != fingerprint_from_inputs(¤t_inputs.inputs) { // Stored inputs that no longer reproduce the stored fingerprint were // recorded by another generator revision, whose input rules (such // as which sources are hashed) may differ from today's. @@ -452,10 +528,10 @@ pub fn inspect(request: InspectRequest) -> Result { fingerprint_from_inputs(&metadata.inputs) != metadata.fingerprint; let message = if metadata.inputs.is_empty() { "Project inputs changed after run configuration generation".to_string() - } else if generator_changed || metadata.inputs == current_inputs { + } else if generator_changed || metadata.inputs == current_inputs.inputs { "Run configuration generator changed; regenerate configurations".to_string() } else { - input_change_summary(&metadata.inputs, ¤t_inputs) + input_change_summary(&metadata.inputs, ¤t_inputs.inputs) }; diagnostics.push(json!({ "code": "staleFingerprint", @@ -507,7 +583,7 @@ pub fn inspect(request: InspectRequest) -> Result { } } Ok(json!({ - "status": if generated.is_some() { "ready" } else { "missing" }, + "status": if generated.is_some() || has_saved_build_run(&local) { "ready" } else { "missing" }, "generated": generated, "toolchainRequirements": requirements, "localToolchains": local_toolchains, @@ -651,7 +727,6 @@ pub fn generate(request: GenerateRequest) -> Result { }) .collect::, _>>()?; let mut configurations = deduplicate_java_configurations(configurations); - let java_entry_count = configurations.len(); if has_java_sources { configurations.push(RunConfiguration { id: "current-file".to_string(), @@ -694,10 +769,20 @@ pub fn generate(request: GenerateRequest) -> Result { // Counts real entry points, not documents: the always-present "Current File" // fallback is excluded so an empty project still reports zero and the UI can // say so, while a detected npm service correctly reports one. - let entry_count = java_entry_count + detected.len(); configurations.extend(detected); + let mut snapshot = project_inputs(&root, &java_entry_sources(&configurations))?; + // Unknown subtrees cannot delete known configurations or baseline inputs. + // Invalid required documents still fail instead of being silently overwritten. + if !snapshot.unreadable_paths.is_empty() { + if let Some(previous) = read_document(&root, "run/generated.json")? { + retain_unknown_inputs(&mut configurations, &mut snapshot, previous); + } + } disambiguate_configuration_names(&mut configurations); - let inputs = project_inputs(&root, &java_entry_sources(&configurations))?; + let entry_count = configurations + .iter() + .filter(|item| item.id != "current-file") + .count(); let requirements = detect_requirements( &root, maven_root.as_ref().map(|(path, _)| path.as_path()), @@ -707,8 +792,9 @@ pub fn generate(request: GenerateRequest) -> Result { let generated = RunConfigurationDocument { version: VERSION, generator: Some(GeneratorMetadata { - fingerprint: fingerprint_from_inputs(&inputs), - inputs, + fingerprint: fingerprint_from_inputs(&snapshot.inputs), + inputs: snapshot.inputs, + unreadable_paths: snapshot.unreadable_paths, }), configurations, }; @@ -1097,15 +1183,19 @@ fn is_nested_checkout_path(value: &str) -> bool { /// Merges generated, team, and local layers and selects compatible toolchains. pub fn resolve(request: ResolveRequest) -> Result { let root = existing_root(&request.root)?; - let generated = read_document_value(&root, "run/generated.json")?.ok_or_else(|| { - CoreError::new( - ErrorCode::WorkspaceNotFound, - "Run configuration has not been generated", - ) - })?; let team = read_document_value(&root, "run/configurations.json")? .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); let local = local_layer_document(&root, request.local_document)?; + let generated = match read_document_value(&root, "run/generated.json")? { + Some(document) => document, + None if has_saved_build_run(&local) => json!({"version": VERSION, "configurations": []}), + None => { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + "Run configuration has not been generated", + )) + } + }; let local_toolchains = read_local_toolchains(&root)?; let manifest = read_document_value(&root, "project.json")?; validate_version_value(&generated)?; @@ -1141,6 +1231,23 @@ pub fn resolve(request: ResolveRequest) -> Result { )?); for configuration in &mut configurations { validate_configuration(configuration)?; + if configuration.provider == "build.provider" { + // Materialize optional defaults in the shared output so native + // consumers receive the same shape as a newly saved selector. + let reference: BuildProviderRunReference = serde_json::from_value( + configuration.extensions["build"].clone(), + ) + .map_err(|_| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid build provider run reference", + ) + })?; + configuration.extensions.insert( + "build".to_string(), + serde_json::to_value(reference).expect("reference encodes"), + ); + } if configuration.disabled { diagnostics.push(json!({ "id": configuration.id, @@ -1562,11 +1669,133 @@ fn remove_maven_skip_tests_override(configuration: &mut serde_json::Map bool { + document["configurations"].as_array().is_some_and(|items| { + items.iter().any(|item| { + item["provider"] == "build.provider" + && item["id"] + .as_str() + .is_some_and(|id| id.starts_with("user:")) + }) + }) +} + +/// Upserts a local reference while preserving unrelated settings and stable IDs. +fn create_build_provider_configuration( + root: &Path, + request: &CreateUserConfigurationRequest, +) -> Result { + if request.scope != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Build provider references must be saved locally", + )); + } + let reference = request.build.as_ref().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Missing build provider run reference", + ) + })?; + reference.validate()?; + normalize_project_directory(root, &reference.root)?; + let name = request.name.trim(); + if name.is_empty() || name.len() > 256 || name.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid run configuration name", + )); + } + let mut document = read_document_value(root, "run/local.json")? + .unwrap_or_else(|| json!({"version":VERSION,"configurations":[]})); + validate_version_value(&document)?; + let mut ids: std::collections::BTreeSet = + configuration_ids(&document)?.into_keys().collect(); + for relative in ["run/generated.json", "run/configurations.json"] { + if let Some(other) = read_document_value(root, relative)? { + validate_version_value(&other)?; + ids.extend(configuration_ids(&other)?.into_keys()); + } + } + let encoded = serde_json::to_value(reference).expect("reference encodes"); + let mut identity = encoded.clone(); + identity.as_object_mut().unwrap().remove("userArguments"); + let items = document["configurations"] + .as_array_mut() + .ok_or_else(|| CoreError::new(ErrorCode::ParseFailed, "configurations must be an array"))?; + let existing = items.iter().position(|item| { + if item["provider"] != "build.provider" { + return false; + } + let mut saved = item["extensions"]["build"].clone(); + if let Some(object) = saved.as_object_mut() { + object.remove("userArguments"); + } + saved == identity + }); + let id = existing + .and_then(|index| items[index]["id"].as_str()) + .map(str::to_string) + .unwrap_or_else(|| unique_user_configuration_id(name, &ids)); + let mut configuration = existing + .map(|index| items[index].clone()) + .unwrap_or_else(|| json!({})); + let object = configuration.as_object_mut().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration must be an object", + ) + })?; + let managed = json!({"id":id,"name":name,"provider":"build.provider","execution":"application", + "cwd":reference.root,"confidence":"declared"}); + object.extend( + managed + .as_object() + .expect("managed fields are an object") + .clone(), + ); + // Other namespaces may hold user/plugin metadata; replacing the whole + // entry would erase data unrelated to this selector update. + let extensions = object + .entry("extensions") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration extensions must be an object", + ) + })?; + extensions.insert("build".to_string(), encoded); + let typed: RunConfiguration = serde_json::from_value(configuration.clone()) + .map_err(|_| CoreError::new(ErrorCode::ParseFailed, "Invalid saved run configuration"))?; + validate_configuration(&typed)?; + if let Some(index) = existing { + items[index] = configuration; + } else { + items.push(configuration); + } + items.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str())); + Ok( + json!({"id":id,"document":serde_json::to_string_pretty(&document).expect("document encodes")}), + ) +} + /// Creates a user configuration while preserving stable IDs in existing layers. pub fn create_user_configuration( request: CreateUserConfigurationRequest, ) -> Result { let root = existing_root(&request.root)?; + if request.kind == "buildProvider" { + return create_build_provider_configuration(&root, &request); + } + if request.build.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Build reference requires a buildProvider configuration", + )); + } let relative = scope_document(&request.scope)?; let name = request.name.trim(); if name.is_empty() { @@ -2551,6 +2780,34 @@ fn validate_configuration(configuration: &RunConfiguration) -> Result<(), CoreEr ) .with_details(configuration.provider.clone())); } + if configuration.provider == "build.provider" { + let reference: BuildProviderRunReference = serde_json::from_value( + configuration + .extensions + .get("build") + .cloned() + .unwrap_or(Value::Null), + ) + .map_err(|_| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid build provider run reference", + ) + })?; + reference.validate()?; + if configuration.command.is_some() + || !configuration.args.is_empty() + || !configuration.env.is_empty() + || !configuration.toolchains.is_empty() + || configuration.debug.is_some() + || configuration.execution != Execution::Application + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Build provider configurations store selectors, not process launch data", + )); + } + } let module = configuration.module().unwrap_or_else(|| ".".to_string()); for (field, value) in [ ("module", module.as_str()), @@ -3085,38 +3342,92 @@ pub(super) fn java_feature_version_parts(parts: Vec) -> Vec { /// Collects generation inputs. Java sources count by path unless generation /// reads their content (`content_sources`); every other input is hashed. +struct ProjectInputs { + inputs: BTreeMap, + unreadable_paths: BTreeSet, +} + +// Retention is independent of filesystem permissions so both hosts preserve the +// same last-known configuration when an optional subtree becomes unreadable. +fn retain_unknown_inputs( + configurations: &mut Vec, + snapshot: &mut ProjectInputs, + previous: RunConfigurationDocument, +) { + for configuration in previous.configurations { + let source = configuration.source.as_deref().or_else(|| { + configuration + .extensions + .get("java")? + .get("source")? + .as_str() + }); + if source.is_some_and(|path| under_unknown(path, &snapshot.unreadable_paths)) + && !configurations + .iter() + .any(|current| current.id == configuration.id) + { + configurations.push(configuration); + } + } + if let Some(metadata) = previous.generator { + for (path, hash) in metadata.inputs { + if under_unknown(&path, &snapshot.unreadable_paths) { + snapshot.inputs.entry(path).or_insert(hash); + } + } + } +} + +fn under_unknown(path: &str, unknown: &BTreeSet) -> bool { + unknown + .iter() + .any(|prefix| path == prefix || path.starts_with(&format!("{prefix}/"))) +} + +fn incomplete_scan_diagnostic(paths: &BTreeSet) -> Value { + json!({"code": "incompleteProjectScan", "message": format!( + "Project scan is incomplete; freshness and removals could not be verified. Unreadable inputs: {}", + paths.iter().cloned().collect::>().join(", "))}) +} + fn project_inputs( root: &Path, content_sources: &BTreeSet, -) -> Result, CoreError> { - let mut files = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - if !ignored_directory(&path) { - stack.push(path); +) -> Result { + let scan = super::workspace_scan::scan_run_workspace(root, None)?; + let mut result = ProjectInputs { + inputs: BTreeMap::new(), + unreadable_paths: scan.unreadable_paths, + }; + for directory in scan.directories { + for name in directory.files { + let path = directory.path.join(name); + if !fingerprint_input(&path) { + continue; + } + let relative = path + .strip_prefix(root) + .expect("scan stays within root") + .to_string_lossy() + .replace('\\', "/"); + if relative.ends_with(".java") && !content_sources.contains(&relative) { + result.inputs.insert(relative, PATH_ONLY_INPUT.to_string()); + continue; + } + crate::protocol::cancellation::check()?; + match fs::read(&path) { + Ok(bytes) => { + result + .inputs + .insert(relative, format!("sha256:{:x}", Sha256::digest(bytes))); } - } else if fingerprint_input(&path) { - if let Ok(relative) = path.strip_prefix(root) { - files.push(relative.to_string_lossy().replace('\\', "/")); + Err(_) => { + result.unreadable_paths.insert(relative); } } } } - files.sort(); - let mut result = BTreeMap::new(); - for relative in files { - if relative.ends_with(".java") && !content_sources.contains(&relative) { - result.insert(relative, PATH_ONLY_INPUT.to_string()); - continue; - } - if let Ok(bytes) = fs::read(root.join(&relative)) { - result.insert(relative, format!("sha256:{:x}", Sha256::digest(bytes))); - } - } Ok(result) } @@ -3154,29 +3465,6 @@ fn input_change_summary( format!("Project inputs changed: {added} added, {removed} removed, {changed} modified") } -fn ignored_directory(path: &Path) -> bool { - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - return false; - }; - name.eq_ignore_ascii_case(".worktree") - || name.eq_ignore_ascii_case(".worktrees") - || matches!( - name, - ".git" - | ".lithe" - | ".idea" - | ".gradle" - | ".venv" - | "venv" - | "node_modules" - | "vendor" - | "target" - | "build" - | "dist" - | "out" - ) -} - fn fingerprint_input(path: &Path) -> bool { if path.extension().and_then(|extension| extension.to_str()) == Some("java") { return true; @@ -3250,28 +3538,21 @@ fn highest_version(versions: Vec) -> Option { } fn project_manifest_paths(root: &Path, names: &[&str]) -> Vec { - let mut result = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - while let Some(directory) = stack.pop() { - let Ok(entries) = fs::read_dir(directory) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if !ignored_directory(&path) { - stack.push(path); - } - } else if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| names.contains(&name)) - { - result.push(path); - } - } - } - result + // This heuristic only supplements declared toolchain requirements. Fingerprint + // scanning separately reports unknown subtrees to both product hosts. + let Ok(scan) = super::workspace_scan::scan_run_workspace(root, None) else { + return Vec::new(); + }; + scan.directories + .into_iter() + .flat_map(|directory| { + directory + .files + .into_iter() + .filter(|name| names.contains(&name.as_str())) + .map(move |name| directory.path.join(name)) + }) + .collect() } fn declared_java_version(root: &Path) -> Option<(String, Option)> { @@ -3370,3 +3651,42 @@ fn valid_provider(value: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') }) } + +#[cfg(test)] +mod incomplete_scan_tests { + use super::*; + + #[test] + fn unknown_inputs_retain_only_affected_configurations_and_hashes() { + let previous: RunConfigurationDocument = serde_json::from_value(json!({ + "version": 2, + "generator": {"fingerprint": "old", "inputs": { + "blocked/package.json": "old-blocked", "removed/package.json": "old-removed" + }}, + "configurations": [ + {"id":"blocked", "name":"Blocked", "provider":"npm.script", "source":"blocked/package.json"}, + {"id":"removed", "name":"Removed", "provider":"npm.script", "source":"removed/package.json"} + ] + })).unwrap(); + let mut configurations = Vec::new(); + let mut snapshot = ProjectInputs { + inputs: BTreeMap::from([("visible/package.json".into(), "new".into())]), + unreadable_paths: BTreeSet::from(["blocked".into()]), + }; + retain_unknown_inputs(&mut configurations, &mut snapshot, previous); + assert_eq!(configurations.len(), 1); + assert_eq!(configurations[0].id, "blocked"); + assert_eq!( + snapshot + .inputs + .get("blocked/package.json") + .map(String::as_str), + Some("old-blocked") + ); + assert!(!snapshot.inputs.contains_key("removed/package.json")); + assert!(!under_unknown( + "blocked-sibling/package.json", + &snapshot.unreadable_paths + )); + } +} diff --git a/rust/lithe-core/src/execution/detectors/scan.rs b/rust/lithe-core/src/execution/detectors/scan.rs index b54db4b383..a6576d47fd 100644 --- a/rust/lithe-core/src/execution/detectors/scan.rs +++ b/rust/lithe-core/src/execution/detectors/scan.rs @@ -8,49 +8,6 @@ use std::path::{Path, PathBuf}; /// Detectors only care about manifests near the top of a project. Anything /// deeper is vendored code or build output that slipped past the prune list. const MAX_DEPTH: usize = 6; -/// A ceiling on how many directories one project may contribute. Without it a -/// pathological tree turns project open into an unbounded walk. -const MAX_DIRECTORIES: usize = 4000; - -/// Directories that never contain a service worth offering, and whose contents -/// are large enough that descending into them would dominate the walk. -const PRUNED: &[&str] = &[ - ".git", - ".worktree", - ".worktrees", - ".lithe", - ".idea", - ".vscode", - ".gradle", - ".venv", - "venv", - ".tox", - ".mypy_cache", - ".pytest_cache", - "__pycache__", - ".next", - ".nuxt", - ".svelte-kit", - ".terraform", - ".cargo", - ".stack-work", - ".bundle", - ".dart_tool", - ".build", - ".swiftpm", - "node_modules", - "bower_components", - "vendor", - "target", - "build", - "dist", - "out", - "obj", - "coverage", - "DerivedData", - "Pods", -]; - /// One directory plus the file names directly inside it. /// /// Detectors read this instead of touching the filesystem, so checking "is there @@ -138,51 +95,19 @@ impl DirectoryContext { /// Walks the project, pruning uninteresting subtrees *before* descending rather /// than filtering afterwards, so a `node_modules` tree is never read at all. pub fn scan(root: &Path) -> Result, CoreError> { - let mut results = Vec::new(); - let mut queue = vec![(root.to_path_buf(), 0usize)]; - while let Some((directory, depth)) = queue.pop() { - if results.len() >= MAX_DIRECTORIES { - break; - } - let Ok(entries) = fs::read_dir(&directory) else { - continue; - }; - let mut files = BTreeSet::new(); - let mut children = Vec::new(); - for entry in entries.flatten() { - let Ok(kind) = entry.file_type() else { - continue; - }; - let Some(name) = entry.file_name().to_str().map(str::to_string) else { - continue; - }; - // `file_type` does not follow symlinks, so a link pointing at an - // ancestor is recorded as a file and never re-entered. That is what - // keeps this walk loop-free without tracking visited inodes. - if kind.is_dir() { - if depth < MAX_DEPTH && !pruned(&name) { - children.push(entry.path()); - } - } else { - files.insert(name); - } - } - results.push(DirectoryContext { - root: root.to_path_buf(), - relative: relative_path(root, &directory)?, - path: directory, - files, - }); - for child in children { - queue.push((child, depth + 1)); - } - } - results.sort_by(|left, right| left.relative.cmp(&right.relative)); - Ok(results) -} - -fn pruned(name: &str) -> bool { - PRUNED.iter().any(|value| value.eq_ignore_ascii_case(name)) + let snapshot = super::super::workspace_scan::scan_run_workspace(root, Some(MAX_DEPTH))?; + snapshot + .directories + .into_iter() + .map(|directory| { + Ok(DirectoryContext { + root: root.to_owned(), + relative: relative_path(root, &directory.path)?, + path: directory.path, + files: directory.files, + }) + }) + .collect() } fn relative_path(root: &Path, directory: &Path) -> Result { diff --git a/rust/lithe-core/src/execution/environment.rs b/rust/lithe-core/src/execution/environment.rs new file mode 100644 index 0000000000..197b5dff6b --- /dev/null +++ b/rust/lithe-core/src/execution/environment.rs @@ -0,0 +1,112 @@ +//! Resolves launch environment precedence after platform-owned tool discovery. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +/// Platform-resolved homes and inherited environment for one launch step. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchEnvironmentRequest { + /// Toolchain of this step, not the later application process. + pub toolchain: Option, + /// Resolved project JDK home. + pub java_home: Option, + /// Resolved Maven JVM home, falling back to the project home when absent. + pub maven_java_home: Option, + /// Native parent process environment; selected homes override it. + #[serde(default)] + pub inherited: BTreeMap, + /// Plan bindings followed by explicit user overrides, already merged by key. + #[serde(default)] + pub overrides: BTreeMap, + /// Windows environment keys are case-insensitive even in a Rust map. + #[serde(default)] + pub case_insensitive: bool, +} + +/// Final values passed directly to the native process adapter. +#[derive(Debug, Serialize)] +pub struct LaunchEnvironmentResponse { + /// Deterministically ordered final environment. + pub environment: BTreeMap, +} + +/// Applies inherited < selected toolchain < explicit launch overrides. +/// Maven steps use their Maven JDK; direct Java steps retain the project JDK. +pub fn resolve_launch_environment(request: LaunchEnvironmentRequest) -> LaunchEnvironmentResponse { + let mut environment = BTreeMap::new(); + let mut insert = |key: String, value: String| { + let key = if request.case_insensitive { + key.to_ascii_uppercase() + } else { + key + }; + environment.insert(key, value); + }; + for (key, value) in request.inherited { + insert(key, value); + } + let selected = if request.toolchain.as_deref() == Some("project-maven") { + request + .maven_java_home + .as_ref() + .or(request.java_home.as_ref()) + } else if request.toolchain.as_deref() == Some("project-jdk") { + request.java_home.as_ref() + } else { + None + }; + if let Some(home) = selected { + insert("JAVA_HOME".into(), home.clone()); + } + for (key, value) in request.overrides { + let resolved = if let Some(text) = value.as_str() { + Some(text.to_owned()) + } else if value.get("property").and_then(Value::as_str) == Some("home") { + // A pre-launch Maven step may inherit its application's Java binding. + // The executable's role owns JAVA_HOME; literal user overrides remain + // authoritative. Other variables keep their explicit binding. + if key.eq_ignore_ascii_case("JAVA_HOME") && selected.is_some() { + selected.cloned() + } else { + match value.get("toolchain").and_then(Value::as_str) { + Some("project-jdk") => request.java_home.clone(), + Some("project-maven") => request + .maven_java_home + .clone() + .or(request.java_home.clone()), + _ => None, + } + } + } else { + None + }; + if let Some(value) = resolved { + insert(key, value); + } + } + LaunchEnvironmentResponse { environment } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_launch_environment_fixtures() { + let cases: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/execution/launch-environment-v1.json" + )) + .unwrap(); + for case in cases["cases"].as_array().unwrap() { + let request = serde_json::from_value(case["request"].clone()).unwrap(); + assert_eq!( + serde_json::to_value(resolve_launch_environment(request)).unwrap(), + case["expected"], + "{}", + case["name"] + ); + } + } +} diff --git a/rust/lithe-core/src/execution/mod.rs b/rust/lithe-core/src/execution/mod.rs index 0b8e69c417..6cd305a8bb 100644 --- a/rust/lithe-core/src/execution/mod.rs +++ b/rust/lithe-core/src/execution/mod.rs @@ -21,3 +21,11 @@ pub use launch_command::{ LaunchCommandPlan, LaunchCommandPlanRequest, LaunchCommandPlanResponse, WINDOWS_COMMAND_LINE_LIMIT, }; + +mod workspace_scan; +pub use workspace_scan::{is_run_scan_excluded_directory, list_run_java_sources}; + +mod environment; +pub use environment::{ + resolve_launch_environment, LaunchEnvironmentRequest, LaunchEnvironmentResponse, +}; diff --git a/rust/lithe-core/src/execution/workspace_scan.rs b/rust/lithe-core/src/execution/workspace_scan.rs new file mode 100644 index 0000000000..8e59d1d5df --- /dev/null +++ b/rust/lithe-core/src/execution/workspace_scan.rs @@ -0,0 +1,347 @@ +//! Shared run-discovery traversal and incomplete-snapshot diagnostics. +//! +//! Automatic discovery is best effort below the required workspace root. Declared +//! build-system modules remain owned by their provider and bypass these defaults. + +use crate::protocol::CoreError; +use std::collections::BTreeSet; +use std::fs; +use std::io; +use std::ops::ControlFlow; +use std::path::{Path, PathBuf}; + +/// Heuristic discovery budget; full fingerprint scans retain cancellation checks. +const MAX_HEURISTIC_DIRECTORIES: usize = 4000; + +const EXCLUDED_DIRECTORIES: &[&str] = &[ + ".git", + ".worktree", + ".worktrees", + ".lithe", + ".idea", + ".vscode", + ".gradle", + ".venv", + "venv", + ".tox", + ".mypy_cache", + ".pytest_cache", + "__pycache__", + ".next", + ".nuxt", + ".svelte-kit", + ".terraform", + ".cargo", + ".stack-work", + ".bundle", + ".dart_tool", + ".build", + ".swiftpm", + "node_modules", + "bower_components", + "vendor", + "target", + "build", + "dist", + "out", + "obj", + "coverage", + "DerivedData", + "Pods", +]; + +/// Default pruning for automatic run discovery, fingerprints and host Java lists. +/// Hidden source directories are allowed unless explicitly named here. +pub fn is_run_scan_excluded_directory(name: &str) -> bool { + EXCLUDED_DIRECTORIES + .iter() + .any(|excluded| excluded.eq_ignore_ascii_case(name)) +} + +/// One directory's files, without following directory symlinks. +pub(crate) struct ScannedDirectory { + pub path: PathBuf, + pub files: BTreeSet, +} + +/// A partial scan must never be used as evidence of deletion or freshness. +pub(crate) struct WorkspaceScan { + pub directories: Vec, + pub unreadable_paths: BTreeSet, +} + +/// Traverses automatic discovery inputs. A depth limit belongs only to heuristic +/// service detection; fingerprints and explicit host file lists use no depth limit. +pub(crate) fn scan_run_workspace( + root: &Path, + max_depth: Option, +) -> Result { + scan_with_reader(root, max_depth, &read_directory) +} + +fn read_directory(path: &Path) -> io::Result> { + fs::read_dir(path)? + .map(|entry| { + let entry = entry?; + let kind = entry.file_type()?; + Ok((entry.path(), kind.is_dir(), kind.is_symlink())) + }) + .collect() +} + +fn scan_with_reader( + root: &Path, + max_depth: Option, + read: &impl Fn(&Path) -> io::Result>, +) -> Result { + let mut directories = Vec::new(); + let unreadable_paths = visit_with_reader(root, max_depth, read, |directory| { + directories.push(directory); + ControlFlow::Continue(()) + })?; + directories.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(WorkspaceScan { + directories, + unreadable_paths, + }) +} + +// Consumers decide whether to retain each directory and when enough inputs have +// been found. Early stopping must not change full fingerprint scan semantics. +fn visit_with_reader( + root: &Path, + max_depth: Option, + read: &impl Fn(&Path) -> io::Result>, + mut visit: impl FnMut(ScannedDirectory) -> ControlFlow<()>, +) -> Result, CoreError> { + let mut unreadable_paths = BTreeSet::new(); + let mut directory_count = 0; + let mut pending = vec![(root.to_owned(), 0)]; + while let Some((path, depth)) = pending.pop() { + crate::protocol::cancellation::check()?; + if max_depth.is_some() && directory_count >= MAX_HEURISTIC_DIRECTORIES { + unreadable_paths.insert(relative(root, &path)); + unreadable_paths.extend(pending.iter().map(|(path, _)| relative(root, path))); + break; + } + let entries = match read(&path) { + Ok(entries) => entries, + Err(error) if path == root => return Err(CoreError::from(error)), + Err(_) => { + unreadable_paths.insert(relative(root, &path)); + continue; + } + }; + let mut files = BTreeSet::new(); + let mut children = Vec::new(); + for (child, directory, symlink) in entries { + crate::protocol::cancellation::check()?; + let Some(name) = child.file_name().and_then(|name| name.to_str()) else { + unreadable_paths.insert(relative(root, &child)); + continue; + }; + if symlink { + continue; + } + if directory { + if !is_run_scan_excluded_directory(name) + && max_depth.is_none_or(|limit| depth < limit) + { + children.push((child, depth + 1)); + } + } else { + files.insert(name.to_owned()); + } + } + children.sort_by(|left, right| right.0.cmp(&left.0)); + pending.extend(children); + directory_count += 1; + if visit(ScannedDirectory { path, files }).is_break() { + break; + } + } + Ok(unreadable_paths) +} + +fn relative(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +/// Returns Java paths using the same scan policy as run generation. Failure below +/// the root is tolerated; the subsequent Core fingerprint supplies diagnostics. +/// Stops before opening another directory once the limit is reached, retaining +/// only Java paths from the deterministic traversal rather than a full snapshot. +pub fn list_run_java_sources(root: &Path, limit: usize) -> Result, CoreError> { + list_java_sources_with_reader(root, limit, &read_directory) +} + +fn list_java_sources_with_reader( + root: &Path, + limit: usize, + read: &impl Fn(&Path) -> io::Result>, +) -> Result, CoreError> { + let mut paths = Vec::new(); + if limit == 0 { + return Ok(paths); + } + visit_with_reader(root, None, read, |directory| { + for name in directory.files { + if name.to_ascii_lowercase().ends_with(".java") { + paths.push(relative(root, &directory.path.join(name))); + if paths.len() == limit { + return ControlFlow::Break(()); + } + } + } + ControlFlow::Continue(()) + })?; + paths.sort(); + Ok(paths) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn java_source_limit_stops_directory_reads_and_keeps_deterministic_results() { + let root = Path::new("fixture"); + for limit in [1, 8_000] { + let visited = std::cell::RefCell::new(Vec::new()); + let paths = list_java_sources_with_reader(root, limit, &|path| { + visited.borrow_mut().push(path.to_owned()); + if path == root { + Ok(vec![ + (root.join("z-unvisited"), true, false), + (root.join("b-sources"), true, false), + (root.join("a-blocked"), true, false), + (root.join(".pytest_cache"), true, false), + (root.join("link"), true, true), + ]) + } else if path.ends_with("a-blocked") { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else if path.ends_with("b-sources") { + // More than the cap in one directory must neither exceed the + // result limit nor open a later directory. Reverse native + // enumeration order to protect deterministic selection. + Ok((0..=limit) + .rev() + .map(|index| (path.join(format!("Main{index:05}.java")), false, false)) + .chain([(path.join("nested"), true, false)]) + .collect()) + } else { + panic!("source budget must prevent reading {}", path.display()); + } + }) + .unwrap(); + assert_eq!( + paths, + (0..limit) + .map(|index| format!("b-sources/Main{index:05}.java")) + .collect::>() + ); + assert_eq!( + *visited.borrow(), + [ + root.to_owned(), + root.join("a-blocked"), + root.join("b-sources") + ] + ); + } + } + + #[test] + fn java_source_zero_limit_does_not_read_the_workspace() { + let paths = list_java_sources_with_reader(Path::new("fixture"), 0, &|_| { + panic!("zero budget must not read directories") + }) + .unwrap(); + assert!(paths.is_empty()); + } + + #[test] + fn early_stop_retains_observed_unreadable_paths_without_affecting_full_scans() { + let root = Path::new("fixture"); + let read = |path: &Path| { + if path == root { + Ok(vec![ + (root.join("a-blocked"), true, false), + (root.join("b-sources"), true, false), + (root.join("z-later"), true, false), + ]) + } else if path.ends_with("a-blocked") { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + Ok(vec![(path.join("Main.java"), false, false)]) + } + }; + let unknown = visit_with_reader(root, None, &read, |directory| { + if directory.files.contains("Main.java") { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }) + .unwrap(); + assert_eq!(unknown, BTreeSet::from(["a-blocked".into()])); + let full = scan_with_reader(root, None, &read).unwrap(); + assert_eq!(full.unreadable_paths, unknown); + assert!(full + .directories + .iter() + .any(|dir| dir.path.ends_with("z-later"))); + assert_eq!( + list_java_sources_with_reader(root, 10, &read).unwrap(), + ["b-sources/Main.java", "z-later/Main.java"] + ); + } + + #[test] + fn unreadable_child_preserves_siblings_but_root_is_required() { + let root = Path::new("fixture"); + let read = |path: &Path| { + if path == root { + Ok(vec![ + (root.join("blocked"), true, false), + (root.join(".sources"), true, false), + (root.join(".pytest_cache"), true, false), + ]) + } else if path.ends_with(".sources") { + Ok(vec![(path.join("Main.java"), false, false)]) + } else { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } + }; + let result = scan_with_reader(root, None, &read).unwrap(); + assert_eq!(result.unreadable_paths, BTreeSet::from(["blocked".into()])); + assert!(result + .directories + .iter() + .any(|dir| dir.files.contains("Main.java"))); + assert!(scan_with_reader(root, None, &|_| Err(io::Error::from( + io::ErrorKind::PermissionDenied + ))) + .is_err()); + } + #[test] + fn directory_links_do_not_escape_or_reenter_the_scan() { + let root = Path::new("fixture"); + let result = scan_with_reader(root, None, &|path| { + assert_eq!(path, root, "links must never be traversed"); + Ok(vec![ + (root.join("cycle"), true, true), + (root.join("Main.java"), false, false), + ]) + }) + .unwrap(); + assert_eq!(result.directories.len(), 1); + assert_eq!( + result.directories[0].files, + BTreeSet::from(["Main.java".into()]) + ); + } +} diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index dd25d65386..f3100cf1e2 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -71,3 +71,6 @@ pub fn execute_json_with_events( pub fn git_askpass_main(prompt: &str) -> i32 { lithe_git_host::authentication::helper_main(prompt) } + +/// Shared classification for ordinary project watcher events after root resolution. +pub use project::is_workspace_event_relevant; diff --git a/rust/lithe-core/src/lsp/interface/build_provider.rs b/rust/lithe-core/src/lsp/interface/build_provider.rs index b99f36fbb4..4da8ba7805 100644 --- a/rust/lithe-core/src/lsp/interface/build_provider.rs +++ b/rust/lithe-core/src/lsp/interface/build_provider.rs @@ -15,15 +15,51 @@ use serde_json::{json, Value}; const EXPERIMENTAL_KEY: &str = "litheBuild"; /// Build-provider protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 1] = [1]; +const SUPPORTED_VERSIONS: [u64; 6] = [6, 5, 4, 3, 2, 1]; /// Feature name recorded in the session's server capabilities once a /// provider answered a supported version. pub(crate) const BUILD_PROVIDER_FEATURE: &str = "litheBuild"; -/// Requests of protocol version 1. Core plans both and decodes their results, -/// so the host can only forward what `build.modelPlan` and -/// `build.dependencyPlan` returned. +/// Version 2 uses the same workspace session and operation lifecycle. +pub(crate) const EXECUTE_METHOD: &str = "lithe/build/execute"; +/// Version 4 prepares a bounded launch, while the host owns the application. +pub(crate) const PREPARE_RUN_METHOD: &str = "lithe/build/prepareRun"; +/// The negotiated ability to accept host-resolved public model inputs. +pub(crate) const MODEL_INPUTS_FEATURE: &str = "litheBuildModelInputs"; +/// Old backends cannot silently ignore cross-provider context. +pub(crate) fn model_inputs_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + == Some(6) +} + +/// Version 5 prepares a native debug target, never an adapter process. +pub(crate) const PREPARE_DEBUG_METHOD: &str = "lithe/build/prepareDebug"; +/// Admission feature for native debug preparation. +pub(crate) const DEBUG_PREPARATION_FEATURE: &str = "litheDebugPreparation"; +/// Debug preparation requires the explicit version 5 agreement. +pub(crate) fn debug_preparation_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + .is_some_and(|version| matches!(version, 5 | 6)) +} + +/// Admission feature for the version 4 method. +pub(crate) const RUN_PREPARATION_FEATURE: &str = "litheRunPreparation"; +/// Version 3 adds tests while retaining the bounded execution lifecycle. +pub(crate) const TEST_METHOD: &str = "lithe/build/test"; +/// Version 3 returns exact selectors; decoding remains a separate Core command. +pub(crate) const DISCOVER_TESTS_METHOD: &str = "lithe/build/discoverTests"; +/// Feature required before writing a version 3 test request. +pub(crate) const TEST_EXECUTION_FEATURE: &str = "litheBuildTestExecution"; +/// Progress never uses caller-chosen host operation IDs. +pub(crate) const PROGRESS_METHOD: &str = "lithe/build/progress"; +/// Separate feature so version 1 cannot accidentally execute version 2 tasks. +pub(crate) const EXECUTION_FEATURE: &str = "litheBuildExecution"; + const VERSION_1_METHODS: [&str; 2] = ["lithe/build/model", "lithe/build/dependencies"]; /// The client half of the negotiation, placed under `capabilities.experimental`. @@ -45,6 +81,97 @@ pub(crate) fn negotiated(capabilities: &Value) -> bool { /// Whether `method` is a request of the negotiated protocol. pub(crate) fn is_build_method(method: &str) -> bool { VERSION_1_METHODS.contains(&method) + || is_execution_method(method) + || method == DISCOVER_TESTS_METHOD + || method == PREPARE_RUN_METHOD + || method == PREPARE_DEBUG_METHOD +} + +/// Whether the method shares bounded output, progress, and final result handling. +pub(crate) fn is_execution_method(method: &str) -> bool { + matches!(method, EXECUTE_METHOD | TEST_METHOD) +} + +/// True only for the execution protocol, not merely any negotiated provider. +pub(crate) fn execution_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + .is_some_and(|version| matches!(version, 2 | 3 | 4 | 5 | 6)) +} + +/// Tests require an explicit version 3 agreement, independent of task support. +pub(crate) fn tests_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + .is_some_and(|version| matches!(version, 3 | 4 | 5 | 6)) +} + +/// A preparation has progress and cancellation, but its reply is a launch plan. +pub(crate) fn is_progress_method(method: &str) -> bool { + is_execution_method(method) || method == PREPARE_RUN_METHOD || method == PREPARE_DEBUG_METHOD +} + +/// Run preparation requires an explicit version 4 agreement. +pub(crate) fn run_preparation_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + .is_some_and(|version| matches!(version, 4 | 5 | 6)) +} + +/// Normalizes bounded progress; the engine checks the pending request identity. +pub(crate) fn progress(params: Option<&Value>) -> Option { + let params = params?.as_object()?; + let id = params.get("operationID")?.as_str()?; + let mut result = json!({ "operationID": id }); + for (field, limit) in [("message", 4096), ("output", 65536)] { + if let Some(value) = params.get(field) { + let text = value.as_str()?; + if text.len() > limit { + return None; + } + result[field] = json!(text); + } + } + if let Some(value) = params.get("percentage") { + result["percentage"] = json!(value.as_u64().filter(|value| *value <= 100)?); + } + Some(result) +} + +/// Stable outcome for a provider-owned task. Tool failure is an exit code, +/// while malformed replies and JSON-RPC errors are protocol failures. +pub(crate) fn execution_outcome(reply: &Value) -> Result { + if reply.get("error").is_some() { + return Err(( + "plugin.build.failed", + reply + .pointer("/error/message") + .and_then(Value::as_str) + .unwrap_or("The build provider rejected the task.") + .chars() + .take(4096) + .collect(), + )); + } + let invalid = ( + "plugin.build.invalidResult", + "The build provider returned an invalid task result.".to_string(), + ); + let value = reply.get("result").ok_or_else(|| invalid.clone())?; + let exit = value + .get("exitCode") + .and_then(Value::as_i64) + .filter(|code| i32::try_from(*code).is_ok()) + .ok_or_else(|| invalid.clone())?; + let output = value + .get("output") + .and_then(Value::as_str) + .filter(|text| text.len() <= 32 * 1024 * 1024) + .ok_or_else(|| invalid.clone())?; + Ok(json!({ "value": { "exitCode": exit, "output": output } })) } #[cfg(test)] @@ -63,7 +190,7 @@ mod tests { &json!({ "experimental": { "litheBuild": {} } }) )); assert!(!negotiated( - &json!({ "experimental": { "litheBuild": { "version": 2 } } }) + &json!({ "experimental": { "litheBuild": { "version": 7 } } }) )); assert!(!negotiated( &json!({ "experimental": { "litheBuild": { "version": "1" } } }) @@ -74,10 +201,57 @@ mod tests { } #[test] - fn version_one_accepts_only_its_two_requests() { + fn only_typed_provider_requests_are_accepted() { assert!(is_build_method("lithe/build/model")); assert!(is_build_method("lithe/build/dependencies")); - assert!(!is_build_method("lithe/build/execute")); + assert!(is_build_method("lithe/build/execute")); + assert!(is_build_method("lithe/build/test")); assert!(!is_build_method("workspace/executeCommand")); } + #[test] + fn test_protocol_does_not_remove_task_support() { + let capabilities = json!({"experimental":{"litheBuild":{"version":3}}}); + assert!(negotiated(&capabilities)); + assert!(execution_negotiated(&capabilities)); + assert!(tests_negotiated(&capabilities)); + assert!(!tests_negotiated( + &json!({"experimental":{"litheBuild":{"version":2}}}) + )); + } + + #[test] + fn execution_replies_and_progress_match_the_shared_contract() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/build-execution-protocol.json" + )) + .unwrap(); + assert_eq!( + execution_outcome(&json!({"result": fixture["result"]})).unwrap(), + json!({"value": fixture["result"]}) + ); + for result in fixture["invalidResults"].as_array().unwrap() { + assert_eq!( + execution_outcome(&json!({"result": result})).unwrap_err().0, + "plugin.build.invalidResult" + ); + } + assert_eq!( + execution_outcome(&json!({"error": {"message": "private"}})) + .unwrap_err() + .0, + "plugin.build.failed" + ); + assert_eq!( + progress(Some(&fixture["progress"])), + Some(fixture["progress"].clone()) + ); + for invalid in fixture["invalidProgress"].as_array().unwrap() { + assert!(progress(Some(invalid)).is_none()); + } + assert!(progress(Some(&json!({"operationID":"1","output":"x".repeat(65537)}))).is_none()); + assert!(execution_outcome( + &json!({"result": {"exitCode": 0,"output":"x".repeat(32*1024*1024+1)}}) + ) + .is_err()); + } } diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index b56d5918dd..131bdb8c3e 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -469,6 +469,26 @@ pub(super) fn client_apply_server_message_for_plugin( } } } + super::build_provider::PROGRESS_METHOD + if !state.shutdown_requested + && state + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::EXECUTION_FEATURE) => + { + if let Some(result) = super::build_provider::progress(message.get("params")) { + events.push(LspClientEvent { + kind: "buildProgress".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(result), + error: None, + }); + } + } plugin_function::PROGRESS_METHOD if plugin_owned && !state.shutdown_requested @@ -1730,6 +1750,24 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if super::build_provider::negotiated(capabilities) { insert_unique(&mut values, super::build_provider::BUILD_PROVIDER_FEATURE); } + if super::build_provider::execution_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::EXECUTION_FEATURE); + } + if super::build_provider::tests_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::TEST_EXECUTION_FEATURE); + } + if super::build_provider::model_inputs_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::MODEL_INPUTS_FEATURE); + } + if super::build_provider::debug_preparation_negotiated(capabilities) { + insert_unique( + &mut values, + super::build_provider::DEBUG_PREPARATION_FEATURE, + ); + } + if super::build_provider::run_preparation_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::RUN_PREPARATION_FEATURE); + } if plugin_function::negotiate(capabilities).is_some() { insert_unique(&mut values, plugin_function::FUNCTIONS_FEATURE); } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 60b26ce11b..e1b7285a23 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -2101,14 +2101,93 @@ impl RuntimeSession { ) .with_details(super::build_provider::BUILD_PROVIDER_FEATURE)); } + if super::build_provider::is_execution_method(&request.method) + && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::EXECUTION_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate build execution.", + ) + .with_details("plugin.build.unavailable")); + } + if matches!( + request.method.as_str(), + super::build_provider::TEST_METHOD | super::build_provider::DISCOVER_TESTS_METHOD + ) && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::TEST_EXECUTION_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate test execution.", + ) + .with_details("plugin.build.unavailable")); + } + if request.method == super::build_provider::PREPARE_DEBUG_METHOD + && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::DEBUG_PREPARATION_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate debug preparation.", + ) + .with_details("plugin.build.unavailable")); + } + if request.method == super::build_provider::PREPARE_RUN_METHOD + && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::RUN_PREPARATION_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate run preparation.", + ) + .with_details("plugin.build.unavailable")); + } + if request.params.get("modelInputs").is_some() + && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::MODEL_INPUTS_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate model inputs.", + ) + .with_details("plugin.build.unavailable")); + } + let mut params = request.params; + if super::build_provider::is_progress_method(&request.method) { + let object = params.as_object_mut().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Build task parameters must be an object.", + ) + })?; + object.insert( + "operationID".to_string(), + json!(state.client.next_request_id.to_string()), + ); + } let timeout = request .timeout_milliseconds .map(|milliseconds| { Duration::from_millis(milliseconds.clamp(1, MAX_BUILD_PROVIDER_TIMEOUT_MS)) }) .unwrap_or(state.request_timeout); - let response = - allocate_raw_request(state.client.clone(), &request.method, request.params)?; + let response = allocate_raw_request(state.client.clone(), &request.method, params)?; let request_id = (response.state.next_request_id - 1).to_string(); let now = Instant::now(); state.client = response.state; @@ -3123,6 +3202,41 @@ impl RuntimeSession { ); } } + Some(PendingKind::Feature) + if pending_before.as_ref().is_some_and(|pending| { + super::build_provider::is_execution_method(&pending.method) + }) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = + match super::build_provider::execution_outcome(&value) { + Ok(result) => (Some(result), None), + Err((code, message)) => ( + None, + Some(runtime_error( + self, + code, + "request", + Some(&pending.method), + None, + &message, + None, + None, + )), + ), + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } Some(PendingKind::Feature) if pending_before .as_ref() @@ -3779,6 +3893,8 @@ impl RuntimeSession { push_functions_event(self, &mut state, result); } else if event.kind == "functionProgress" { push_function_progress_locked(self, &mut state, event.result); + } else if event.kind == "buildProgress" { + push_build_progress_locked(self, &mut state, event.result); } else if event.kind == "hostRequest" { outbound.extend(forward_host_request_locked( self, @@ -5495,6 +5611,58 @@ fn revoke_function_calls_locked( cancellations } +/// A wire request ID identifies one exact build operation in this session. +/// Cancellation/completion removes it from pending, so late progress is inert. +fn push_build_progress_locked( + session: &RuntimeSession, + state: &mut SessionState, + progress: Option, +) { + let Some(mut progress) = progress else { + return; + }; + let Some(id) = progress.get("operationID").and_then(Value::as_str) else { + return; + }; + let Some(pending) = state.pending.get(id) else { + return; + }; + if !super::build_provider::is_progress_method(&pending.method) { + return; + } + let Some(operation_id) = pending.operation_id.clone() else { + return; + }; + progress["operationID"] = json!(operation_id); + let method = pending.method.clone(); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "requestProgress".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: Some(operation_id), + method: Some(method), + uri: None, + version: None, + diagnostics: None, + result: Some(progress), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); +} + /// Publishes a progress report of a call that is still running. Reports for /// an unknown, finished, cancelled, or revoked operation are dropped, so a /// late report never revives an ended call in the host. @@ -11101,6 +11269,324 @@ public class Main { assert!(harness.snapshot().pending_operation_ids.is_empty()); } + /// Execution must not reach a model-only provider. + #[test] + fn provider_execution_requires_version_two() { + let harness = negotiated_build_provider(); + let error = build_request(&harness, "lithe/build/execute", json!({}), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/execute")); + } + + #[test] + fn provider_test_execution_requires_version_three() { + for version in [1, 2] { + for method in ["lithe/build/test", "lithe/build/discoverTests"] { + let harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let error = build_request(&harness, method, json!({}), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != method)); + } + } + } + + #[test] + fn provider_debug_preparation_requires_version_five_and_retains_progress() { + for version in [1, 2, 3, 4] { + let harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let error = + build_request(&harness, "lithe/build/prepareDebug", json!({}), None).unwrap_err(); + assert_eq!(error.details.as_deref(), Some("plugin.build.unavailable")); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/prepareDebug")); + } + let mut harness = plugin_backend(json!({"litheBuild":{"version":5}})); + let operation = + build_request(&harness, "lithe/build/prepareDebug", json!({}), None).unwrap(); + let request = harness + .server + .await_request("lithe/build/prepareDebug") + .unwrap(); + let wire_id = request.clone(); + assert_eq!( + harness.written_request(&request)["params"]["operationID"], + wire_id + ); + harness.server.send(json!({"jsonrpc":"2.0","method":"lithe/build/progress","params":{"operationID":wire_id,"output":"built"}})); + let progress = harness.await_event(|event| { + event.kind == "requestProgress" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(progress.result.as_ref().unwrap()["output"], "built"); + harness.server.send(json!({"jsonrpc":"2.0","id":request,"result":{"program":"app/bin/main","arguments":[],"environment":{},"workingDirectory":"app"}})); + let done = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!( + done.result.as_ref().unwrap()["value"]["program"], + "app/bin/main" + ); + } + + #[test] + fn provider_run_preparation_requires_version_four_and_returns_raw_plan() { + for version in [1, 2, 3] { + let harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let error = + build_request(&harness, "lithe/build/prepareRun", json!({}), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/prepareRun")); + } + let mut harness = plugin_backend(json!({"litheBuild":{"version":4}})); + let operation = build_request(&harness, "lithe/build/prepareRun", json!({}), None).unwrap(); + let id = harness + .server + .await_request("lithe/build/prepareRun") + .unwrap(); + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/build-run-preparation.json" + )) + .unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":fixture["result"]})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); + } + + #[test] + fn provider_test_discovery_uses_the_existing_request_lifecycle() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":3}})); + let operation = build_request( + &harness, + "lithe/build/discoverTests", + json!({"root":"."}), + None, + ) + .unwrap(); + let id = harness + .server + .await_request("lithe/build/discoverTests") + .unwrap(); + let result = json!({"format":"litheTestDiscovery","version":1,"tests":[]}); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":result})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], result); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } + + /// Ordered messages use a completed round trip as a barrier: old progress + /// and replies are drained before checking that cancellation stayed final. + #[test] + fn provider_execution_progress_cancel_and_late_results_are_isolated() { + for (version, method) in [ + (2, "lithe/build/execute"), + (3, "lithe/build/test"), + (4, "lithe/build/prepareRun"), + ] { + let mut harness = Harness::start(|_| {}); + harness + .server + .complete_initialize(json!({"experimental":{"litheBuild":{"version":version}}})); + harness.await_state(LspLifecycleState::Ready); + let operation = + build_request(&harness, method, json!({"operationID":"spoof"}), None).unwrap(); + let id = harness.server.await_request(method).unwrap(); + assert_eq!(harness.written_request(&id)["params"]["operationID"], id); + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":"first","percentage":20}})); + let progress = harness + .await_event(|event| event.kind == "requestProgress") + .clone(); + assert_eq!(progress.operation_id.as_deref(), Some(operation.as_str())); + assert_eq!(progress.result.unwrap()["output"], "first"); + harness.session().cancel_operation(&operation).unwrap(); + let ended = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert_eq!(ended.error.unwrap().code, "requestCancelled"); + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":"late"}})); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":{"exitCode":0,"output":"late"}})); + let barrier = build_request(&harness, "lithe/build/model", json!({}), None).unwrap(); + let barrier_id = harness.server.await_request("lithe/build/model").unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":barrier_id,"result":{}})); + harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(barrier.as_str()) + }); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestProgress") + .count(), + 1 + ); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str())) + .count(), + 1 + ); + } + } + + /// Host operation IDs may be reused; only the current wire request ID + /// may contribute progress to that new call. + #[test] + fn provider_execution_reused_host_ids_reject_old_wire_progress() { + for (version, method) in [ + (2, "lithe/build/execute"), + (3, "lithe/build/test"), + (4, "lithe/build/prepareRun"), + ] { + let mut harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let operation = "reused-build".to_string(); + let send = |harness: &Harness| { + harness + .session() + .build_request( + BuildProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation.clone()), + method: method.to_string(), + params: json!({}), + timeout_milliseconds: None, + }, + operation.clone(), + ) + .unwrap() + }; + send(&harness); + let old = harness.server.await_request(method).unwrap(); + harness.session().cancel_operation(&operation).unwrap(); + harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + send(&harness); + let current = harness.server.await_request_at(method, 1).unwrap(); + assert_ne!(old, current); + for (id, output) in [(old, "old"), (current, "current")] { + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":output}})); + } + harness.await_event(|event| { + event.kind == "requestProgress" + && event + .result + .as_ref() + .is_some_and(|result| result["output"] == "current") + }); + assert!(harness + .events + .iter() + .filter(|event| event.kind == "requestProgress") + .all(|event| event.result.as_ref().unwrap()["output"] != "old")); + harness.session().cancel_operation(&operation).unwrap(); + } + } + + #[test] + fn provider_execution_validates_results_and_expires_without_sleeping() { + for (version, method) in [ + (2, "lithe/build/execute"), + (3, "lithe/build/test"), + (4, "lithe/build/prepareRun"), + ] { + let mut harness = Harness::start(|_| {}); + harness + .server + .complete_initialize(json!({"experimental":{"litheBuild":{"version":version}}})); + harness.await_state(LspLifecycleState::Ready); + for (index, (reply, code)) in [ + (json!({"exitCode":7,"output":"failed build"}), None), + ( + json!({"exitCode":"bad"}), + Some("plugin.build.invalidResult"), + ), + ] + .into_iter() + .enumerate() + { + let operation = build_request(&harness, method, json!({}), None).unwrap(); + let id = harness.server.await_request_at(method, index).unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":reply})); + let ended = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + // Preparation returns raw launch data; the build decoder validates it. + let code = if version == 4 { None } else { code }; + assert_eq!(ended.error.as_ref().map(|error| error.code.as_str()), code); + if code.is_none() { + assert_eq!(ended.result.unwrap()["value"], reply); + } + } + let operation = build_request(&harness, method, json!({}), Some(1000)).unwrap(); + harness.server.await_request_at(method, 2).unwrap(); + harness + .session() + .expire_deadlines_at(Instant::now() + Duration::from_secs(2)); + assert_eq!( + harness + .await_event(|event| event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str())) + .error + .as_ref() + .unwrap() + .code, + "requestTimeout" + ); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } + } + /// A process that answered build-provider protocol version 1. fn negotiated_build_provider() -> Harness { let mut harness = Harness::start(|_| {}); @@ -11147,7 +11633,7 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"]["litheBuild"], - json!({ "versions": [1] }) + json!({ "versions": [6, 5, 4, 3, 2, 1] }) ); let error = build_request(&harness, "lithe/build/model", json!({}), None) @@ -11160,6 +11646,42 @@ public class Main { .all(|message| message["method"] != "lithe/build/model")); } + /// A legacy backend must never silently execute without a declared shared input. + #[test] + fn model_inputs_need_version_six_even_for_an_old_model_method() { + for version in [5, 6] { + let mut harness = Harness::start(|_| {}); + harness + .server + .complete_initialize(json!({"experimental":{"litheBuild":{"version":version}}})); + harness.await_state(LspLifecycleState::Ready); + let params = json!({"modelInputs":{"version":1,"revision":7,"inputs":[]}}); + let result = build_request(&harness, "lithe/build/model", params.clone(), None); + if version == 5 { + assert!(matches!(result.unwrap_err().code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/model")); + } else { + let operation = result.unwrap(); + let id = harness + .server + .await_request("lithe/build/model") + .expect("version six receives its snapshot"); + assert_eq!(harness.written_request(&id)["params"], params); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":{"projects":[]}})); + harness.await_event(|event| { + event.operation_id.as_deref() == Some(&operation) + && event.kind == "requestCompleted" + }); + } + } + } + #[test] fn a_negotiated_build_request_forwards_the_plan_and_returns_the_reply() { let mut harness = negotiated_build_provider(); @@ -11200,11 +11722,7 @@ public class Main { #[test] fn build_requests_accept_only_the_protocol_methods() { let harness = negotiated_build_provider(); - for method in [ - "workspace/executeCommand", - "lithe/build/execute", - "shutdown", - ] { + for method in ["workspace/executeCommand", "shutdown"] { let error = build_request(&harness, method, json!({}), None) .expect_err("only protocol methods are build requests"); assert!(matches!(error.code, ErrorCode::InvalidRequest), "{method}"); @@ -11658,7 +12176,7 @@ public class Main { assert_eq!( initialize["params"]["capabilities"]["experimental"], json!({ - "litheBuild": { "versions": [1] }, + "litheBuild": { "versions": [6, 5, 4, 3, 2, 1] }, "litheFunctions": { "versions": [1, 2] }, "litheVcs": { "versions": [1] } }) diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index c5430e1ae6..4a08226861 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -29,6 +29,9 @@ pub(crate) const LANGUAGES: &str = "languages"; pub(crate) const LANGUAGE_SERVERS: &str = "languageServers"; /// The launch fields language servers and own build providers share. pub(crate) const SERVER_PROCESS: &str = "serverProcess"; +/// `vendor.bundledSignatureRequirement`: origin-scoped host-seal trust for a +/// package whose independently installed versions require a publisher signature. +pub(crate) const PACKAGE_TRUST: &str = "packageTrust"; /// `executionProviders[]`: run and test templates. pub(crate) const EXECUTION_PROVIDERS: &str = "executionProviders"; /// `settings[]`. Version 2 adds scopes, effects, settings pages, and the @@ -41,6 +44,8 @@ pub(crate) const UI: &str = "ui"; pub(crate) const BUILD_SYSTEMS: &str = "buildSystems"; /// `buildSystems[].provider`, whether it owns or shares a language server process. pub(crate) const BUILD_PROVIDERS: &str = "buildProviders"; +/// Optional public model snapshots between enabled build providers. +pub(crate) const BUILD_MODEL_INPUTS: &str = "buildModelInputs"; /// `vcsProviders[]`: version control systems implemented by a plugin /// process; see `crate::vcs`. pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; @@ -66,13 +71,15 @@ pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; /// capability keeps the meaning its section had at API level 2. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BACKEND, &[1]), - (BUILD_PROVIDERS, &[1]), + (BUILD_MODEL_INPUTS, &[1]), + (BUILD_PROVIDERS, &[1, 2, 3, 4, 5, 6]), (BUILD_SYSTEMS, &[1, 2]), - (DEBUG_ADAPTERS, &[1]), + (DEBUG_ADAPTERS, &[1, 2]), (EXECUTION_PROVIDERS, &[1]), (EXECUTABLE_RESOLUTION, &[1]), (LANGUAGE_SERVERS, &[1, 2]), (LANGUAGES, &[1]), + (PACKAGE_TRUST, &[1]), (PROJECT_GENERATORS, &[1]), (SERVER_PROCESS, &[1, 2]), (SETTINGS, &[1, 2]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index bce3582b44..43122b5314 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -380,6 +380,10 @@ struct PluginVendor { id: String, display_name: String, signature_requirement: SignatureRequirement, + /// Optional trust for the host's sealed built-in copy only. Installation + /// from every other origin still requires the primary publisher signature. + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled_signature_requirement: Option, } /// How a package proves its origin. `sameTeamAsHost` is only honored by a @@ -817,6 +821,18 @@ impl DeclarativePluginManifest { "Official plugin IDs must carry a Lithe signature.", )); } + if let Some(bundled) = self.vendor.bundled_signature_requirement { + if self.plugin_api_version < CAPABILITIES_API_VERSION + || self.vendor.signature_requirement != SignatureRequirement::PublisherPackage + || bundled != SignatureRequirement::SameTeamAsHost + || !is_official_namespace(&self.id) + { + return Err(invalid( + "vendor.bundledSignatureRequirement", + "Bundled host-seal trust requires API 3, an official plugin ID, and publisherPackage for installed copies.", + )); + } + } if self.plugin_api_version >= CAPABILITIES_API_VERSION { capabilities::check_used(&self.requires, &self.used_capabilities())?; } @@ -973,8 +989,11 @@ impl DeclarativePluginManifest { crate::build::validate_declarations( &mut self.build_systems, &crate::build::DeclarationContext { + debug_adapters: &self.debug_adapters, plugin_api_version: self.plugin_api_version, build_systems_version: self.requires.get("buildSystems").copied().unwrap_or(1), + build_providers_version: self.requires.get("buildProviders").copied().unwrap_or(1), + model_inputs_version: self.requires.get("buildModelInputs").copied().unwrap_or(0), official: is_official_namespace(&self.id), declared_settings: &declared_settings, language_servers: &server_languages, @@ -1007,6 +1026,10 @@ impl DeclarativePluginManifest { /// server, own build provider, and VCS provider is a `serverProcess`. fn used_capabilities(&self) -> BTreeSet<&'static str> { let sections = [ + ( + capabilities::PACKAGE_TRUST, + self.vendor.bundled_signature_requirement.is_some(), + ), ( capabilities::EXECUTABLE_RESOLUTION, self.build_systems.iter().any(|system| { @@ -1060,6 +1083,12 @@ impl DeclarativePluginManifest { .iter() .any(|system| system.provider.is_some()), ), + ( + capabilities::BUILD_MODEL_INPUTS, + self.build_systems.iter().any(|system| { + system.model_sharing.is_some() || !system.model_inputs.is_empty() + }), + ), (capabilities::VCS_PROVIDERS, !self.vcs_providers.is_empty()), ( capabilities::DEBUG_ADAPTERS, @@ -1099,6 +1128,19 @@ impl DeclarativePluginManifest { language_servers: &language_servers, }, )?; + if crate::debug::adapters::uses_target_arguments(&self.debug_adapters) + && self + .requires + .get(capabilities::DEBUG_ADAPTERS) + .copied() + .unwrap_or(1) + < 2 + { + return Err(invalid( + "requires.debugAdapters", + "Argument and environment bindings require debugAdapters 2.", + )); + } if self.plugin_api_version >= CAPABILITIES_API_VERSION { // serverProcess 2 exists for the selected-runtime launch alone, so // a host without it never accepts an adapter it cannot start. diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index c9eaed5ac5..18833b5a70 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -33,6 +33,17 @@ pub(crate) const BUILT_IN_HIDDEN_DIRECTORIES: &[&str] = &[ "design-qa-artifacts", ".artifacts", ]; +/// Whether an ordinary workspace event belongs to visible project inputs. +/// Callers first relativize to the most specific explicitly watched root; native +/// event-loss flags always bypass this filter and request recovery. +pub fn is_workspace_event_relevant(relative: &str) -> bool { + !relative.split(['/', '\\']).any(|component| { + BUILT_IN_HIDDEN_DIRECTORIES + .iter() + .any(|name| component.eq_ignore_ascii_case(name)) + }) +} + /// File names omitted from workspace snapshots, search, and related scans. const BUILT_IN_HIDDEN_FILE_PATTERNS: &[&str] = &[".DS_Store"]; const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024; @@ -1528,3 +1539,31 @@ pub(crate) fn is_plain_text(text: &str) -> bool { fn is_word_character(character: char) -> bool { character.is_ascii_alphanumeric() || character == '_' || character == '$' } + +#[cfg(test)] +mod event_filter_tests { + use super::*; + + #[test] + fn shared_workspace_change_fixture() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/change-filter-v1.json" + )) + .unwrap(); + let paths: Vec = serde_json::from_value(fixture["paths"].clone()).unwrap(); + let retained: Vec<_> = paths + .iter() + .filter(|path| is_workspace_event_relevant(path)) + .collect(); + assert_eq!(serde_json::to_value(retained).unwrap(), fixture["expected"]); + let response: serde_json::Value = serde_json::from_str(&crate::execute_json( + &serde_json::json!({ + "id": "filter", "command": "workspace.filterChanges", "payload": paths + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"], fixture["expected"]); + } +} diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index 4210f0f712..a1b711f315 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -20,3 +20,5 @@ pub(crate) use maven_dependency_tree::{ dependencies, dependency_tree_arguments, validated_output_file, MavenDependenciesRequest, }; pub(crate) use maven_test_reports::MavenTestReportsRequest; + +pub use files::is_workspace_event_relevant; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 94b55d1bf4..41f83fa89d 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -115,6 +115,20 @@ pub enum CoreCommand { BuildTaskPlan, /// Expands a build test run and its result location (`build.testPlan`). BuildTestPlan, + /// Plans typed provider run preparation (`build.prepareRunPlan`). + BuildPrepareRunPlan, + /// Resolves optional public build inputs (`build.resolveModelInputs`). + BuildResolveModelInputs, + /// Plans native debug preparation (`build.prepareDebugPlan`). + BuildPrepareDebugPlan, + /// Validates native debug target facts (`build.decodeDebugPreparation`). + BuildDecodeDebugPreparation, + /// Validates prepared launch data (`build.decodeRunPreparation`). + BuildDecodeRunPreparation, + /// Plans provider-owned test discovery (`build.testDiscoveryPlan`). + BuildTestDiscoveryPlan, + /// Validates provider-owned test selectors (`build.decodeTestDiscovery`). + BuildDecodeTestDiscovery, /// Expands the command that prints a project's dependencies (`build.dependencyPlan`). BuildDependencyPlan, /// Normalizes a build model and derives its run entries (`build.decodeModel`). @@ -169,6 +183,8 @@ pub enum CoreCommand { WorkspaceSnapshot, /// Lists the visible children of project directories (`workspace.listDirectories`). WorkspaceListDirectories, + /// Filter ordinary workspace-relative event paths using shared defaults. + WorkspaceFilterChanges, /// Finds Git repositories that belong to an opened workspace (`workspace.repositories`). WorkspaceRepositories, /// Builds or reuses the workspace search index (`workspace.searchIndex.warm`). @@ -209,6 +225,8 @@ pub enum CoreCommand { MavenLaunchPlan, /// Shortens an oversized Java launch using a JDK argument file when needed. ExecutionPlanLaunchCommand, + /// Resolve environment precedence after platform tool discovery. + ExecutionResolveEnvironment, /// Decides how each Lithe-owned process ends (`processLifecycle.resolve`). ProcessLifecycleResolve, /// Classifies a process snapshot into lifecycle items (`processLifecycle.inventory`). @@ -539,6 +557,13 @@ impl CoreCommand { "build.modelPlan" => Some(Self::BuildModelPlan), "build.taskPlan" => Some(Self::BuildTaskPlan), "build.testPlan" => Some(Self::BuildTestPlan), + "build.prepareDebugPlan" => Some(Self::BuildPrepareDebugPlan), + "build.decodeDebugPreparation" => Some(Self::BuildDecodeDebugPreparation), + "build.resolveModelInputs" => Some(Self::BuildResolveModelInputs), + "build.prepareRunPlan" => Some(Self::BuildPrepareRunPlan), + "build.decodeRunPreparation" => Some(Self::BuildDecodeRunPreparation), + "build.testDiscoveryPlan" => Some(Self::BuildTestDiscoveryPlan), + "build.decodeTestDiscovery" => Some(Self::BuildDecodeTestDiscovery), "build.dependencyPlan" => Some(Self::BuildDependencyPlan), "build.decodeModel" => Some(Self::BuildDecodeModel), "build.decodeTestResults" => Some(Self::BuildDecodeTestResults), @@ -565,6 +590,7 @@ impl CoreCommand { "community.discourse.search" => Some(Self::CommunityDiscourseSearch), "community.discourse.auth.revoke" => Some(Self::CommunityDiscourseAuthRevoke), "workspace.snapshot" => Some(Self::WorkspaceSnapshot), + "workspace.filterChanges" => Some(Self::WorkspaceFilterChanges), "workspace.listDirectories" => Some(Self::WorkspaceListDirectories), "workspace.repositories" => Some(Self::WorkspaceRepositories), "workspace.searchIndex.warm" => Some(Self::WorkspaceSearchIndexWarm), @@ -585,6 +611,7 @@ impl CoreCommand { "history.delete" => Some(Self::HistoryDelete), "maven.scan" => Some(Self::MavenScan), "maven.launchPlan" => Some(Self::MavenLaunchPlan), + "execution.resolveEnvironment" => Some(Self::ExecutionResolveEnvironment), "execution.planLaunchCommand" => Some(Self::ExecutionPlanLaunchCommand), "processLifecycle.resolve" => Some(Self::ProcessLifecycleResolve), "processLifecycle.inventory" => Some(Self::ProcessLifecycleInventory), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index b0c6b14cae..ffae53ecd3 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -284,9 +284,44 @@ fn execute(request: &str) -> CoreResponse { CoreCommand::BuildTaskPlan => { plugin_response(id, parsed.payload, crate::build::build_task_plan_command) } + CoreCommand::BuildPrepareDebugPlan => plugin_response( + id, + parsed.payload, + crate::build::build_prepare_debug_plan_command, + ), + CoreCommand::BuildDecodeDebugPreparation => plugin_response( + id, + parsed.payload, + crate::build::build_decode_debug_preparation_command, + ), + CoreCommand::BuildResolveModelInputs => plugin_response( + id, + parsed.payload, + crate::build::build_resolve_model_inputs_command, + ), + CoreCommand::BuildPrepareRunPlan => plugin_response( + id, + parsed.payload, + crate::build::build_prepare_run_plan_command, + ), + CoreCommand::BuildDecodeRunPreparation => plugin_response( + id, + parsed.payload, + crate::build::build_decode_run_preparation_command, + ), CoreCommand::BuildTestPlan => { plugin_response(id, parsed.payload, crate::build::build_test_plan_command) } + CoreCommand::BuildTestDiscoveryPlan => plugin_response( + id, + parsed.payload, + crate::build::build_test_discovery_plan_command, + ), + CoreCommand::BuildDecodeTestDiscovery => plugin_response( + id, + parsed.payload, + crate::build::build_decode_test_discovery_command, + ), CoreCommand::BuildDependencyPlan => plugin_response( id, parsed.payload, @@ -514,6 +549,22 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::WorkspaceFilterChanges => { + match serde_json::from_value::>(parsed.payload) { + Ok(paths) => CoreResponse::success( + id, + serde_json::json!(paths + .into_iter() + .filter(|path| crate::project::is_workspace_event_relevant(path)) + .collect::>()), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid workspace event paths") + .with_details(error.to_string()), + ), + } + } CoreCommand::WorkspaceListDirectories => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -823,6 +874,25 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::ExecutionResolveEnvironment => { + match serde_json::from_value::( + parsed.payload, + ) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(crate::execution::resolve_launch_environment(request)) + .expect("environment encodes"), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid launch environment request", + ) + .with_details(error.to_string()), + ), + } + } CoreCommand::ExecutionPlanLaunchCommand => { match serde_json::from_value::( parsed.payload, diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index 55b69c4b42..f3f73b3c00 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -5,9 +5,11 @@ use super::support::temporary_root; use crate::build::{ build_check_tool_version_command, build_decode_dependencies_command, build_decode_diagnostics_command, build_decode_model_command, + build_decode_run_preparation_command, build_decode_test_discovery_command, build_decode_test_results_command, build_dependency_plan_command, build_detect_command, - build_invocation_plan_command, build_model_plan_command, build_resolve_options_command, - build_sync_fingerprint_command, build_task_plan_command, build_test_plan_command, + build_invocation_plan_command, build_model_plan_command, build_prepare_run_plan_command, + build_resolve_options_command, build_sync_fingerprint_command, build_task_plan_command, + build_test_discovery_plan_command, build_test_plan_command, }; use crate::plugins::declarative::{language_server_launch_plan_command, validate_command}; use crate::plugins::marketplace::install_review_command; @@ -1096,7 +1098,8 @@ fn option_arguments_may_repeat_under_opposite_conditions() { { "value": "${target.name}", "when": "targetSelected" }, { "value": "${optionArguments}", "when": "noTargetSelected" } ]); - assert_eq!(validate(manifest.clone())["status"], "ok"); + let validated = validate(manifest.clone()); + assert_eq!(validated["status"], "ok", "{validated}"); manifest["buildSystems"][0]["test"]["arguments"][4] = json!({ "value": "${optionArguments:release}", "when": "caseSelected" }); @@ -1599,7 +1602,8 @@ fn task_categories_and_option_placements_are_display_only() { fn explicit_build_tool_path_requires_supported_global_file_setting() { let manifest: Value = serde_json::from_str(contract_file!("fixtures/build-tool-path.plugin.json")).unwrap(); - assert_eq!(validate(manifest.clone())["status"], "ok"); + let validated = validate(manifest.clone()); + assert_eq!(validated["status"], "ok", "{validated}"); let invoke = |settings: Value| { call( "invocationPlan", @@ -1784,3 +1788,624 @@ fn executable_resolution_is_opt_in_and_custom_runtimes_share_the_same_plan() { json!("runtime-only"); assert_eq!(validate(manifest)["status"], "rejected"); } + +/// A provider-only task is admitted only with the host capability and never +/// becomes argv. Selection boundaries and effectful admission remain shared. +#[test] +fn provider_execution_tasks_are_typed_and_version_gated() { + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-execution.plugin.json" + )) + .unwrap(); + let validated = validate(manifest.clone()); + assert_eq!(validated["status"], "ok", "{validated}"); + let mut old = manifest.clone(); + old["requires"]["buildProviders"] = json!(1); + assert_eq!(validate(old)["status"], "rejected"); + let mut command = manifest.clone(); + command["buildSystems"][0]["tasks"][0]["arguments"] = json!(["build"]); + assert_eq!(validate(command)["status"], "rejected"); + // An older host must refuse admission, not try a command fallback. + let refused = validate_command(json!({ + "manifest": manifest, "hostCapabilities": { + "buildProviders":[1], "buildSystems":[1], "serverProcess":[1], "settings":[1] + } + })) + .unwrap(); + assert_eq!(refused["status"], "rejected"); + assert_eq!(refused["error"]["field"], "requires.buildProviders"); + + let mut run = manifest.clone(); + run["buildSystems"][0]["tasks"][0]["fromModel"] = json!(true); + run["buildSystems"][0]["tasks"][0] + .as_object_mut() + .unwrap() + .remove("scopes"); + run["buildSystems"][0]["run"] = json!([{"id":"run", "fromTasks":{"templates":["build"]}}]); + assert_eq!(validate(run)["status"], "rejected"); + let escape = build_task_plan_command(json!({ + "manifest":manifest, "buildSystemID":"gradle", + "request":{"root":"app","task":"build","project":{"name":"escape","path":"../escape"}} + })) + .unwrap(); + assert_eq!(escape["status"], "rejected"); + let oversized = build_task_plan_command(json!({ + "manifest":manifest, "buildSystemID":"gradle", + "request":{"root":".","task":"custom","userArguments":vec!["arg";65]} + })) + .unwrap(); + assert_eq!(oversized["status"], "rejected"); + let result = build_task_plan_command(json!({ + "manifest": manifest, "buildSystemID":"gradle", "request": {"root":".", "task":"build", + "project": {"name":"app","path":"app"}, "options":{"offline":true}} + })) + .unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + assert!(result["plan"].get("arguments").is_none(), "{result}"); + assert_eq!(result["plan"]["provider"]["method"], "lithe/build/execute"); + assert_eq!( + result["plan"]["provider"]["params"]["project"]["path"], + "app" + ); + assert_eq!( + result["plan"]["provider"]["params"]["options"]["offline"], + true + ); +} + +/// Test selection remains typed and report paths reuse the existing decoder; +/// no test selector is interpolated into a host command. +#[test] +fn provider_tests_preserve_filters_and_report_ownership() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-provider-tests.plugin.json")).unwrap(); + let fixture: Value = + serde_json::from_str(contract_file!("fixtures/build-test-protocol.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let plan = build_test_plan_command( + json!({"manifest": manifest, "buildSystemID":"gradle", "request":fixture["request"]}), + ) + .unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!(plan["plan"]["provider"], fixture["provider"]); + assert_eq!(plan["plan"]["results"], fixture["results"]); + assert!(plan["plan"].get("arguments").is_none()); + let review = install_review_command(json!({"manifest":manifest})).unwrap(); + let program = review["review"]["programs"] + .as_array() + .unwrap() + .iter() + .find(|program| program["kind"] == "buildSystem") + .unwrap(); + assert_eq!( + program["test"], + json!({"source":"provider","arguments":[],"discovery":true}) + ); + + // A case-only filter is a typed value, not a CLI expression needing a suite. + let case_only = build_test_plan_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"root":".","case":"exact"}})) + .unwrap(); + assert_eq!(case_only["status"], "ok", "{case_only}"); + for request in [ + json!({"root":"app","project":{"name":"escape","path":"../escape"}}), + json!({"root":".","target":{"name":"orphan","path":"app"}}), + json!({"root":".","suite":"bad\u{0}filter"}), + json!({"root":".","userArguments":vec!["arg";65]}), + ] { + let rejected = build_test_plan_command( + json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), + ) + .unwrap(); + assert_eq!(rejected["status"], "rejected", "{rejected}"); + } + let mut unsupported = manifest.clone(); + unsupported["buildSystems"][0]["test"]["filters"] = json!([]); + assert_eq!( + build_test_plan_command(json!({"manifest":unsupported,"buildSystemID":"gradle", + "request":fixture["request"]})) + .unwrap()["status"], + "rejected" + ); +} + +/// Older hosts/processes must reject provider tests rather than silently run +/// every test or fall back to host-owned business logic. +#[test] +fn provider_tests_require_version_three_and_an_owned_process() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-provider-tests.plugin.json")).unwrap(); + for version in [1, 2] { + let mut old = manifest.clone(); + old["requires"]["buildProviders"] = json!(version); + assert_eq!(validate(old)["status"], "rejected"); + } + let refused = validate_command(json!({"manifest":manifest,"hostCapabilities":{ + "buildProviders":[1,2],"buildSystems":[1],"serverProcess":[1],"settings":[1]}})) + .unwrap(); + assert_eq!(refused["error"]["field"], "requires.buildProviders"); + for (field, value) in [ + ("arguments", json!(["test"])), + ("source", json!("command")), + ("results", json!({"decoder":"hostIntegration"})), + ] { + let mut invalid = manifest.clone(); + invalid["buildSystems"][0]["test"][field] = value; + assert_eq!(validate(invalid)["status"], "rejected"); + } + // A provider used only for tests is valid; removing it must be rejected. + let mut only_tests = manifest.clone(); + for field in ["model", "dependencies", "tasks"] { + only_tests["buildSystems"][0] + .as_object_mut() + .unwrap() + .remove(field); + } + assert_eq!(validate(only_tests.clone())["status"], "ok"); + only_tests["buildSystems"][0] + .as_object_mut() + .unwrap() + .remove("provider"); + assert_eq!(validate(only_tests)["status"], "rejected"); + let mut shared = manifest; + shared["buildSystems"][0]["provider"] = + json!({"languageServer":"java", "executesProjectCode":true,"mayUseNetwork":false}); + assert_eq!(validate(shared)["status"], "rejected"); +} + +/// Discovery produces only exactly runnable selectors, preserving module +/// identity and rejecting malformed lists instead of silently hiding tests. +#[test] +fn provider_test_discovery_is_bounded_deterministic_and_runnable() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-provider-tests.plugin.json")).unwrap(); + let fixture: Value = + serde_json::from_str(contract_file!("fixtures/build-test-discovery.json")).unwrap(); + let plan = + build_test_discovery_plan_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"root":".","refresh":true}})) + .unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!( + plan["plan"]["provider"]["method"], + "lithe/build/discoverTests" + ); + assert_eq!(plan["plan"]["provider"]["params"]["refresh"], true); + let decode = |result: Value| { + build_decode_test_discovery_command(json!({"manifest":manifest, + "buildSystemID":"gradle","request":{"root":".","result":result}})) + .unwrap() + }; + let original = decode(fixture["result"].clone()); + assert_eq!(original["status"], "ok", "{original}"); + let tests = original["discovery"]["tests"].as_array().unwrap(); + assert_eq!(tests.len(), 2); + assert_ne!(tests[0]["id"], tests[1]["id"]); + let mut reordered = fixture["result"].clone(); + reordered["tests"].as_array_mut().unwrap().reverse(); + reordered["tests"] + .as_array_mut() + .unwrap() + .push(fixture["result"]["tests"][0].clone()); + assert_eq!(decode(reordered), original); + for test in tests { + let mut request = test.clone(); + request.as_object_mut().unwrap().remove("id"); + request["root"] = json!("."); + let runnable = build_test_plan_command( + json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), + ) + .unwrap(); + assert_eq!(runnable["status"], "ok", "{runnable}"); + } + for invalid in [ + json!({"format":"litheTestDiscovery","version":2,"tests":[]}), + json!({"format":"litheTestDiscovery","version":1,"tests":vec![json!({});10001]}), + json!({"format":"litheTestDiscovery","version":1,"tests":[{"suite":"a"}]}), + json!({"format":"litheTestDiscovery","version":1,"tests":[{"suite":"a","case":"b","project":{"name":"escape","path":"../escape"}}]}), + json!({"format":"litheTestDiscovery","version":1,"tests":[{"suite":"a","case":"bad\u{0}"}]}), + ] { + let rejected = decode(invalid); + assert_eq!( + rejected["error"]["code"], "plugin.build.invalidOutput", + "{rejected}" + ); + } + let mut missing_filter = manifest; + missing_filter["buildSystems"][0]["test"]["filters"] = json!(["suite"]); + assert_eq!(validate(missing_filter)["status"], "rejected"); +} + +/// Preparation is a typed provider request, never an empty argv configuration. +#[test] +fn provider_run_preparation_selects_targets_and_validates_launch_data() { + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-run-preparation.plugin.json" + )) + .unwrap(); + let fixture: Value = + serde_json::from_str(contract_file!("fixtures/build-run-preparation.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let plan = |request: Value| { + build_prepare_run_plan_command( + json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), + ) + .unwrap() + }; + let prepared = plan(fixture["request"].clone()); + assert_eq!(prepared["status"], "ok", "{prepared}"); + assert_eq!( + prepared["plan"]["provider"]["method"], + "lithe/build/prepareRun" + ); + assert_eq!( + prepared["plan"]["provider"]["params"]["userArguments"], + json!(["a b"]) + ); + assert_eq!( + prepared["plan"]["provider"]["params"]["target"]["tags"], + json!(["application"]) + ); + assert!(prepared["plan"].get("arguments").is_none()); + for (field, value) in [ + ( + "target", + json!({"name":"Other","path":"app","tags":["test"]}), + ), + ("project", json!({"name":"escape","path":"../escape"})), + ("userArguments", json!(["bad\u{0}"])), + ("outputFile", json!("out.json")), + ] { + let mut invalid = fixture["request"].clone(); + invalid[field] = value; + assert_eq!(plan(invalid)["status"], "rejected"); + } + for version in [1, 2, 3] { + let mut old = manifest.clone(); + old["requires"]["buildProviders"] = json!(version); + assert_eq!(validate(old)["status"], "rejected"); + } + for (field, value) in [ + ("fromTasks", json!({"templates":["run"]})), + ("arguments", json!(["Main"])), + ("execution", json!("service")), + ("extension", json!({"main":"Main"})), + ] { + let mut invalid = manifest.clone(); + invalid["buildSystems"][0]["run"][0][field] = value; + assert_eq!(validate(invalid)["status"], "rejected"); + } + let workspace = TemporaryWorkspace::new("provider-run-launch"); + workspace.write("app/Main.java", "class Main {}"); + let decode = |result: Value| { + build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"workspaceRoot":workspace.root(),"root":"app","result":result}})) + .unwrap() + }; + let valid = decode(fixture["result"].clone()); + assert_eq!(valid["status"], "ok", "{valid}"); + assert_eq!(valid["launch"], fixture["result"]); + for (field, value) in [ + ("executable", json!({"kind":"command","value":"/bin/java"})), + ("executable", json!({"kind":"absolute","value":"java"})), + ("arguments", json!(["bad\u{0}"])), + ("arguments", json!(vec!["arg"; 1025])), + ("workingDirectory", json!("../escape")), + ("workingDirectory", json!(".")), + ("workingDirectory", json!("app/missing")), + ("environment", json!({"1INVALID":"x"})), + ("environment", json!({"A":"bad\u{0}"})), + ("environment", json!({"A":1})), + ] { + let mut invalid = fixture["result"].clone(); + invalid[field] = value; + let result = decode(invalid); + assert_eq!( + result["error"]["code"], "plugin.build.invalidOutput", + "{result}" + ); + } +} + +/// A workspace alias cannot make a launch execute from outside the workspace. +#[cfg(unix)] +#[test] +fn provider_run_working_directory_rejects_external_symlinks() { + let workspace = TemporaryWorkspace::new("provider-run-symlink"); + let outside = TemporaryWorkspace::new("provider-run-outside"); + std::os::unix::fs::symlink(&outside.0, workspace.0.join("escape")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-run-preparation.plugin.json" + )) + .unwrap(); + let result=build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"workspaceRoot":workspace.root(),"root":".","result":{"executable":{"kind":"command","value":"java"}, + "arguments":[],"workingDirectory":"escape","environment":{}}}})).unwrap(); + assert_eq!(result["error"]["code"], "plugin.build.invalidOutput"); +} + +#[test] +fn provider_debug_preparation_validates_native_target_and_same_plugin_adapter() { + use crate::build::{build_decode_debug_preparation_command, build_prepare_debug_plan_command}; + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-debug-preparation.plugin.json" + )) + .unwrap(); + let fixture: Value = + serde_json::from_str(contract_file!("fixtures/build-debug-preparation.json")).unwrap(); + let run: Value = + serde_json::from_str(contract_file!("fixtures/build-run-preparation.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let planned = build_prepare_debug_plan_command( + json!({"manifest":manifest,"buildSystemID":"gradle","request":run["request"]}), + ) + .unwrap(); + assert_eq!( + planned["plan"]["provider"]["method"], "lithe/build/prepareDebug", + "{planned}" + ); + assert_eq!(planned["plan"]["decoder"], "litheDebugPreparation"); + for (field, value) in [("buildProviders", json!(4)), ("debugAdapters", json!(1))] { + let mut old = manifest.clone(); + old["requires"][field] = value; + assert_eq!(validate(old)["status"], "rejected"); + } + for adapter in ["other/native", "missing", ""] { + let mut invalid = manifest.clone(); + invalid["buildSystems"][0]["run"][0]["debug"]["adapter"] = json!(adapter); + assert_eq!(validate(invalid)["status"], "rejected"); + } + let mut overridden = manifest.clone(); + overridden["debugAdapters"][0]["configurations"]["launch"]["arguments"] = + json!({"program":"other"}); + assert_eq!(validate(overridden)["status"], "rejected"); + let workspace = TemporaryWorkspace::new("provider-native-debug"); + workspace.write("app/bin/sample", "sample"); + let decode = |result: Value| { + build_decode_debug_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"workspaceRoot":workspace.root(),"root":"app","run":run["request"]["run"],"result":result}})).unwrap() + }; + let valid = decode(fixture["result"].clone()); + assert_eq!(valid["status"], "ok", "{valid}"); + assert_eq!(valid["preparation"]["adapter"], "native"); + assert_eq!(valid["preparation"]["arguments"], json!(["a b", "x#y"])); + for invalid in fixture["invalidResults"].as_array().unwrap() { + assert_eq!(decode(invalid.clone())["status"], "rejected"); + } + for (field, value) in [ + ("program", json!("app/missing")), + ("program", json!("app")), + ("program", json!("/tmp/sample")), + ("arguments", json!(["bad\u{0}"])), + ("environment", json!({"1BAD":"x"})), + ("workingDirectory", json!(".")), + ] { + let mut invalid = fixture["result"].clone(); + invalid[field] = value; + assert_eq!(decode(invalid)["status"], "rejected"); + } +} + +#[cfg(unix)] +#[test] +fn provider_debug_preparation_rejects_a_program_symlink_outside_the_instance() { + use crate::build::build_decode_debug_preparation_command; + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-debug-preparation.plugin.json" + )) + .unwrap(); + let workspace = TemporaryWorkspace::new("debug-native-symlink"); + workspace.write("app/keep", ""); + workspace.write("other/program", ""); + std::os::unix::fs::symlink( + workspace.0.join("other/program"), + workspace.0.join("app/program"), + ) + .unwrap(); + let result = build_decode_debug_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle","request":{ + "workspaceRoot":workspace.root(),"root":"app","run":"application","result":{"program":"app/program","arguments":[],"workingDirectory":"app","environment":{}}}})).unwrap(); + assert_eq!(result["status"], "rejected"); +} + +#[test] +fn provider_debug_target_bindings_keep_exact_arguments_and_require_bounded_facts() { + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-debug-preparation.plugin.json" + )) + .unwrap(); + let target = json!({"kind":"executable","path":"/workspace/app/main","workingDirectory":"/workspace/app", + "arguments":["a b","x#y"],"environment":{"MODE":"debug"}}); + let arguments = |target: Value| { + crate::plugins::declarative::debug_start_arguments_command(json!({"manifest":manifest, + "adapterID":"native","request":"launch","facts":{"registration":{"generation":7,"currentGeneration":7},"target":target}})).unwrap() + }; + let result = arguments(target.clone()); + assert_eq!( + result["arguments"]["args"], + json!(["a b", "x#y"]), + "{result}" + ); + assert_eq!(result["arguments"]["env"], json!({"MODE":"debug"})); + for (field, value) in [ + ("arguments", Value::Null), + ("arguments", json!(["bad\u{0}"])), + ("arguments", json!(vec!["x"; 1025])), + ("environment", Value::Null), + ("environment", json!({"1BAD":"x"})), + ] { + let mut invalid = target.clone(); + invalid[field] = value; + assert_eq!(arguments(invalid)["status"], "rejected"); + } +} + +/// Only explicitly published normalized facts and selected options reach the exact consumer. +#[test] +fn model_inputs_project_public_facts_and_preserve_configuration_identity() { + use crate::build::build_resolve_model_inputs_command; + let producer: Value = + serde_json::from_str(contract_file!("fixtures/build-model-producer.plugin.json")).unwrap(); + let consumer: Value = + serde_json::from_str(contract_file!("fixtures/build-model-consumer.plugin.json")).unwrap(); + assert_eq!( + validate(producer.clone())["status"], + "ok", + "{}", + validate(producer.clone()) + ); + assert_eq!( + validate(consumer.clone())["status"], + "ok", + "{}", + validate(consumer.clone()) + ); + let request = json!({"revision":7,"sources":[{"manifest":producer,"instances":[{ + "buildSystemID":"source","root":"app","state":"ready","options":{"offline":true}, + "model":{"projects":[{"id":"source:app/demo","path":"app","name":"demo","properties":{"group":"org.example"}}], + "targets":[{"id":"source:app/main","projectID":"source:app/demo","path":"app","name":"main","tags":["application"],"properties":{}}], + "contentRoots":[{"projectID":"source:app/demo","path":"app/src","kind":"source"}], + "tasks":[{"command":"private"}],"run":[{"command":"private"}],"optionValues":{},"settings":{"secret":"private"}} + }]}]}); + let resolve = |request: Value| { + build_resolve_model_inputs_command( + json!({"manifest":consumer,"buildSystemID":"gradle","request":request}), + ) + .unwrap() + }; + let result = resolve(request.clone()); + assert_eq!(result["status"], "ok", "{result}"); + let snapshot = &result["modelInputs"]; + let instance = &snapshot["inputs"][0]["instances"][0]; + assert_eq!(instance["options"], json!({"offline":true})); + assert!(instance["model"].get("tasks").is_none()); + assert!(instance["model"].get("run").is_none()); + assert!(instance["model"].get("settings").is_none()); + assert!(instance["model"].get("optionValues").is_none()); + assert_eq!( + instance["model"]["targets"][0]["tags"], + json!(["application"]) + ); + let plan = build_task_plan_command(json!({"manifest":consumer,"buildSystemID":"gradle","request":{"root":"app","task":"build","modelInputs":snapshot}})).unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!(plan["plan"]["provider"]["params"]["modelInputs"], *snapshot); + let mut no_sharing = request.clone(); + no_sharing["sources"][0]["manifest"]["buildSystems"][0]["modelSharing"] = json!({}); + let stripped = resolve(no_sharing); + assert_eq!( + stripped["modelInputs"]["inputs"][0]["instances"][0]["options"], + json!({}) + ); + let mut changed = request.clone(); + changed["sources"][0]["instances"][0]["options"]["offline"] = json!(false); + assert_ne!( + resolve(changed)["modelInputs"]["inputs"][0]["instances"][0]["configurationFingerprint"], + instance["configurationFingerprint"] + ); + for state in ["loading", "stale", "failed"] { + let mut pending = request.clone(); + pending["sources"][0]["instances"][0]["state"] = json!(state); + let value = resolve(pending); + assert_eq!(value["status"], "ok"); + assert_eq!( + value["modelInputs"]["inputs"][0]["instances"][0], + json!({"root":"app","state":state}) + ); + } + let missing = resolve(json!({"revision":8,"sources":[]})); + assert_eq!(missing["modelInputs"]["inputs"][0]["state"], "unavailable"); + assert_eq!(missing["modelInputs"]["inputs"][0]["instances"], json!([])); +} + +/// Capability admission and every later plan reject opaque or stale authority-shaped payloads. +#[test] +fn model_inputs_reject_invalid_manifests_models_and_snapshot_smuggling() { + use crate::build::build_resolve_model_inputs_command; + let producer: Value = + serde_json::from_str(contract_file!("fixtures/build-model-producer.plugin.json")).unwrap(); + let consumer: Value = + serde_json::from_str(contract_file!("fixtures/build-model-consumer.plugin.json")).unwrap(); + for change in ["oldWire", "noCapability", "reexport", "unknownOption"] { + let mut manifest = consumer.clone(); + match change { + "oldWire" => manifest["requires"]["buildProviders"] = json!(5), + "noCapability" => { + manifest["requires"] + .as_object_mut() + .unwrap() + .remove("buildModelInputs"); + } + "reexport" => manifest["buildSystems"][0]["modelSharing"] = json!({}), + _ => { + manifest = producer.clone(); + manifest["buildSystems"][0]["modelSharing"]["options"] = json!(["unknown"]); + } + } + assert_eq!(validate(manifest)["status"], "rejected", "{change}"); + } + let model = json!({"projects":[{"id":"p","path":"app","name":"demo","properties":{}}],"targets":[],"contentRoots":[]}); + let request = json!({"revision":1,"sources":[{"manifest":producer,"instances":[{"buildSystemID":"source","root":"app","state":"ready","model":model}]}]}); + for change in [ + "escape", + "badName", + "missingOwner", + "parentCycle", + "duplicate", + "truncated", + ] { + let mut request = request.clone(); + let model = &mut request["sources"][0]["instances"][0]["model"]; + match change { + "escape" => model["projects"][0]["path"] = json!("../private"), + "badName" => model["projects"][0]["name"] = json!({"command":"run"}), + "missingOwner" => { + model["contentRoots"] = + json!([{"projectID":"elsewhere","path":"app/src","kind":"source"}]) + } + "parentCycle" => model["projects"][0]["parentID"] = json!("p"), + "duplicate" => { + let row = model["projects"][0].clone(); + model["projects"].as_array_mut().unwrap().push(row); + } + _ => model["truncated"] = json!(true), + } + let value = build_resolve_model_inputs_command( + json!({"manifest":consumer,"buildSystemID":"gradle","request":request}), + ) + .unwrap(); + if change == "truncated" { + assert_eq!( + value["modelInputs"]["inputs"][0]["instances"][0], + json!({"root":"app","state":"stale"}) + ); + } else { + assert_eq!(value["status"], "rejected", "{change}: {value}"); + } + } + let resolved = build_resolve_model_inputs_command( + json!({"manifest":consumer,"buildSystemID":"gradle","request":request}), + ) + .unwrap(); + for change in [ + "private", + "wrongPlugin", + "optionsObject", + "unavailablePayload", + "wrongRevision", + ] { + let mut snapshot = resolved["modelInputs"].clone(); + match change { + "private" => { + snapshot["inputs"][0]["instances"][0]["model"]["run"] = json!([{"command":"run"}]) + } + "wrongPlugin" => snapshot["inputs"][0]["pluginID"] = json!("dev.other"), + "optionsObject" => { + snapshot["inputs"][0]["instances"][0]["options"] = + json!({"private":{"command":"run"}}) + } + "unavailablePayload" => snapshot["inputs"][0]["state"] = json!("unavailable"), + _ => snapshot["revision"] = json!(9007199254740992_u64), + } + let plan = build_task_plan_command(json!({"manifest":consumer,"buildSystemID":"gradle","request":{"root":"app","task":"build","modelInputs":snapshot}})).unwrap(); + assert_eq!(plan["status"], "rejected", "{change}: {plan}"); + } +} diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index f3982a282d..989116174f 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -21,6 +21,7 @@ const WEB_PANEL_FIXTURE: &str = contract_file!("fixtures/web-panel.plugin.json") const GO_FIXTURE: &str = contract_file!("fixtures/language-system-command.plugin.json"); const JAVA_FIXTURE: &str = contract_file!("fixtures/language-host-integration.plugin.json"); const NODE_RUNTIME_FIXTURE: &str = contract_file!("fixtures/node-runtime.plugin.json"); +const PUBLISHER_BUNDLED_FIXTURE: &str = contract_file!("fixtures/publisher-bundled.plugin.json"); const CARGO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); const GO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-command-stdout.plugin.json"); const GRADLE_BUILD_FIXTURE: &str = contract_file!("fixtures/build-command-output-file.plugin.json"); @@ -81,6 +82,7 @@ fn contract_fixtures_and_official_plugins_validate() { WEB_PANEL_FIXTURE, JAVA_FIXTURE, NODE_RUNTIME_FIXTURE, + PUBLISHER_BUNDLED_FIXTURE, BACKEND_FIXTURE, DEBUG_ADAPTER_FIXTURE, DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, @@ -91,6 +93,59 @@ fn contract_fixtures_and_official_plugins_validate() { } } +/// The package cannot grant itself host-seal trust on an older host or +/// downgrade independently installed copies to unsigned/local-only trust. +#[test] +fn bundled_publisher_trust_requires_an_explicit_host_capability() { + let manifest: Value = serde_json::from_str(PUBLISHER_BUNDLED_FIXTURE).unwrap(); + let mut host = json!({"languages":[1], "languageServers":[1], "serverProcess":[1]}); + let refused = + validate_command(json!({"manifest": manifest, "hostCapabilities": host})).unwrap(); + assert_eq!(refused["error"]["code"], "plugin.api.unsupported"); + assert_eq!(refused["error"]["field"], "requires.packageTrust"); + host["packageTrust"] = json!([1]); + let accepted = + validate_command(json!({"manifest": manifest, "hostCapabilities": host})).unwrap(); + assert_eq!(accepted["status"], "ok", "{accepted}"); + assert_eq!( + accepted["plugin"]["vendor"]["bundledSignatureRequirement"], + "sameTeamAsHost" + ); + + for (field, value) in [ + ("signatureRequirement", "none"), + ("signatureRequirement", "sameTeamAsHost"), + ("bundledSignatureRequirement", "publisherPackage"), + ("bundledSignatureRequirement", "none"), + ] { + let mut bad = manifest.clone(); + bad["vendor"][field] = json!(value); + assert_eq!(validate(bad)["status"], "rejected", "{field}: {value}"); + } + let mut community = manifest.clone(); + community["id"] = json!("io.example.fixture"); + assert_eq!(validate(community)["status"], "rejected"); + let mut missing = manifest.clone(); + missing["requires"] + .as_object_mut() + .unwrap() + .remove("packageTrust"); + assert_eq!(validate(missing)["error"]["field"], "requires"); + let mut legacy = manifest.clone(); + legacy["pluginAPIVersion"] = json!(2); + legacy.as_object_mut().unwrap().remove("requires"); + assert_eq!( + validate(legacy)["error"]["field"], + "vendor.bundledSignatureRequirement" + ); + let mut unused = manifest; + unused["vendor"] + .as_object_mut() + .unwrap() + .remove("bundledSignatureRequirement"); + assert_eq!(validate(unused)["error"]["field"], "requires.packageTrust"); +} + #[test] fn validation_normalizes_order_and_spelling_without_touching_arguments() { let mut manifest = fixture("language-packaged-server.plugin.json"); diff --git a/rust/lithe-core/src/tests/marketplace_plugins.rs b/rust/lithe-core/src/tests/marketplace_plugins.rs index 38ec2e802a..a88cbf56b3 100644 --- a/rust/lithe-core/src/tests/marketplace_plugins.rs +++ b/rust/lithe-core/src/tests/marketplace_plugins.rs @@ -396,3 +396,45 @@ fn host_capabilities_do_not_change_legacy_levels() { assert_eq!(plugin["availability"]["status"], expected, "{result}"); } } + +/// Download availability must reject the new trust semantics before any +/// archive is transferred, even when the host already runs every Java feature. +#[test] +fn publisher_bundled_versions_require_host_trust_support_before_download() { + let mut index = json_file(INDEX); + let entry = index["plugins"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|entry| entry["id"] == "io.example.elixir-tools") + .unwrap(); + entry["versions"] = json!([{ + "version":"3.0.0", "pluginAPIVersion":3, "releaseDate":"2026-10-25", + "requires":{"languages":1,"packageTrust":1}, + "downloads":[{"platforms":["darwin-arm64"], + "downloadURL":"https://github.com/example/elixir-tools/releases/download/v3.0.0/elixir.zip", + "sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}] + }]); + // Reuse the fixture's repository so only capability availability varies. + let repository = entry["repository"].as_str().unwrap().to_string(); + entry["versions"][0]["downloads"][0]["downloadURL"] = + json!(format!("{repository}/releases/download/v3.0.0/elixir.zip")); + for (capabilities, expected) in [ + (json!({"languages":[1]}), "unsupported"), + (json!({"languages":[1],"packageTrust":[1]}), "available"), + ] { + let outcome = validate_for_host( + index.clone(), + "thirdParty", + "darwin-arm64", + Some(&capabilities), + ); + let plugin = outcome["index"]["plugins"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["id"] == "io.example.elixir-tools") + .unwrap(); + assert_eq!(plugin["availability"]["status"], expected, "{outcome}"); + } +} diff --git a/rust/lithe-core/src/tests/project_generators.rs b/rust/lithe-core/src/tests/project_generators.rs index 934a8a8291..50d1dd4539 100644 --- a/rust/lithe-core/src/tests/project_generators.rs +++ b/rust/lithe-core/src/tests/project_generators.rs @@ -58,7 +58,7 @@ fn official_package_path() -> PathBuf { fn host() -> Value { json!({"languages": [1], "languageServers": [1, 2], "serverProcess": [1], "settings": [1, 2], - "buildSystems": [1], "projectGenerators": [1]}) + "buildSystems": [1], "projectGenerators": [1], "packageTrust": [1]}) } #[test] diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 7a48f10033..a447c2f541 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -4280,3 +4280,185 @@ fn automatic_java_selection_uses_shared_fixture_and_current_requirements() { } } } + +#[test] +fn shared_run_scan_excludes_caches_and_keeps_hidden_sources() { + let root = temporary_root("shared-run-scan"); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + fs::create_dir_all(&root).unwrap(); + let _cleanup = Cleanup(root.clone()); + for path in [ + ".sources/Main.java", + "src/Main.java", + ".pytest_cache/Bad.java", + "node_modules/Bad.java", + "target/Bad.java", + ] { + let file = root.join(path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(file, "class Main {}").unwrap(); + } + let paths = crate::execution::list_run_java_sources(&root, 100).unwrap(); + assert_eq!(paths, vec![".sources/Main.java", "src/Main.java"]); + let call = |command, payload| -> Value { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "scan", "command": command, "payload": payload + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() + }; + let generated = call( + "runConfig.generate", + serde_json::json!({"root": root, "paths": paths}), + ); + let inputs = generated["generated"]["generator"]["inputs"] + .as_object() + .unwrap(); + assert!(inputs.contains_key(".sources/Main.java")); + assert!(!inputs.keys().any(|path| path.contains("Bad.java"))); + let mut document = generated["generated"].clone(); + // A persisted incomplete baseline cannot certify freshness or infer deletion, + // even after the previously inaccessible directory becomes readable again. + document["generator"]["unreadablePaths"] = serde_json::json!(["blocked"]); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write(root.join(".lithe/run/generated.json"), document.to_string()).unwrap(); + for full in [false, true] { + let inspected = call( + "runConfig.inspect", + serde_json::json!({"root": root, "checkFingerprint": full}), + ); + assert_eq!(inspected["status"], "ready"); + assert!(inspected["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|d| d["code"] == "incompleteProjectScan")); + assert!(!inspected["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|d| d["code"] == "staleFingerprint")); + } +} + +/// Saving a selector never stores an executable, generates a model, or loses local defaults. +#[test] +fn build_provider_configurations_persist_before_generation_and_reuse_identity() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/build-provider.json" + )) + .unwrap(); + let root = temporary_root("saved-build-provider"); + fs::create_dir_all(root.join("app")).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + let local = root.join(".lithe/run/local.json"); + fs::write(&local, fixture["unrelated"].to_string()).unwrap(); + let call = |command: &str, payload: Value| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({"id":"saved-provider","command":command,"payload":payload}) + .to_string(), + )) + .unwrap() + }; + let save = |reference: Value| { + call( + "runConfig.createUserConfiguration", + serde_json::json!({"root":root,"scope":"local","type":"buildProvider","name":fixture["name"],"build":reference}), + ) + }; + let saved = save(fixture["reference"].clone()); + assert_eq!(saved["ok"], true, "{saved}"); + let mut document: Value = + serde_json::from_str(saved["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!(document["toolchain"], fixture["unrelated"]["toolchain"]); + assert_eq!( + document["configurations"][0]["extensions"]["build"], + fixture["reference"] + ); + assert!(document["configurations"][0].get("command").is_none()); + // The Core mutation is read-only until the platform atomically commits it. + assert_eq!( + serde_json::from_slice::(&fs::read(&local).unwrap()).unwrap(), + fixture["unrelated"] + ); + document["configurations"][0]["extensions"]["annotations"] = + serde_json::json!({"label":"keep user metadata"}); + fs::write(&local, document.to_string()).unwrap(); + let inspected = call( + "runConfig.inspect", + serde_json::json!({"root":root,"checkFingerprint":false}), + ); + assert_eq!( + inspected["data"]["status"], fixture["expected"]["inspectionStatus"], + "{inspected}" + ); + let resolved = call( + "runConfig.resolve", + serde_json::json!({"root":root,"toolchainCandidates":[]}), + ); + assert_eq!(resolved["ok"], true, "{resolved}"); + assert_eq!( + resolved["data"]["configurations"].as_array().unwrap().len(), + 1 + ); + assert!(!root.join(".lithe/run/generated.json").exists()); + let mut changed = fixture["reference"].clone(); + changed["userArguments"] = serde_json::json!(["new argument"]); + let updated = save(changed); + assert_eq!(updated["data"]["id"], saved["data"]["id"]); + let updated_doc: Value = + serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!(updated_doc["configurations"].as_array().unwrap().len(), 1); + assert_eq!( + updated_doc["configurations"][0]["extensions"]["annotations"], + serde_json::json!({"label":"keep user metadata"}) + ); + for (field, value) in [ + ("root", serde_json::json!("../escape")), + ("root", serde_json::json!("/outside")), + ("root", serde_json::json!("app\\bad")), + ("projectID", serde_json::json!("")), + ("userArguments", serde_json::json!(["bad\u{0}"])), + ("userArguments", serde_json::json!(vec!["a"; 65])), + ("command", serde_json::json!("java")), + ] { + let mut invalid = fixture["reference"].clone(); + invalid[field] = value; + assert_eq!(save(invalid)["ok"], false, "{field}"); + } + let mut omitted_arguments = document.clone(); + omitted_arguments["configurations"][0]["extensions"]["build"] + .as_object_mut() + .unwrap() + .remove("userArguments"); + fs::write(&local, omitted_arguments.to_string()).unwrap(); + let normalized = call("runConfig.resolve", serde_json::json!({"root":root})); + assert_eq!(normalized["ok"], true, "{normalized}"); + assert_eq!( + normalized["data"]["configurations"][0]["extensions"]["build"]["userArguments"], + serde_json::json!([]) + ); + let mut invalid = document.clone(); + invalid["configurations"][0]["command"] = serde_json::json!("java"); + fs::write(&local, invalid.to_string()).unwrap(); + assert_eq!( + call("runConfig.resolve", serde_json::json!({"root":root}))["ok"], + false + ); +} diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index b35b7843f9..76616c2b02 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -15,6 +15,7 @@ TRIPLE="" OUTPUT_DIR="" PLUGIN_ID="" BUNDLED_ONLY=false +MARKETPLACE_INDEX="$ROOT_DIR/Plugins/marketplace/index.json" # Architectures a package's own runtime is assembled for: arm64, x86_64, or # universal. Defaults to the architecture of --triple. RUNTIME_ARCH="" @@ -29,10 +30,11 @@ while [[ $# -gt 0 ]]; do --triple) TRIPLE="$2"; shift 2 ;; --output) OUTPUT_DIR="$2"; shift 2 ;; --bundled-only) BUNDLED_ONLY=true; shift ;; + --marketplace-index) MARKETPLACE_INDEX="$2"; shift 2 ;; --plugin-id) PLUGIN_ID="$2"; shift 2 ;; --arch) RUNTIME_ARCH="$2"; shift 2 ;; --development-unsigned) DEVELOPMENT_UNSIGNED=true; shift ;; - *) print -u2 -- "Usage: $0 --triple triple [--configuration debug|release] [--output directory] [--plugin-id id] [--arch arm64|x86_64|universal] [--bundled-only] [--development-unsigned]"; exit 2 ;; + *) print -u2 -- "Usage: $0 --triple triple [--configuration debug|release] [--output directory] [--plugin-id id] [--arch arm64|x86_64|universal] [--bundled-only] [--marketplace-index path] [--development-unsigned]"; exit 2 ;; esac done @@ -82,8 +84,9 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do if [[ -n "$PLUGIN_ID" && "$package_id" != "$PLUGIN_ID" ]]; then continue fi - if [[ "$BUNDLED_ONLY" == true ]] && ! node "$ROOT_DIR/scripts/official-plugin-distribution.mjs" "$package_id"; then - continue + if [[ "$BUNDLED_ONLY" == true ]]; then + bundled=$(node "$ROOT_DIR/scripts/official-plugin-distribution.mjs" "$package_id" "$MARKETPLACE_INDEX") + [[ "$bundled" == true ]] || continue fi schema_version=$(/usr/bin/plutil -extract schemaVersion raw "$manifest") if [[ "$schema_version" != "2" ]]; then @@ -91,8 +94,16 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do exit 1 fi signature_requirement=$(/usr/bin/plutil -extract vendor.signatureRequirement raw "$manifest") + bundled_signature_requirement=$(/usr/bin/plutil -extract vendor.bundledSignatureRequirement raw "$manifest" 2>/dev/null || true) + # The same manifest describes both distributions. Only the explicitly + # selected application-bundled build may rely on the application's seal; + # standalone packages always go through publisher signing below. + uses_host_seal=false + if [[ "$BUNDLED_ONLY" == true && "$bundled_signature_requirement" == "sameTeamAsHost" ]]; then + uses_host_seal=true + fi publisher_signed=false - if [[ "$signature_requirement" == "publisherPackage" && "$DEVELOPMENT_UNSIGNED" == false ]]; then + if [[ "$signature_requirement" == "publisherPackage" && "$DEVELOPMENT_UNSIGNED" == false && "$uses_host_seal" == false ]]; then publisher_signed=true fi if [[ "$publisher_signed" == true && -z "${LITHE_PLUGIN_PACKAGE_PRIVATE_KEY:-}" ]]; then @@ -118,7 +129,7 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do "$package_assembler" --output "$package_dir" --arch "$RUNTIME_ARCH" >&2 fi - if [[ "$signature_requirement" == "publisherPackage" && "$publisher_signed" == false ]]; then + if [[ "$signature_requirement" == "publisherPackage" && "$publisher_signed" == false && "$uses_host_seal" == false ]]; then print -u2 -- "Built unsigned development package $package_id; only development builds of Lithe can install it" fi if [[ "$publisher_signed" == true ]]; then diff --git a/scripts/official-plugin-distribution.mjs b/scripts/official-plugin-distribution.mjs index f29ce82b96..66119b05db 100644 --- a/scripts/official-plugin-distribution.mjs +++ b/scripts/official-plugin-distribution.mjs @@ -1,15 +1,46 @@ #!/usr/bin/env node -// A package must opt into the base application; all other official plugins -// remain separately buildable and installable through plugin management. -// Go Support and PHP Support moved to community plugins in Lithe Store. Java -// Support ships inside Lithe.app so Java works offline on first launch; its -// manifest uses `sameTeamAsHost`, and the user can remove and restore it in -// plugin management. +// Keep a transition copy until the checked-in official index offers a complete, +// compatible release. The decision is made at build time, never at app startup. +import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; -const bundledOfficialPluginIDs = new Set(["dev.lithe.plugin.java-support"]); -export function isBundledOfficialPlugin(id) { - return bundledOfficialPluginIDs.has(id); +import { loadConfig, releaseFor } from "./official-plugin-releases.mjs"; + +export const defaultIndexPath = new URL("../Plugins/marketplace/index.json", import.meta.url); +export function readDistributionIndex(path = defaultIndexPath) { + const index = JSON.parse(readFileSync(path, "utf8")); + if (index.schemaVersion !== 1 || !Array.isArray(index.plugins)) throw new Error("Invalid official plugin index"); + return index; +} +const canonical = (value) => JSON.stringify(Object.entries(value ?? {}).sort(([a], [b]) => a.localeCompare(b))); +const versionParts = (value) => /^\d+\.\d+\.\d+$/.test(value ?? "") ? value.split(".").map(Number) : null; + +export function isBundledOfficialPlugin(id, index = readDistributionIndex(), config = loadConfig()) { + const plugin = config.find((entry) => entry.id === id); + if (!plugin?.bundledUntilPublishedVersion) return false; + const minimum = versionParts(plugin.bundledUntilPublishedVersion); + if (!minimum) throw new Error("Invalid bundledUntilPublishedVersion"); + const current = releaseFor(plugin); + const published = index.plugins.find((entry) => entry.id === id); + if (published?.repository !== current.index.repository) return true; + return !published.versions?.some((version) => { + const parts = versionParts(version.version); + if (!parts) return false; + const comparison = parts[0] - minimum[0] || parts[1] - minimum[1] || parts[2] - minimum[2]; + if (comparison < 0 || version.pluginAPIVersion !== current.pluginAPIVersion + || canonical(version.requires) !== canonical(current.requires)) return false; + const assets = plugin.assets; + // Exactly one download per configured asset, with no ambiguous platform. + if (!Array.isArray(version.downloads) || version.downloads.length !== assets.length) return false; + return assets.every((asset) => version.downloads.filter((download) => + JSON.stringify(download.platforms?.slice().sort()) === JSON.stringify(asset.platforms.slice().sort()) + && /^[0-9a-f]{64}$/.test(download.sha256 ?? "") + && download.downloadURL === `${current.index.repository}/releases/download/${plugin.tagPrefix}${version.version}/${asset.assetName.replaceAll("{version}", version.version)}` + ).length === 1); + }); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - process.exitCode = isBundledOfficialPlugin(process.argv[2]) ? 0 : 1; + // Print a boolean so callers propagate malformed/missing input as a build + // failure instead of interpreting an exception's exit code as "omit". + const index = readDistributionIndex(process.argv[3]); + process.stdout.write(`${isBundledOfficialPlugin(process.argv[2], index)}\n`); } diff --git a/scripts/official-plugin-releases.mjs b/scripts/official-plugin-releases.mjs index 8537b37086..f677e2d468 100644 --- a/scripts/official-plugin-releases.mjs +++ b/scripts/official-plugin-releases.mjs @@ -1,57 +1,43 @@ #!/usr/bin/env node -// Plans and records official plugin releases. Each official downloadable -// plugin has its own tag series (``); a tag is created -// only when the plugin's package directory changed and its plugin.json -// declares a version that has no tag yet. See -// .agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md. -// -// plan JSON list of plugins whose version is untagged -// pending-index JSON list of tagged versions missing from -// index.json; publishing and listing are separate -// steps, so a failed listing is retried from here -// check fail when a tagged version's directory changed -// update-index -// add the released archive to the official index +// Plan immutable official plugin releases and list their exact tagged inputs. +// An index retry must never describe an old archive using today's manifest. import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const root = join(fileURLToPath(new URL(".", import.meta.url)), ".."); -const configPath = join(root, "Plugins/marketplace/official-plugins.json"); +const configFile = "Plugins/marketplace/official-plugins.json"; +const configPath = join(root, configFile); const indexPath = join(root, "Plugins/marketplace/index.json"); +const versionPattern = /^\d+\.\d+\.\d+$/; export function loadConfig(path = configPath) { const config = JSON.parse(readFileSync(path, "utf8")); - if (config.schemaVersion !== 1) throw new Error("official-plugins.json must use schemaVersion 1"); + if (config.schemaVersion !== 1 || !Array.isArray(config.plugins)) { + throw new Error("official-plugins.json must use schemaVersion 1"); + } return config.plugins; } -function git(args, options = {}) { - return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options }).trim(); +function git(args) { + return execFileSync("git", args, { + cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, + }).trim(); } function tagExists(tag) { - try { - git(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`]); - return true; - } catch { - return false; - } + try { git(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`]); return true; } + catch (error) { if (error.status === 1) return false; throw error; } } function tagsWithPrefix(prefix) { - const output = git(["tag", "--list", `${prefix}*`]); - return output ? output.split("\n") : []; + return git(["tag", "--list", `${prefix}*`]).split("\n").filter(Boolean); } -function directoryChangedSince(tag, directory) { - try { - git(["diff", "--quiet", tag, "HEAD", "--", directory]); - return false; - } catch { - return true; - } +function changedSince(tag, paths) { + try { git(["diff", "--quiet", tag, "HEAD", "--", ...paths]); return false; } + catch (error) { if (error.status === 1) return true; throw error; } } function readPackageManifest(directory) { @@ -60,99 +46,126 @@ function readPackageManifest(directory) { export function releaseFor(plugin, readManifest = readPackageManifest) { const manifest = readManifest(plugin.packageDirectory); - if (manifest.id !== plugin.id) { - throw new Error(`${plugin.packageDirectory}/plugin.json has id ${manifest.id}, expected ${plugin.id}`); + if (manifest.id !== plugin.id) throw new Error("Plugin manifest ID does not match its release configuration"); + if (!versionPattern.test(manifest.version)) throw new Error(`${plugin.id} version must be major.minor.patch`); + if (![1, 2, 3].includes(manifest.pluginAPIVersion)) throw new Error("Missing or unsupported pluginAPIVersion"); + if (!/^[a-z0-9-]+-v$/.test(plugin.tagPrefix)) throw new Error("Invalid plugin tag prefix"); + const repository = plugin.index.repository.replace(/\/$/, ""); + if (!/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error("Invalid plugin release repository"); } - if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) { - throw new Error(`${plugin.id} version must be major.minor.patch`); + const platforms = new Set(); + const names = new Set(); + const assets = (plugin.assets ?? [{ assetName: plugin.assetName, platforms: plugin.platforms }]).map((asset) => { + const assetName = asset.assetName?.replaceAll("{version}", manifest.version); + if (!assetName || !/^[A-Za-z0-9_.-]+\.zip$/.test(assetName) || names.has(assetName)) { + throw new Error("Invalid or duplicate plugin asset name"); + } + names.add(assetName); + if (!Array.isArray(asset.platforms) || !asset.platforms.length) throw new Error("An asset needs platforms"); + for (const platform of asset.platforms) { + if (!/^[a-z0-9]+-[a-z0-9]+$/.test(platform) || platforms.has(platform)) { + throw new Error("Invalid or duplicate plugin platform"); + } + platforms.add(platform); + } + if (asset.arch !== undefined && !["arm64", "x86_64", "universal"].includes(asset.arch)) { + throw new Error("Invalid macOS runtime architecture"); + } + if (asset.arch && asset.platforms.some((platform) => + !({ arm64: ["darwin-arm64"], x86_64: ["darwin-x64"], universal: ["darwin-arm64", "darwin-x64"] }[asset.arch]).includes(platform))) { + throw new Error("Plugin asset platforms do not match its runtime architecture"); + } + return { assetName, platforms: [...asset.platforms], ...(asset.arch ? { arch: asset.arch } : {}) }; + }); + if (!assets.length) throw new Error("A release needs assets"); + let requires; + if (manifest.pluginAPIVersion === 3) { + if (!manifest.requires || typeof manifest.requires !== "object" || Array.isArray(manifest.requires) + || !Object.keys(manifest.requires).length + || Object.entries(manifest.requires).some(([id, v]) => !/^[a-z][A-Za-z0-9]*$/.test(id) || !Number.isSafeInteger(v) || v < 1)) { + throw new Error("API 3 requires the manifest's capability versions"); + } + requires = Object.fromEntries(Object.entries(manifest.requires).sort(([a], [b]) => a.localeCompare(b))); } - const version = manifest.version; return { - id: plugin.id, - version, - tag: `${plugin.tagPrefix}${version}`, - assetName: plugin.assetName.replace("{version}", version), - packageDirectory: plugin.packageDirectory, + id: plugin.id, version: manifest.version, tag: `${plugin.tagPrefix}${manifest.version}`, + packageDirectory: plugin.packageDirectory, pluginAPIVersion: manifest.pluginAPIVersion, + ...(requires ? { requires } : {}), index: { ...plugin.index, repository }, assets, }; } -/** Plugins whose current version has no tag yet. */ export function plan(plugins = loadConfig(), exists = tagExists, readManifest = readPackageManifest) { - return plugins.map((plugin) => releaseFor(plugin, readManifest)).filter((release) => !exists(release.tag)); + return plugins.filter((plugin) => plugin.releaseEnabled !== false) + .map((plugin) => releaseFor(plugin, readManifest)).filter((release) => !exists(release.tag)); +} + +// Read both configuration and manifest at the immutable release tag, including +// old asset names, API levels, and requires after subsequent plugin upgrades. +function readTaggedRelease(plugin, tag) { + const config = JSON.parse(git(["show", `${tag}:${configFile}`])); + const tagged = config.plugins.find((entry) => entry.id === plugin.id); + if (!tagged) throw new Error(`${tag} has no release configuration for ${plugin.id}`); + return releaseFor(tagged, (directory) => JSON.parse(git(["show", `${tag}:${directory}/plugin.json`]))); } -/** Released (tagged) versions that the official index does not list yet. - * Every tag of each series is considered, so a version whose listing - * failed is still picked up after the plugin has moved on. */ -export function pendingIndex(plugins = loadConfig(), index = readIndex(), tags = tagsWithPrefix) { +export function pendingIndex(plugins = loadConfig(), index = readIndex(), tags = tagsWithPrefix, readTagged = readTaggedRelease) { const pending = []; for (const plugin of plugins) { - const listed = new Set( - (index.plugins.find((entry) => entry.id === plugin.id)?.versions ?? []).map((entry) => entry.version), - ); + if (plugin.releaseEnabled === false) continue; + const listed = new Set((index.plugins.find((entry) => entry.id === plugin.id)?.versions ?? []).map((entry) => entry.version)); for (const tag of tags(plugin.tagPrefix)) { const version = tag.slice(plugin.tagPrefix.length); - if (!/^\d+\.\d+\.\d+$/.test(version) || listed.has(version)) continue; - pending.push({ - id: plugin.id, - version, - tag, - assetName: plugin.assetName.replace("{version}", version), - }); + if (!versionPattern.test(version) || listed.has(version)) continue; + const release = readTagged(plugin, tag); + if (release.tag !== tag || release.version !== version) throw new Error(`${tag} disagrees with its manifest`); + pending.push(release); } } return pending; } -function readIndex() { - return JSON.parse(readFileSync(indexPath, "utf8")); -} +function readIndex() { return JSON.parse(readFileSync(indexPath, "utf8")); } -/** Changing a released plugin without a new version would ship two - * different packages under one version, so it is an error. */ -export function check( - plugins = loadConfig(), - exists = tagExists, - changed = directoryChangedSince, - readManifest = readPackageManifest, -) { +export function check(plugins = loadConfig(), exists = tagExists, changed = changedSince, readManifest = readPackageManifest) { const problems = []; for (const plugin of plugins) { const release = releaseFor(plugin, readManifest); - if (exists(release.tag) && changed(release.tag, release.packageDirectory)) { - problems.push(`${release.id}: ${release.packageDirectory} changed since ${release.tag}; bump the version in plugin.json`); + if (exists(release.tag) && changed(release.tag, [plugin.packageDirectory, ...(plugin.inputPaths ?? [])])) { + problems.push(`${release.id}: release inputs changed since ${release.tag}; bump the version in plugin.json`); } } return problems; } -/** Inserts the released archive, keeping versions newest first. */ -export function updateIndex(index, plugin, { version, sha256, releaseDate }) { - if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error("sha256 must be 64 lowercase hex characters"); +/** List all assets atomically; a partial download cannot publish a version. */ +export function updateIndex(index, release, { checksums, releaseDate }) { if (!/^\d{4}-\d{2}-\d{2}$/.test(releaseDate)) throw new Error("releaseDate must be YYYY-MM-DD"); - const repository = plugin.index.repository.replace(/\/$/, ""); - const tag = `${plugin.tagPrefix}${version}`; - const versionEntry = { - version, - pluginAPIVersion: 1, - releaseDate, - downloads: [{ - platforms: plugin.platforms, - downloadURL: `${repository}/releases/download/${tag}/${plugin.assetName.replace("{version}", version)}`, + if (!checksums || Array.isArray(checksums) || typeof checksums !== "object" + || Object.keys(checksums).length !== release.assets.length) throw new Error("Expected every release asset checksum"); + const downloads = release.assets.map((asset) => { + const sha256 = checksums[asset.assetName]; + if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)) throw new Error(`Invalid checksum for ${asset.assetName}`); + return { + platforms: [...asset.platforms], + downloadURL: `${release.index.repository}/releases/download/${release.tag}/${asset.assetName}`, sha256, - }], + }; + }); + const versionEntry = { + version: release.version, pluginAPIVersion: release.pluginAPIVersion, + ...(release.requires ? { requires: release.requires } : {}), releaseDate, downloads, }; - const plugins = index.plugins.filter((entry) => entry.id !== plugin.id); - const existing = index.plugins.find((entry) => entry.id === plugin.id); - const versions = (existing?.versions ?? []).filter((entry) => entry.version !== version); + const plugins = index.plugins.filter((entry) => entry.id !== release.id); + const existing = index.plugins.find((entry) => entry.id === release.id); + const versions = (existing?.versions ?? []).filter((entry) => entry.version !== release.version); versions.push(versionEntry); - const numeric = (value) => value.split(".").map(Number); - versions.sort((left, right) => { - const [a, b] = [numeric(left.version), numeric(right.version)]; - return b[0] - a[0] || b[1] - a[1] || b[2] - a[2]; + versions.sort((a, b) => { + const left = a.version.split(".").map(Number), right = b.version.split(".").map(Number); + return right[0] - left[0] || right[1] - left[1] || right[2] - left[2]; }); - plugins.push({ id: plugin.id, ...plugin.index, versions }); - plugins.sort((left, right) => left.id.localeCompare(right.id)); + plugins.push({ id: release.id, ...release.index, versions }); + plugins.sort((a, b) => a.id.localeCompare(b.id)); return { schemaVersion: 1, plugins }; } @@ -164,16 +177,23 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.stdout.write(`${JSON.stringify(pendingIndex())}\n`); } else if (command === "check") { const problems = check(); - for (const problem of problems) console.error(problem); + for (const problem of problems) process.stderr.write(`${problem}\n`); process.exitCode = problems.length ? 1 : 0; + } else if (command === "describe") { + const plugin = loadConfig().find((entry) => entry.id === args[0]); + if (!plugin) throw new Error("Unknown official plugin"); + process.stdout.write(`${JSON.stringify(releaseFor(plugin))}\n`); } else if (command === "update-index") { - const [id, version, sha256, releaseDate] = args; + const [id, version, checksumsJSON, releaseDate] = args; const plugin = loadConfig().find((entry) => entry.id === id); - if (!plugin) throw new Error(`${id} is not an official released plugin`); - const index = readIndex(); - writeFileSync(indexPath, `${JSON.stringify(updateIndex(index, plugin, { version, sha256, releaseDate }), null, 2)}\n`); + if (!plugin || !versionPattern.test(version)) throw new Error("Unknown plugin or invalid version"); + const tag = `${plugin.tagPrefix}${version}`; + const release = readTaggedRelease(plugin, tag); + if (release.tag !== tag) throw new Error("Release tag disagrees with manifest"); + writeFileSync(indexPath, `${JSON.stringify(updateIndex(readIndex(), release, { + checksums: JSON.parse(checksumsJSON), releaseDate, + }), null, 2)}\n`); } else { - console.error("Usage: official-plugin-releases.mjs plan | pending-index | check | update-index "); - process.exitCode = 2; + throw new Error("Usage: official-plugin-releases.mjs plan | pending-index | check | describe | update-index "); } } diff --git a/scripts/official-plugin-releases.test.mjs b/scripts/official-plugin-releases.test.mjs index 2732cdf3c6..666b062ebc 100644 --- a/scripts/official-plugin-releases.test.mjs +++ b/scripts/official-plugin-releases.test.mjs @@ -2,90 +2,93 @@ import assert from "node:assert/strict"; import test from "node:test"; import { check, loadConfig, pendingIndex, plan, releaseFor, updateIndex } from "./official-plugin-releases.mjs"; -// Go and PHP Support moved to Lithe Store, so no downloadable official plugin -// is configured right now. The release flow is kept for future official -// plugins and tested with a synthetic one. const sample = { - id: "dev.lithe.plugin.sample-support", - tagPrefix: "plugin-sample-support-v", + id: "dev.lithe.plugin.sample-support", tagPrefix: "plugin-sample-support-v", packageDirectory: "Plugins/mac/Official/SampleSupport", - assetName: "Lithe-Sample-Support-{version}-darwin.zip", - platforms: ["darwin-arm64", "darwin-x64"], - index: { - name: "Sample Support", - author: "Lithe", - repository: "https://github.com/Lithe-IDEA/Lithe-IDEA", - description: "Sample official plugin.", - tags: ["language"], - license: "Apache-2.0", - }, -}; -const plugins = [sample]; -const readManifest = (directory) => { - assert.equal(directory, sample.packageDirectory); - return { id: sample.id, version: "1.0.0" }; + assets: [ + { assetName: "Sample-{version}-arm64.zip", platforms: ["darwin-arm64"], arch: "arm64" }, + { assetName: "Sample-{version}-x64.zip", platforms: ["darwin-x64"], arch: "x86_64" }, + ], + inputPaths: ["third_party/sample/manifest.json"], + index: { name: "Sample", author: "Lithe", repository: "https://github.com/example/ide", description: "Sample official plugin", license: "MIT" }, }; +const manifest = { id: sample.id, version: "1.1.0", pluginAPIVersion: 3, requires: { languages: 1, packageTrust: 1 } }; +const readManifest = () => structuredClone(manifest); +const empty = () => ({ schemaVersion: 1, plugins: [] }); +const checksumsFor = (release) => Object.fromEntries(release.assets.map((asset, i) => [asset.assetName, String(i + 1).repeat(64)])); +const list = (index, release) => updateIndex(index, release, { checksums: checksumsFor(release), releaseDate: "2026-10-25" }); -test("the repository configuration currently lists no downloadable official plugin", () => { - assert.deepEqual(loadConfig(), []); - assert.deepEqual(plan(), []); - assert.deepEqual(check(), []); - assert.deepEqual(pendingIndex(), []); +test("Java release inputs are configured but automatic publication waits for host rollout", () => { + const config = loadConfig(); + const java = config.find((entry) => entry.id === "dev.lithe.plugin.java-support"); + assert.equal(java.releaseEnabled, false); + assert.deepEqual(releaseFor(java).assets.map((asset) => asset.platforms), [["darwin-arm64"], ["darwin-x64"]]); + assert.deepEqual(plan(config, () => false), []); + assert.deepEqual(pendingIndex(config, empty(), () => ["plugin-java-support-v1.1.0"], () => { + throw new Error("A paused plugin must not be indexed even if a tag exists"); + }), []); }); -test("each official plugin has its own tag series", () => { +test("plans carry the actual API and capability metadata plus every architecture", () => { const release = releaseFor(sample, readManifest); - assert.equal(release.tag, `plugin-sample-support-v${release.version}`); - assert.equal(release.assetName, `Lithe-Sample-Support-${release.version}-darwin.zip`); - assert.throws(() => releaseFor(sample, () => ({ id: "dev.lithe.plugin.other", version: "1.0.0" }))); - assert.throws(() => releaseFor(sample, () => ({ id: sample.id, version: "1.0" }))); + assert.equal(release.pluginAPIVersion, 3); + assert.deepEqual(release.requires, manifest.requires); + assert.equal(release.tag, "plugin-sample-support-v1.1.0"); + assert.deepEqual(release.assets.map((asset) => asset.assetName), ["Sample-1.1.0-arm64.zip", "Sample-1.1.0-x64.zip"]); + assert.deepEqual(plan([sample], () => true, readManifest), []); + assert.equal(plan([sample], () => false, readManifest).length, 1); +}); + +test("rejects inconsistent manifest, overlapping platforms, assets and missing capability metadata", () => { + assert.throws(() => releaseFor(sample, () => ({ ...manifest, id: "wrong" }))); + assert.throws(() => releaseFor(sample, () => ({ ...manifest, version: "1.0" }))); + assert.throws(() => releaseFor(sample, () => ({ ...manifest, requires: undefined }))); + assert.throws(() => releaseFor({ ...sample, assets: [sample.assets[0], sample.assets[0]] }, readManifest)); + assert.throws(() => releaseFor({ ...sample, assets: [{ ...sample.assets[0], assetName: "../bad.zip" }] }, readManifest)); + assert.throws(() => releaseFor({ ...sample, assets: [{ ...sample.assets[0], platforms: [] }] }, readManifest)); + assert.throws(() => releaseFor({ ...sample, assets: [{ ...sample.assets[0], arch: "x86_64" }] }, readManifest)); }); -test("only untagged versions are planned", () => { - assert.equal(plan(plugins, () => true, readManifest).length, 0); - assert.deepEqual(plan(plugins, () => false, readManifest).map((release) => release.id), [sample.id]); +test("released plugin resources and shared upstream inputs require a version bump", () => { + assert.deepEqual(check([sample], () => true, () => false, readManifest), []); + assert.equal(check([sample], () => true, (_tag, paths) => { + assert.deepEqual(paths, [sample.packageDirectory, ...sample.inputPaths]); return true; + }, readManifest).length, 1); + assert.deepEqual(check([sample], () => false, () => true, readManifest), []); }); -test("changing a released plugin without a new version fails", () => { - assert.deepEqual(check(plugins, () => true, () => false, readManifest), []); - assert.equal(check(plugins, () => true, () => true, readManifest).length, plugins.length); - // A version that was never tagged is released on merge instead. - assert.deepEqual(check(plugins, () => false, () => true, readManifest), []); +test("index lists all architectures with actual manifest metadata and no partial version", () => { + const release = releaseFor(sample, readManifest); + const index = list(empty(), release); + const version = index.plugins[0].versions[0]; + assert.equal(version.pluginAPIVersion, 3); + assert.deepEqual(version.requires, manifest.requires); + assert.equal(version.downloads.length, 2); + assert.equal(version.downloads[1].downloadURL, "https://github.com/example/ide/releases/download/plugin-sample-support-v1.1.0/Sample-1.1.0-x64.zip"); + assert.equal(version.downloads[1].sha256, "2".repeat(64)); + for (const checksums of [{}, { [release.assets[0].assetName]: "1".repeat(64) }, { ...checksumsFor(release), extra: "3".repeat(64) }, { ...checksumsFor(release), [release.assets[0].assetName]: "ABC" }]) { + assert.throws(() => updateIndex(index, release, { checksums, releaseDate: "2026-10-25" })); + } + assert.deepEqual(empty(), { schemaVersion: 1, plugins: [] }); }); -test("index update keeps versions newest first and points at the tag asset", () => { - const sha = "a".repeat(64); - let index = { schemaVersion: 1, plugins: [] }; - index = updateIndex(index, sample, { version: "1.0.0", sha256: sha, releaseDate: "2026-10-12" }); - index = updateIndex(index, sample, { version: "1.2.0", sha256: sha, releaseDate: "2026-11-01" }); - index = updateIndex(index, sample, { version: "1.1.0", sha256: sha, releaseDate: "2026-10-20" }); - const entry = index.plugins[0]; - assert.deepEqual(entry.versions.map((version) => version.version), ["1.2.0", "1.1.0", "1.0.0"]); - assert.equal( - entry.versions[0].downloads[0].downloadURL, - "https://github.com/Lithe-IDEA/Lithe-IDEA/releases/download/plugin-sample-support-v1.2.0/Lithe-Sample-Support-1.2.0-darwin.zip", - ); - assert.deepEqual(entry.versions[0].downloads[0].platforms, ["darwin-arm64", "darwin-x64"]); - assert.throws(() => updateIndex(index, sample, { version: "1.3.0", sha256: "ABC", releaseDate: "2026-11-02" })); +test("index retry uses old tagged API, requirements and asset names after a source upgrade", () => { + const old = releaseFor({ ...sample, assets: [{ assetName: "Original-{version}.zip", platforms: ["darwin-arm64", "darwin-x64"] }] }, () => ({ id: sample.id, version: "1.0.0", pluginAPIVersion: 1 })); + const current = releaseFor(sample, readManifest); + const readTagged = (_plugin, tag) => tag.endsWith("1.0.0") ? old : current; + const tags = () => [old.tag, current.tag, `${sample.tagPrefix}not-a-version`]; + const pending = pendingIndex([sample], empty(), tags, readTagged); + assert.deepEqual(pending, [old, current]); + const oldIndex = list(empty(), pending[0]); + assert.equal(oldIndex.plugins[0].versions[0].pluginAPIVersion, 1); + assert.equal(oldIndex.plugins[0].versions[0].requires, undefined); + assert.match(oldIndex.plugins[0].versions[0].downloads[0].downloadURL, /Original-1.0.0.zip$/); + assert.deepEqual(pendingIndex([sample], oldIndex, tags, readTagged), [current]); + const complete = list(oldIndex, current); + assert.deepEqual(pendingIndex([sample], complete, tags, readTagged), []); + assert.deepEqual(complete.plugins[0].versions.map((version) => version.version), ["1.1.0", "1.0.0"]); }); -test("tagged versions missing from the index are pending, so a failed listing is retried", () => { - const sha = "a".repeat(64); - const tags = (prefix) => (prefix === sample.tagPrefix - ? [`${prefix}1.0.0`, `${prefix}1.1.0`, `${prefix}not-a-version`] - : []); - // Nothing listed yet: every released version is pending. - assert.deepEqual( - pendingIndex(plugins, { schemaVersion: 1, plugins: [] }, tags).map((release) => release.tag), - [`${sample.tagPrefix}1.0.0`, `${sample.tagPrefix}1.1.0`], - ); - // 1.0.0 was listed but 1.1.0's index pull request failed: only 1.1.0 is - // retried, with the asset name of that version. - const index = updateIndex({ schemaVersion: 1, plugins: [] }, sample, { version: "1.0.0", sha256: sha, releaseDate: "2026-10-12" }); - const pending = pendingIndex(plugins, index, tags); - assert.deepEqual(pending.map((release) => release.version), ["1.1.0"]); - assert.equal(pending[0].assetName, "Lithe-Sample-Support-1.1.0-darwin.zip"); - // Once listed, nothing is pending. - const listed = updateIndex(index, sample, { version: "1.1.0", sha256: sha, releaseDate: "2026-10-20" }); - assert.deepEqual(pendingIndex(plugins, listed, tags), []); +test("refuses a tag whose package version disagrees rather than listing another release", () => { + assert.throws(() => pendingIndex([sample], empty(), () => [`${sample.tagPrefix}1.0.0`], () => releaseFor(sample, readManifest))); }); diff --git a/scripts/package-app.sh b/scripts/package-app.sh index 96a93eac57..8082bd057d 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -13,6 +13,7 @@ DIST_ROOT="${LITHE_DIST_ROOT:-$ROOT_DIR/dist}" SIGNING_IDENTITY="${LITHE_CODESIGN_IDENTITY:--}" ARM64_TRIPLE="arm64-apple-macosx" X86_64_TRIPLE="x86_64-apple-macosx" +MARKETPLACE_INDEX="${LITHE_OFFICIAL_PLUGIN_INDEX:-$ROOT_DIR/Plugins/marketplace/index.json}" case "$ARCH" in universal) APP_DIR="$DIST_ROOT/Lithe.app" ;; @@ -24,19 +25,22 @@ cd "$ROOT_DIR" # Java Support assembles JDT LS and its JDK into its own plugin package later # in this script. They are prepared here first so a missing or invalid # download fails before the application is compiled. -LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdtls.sh" > /dev/null -if [[ "$ARCH" == "universal" ]]; then - if [[ -n "${LITHE_JDK_ROOT:-}" ]]; then - print -u2 -- \ - "Universal packaging requires LITHE_JDK_ARM64_ROOT and LITHE_JDK_X86_64_ROOT instead of LITHE_JDK_ROOT" - exit 1 +bundle_java=$(node "$ROOT_DIR/scripts/official-plugin-distribution.mjs" dev.lithe.plugin.java-support "$MARKETPLACE_INDEX") +if [[ "$bundle_java" == true ]]; then + LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdtls.sh" > /dev/null + if [[ "$ARCH" == "universal" ]]; then + if [[ -n "${LITHE_JDK_ROOT:-}" ]]; then + print -u2 -- \ + "Universal packaging requires LITHE_JDK_ARM64_ROOT and LITHE_JDK_X86_64_ROOT instead of LITHE_JDK_ROOT" + exit 1 + fi + LITHE_JDK_ROOT="${LITHE_JDK_ARM64_ROOT:-}" LITHE_JDK_TARGET_ARCH="arm64" \ + "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null + LITHE_JDK_ROOT="${LITHE_JDK_X86_64_ROOT:-}" LITHE_JDK_TARGET_ARCH="x86_64" \ + "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null + else + LITHE_JDK_TARGET_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null fi - LITHE_JDK_ROOT="${LITHE_JDK_ARM64_ROOT:-}" LITHE_JDK_TARGET_ARCH="arm64" \ - "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null - LITHE_JDK_ROOT="${LITHE_JDK_X86_64_ROOT:-}" LITHE_JDK_TARGET_ARCH="x86_64" \ - "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null -else - LITHE_JDK_TARGET_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null fi if [[ "$ARCH" == "universal" ]]; then scripts/build-macos.sh --configuration release --triple "$ARM64_TRIPLE" @@ -94,6 +98,7 @@ else plugin_triple="$ARCH-apple-macosx" fi plugin_root=$(scripts/build-official-plugins.sh --bundled-only \ + --marketplace-index "$MARKETPLACE_INDEX" \ --configuration release \ --arch "$ARCH" \ --triple "$plugin_triple") @@ -109,7 +114,7 @@ cp -R "$remote_server_root" "$APP_DIR/Contents/Resources/RemoteServers" # The official marketplace index ships read-only so the catalog works # offline; refreshed copies are written to the user's Caches at runtime. mkdir -p "$APP_DIR/Contents/Resources/PluginMarketplace" -cp "$ROOT_DIR/Plugins/marketplace/index.json" "$APP_DIR/Contents/Resources/PluginMarketplace/index.json" +cp "$MARKETPLACE_INDEX" "$APP_DIR/Contents/Resources/PluginMarketplace/index.json" cp "$INFO_PLIST" "$APP_DIR/Contents/Info.plist" zsh "$ROOT_DIR/scripts/embed-sparkle.sh" "$APP_DIR" diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 5f0747da99..12b76cb56d 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -389,6 +389,9 @@ async function main() { // Includes agent-ui-test-evidence: fixture screenshots belong to one test // revision/theme/viewport, never to a reusable build or runtime resource. // Includes plugin-ui-icons: signed runtime packages are never build-cache inputs. + // official-plugin-packages also rejects dist/packages, dist/verify, per-architecture + // release ZIPs, synthetic published-index.fixture.json and java-plugin-distribution probes; + // origin trust and build-index ownership are not transferable. // Includes agent-hook-library-state: user commands, native ownership journals and locks have no copy stage. // Includes agent-instruction-library-state: user text and sync locks have no build identity or copy stage. // Includes mcp-tool-probe-state: SDK sessions, native tool exclusions and diff --git a/scripts/test-official-plugin-distribution.mjs b/scripts/test-official-plugin-distribution.mjs index 9dbd7de5f8..d8224b4443 100644 --- a/scripts/test-official-plugin-distribution.mjs +++ b/scripts/test-official-plugin-distribution.mjs @@ -1,6 +1,75 @@ import assert from "node:assert/strict"; -import { isBundledOfficialPlugin } from "./official-plugin-distribution.mjs"; -assert.equal(isBundledOfficialPlugin("dev.lithe.plugin.go-support"), false); -assert.equal(isBundledOfficialPlugin("dev.lithe.plugin.php-support"), false); -assert.equal(isBundledOfficialPlugin("new.optional.plugin"), false); -console.log("Official plugin distribution checks passed"); +import test from "node:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { isBundledOfficialPlugin, readDistributionIndex } from "./official-plugin-distribution.mjs"; +import { loadConfig, releaseFor, updateIndex } from "./official-plugin-releases.mjs"; +const id = "dev.lithe.plugin.java-support"; +const config = loadConfig(); +const java = config.find((entry) => entry.id === id); +const release = releaseFor(java); +const empty = { schemaVersion: 1, plugins: [] }; +function published() { + return structuredClone(updateIndex(empty, release, { + checksums: Object.fromEntries(release.assets.map((asset, i) => [asset.assetName, String(i + 1).repeat(64)])), + releaseDate: "2026-10-25", + })); +} +test("unknown and retired plugins never enter the default app", () => { + for (const plugin of ["dev.lithe.plugin.go-support", "dev.lithe.plugin.php-support", "new.optional.plugin"]) { + assert.equal(isBundledOfficialPlugin(plugin), false); + } +}); +test("empty official source retains the transition Java package", () => { + assert.equal(isBundledOfficialPlugin(id, empty), true); + assert.equal(readDistributionIndex().schemaVersion, 1); +}); +test("both published architectures remove the default package even while automatic release is paused", () => { + assert.equal(java.releaseEnabled, false); + assert.equal(isBundledOfficialPlugin(id, published()), false); +}); +test("partial, ambiguous, invalid or foreign downloads keep the transition copy", () => { + const changes = [ + (entry) => entry.versions[0].downloads.pop(), + (entry) => { entry.versions[0].downloads[1] = entry.versions[0].downloads[0]; }, + (entry) => { entry.versions[0].downloads[0].sha256 = "invalid"; }, + (entry) => { entry.versions[0].downloads[0].downloadURL += "?wrong"; }, + (entry) => { entry.repository = "https://github.com/untrusted/plugin"; }, + (entry) => { entry.versions[0].requires.packageTrust = 2; }, + (entry) => { delete entry.versions[0].requires.packageTrust; }, + (entry) => { entry.versions[0].pluginAPIVersion = 1; }, + (entry) => { entry.versions[0].version = "1.0.0"; }, + ]; + for (const change of changes) { + const index = published(); change(index.plugins[0]); + assert.equal(isBundledOfficialPlugin(id, index), true); + } +}); +test("later compatible published versions also satisfy the transition cutoff", () => { + const index = published(); + const version = index.plugins[0].versions[0]; + version.version = "1.2.0"; + for (const download of version.downloads) download.downloadURL = download.downloadURL.replaceAll(release.version, version.version); + assert.equal(isBundledOfficialPlugin(id, index), false); +}); + +test("broken index input fails the CLI instead of returning an omit decision", { timeout: 15000 }, () => { + const directory = mkdtempSync(join(tmpdir(), "lithe-package-verification.")); + try { + const index = join(directory, "published-index.fixture.json"); + for (const contents of ["{broken", '{"schemaVersion":2,"plugins":[]}']) { + writeFileSync(index, contents); + const result = spawnSync(process.execPath, [ + fileURLToPath(new URL("./official-plugin-distribution.mjs", import.meta.url)), id, index, + ], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 }); + assert.equal(result.error, undefined); + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ""); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 0bdabe9e56..8b21c26724 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -239,6 +239,18 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /plugin-web-state.*cannot be reused/); }); + await test("architecture release archives and extracted signature probes are never reusable", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const packages = registry.excludedResources.find(item => item.id === "official-plugin-packages"); + assert.ok(packages.locations.includes("dist/packages///")); + assert.ok(packages.locations.includes("dist/verify///")); + assert.ok(packages.locations.includes(".artifacts/java-plugin-distribution/")); + assert.ok(packages.locations.includes("/lithe-package-verification.*/published-index.fixture.json")); + assert.match(packages.reason, /LITHE_OFFICIAL_PLUGIN_INDEX/); + const refused = reuse(["--resource", packages.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /official-plugin-packages is isolated.*cannot be reused/); + }); await test("plugin packages and marketplace state stay isolated in each worktree", { timeout: 15000 }, () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index 9576163936..d8645173ec 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -3,6 +3,12 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" +SLIM=false +case "${1:-}" in + --slim) SLIM=true ;; + "") ;; + *) print -u2 -- "Usage: $0 [--slim]"; exit 2 ;; +esac java_debug_server_version="$( /usr/bin/plutil -extract javaDebugServerVersion raw -o - third_party/jdtls/manifest.json @@ -27,6 +33,20 @@ x86_64_jdk_root="$temporary_directory/jdk-x86_64" dist_root="$temporary_directory/dist" package_log="$temporary_directory/package.log" dmg_log="$temporary_directory/dmg.log" +marketplace_index="$ROOT_DIR/Plugins/marketplace/index.json" +if [[ "$SLIM" == true ]]; then + marketplace_index="$temporary_directory/published-index.fixture.json" + node --input-type=module - "$marketplace_index" <<'NODE' +import { writeFileSync } from "node:fs"; +import { loadConfig, releaseFor, updateIndex } from "./scripts/official-plugin-releases.mjs"; +const release = releaseFor(loadConfig().find((plugin) => plugin.id === "dev.lithe.plugin.java-support")); +writeFileSync(process.argv[2], JSON.stringify(updateIndex({ schemaVersion: 1, plugins: [] }, release, { + checksums: Object.fromEntries(release.assets.map((asset, i) => [asset.assetName, String(i + 1).repeat(64)])), + releaseDate: "2026-10-25", +}))); +NODE +fi +bundle_java=$(node scripts/official-plugin-distribution.mjs dev.lithe.plugin.java-support "$marketplace_index") # The package smoke test validates assembly rather than third-party downloads. # This fixture satisfies prepare-jdtls.sh's production layout checks while @@ -88,6 +108,7 @@ done : > "$jdtls_root/java-test/runner/$java_test_runner_name" : > "$jdtls_root/java-test/LICENSE-MIT.txt" +if [[ "$bundle_java" == true ]]; then for missing_configuration in config_mac_arm config_mac; do broken_jdtls_root="$temporary_directory/jdtls-missing-$missing_configuration" cp -R "$jdtls_root" "$broken_jdtls_root" @@ -106,6 +127,7 @@ for missing_configuration in config_mac_arm config_mac; do exit 1 fi done +fi cat > "$temporary_directory/java.c" <<'SOURCE' int main(void) { return 0; } @@ -119,7 +141,14 @@ JAVA_VERSION="21.0.0" RELEASE done +# Missing download inputs prove slim assembly never prepares Java resources. +if [[ "$bundle_java" == false ]]; then + jdtls_root="$temporary_directory/must-not-read-jdtls" + arm64_jdk_root="$temporary_directory/must-not-read-arm64-jdk" + x86_64_jdk_root="$temporary_directory/must-not-read-x64-jdk" +fi env -u LITHE_ARCH -u LITHE_JDK_ROOT \ + LITHE_OFFICIAL_PLUGIN_INDEX="$marketplace_index" \ LITHE_DIST_ROOT="$dist_root" \ LITHE_JDTLS_ROOT="$jdtls_root" \ LITHE_JDK_ARM64_ROOT="$arm64_jdk_root" \ @@ -207,6 +236,9 @@ required_resources=( "$app_path/Contents/Resources/MonacoEditor/ONIGURUMA-LICENSE.txt" "$app_path/Contents/Resources/MonacoEditor/VSCODE-LICENSE.txt" "$app_path/Contents/Resources/MonacoEditor/TEXTMATE-NOTICE.txt" +) +if [[ "$bundle_java" == true ]]; then +required_resources+=( "$app_path/$java_plugin_package/plugin.json" "$app_path/$java_plugin_package/$java_launcher_path" "$app_path/$java_plugin_package/language-server/configuration/arm64/config.ini" @@ -224,6 +256,16 @@ required_resources=( "$app_path/$java_plugin_package/icons/java.svg" "$app_path/$java_plugin_package/templates/java/maven/pom.xml" ) +else + if [[ -e "$app_path/$java_plugin_package" ]]; then + print -u2 -- "Slim app must not contain Java Support" + exit 1 + fi + if find "$app_path/Contents" -type f \( -name 'org.eclipse.jdt.ls.core_*.jar' -o -name 'libjvm.dylib' \) | /usr/bin/grep -q .; then + print -u2 -- "Slim app must not contain another JDT LS or JDK copy" + exit 1 + fi +fi for resource in "${required_resources[@]}"; do if [[ ! -e "$resource" ]]; then print -u2 -- "Packaged resource is missing: $resource" @@ -233,12 +275,14 @@ done /usr/bin/lipo "$app_path/Contents/MacOS/Lithe" -verify_arch arm64 x86_64 /usr/bin/lipo "$app_path/Contents/Frameworks/Sparkle.framework/Sparkle" -verify_arch arm64 x86_64 /usr/bin/otool -l "$app_path/Contents/MacOS/Lithe" | /usr/bin/grep -F '@executable_path/../Frameworks' >/dev/null +if [[ "$bundle_java" == true ]]; then /usr/bin/lipo \ "$app_path/$java_plugin_package/runtime/arm64/bin/java" \ -verify_arch arm64 /usr/bin/lipo \ "$app_path/$java_plugin_package/runtime/x86_64/bin/java" \ -verify_arch x86_64 +fi # JDT LS and its JDK belong to the Java Support plugin package. The # application itself must not carry a second copy outside that package. if [[ -e "$app_path/Contents/Resources/LanguageServers" ]]; then @@ -256,7 +300,7 @@ for optional_plugin in dev.lithe.plugin.php-support dev.lithe.plugin.go-support; fi done -if ! cmp -s "$app_path/Contents/Resources/PluginMarketplace/index.json" "$ROOT_DIR/Plugins/marketplace/index.json"; then +if ! cmp -s "$app_path/Contents/Resources/PluginMarketplace/index.json" "$marketplace_index"; then print -u2 -- "Packaged app must contain the official plugin marketplace index" exit 1 fi @@ -275,4 +319,4 @@ if [[ "$dmg_path" != "$expected_dmg_path" || ! -s "$dmg_path" ]]; then fi hdiutil imageinfo "$dmg_path" > /dev/null -print -- "macOS package and disk image verification passed for the default universal architecture" +print -- "macOS package and disk image verification passed for the universal architecture (Java bundled: $bundle_java)" diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 89ea9c4c36..79c15a0619 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -125,10 +125,15 @@ ".build///OfficialPlugins", "/Lithe/Plugins//", "Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support/", - "/Lithe/PluginState//" - ], - "identity": "Assembled plugin packages carry build-time downloads (JDT LS, its extensions and one JDK per target architecture for Java Support) and an optional publisher signature; no reliable worktree identity stamp. Plugin state directories are mutable language-server state of one installation", - "reason": "Assemble each official declarative plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees. Java Support is assembled from the reusable jdtls and jdk download caches by Plugins/mac/Official/JavaSupport/assemble-package.sh, so reuse those caches rather than the package. Never copy or share plugin state at any stage." + "/Lithe/PluginState//", + "dist/packages///", + "dist/verify///", + "dist/Lithe-Java-Support--darwin-.zip", + ".artifacts/java-plugin-distribution/", + "/lithe-package-verification.*/published-index.fixture.json" + ], + "identity": "Assembled plugin packages carry build-time downloads (JDT LS, its extensions and one JDK per target architecture for Java Support) and an optional publisher signature; no reliable worktree identity stamp. Plugin state directories are mutable language-server state of one installation. Independent release archives bind plugin version, darwin architecture, upstream input hashes, API/requires and final publisher signature; host-sealed bundled copies are a different origin, never a reusable package", + "reason": "Assemble each official declarative plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees. Java Support is assembled from the reusable jdtls and jdk download caches by Plugins/mac/Official/JavaSupport/assemble-package.sh, so reuse those caches rather than the package. Never copy or share plugin state at any stage. Release verification extracts a separate copy and checks its publisher signature; delete per-run dist/verify and local integration output when no longer needed. Never transfer a sealed-origin exception to user-installed copies. Runtime installation versions stay read-only, preserving app signatures and Sparkle delta baselines. Distribution decisions read the current checkout index or explicit LITHE_OFFICIAL_PLUGIN_INDEX/--marketplace-index build input; synthetic packaging indexes are per-test fixtures, never reusable or publishable source indexes." }, { "id": "language-tools", diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index d4b37cf69c..ad260b5f59 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -532,6 +532,42 @@ must not impose a shorter readiness deadline. The terminal timeout code is `serviceReadyTimeout`, and its details preserve the last import, download, and cache snapshot for both products. +### Explicit debug adapter selection + +`DebugLaunchConfiguration.adapterProviderID` is optional host routing metadata. +Plugin registrations use the existing Registry identity `pluginID/adapterID`; +the host Java adapter retains `java`. The field is not a DAP argument, a process +command, or a plugin-private function call. An omitted identity uses the existing +file selection. An explicit identity, including an empty or withdrawn one, must +never fall back to another adapter matching the file. + +Qualified registrations include every declared adapter, including attach-only +and executable/process adapters. They do not become automatic file defaults. +Before creating a transport, the session manager resolves the current registration +and verifies its declared launch/attach request. An explicit target need not be +an open source file. Retry and configuration transformations retain the identity; +failed additional starts clean up their own session and preserve the previous one. + +Source breakpoints are shared by matching registered adapters, including replay +on a later launch. Function, exception and data breakpoint changes use the selected +adapter identity, even when the target has no source extension. Plugin ownership +continues to control withdrawal and process cleanup. The platform resolver still +owns manifest validation, permissions, executable resolution and supported transports; +this routing field grants no new permission and carries no adapter process or socket. + +The host fixture is [adapter-selection-v1.json](../fixtures/debug/adapter-selection-v1.json). +macOS consumes this contract. Windows has no plugin adapter launch consumer yet. +Build Provider version 5 adds a separate native debug preparation request; +version 4 run plans are never reinterpreted as debug plans. The selected run +names a same-plugin launch adapter. Core validates an existing instance-contained +program plus bounded arguments/environment/directory, and the platform rechecks +executable permissions before binding target facts through `debug.adapters.startArguments`. +Build-tree Debug and saved-provider Run Debug save documents first and revalidate +workspace, source, configuration, model and plugin identity before handoff. Debug +owns the resulting session; stopping preparation or accepting late replies cannot +start a session after invalidation. Java/JDT and cross-plugin preparation are not +covered by this native increment. + ## Error Codes Use stable categories rather than platform error strings: @@ -582,6 +618,20 @@ log stream. Changing repositories or references and closing the history view cancels the owning `operationID` and closes any retained cursor; a late result cannot replace the active selection and its returned cursor is also closed. +Launch environment precedence and automatic run scanning are Rust Core policy. +Platform adapters discover executable/home paths and provide the native parent +environment; `execution.resolveEnvironment` applies inherited < selected current +step toolchain < explicit user values. A Maven resource step uses its Maven JVM, +while the subsequent Java process uses the project JVM. Automatic scan exclusions, +partial-snapshot retention and diagnostics share one implementation; unreadable +optional subtrees must not erase previously discovered configurations. + +Ordinary workspace events use Core's `workspace.filterChanges` policy before a +host applies event-count recovery thresholds. Native lost-event/error flags always +request recovery. Hosts retain native watch registration, Git/document channels, +explicit watches and platform-specific lifecycle; the shared filter must not +replace those channels or swallow real source changes mixed with build output. + Run configuration behavior is exposed through the `runConfig.*` commands. Platform clients coordinate inspection, generation, resolution, typed document edits, and launch planning, but must not implement a second JSON merger, @@ -809,3 +859,16 @@ current conversation has used a skill. Explicit create/import operations write only to native user/project directories and preserve existing skills. Explicit toggles write native settings: Codex by discovered path via its official App Server API, Claude by name via skillOverrides in user or project-local settings. Claude states are configured visibility, not effective managed/session policy. Never persist a separate Lithe enabled flag, move packages to simulate disablement, or claim that a change revokes already loaded session content. File contents remain untrusted document data. Settings discovery and file management must not create conversations, send prompts, or interrupt existing Agent work. + +Build-provider application preparation and execution have separate owners. The +workspace Build session validates a current target and prepares it; the native +Run host owns the application process, output and stop. Explicit persistence +uses the existing local Run v2 layer and stores a typed selector rather than +launch facts. Reopening or restarting re-resolves that selector in the enabled +workspace Provider and prepares afresh. Save-before-run completes before taking +the preparation snapshot; source/configuration changes revoke pending results. +The public Run operation ID spans preparation and process handoff, so UI and Agent +queries keep one execution identity. Stop targets the owned preparation ID before +launch and the owned native process ID afterward. Missing or stale references fail without a CLI or old domain fallback. +A Provider application's native output remains visible even without generated +Run configurations. See `../fixtures/run-configuration/build-provider.json`. diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index a172ce3bd8..00cc79412f 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -400,15 +400,17 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | Capability | Covers | Versions | | --- | --- | --- | | `languages` | `languages[]` | `1` | +| `packageTrust` | `vendor.bundledSignatureRequirement`: origin-scoped trust for a host-sealed bundled copy of a publisher-signed plugin | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | | `serverProcess` | The launch fields of language servers, of build providers with a process of their own, of VCS providers, and of the plugin backend: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | | `executionProviders` | `executionProviders[]` | `1` | | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | -| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers | `1` | +| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; v2 tasks, v3 tests, v4 run, v5 native debug preparation, v6 read-only model inputs | `1`, `2`, `3`, `4`, `5`, `6` | +| `buildModelInputs` | Optional public model sharing between enabled workspace plugins | `1` | | `vcsProviders` | `vcsProviders[]` | `1` | -| `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters) | `1` | +| `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters); v2 adds target arguments/environment | `1`, `2` | | `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | | `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process for its pages | `1` | @@ -454,17 +456,13 @@ in `plugins.declarative.validate`, `plugins.marketplace.validateIndex`, and therefore offered on a host that cannot run them. - `hostCapabilities` has no effect on levels 1 and 2. -The macOS host runs levels 1 to 3 and sends its capability table, including -`buildSystems: [1]`, with all three commands. It does not implement build -providers and does not declare `buildProviders`. Provider plugins are therefore -filtered from the market and refused by declarative validation and install -review; template-only build plugins remain available. It does not declare -`vcsProviders` either, so VCS provider plugins are filtered the same way until -the macOS host starts providers. It declares `backend: [1]`: it starts -plugin backends and serves their credentials from the Keychain. Core supports `backend: 1`, `buildProviders: 1`, and -`vcsProviders: 1` for validation and plans without a host table. Windows has -no declarative plugin host and sends no `hostCapabilities`, so it offers no -level 3 version. +The macOS host sends its capability table with validation, marketplace filtering +and install review. It advertises `buildSystems: [1, 2]` and +`buildProviders: [1, 2, 3, 4, 5, 6]`; native Provider models, dependencies, tasks, tests and launch preparation +share workspace-owned sessions. Backend and VCS support use their own entries +in the same table. Core validates supported versions without a host table, +but admission must use the actual host table. Windows does not yet advertise +Build Provider orchestration; Core protocol support alone does not enable it. Rules for publishing across levels and capability versions: @@ -534,6 +532,20 @@ plugin only permits activation; nothing starts until its event occurs. A signature document, when present, must still be verified; community trust cannot silently ignore an invalid signature. +API level 3 packages can declare `requires.packageTrust: 1` and +`vendor.bundledSignatureRequirement: "sameTeamAsHost"`, together with +`vendor.signatureRequirement: "publisherPackage"`. Only official-namespace IDs +may use this declaration. Both the primary and bundled policies must match the +host's pinned official catalog. Only a package whose actual resolved path is +inside the host's sealed installation and whose origin is `bundled` may omit +the publisher signature. Every independently installed, imported or rolled-back +copy still needs the publisher signature. A present signature is always checked, +even inside the host seal. The optional declaration alone grants no trust. +Hosts without `packageTrust: 1` filter this version before download and reject +its manifest; no new application-version field is needed. An older manifest +without the declaration keeps its existing policy. The valid example is +`fixtures/publisher-bundled.plugin.json`. + A host pins the signature requirement of its official plugin IDs so a package cannot downgrade itself. Selecting a file from disk never overrides that pin, namespace restrictions, publisher-key checks, or the installation seal required @@ -886,6 +898,316 @@ Canonical formats let a plugin's own script print the shape Core expects: Unknown fields in canonical documents are ignored so newer scripts work with older hosts. Output that does not fit its decoder is `plugin.build.invalidOutput`. +### Provider task execution (buildProviders 2) + +A task with `source: "provider"` requires `requires.buildProviders: 2` or newer. +It keeps the ordinary task ID, scopes, target tags and model selection, but +`arguments` must be `[]` and `execution` must be `task`. Application/service +launch and test discovery are separate capabilities. Provider model-task +templates cannot be referenced by `run[].fromTasks`; they need a typed Run +capability rather than an empty command plan. A provider task never +falls back to a host command or a plugin-private function. Command tasks +retain their current shape and omit `source`. + +`build.taskPlan` returns a `provider` request instead of argv for this source. +`lithe/build/execute` receives the effective `buildSystemID`, workspace-relative +`root`, `options`, `optionArguments`, option `environment`, `task` declaration ID, +bounded `userArguments`, and optional `project`, `target`, `modelTask` selections +(`name`, workspace-relative `path`, `tags`, `properties`). Scope/path validation +is identical to command tasks. The provider owns build semantics; options +are not a second host-generated command. As with model/dependency requests, +the host may add `tool: { executable: }` from the +same invocation resolver; explicit invalid tool/runtime selection is a failure, +never permission for independent discovery. It must reject stale project facts +rather than guess a target. + +The host offers `litheBuild.versions: [6, 5, 4, 3, 2, 1]`. Version 2 adds task execution +and its progress notification to the two version 1 methods. A process that +chooses version 1 cannot receive execution; unsupported negotiation fails +explicitly. A plugin that needs this behavior declares capability version 2 +so unsupported hosts refuse installation. The two version numbers serve +different purposes: installation admission and per-process negotiation. + +Core sets `params.operationID` to the unique JSON-RPC request ID (as a string); +callers cannot choose it. `lithe/build/progress` notifications echo that ID and +may include `message` (up to 4 KiB UTF-8), integer `percentage` (0–100), and +`output` (up to 64 KiB UTF-8 per notification). Malformed reports and reports +for ended, cancelled, timed-out or other requests are dropped. Core maps the +wire ID back to the host operation ID; reused host IDs cannot accept old +progress. No plugin-function negotiation is needed. + +The reply is `{ "exitCode": , "output": }`. +`output` is the complete bounded diagnostic text (at most 32 MiB UTF-8), +including text streamed in progress; the host displays progress output and +uses final output for the existing diagnostic decoder without duplicating it +in the console. Nonzero exit codes are completed tool failures, not protocol +errors. Malformed replies end with `plugin.build.invalidResult`; JSON-RPC +errors end with `plugin.build.failed`. Timeout and cancellation use the +existing request codes and `$/cancelRequest`. Request completion occurs once; +late replies/progress do not revive it. + +The host binds each call to the plugin package, build-system/workspace +session, current run, effective option fingerprint and watched configuration revision. +Settings, package replacement, disable, workspace close/reopen, cancellation +and backend failure invalidate or stop the owning operation. The existing +Provider session owns children and writable workspace state; no page, +additional runtime, directory or cross-plugin private cache is required. +Only public typed results may cross domain boundaries. + +Canonical examples: `fixtures/build-provider-execution.plugin.json` and +`fixtures/build-execution-protocol.json`. Protocol v2 is a construction +primitive for #1495; it does not by itself migrate Maven or Java consumers. + +### Provider test execution (buildProviders 3) + +A build system's `test` may declare `source: "provider"` with +`requires.buildProviders: 3`. It retains the existing scopes, target tags and +supported filters, but `arguments` must be `[]`. It requires a plugin-owned +Provider process; `results` may be omitted or use `junitXml`/`litheTestResults` +reports. `hostIntegration` is refused: the plugin owns test execution and +report production, and the host reuses the existing report decoder. + +`build.testPlan` returns `provider: { method: "lithe/build/test", params }` +and the usual `results` metadata instead of argv. The effective +`buildSystemID`, `root`, `options`, `optionArguments`, `environment`, bounded +`userArguments`, optional `project`/`target`, and optional `suite`/`case` are +typed fields. A case filter does not require a suite when the provider +supports it; providers must reject selectors they cannot honor rather than +widening the test run. Paths, scopes and declared filters are validated before +the request is sent. The host's report project list only resolves report globs; +it is not a second backend project model. + +Only a process choosing protocol version 3 or 4 receives `lithe/build/test`. +Versions 1 and 2 explicitly refuse tests. Version 3 retains every version 2 +task behavior. Tests use the same Core-assigned operation identity, +`lithe/build/progress`, tool hint, bounded `{ exitCode, output }` result, +timeout, cancellation and late-result rules as task execution. Reports are +read after execution using the existing freshness, path and size checks. +Workspace-wide reports still require a current project model when the build +system has one. Configuration validity is retained through diagnostic and +report decoding; late results cannot update a changed workspace/configuration. + +Java/Maven migration remains separate. No new resource/cache location is introduced. +Canonical examples: `fixtures/build-provider-tests.plugin.json` and +`fixtures/build-test-protocol.json`. + +#### Test discovery + +Provider tests may additionally declare `discovery: true`. This requires both +`suite` and `case` filters: every discovered selector must be exactly runnable. +`build.testDiscoveryPlan` accepts the common plan fields and optional `refresh` +and `tool`, returning a request for the whole instance: +`lithe/build/discoverTests` with its effective options. Project/output-file +selection is refused. This method also requires protocol version 3 or 4 and uses +the existing bounded request/cancellation lifecycle, without execution progress. + +The provider returns `{ "format": "litheTestDiscovery", "version": 1, +"tests": [{ "suite", "case", "project"?, "target"? }] }`. Project and target +are the usual typed selections with workspace-relative paths. There may be at +most 10,000 items and 32 MiB UTF-8 JSON. Unknown document/item fields are +ignored; malformed selectors, invalid paths/scopes, unsupported versions and +oversized lists reject the entire result with `plugin.build.invalidOutput`. +An empty array is a successful discovery with no tests. + +The host passes the reply and original plan configuration to +`build.decodeTestDiscovery`. Core validates every selector through the same +test-plan rules, deterministically orders the list, collapses exact duplicates, +and derives an opaque `id` including project/target identity. Equal suite/case +names in sibling modules therefore remain distinct. The returned `discovery` +contains `tests: [{ id, suite, case, project?, target? }]`. No source parsing, +framework detection, or second project graph occurs in the host/Core. + +Native discovery starts only on a user action, reuses the workspace Provider, +and is independent of plugin pages. Source edits, configuration changes, +package changes and workspace teardown revoke the cached list and in-flight +discovery. A native action must revalidate the selected ID against the current +list before submitting its exact selectors to `lithe/build/test`; stale +toolbar selections cannot run. The backend must reject a selector whose facts +have become stale externally. Canonical example: `fixtures/build-test-discovery.json`. + +### Provider run preparation (buildProviders 4) + +A run declaration may use `source: "provider"` with +`requires.buildProviders: 4`. This increment supports application targets only: +`fromTargets.tags` is required; `fromTasks`, service execution, nonempty +`arguments`, and extension templates are refused. The optional `name` is a +literal display name. `workingDirectory` is supplied by the prepared result; +the declaration's command-source default has no effect. These declarations +never generate empty command-based persisted Run entries. + +`build.prepareRunPlan` validates a selected `run` declaration ID, required +`project` and `target` selections, effective options, and bounded +`userArguments`. It returns a provider request for `lithe/build/prepareRun`, +with the common instance fields, options/option arguments/environment, +`run`, `project`, `target`, and `userArguments`. An output file is refused. +The host may add the resolved build tool hint. The provider owns upstream +synchronization, build, classpath and launch facts; Core does not reconstruct +those facts from source files or build configuration. + +Protocol versions 4 and 5 accept this method. Version 4 retains task and test +support. Preparation shares the Core-assigned wire `operationID`, progress, +timeout, cancellation and stale-result handling of execution. It ends when +preparation finishes; the application is a separate host-owned process. +A JSON-RPC error aborts preparation and cannot launch an application. It retains +the common LSP `serverError` category; it is not an execution exit-code result +and does not forward a private-function `pluginErrorCode`. + +The successful result is a portable launch plan: + +```json +{ + "executable": { "kind": "toolchain", "value": "project-jdk" }, + "arguments": ["-cp", "classes", "example.Main"], + "workingDirectory": "app", + "environment": {} +} +``` + +The executable is either a host toolchain identifier (`toolchain`) or a bare +command resolved by the host (`command`); absolute paths and shell command +strings are refused. Arguments are separate strings, at most 1,024 entries +and 64 KiB UTF-8 each, without NUL. The environment has at most 64 entries; +keys match `[A-Za-z_][A-Za-z0-9_]*` with at most 128 bytes, values are strings +of at most 64 KiB without NUL. The complete JSON is bounded at 32 MiB. +`workingDirectory` must be an existing workspace-relative directory inside +the selected instance, including after resolving filesystem aliases. +Unknown fields are ignored. The host calls `build.decodeRunPreparation` +with its workspace root, instance root and raw result; any malformed plan +fails as `plugin.build.invalidOutput` before executable resolution. + +#### Native debug preparation (version 5) + +A provider run may additionally declare `debug: {"adapter":"native"}` with +`requires.buildProviders: 5` and `requires.debugAdapters: 2`. `adapter` is a +local ID declared by this plugin; qualified cross-package references are refused. +The adapter must serve launch of executable targets, with bindings `program` ← +`target.path`, `cwd` ← `target.workingDirectory`, `args` ← `target.arguments`, +and `env` ← `target.environment`. These fields cannot be overridden by adapter +default arguments. This first increment covers native executable launch only; +it does not accept attach endpoints, backend-selected adapters or arbitrary DAP JSON. + +`build.prepareDebugPlan` validates the same selected run/project/target and +arguments as run preparation and returns `lithe/build/prepareDebug`. Only wire +version 5 accepts it. Progress, cancellation, timeouts, tool hints, workspace +ownership and JSON-RPC failure handling follow run preparation. A successful result +contains exactly these four fields: + +```json +{"program":"app/bin/sample","arguments":["a b","x#y"],"workingDirectory":"app","environment":{}} +``` + +The provider owns synchronization/build before returning. `program` is an existing +workspace-relative regular file contained in the selected instance, including +canonical symlink resolution. Arguments, environment and directory retain run +preparation's size and containment limits. Unknown fields are refused here; there +is no adapter process, socket, shell, private function or DAP argument channel. +`build.decodeDebugPreparation` takes the selected `run` separately and returns +`preparation` with its validated `root`, declaration-owned `adapter`, and these +four fields. Missing, malformed and escaping targets fail before adapter startup. + +The native host saves documents before preparation, retains model/configuration/ +source/plugin identity through decode, and drops stale results. It checks executable +permissions and canonical containment again at handoff. `debug.adapters.startArguments` +binds the validated target facts; the existing Registry launches the adapter using +its own permissions and package process declaration. The launch configuration retains +`pluginID/adapterID` for routing and retry. Stopping preparation cancels only its +operation; the Debug host owns launched sessions and plugin withdrawal cleanup. +Saved Run configurations keep their existing selectors and prepare again for Debug. + +Fixtures: [native declaration](fixtures/build-provider-debug-preparation.plugin.json) +and [native result](fixtures/build-debug-preparation.json). This does not provide +Java/JDT preparation, long-running services or cross-plugin collaboration. + + +The native Build tree re-resolves model IDs before preparation. Source/model, +option, settings, package and workspace changes revoke a pending preparation. +After validation the existing Run host resolves the executable and owns output, +stop, exit status, and argument-resource cleanup. Workspace teardown and plugin +disable stop the application by its exact host operation ID; an old owner +cannot stop a replacement process. The Run host can explicitly save a selector to the existing local Run v2 layer +as `provider: "build.provider"` with `extensions.build`. The reference contains +`pluginID`, `buildSystemID`, instance `root`, `run`, `projectID`, `targetID`, and +raw `userArguments`, never an executable, environment, or cached classpath. +Every restart re-resolves the enabled instance/model and sends a fresh preparation. +Missing plugins/targets/models fail explicitly; there is no command fallback. +Saving and running work before detector-generated Run configurations exist. +Service coordination, debug target registration and Java/Maven migration remain +separate. See `fixtures/build-provider-run-preparation.plugin.json` +and `fixtures/build-run-preparation.json`. + +### Optional read-only model inputs (buildModelInputs 1) + +A producer opts into `buildSystems[].modelSharing: { options: [] }`. +Only normalized `projects`, `targets` and `contentRoots`, including their +ordinary public metadata, are published. Tasks, run entries, settings, +`optionValues`, process handles and private functions are excluded. The +options list names existing ordinary build options whose **effective values** +may be shared; omitted options stay private. A configuration fingerprint +identifies all effective options without publishing their values. + +A consumer declares `modelInputs: [{ pluginID, buildSystemID }]`, requires +`buildModelInputs: 1` and `buildProviders: 6`, and owns a provider and model. +Slots are exact optional identities; Core sorts them by plugin ID and build +system ID and rejects duplicates. Version 1 producers cannot also consume +inputs, so consumers cannot recursively re-export a model. + +The native host calls `build.resolveModelInputs` with the consumer manifest +and `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, +model?, options?}]}]}`. Sources are enabled installed packages in the current +workspace activation, never identities supplied by another backend. +Input states are `ready`, `loading`, `stale`, or `failed`; only ready +instances include a complete normalized model. A truncated model becomes stale. +Core revalidates source manifests, ownership, shape and path containment and +returns `modelInputs: {version: 1, revision, inputs}`, where each input is: + +```json +{ + "pluginID": "dev.example.source", + "buildSystemID": "source", + "state": "available", + "instances": [{ + "root": "app", + "state": "ready", + "model": {"projects": [], "targets": [], "contentRoots": []}, + "configurationFingerprint": "<64 lowercase hex characters>", + "options": {} + }] +} +``` + +A missing, disabled, non-public or mismatched producer gives +`state: "unavailable", instances: []`. An installed public producer with no +detected instance is available with an empty list. A non-ready instance has +only `root` and `state`; no old model or options travel with it. Roots and +model paths remain workspace-relative, and instances are deterministically +ordered. There are at most 8 slots, 64 instances per source, 2,000 projects and +10,000 targets/content roots per instance, and 8 MiB of resolved JSON. The +revision is a nonnegative integer no larger than 9,007,199,254,740,991. + +The host supplies this snapshot as the common plan request's optional +`modelInputs`. It is required exactly for a consumer with declared slots. +Every Provider plan copies it unchanged into `params.modelInputs`; Core +revalidates the snapshot and rejects extra fields or mismatched slots. +The running backend must negotiate `litheBuild` version 6 independently of +manifest admission. Older versions reject an input-bearing request even for +an existing method such as `lithe/build/model`. + +A revision belongs to one workspace activation. The host revokes consumer +model/dependency loads, discovery and in-flight execution/preparation when a +producer's model, options, watched configuration or package availability +changes. Revisions also detect changing an option away and back before a reply +arrives. Guard the result after decoding as well as after the RPC; do not +publish an old reply merely because cancellation was ignored. Already launched +applications retain their existing native ownership. + +This facility never installs or activates a producer, invokes its private +functions, grants permissions, leases a process or resolves a package version. +There is no general plugin-dependency/export API. Backend authors must handle +unavailable and non-ready slots explicitly. Language integration and existing +Java/Maven business migration remain separate work. Shared fixtures: +`fixtures/build-model-producer.plugin.json` and +`fixtures/build-model-consumer.plugin.json`. + ### Build providers Some tools expose their model only through their own API (the Gradle Tooling @@ -936,11 +1258,14 @@ another format is `plugin.build.invalidOutput`. The manifest only says how to start the provider and which sources use it. Everything else is negotiated in the LSP `initialize` exchange, so a provider -can grow without a new manifest field or capability version: +can add optional data within a negotiated protocol version: -- The host sends `capabilities.experimental.litheBuild: { "versions": [1] }`, +- The host sends `capabilities.experimental.litheBuild: { "versions": [6, 5, 4, 3, 2, 1] }`, the build-provider protocol versions it speaks. Version 1 is the two - requests above with the params and result formats of this section. + requests above with the params and result formats of this section; + version 2 adds typed task execution; version 3 adds typed test execution + and discovery; version 4 adds application run preparation; version 5 adds native debug preparation; + version 6 accepts the host's explicitly declared read-only model inputs. - The provider answers `capabilities.experimental.litheBuild: { "version": 1 }` with the version it chose from that list. A provider that answers no version, or one the host did not offer, is not used for build requests; a @@ -1030,7 +1355,7 @@ A manifest below level 4 may not declare `debugAdapters`, and a level 3 manifest | `requests` | `launch` (default) and/or `attach`. | | `selectors[]` | Optional automatic matching by `targetKinds` and `languageIDs`; absent means explicit selection only. | | `configurationTypes` | Imported `launch.json` `type` values the adapter answers to. | -| `configurations.launch/attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings` (`program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`, and for `attach` only `connection` ← `target.host`, `target.port`, `target.pathMappings`; see [Remote attach](#remote-attach)). A configuration for an undeclared request is refused. | +| `configurations.launch/attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings` (`program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`, and for `attach` only `connection` ← `target.host`, `target.port`, `target.pathMappings`; version 2 also supports bounded `args` ← `target.arguments` and `env` ← `target.environment`; see [Remote attach](#remote-attach)). A configuration for an undeclared request is refused. | | `configurations.launch.executable` | For a target that is a source file the adapter cannot debug directly (C, C++, Swift): `setting` names a `file` setting of the plugin, `candidates[]` (up to 8) are the paths a host looks for when the user chose nothing. Each candidate starts with `${workspaceFolder}/` or `${fileDirectory}/`, may use `${fileBasenameNoExtension}`, and has no `..`. Declare it together with `targetBindings.program: "target.executable"` and neither without the other; a launch fills `program` with the path the host settled on. The user's setting always wins: a chosen path that is not a runnable file is an error, not a reason to try the candidates. The host shows the path it found in that setting, and the setting's `description` is where the plugin teaches how to produce the program. | | `capabilities.disabled` | Standard DAP `supports*` names or `exceptionBreakpointFilters` the host treats as absent. | | `console.commandPrefix` | Text (1–8 characters, no spaces; for lldb-dap a backtick) the adapter's console reads as "what follows is a debugger command". The host puts it before the typed line only when it asks the adapter to complete, and moves the adapter's answer back, so the user never sees it. An adapter that treats a bare word as an expression (lldb-dap) otherwise mixes language keywords such as `long` into the first word's completions; the prefix limits them to commands. Declare at least this or `console.commands[]` when `console` is present. | diff --git a/shared/contracts/plugins/v1/fixtures/build-debug-preparation.json b/shared/contracts/plugins/v1/fixtures/build-debug-preparation.json new file mode 100644 index 0000000000..e7cf3e8e40 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-debug-preparation.json @@ -0,0 +1,28 @@ +{ + "result": { + "program": "app/bin/sample", + "arguments": [ + "a b", + "x#y" + ], + "workingDirectory": "app", + "environment": { + "SAMPLE": "debug" + } + }, + "invalidResults": [ + { + "program": "../sample", + "arguments": [], + "workingDirectory": "app", + "environment": {} + }, + { + "program": "app/bin/sample", + "arguments": [], + "workingDirectory": "app", + "environment": {}, + "adapter": "other/native" + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-execution-protocol.json b/shared/contracts/plugins/v1/fixtures/build-execution-protocol.json new file mode 100644 index 0000000000..0faea862a1 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-execution-protocol.json @@ -0,0 +1,51 @@ +{ + "version": 2, + "request": { + "method": "lithe/build/execute", + "params": { + "buildSystemID": "gradle", + "root": ".", + "task": "build", + "options": {}, + "optionArguments": [], + "environment": {}, + "userArguments": [] + } + }, + "result": { + "exitCode": 0, + "output": "built\n" + }, + "progress": { + "operationID": "42", + "message": "Building", + "percentage": 50, + "output": "compiling\n" + }, + "invalidResults": [ + {}, + { + "exitCode": true, + "output": "" + }, + { + "exitCode": 2147483648, + "output": "" + }, + { + "exitCode": 0, + "output": false + } + ], + "invalidProgress": [ + {}, + { + "operationID": "42", + "percentage": 101 + }, + { + "operationID": "42", + "output": false + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-model-consumer.plugin.json b/shared/contracts/plugins/v1/fixtures/build-model-consumer.plugin.json new file mode 100644 index 0000000000..57fc3c55c3 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-model-consumer.plugin.json @@ -0,0 +1,170 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.model-consumer", + "displayName": "Model Consumer Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 6, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1, + "buildModelInputs": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "source": "provider", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [] + } + ], + "run": [], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "modelInputs": [ + { + "pluginID": "dev.lithe.fixture.model-producer", + "buildSystemID": "source" + } + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-model-producer.plugin.json b/shared/contracts/plugins/v1/fixtures/build-model-producer.plugin.json new file mode 100644 index 0000000000..92055e41d6 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-model-producer.plugin.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.model-producer", + "displayName": "Model Producer Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1, + "buildModelInputs": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:source" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "source", + "displayName": "Source", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [], + "run": [], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "modelSharing": { + "options": [ + "offline" + ] + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json new file mode 100644 index 0000000000..e6d09985e3 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json @@ -0,0 +1,254 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-debug-preparation", + "displayName": "Native Debug Preparation Fixture", + "version": "0.1.0", + "pluginAPIVersion": 4, + "requires": { + "buildProviders": 5, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1, + "debugAdapters": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "debug.launch" + ], + "activationEvents": [ + "onBuildSystem:gradle", + "onDebug:native" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "junitXml", + "reports": [ + "build/test-results/**/*.xml" + ] + }, + "discovery": true + }, + "run": [ + { + "id": "application", + "source": "provider", + "name": "Run Application", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [], + "debug": { + "adapter": "native" + } + } + ] + } + ], + "debugAdapters": [ + { + "id": "native", + "displayName": "Native DAP", + "requests": [ + "launch" + ], + "selectors": [ + { + "targetKinds": [ + "executable" + ] + } + ], + "configurationTypes": [ + "native" + ], + "configurations": { + "launch": { + "targetBindings": { + "program": "target.path", + "cwd": "target.workingDirectory", + "args": "target.arguments", + "env": "target.environment" + } + } + }, + "launch": { + "process": { + "launch": { + "kind": "systemCommand", + "commands": [ + "example-dap" + ] + } + }, + "transport": "stdio" + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-execution.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-execution.plugin.json new file mode 100644 index 0000000000..725065e3e3 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-execution.plugin.json @@ -0,0 +1,175 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-execution", + "displayName": "Build Provider Execution Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 2, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-run-preparation.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-run-preparation.plugin.json new file mode 100644 index 0000000000..eb5422c65f --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-run-preparation.plugin.json @@ -0,0 +1,208 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-run-preparation", + "displayName": "Provider Run Preparation Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 4, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "junitXml", + "reports": [ + "build/test-results/**/*.xml" + ] + }, + "discovery": true + }, + "run": [ + { + "id": "application", + "source": "provider", + "name": "Run Application", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [] + } + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-tests.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-tests.plugin.json new file mode 100644 index 0000000000..3749a7a3fd --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-tests.plugin.json @@ -0,0 +1,195 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-tests", + "displayName": "Build Provider Tests Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 3, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "junitXml", + "reports": [ + "build/test-results/**/*.xml" + ] + }, + "discovery": true + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-run-preparation.json b/shared/contracts/plugins/v1/fixtures/build-run-preparation.json new file mode 100644 index 0000000000..bacabb6781 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-run-preparation.json @@ -0,0 +1,39 @@ +{ + "request": { + "root": ".", + "run": "application", + "project": { + "name": "app", + "path": "app" + }, + "target": { + "name": "Main", + "path": "app", + "tags": [ + "application" + ] + }, + "options": { + "offline": true + }, + "userArguments": [ + "a b" + ] + }, + "result": { + "executable": { + "kind": "toolchain", + "value": "project-jdk" + }, + "arguments": [ + "-cp", + "classes", + "example.Main", + "a b" + ], + "workingDirectory": "app", + "environment": { + "APP_ENV": "test" + } + } +} diff --git a/shared/contracts/plugins/v1/fixtures/build-test-discovery.json b/shared/contracts/plugins/v1/fixtures/build-test-discovery.json new file mode 100644 index 0000000000..32b0496deb --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-test-discovery.json @@ -0,0 +1,24 @@ +{ + "result": { + "format": "litheTestDiscovery", + "version": 1, + "tests": [ + { + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "project": { + "name": "app", + "path": "app" + } + }, + { + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "project": { + "name": "lib", + "path": "lib" + } + } + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/build-test-protocol.json b/shared/contracts/plugins/v1/fixtures/build-test-protocol.json new file mode 100644 index 0000000000..bd010d1110 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-test-protocol.json @@ -0,0 +1,48 @@ +{ + "request": { + "root": ".", + "options": { + "offline": true + }, + "project": { + "name": "app", + "path": "app" + }, + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "userArguments": [ + "--rerun-tasks" + ] + }, + "provider": { + "method": "lithe/build/test", + "params": { + "buildSystemID": "gradle", + "root": ".", + "options": { + "offline": true + }, + "optionArguments": [ + "--offline" + ], + "environment": {}, + "project": { + "name": "app", + "path": "app", + "tags": [], + "properties": {} + }, + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "userArguments": [ + "--rerun-tasks" + ] + } + }, + "results": { + "decoder": "junitXml", + "reports": [ + "app/build/test-results/**/*.xml" + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json b/shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json new file mode 100644 index 0000000000..220924b2dc --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.publisher-bundled", + "displayName": "Publisher and bundled trust fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": {"languages": 1, "languageServers": 1, "serverProcess": 1, "packageTrust": 1}, + "vendor": { + "id": "dev.lithe", + "displayName": "Lithe", + "signatureRequirement": "publisherPackage", + "bundledSignatureRequirement": "sameTeamAsHost" + }, + "permissions": ["languageServer.launch"], + "activationEvents": ["onLanguage:fixture"], + "languages": [{"id": "fixture", "displayName": "Fixture", "fileExtensions": ["fixture"]}], + "languageServers": [{ + "languageID": "fixture", + "launch": {"kind": "runtime", "tool": "java", "packagedRuntime": "runtime/${arch}/bin/java", "entry": "server.jar"}, + "arguments": [] + }] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 5b0c193a1e..64bf9bc3df 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -28,13 +28,15 @@ "additionalProperties": false, "properties": { "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, - "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support.", "enum": [1] }, + "buildModelInputs": {"description": "Optional public model sharing between already enabled workspace plugins; no installation, private function, or process authority. Required exactly when modelSharing or modelInputs is used. Consumers require buildProviders 6. Version 1 producers cannot also consume inputs.", "enum": [1]}, + "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, version 4 application run preparation, and version 5 native debug preparation; version 6 optional read-only model inputs.", "enum": [1, 2, 3, 4, 5, 6] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, - "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1] }, + "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. Version 2 adds bounded target argument and environment bindings. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1, 2] }, "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, + "packageTrust": { "description": "Required exactly when vendor.bundledSignatureRequirement is present.", "enum": [1] }, "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, of debug adapters, and of the plugin backend. Version 2 adds runSelectedRuntime and is required exactly when a debug adapter uses it (pluginAPIVersion 4).", "enum": [1, 2] }, "settings": { "enum": [1] }, @@ -49,7 +51,11 @@ "properties": { "id": { "type": "string", "minLength": 1 }, "displayName": { "type": "string", "minLength": 1 }, - "signatureRequirement": { "enum": ["sameTeamAsHost", "publisherPackage", "none"] } + "signatureRequirement": { "enum": ["sameTeamAsHost", "publisherPackage", "none"] }, + "bundledSignatureRequirement": { + "description": "packageTrust 1: host-pinned official packages may trust the host's sealed bundled copy; all other origins require publisherPackage.", + "const": "sameTeamAsHost" + } } }, "permissions": { @@ -569,6 +575,8 @@ "cwd": { "const": "target.workingDirectory" }, + "args": { "const": "target.arguments" }, + "env": { "const": "target.environment" }, "connection": { "description": "Attach only. Where the remote target's host and port go. Unlike the other bindings, each value is the destination argument path, because adapters name and nest these differently.", "$ref": "#/$defs/debugConnectionBinding" @@ -2074,6 +2082,8 @@ "invocation" ], "properties": { + "modelSharing": {"type": "object", "additionalProperties": false, "properties": {"options": {"type": "array", "maxItems": 32, "uniqueItems": true, "items": {"type": "string"}}}, "description": "Opt into publishing normalized projects, targets and content roots, plus only these explicitly shared ordinary build option values. Never tasks, run plans, settings or optionValues."}, + "modelInputs": {"type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["pluginID", "buildSystemID"], "properties": {"pluginID": {"type": "string"}, "buildSystemID": {"type": "string"}}}, "description": "Exact optional producer slots, sorted by Core. Missing or disabled producers are unavailable and never installed or activated automatically."}, "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", @@ -2443,6 +2453,10 @@ "maxLength": 64 } }, + "source": { + "description": "Omitted expands argv in Core; provider sends lithe/build/execute. Provider tasks require buildProviders 2, empty arguments, and task execution.", + "enum": ["provider"] + }, "requiresUserArguments": { "type": "boolean" }, @@ -2473,6 +2487,15 @@ "id" ], "properties": { + "debug": { + "description": "Native executable debug preparation (buildProviders 5); same-package debugAdapters 2 launch adapter with typed program/cwd/args/env bindings.", + "type": "object", "additionalProperties": false, "required": ["adapter"], + "properties": { "adapter": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "maxLength": 64 } } + }, + "source": { + "description": "Provider-owned application preparation (buildProviders 4), fromTargets only, without host command templates.", + "enum": ["provider"] + }, "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", @@ -2571,6 +2594,14 @@ "arguments" ], "properties": { + "discovery": { + "type": "boolean", + "description": "Opt in to lithe/build/discoverTests; requires provider test source and suite/case filters." + }, + "source": { + "description": "Omitted expands argv; provider sends lithe/build/test, requires buildProviders 3, empty arguments, and plugin-owned report decoding.", + "enum": ["provider"] + }, "scopes": { "$ref": "#/$defs/buildScopes" }, diff --git a/shared/contracts/run-configuration-v2.schema.json b/shared/contracts/run-configuration-v2.schema.json index 6d4d258f3e..6869f15180 100644 --- a/shared/contracts/run-configuration-v2.schema.json +++ b/shared/contracts/run-configuration-v2.schema.json @@ -11,6 +11,7 @@ "required": ["fingerprint"], "properties": { "fingerprint": { "type": "string", "pattern": "^sha256:" }, + "unreadablePaths": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "inputs": { "type": "object", "propertyNames": { "type": "string", "minLength": 1 }, @@ -51,6 +52,56 @@ "type": "string", "pattern": "^(?!/)(?!.*(^|/)\\.\\.(?:/|$)).*$" }, + "buildProviderReference": { + "type": "object", + "required": [ + "pluginID", + "buildSystemID", + "root", + "run", + "projectID", + "targetID" + ], + "properties": { + "pluginID": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "buildSystemID": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "root": { + "$ref": "#/$defs/relativePath" + }, + "run": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "projectID": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "targetID": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "userArguments": { + "type": "array", + "maxItems": 64, + "items": { + "type": "string", + "maxLength": 4096 + } + } + }, + "additionalProperties": false + }, "configuration": { "type": "object", "required": ["id", "provider"], @@ -90,7 +141,25 @@ "disabled": { "type": "boolean" }, "source": { "type": "string" } }, - "additionalProperties": false + "additionalProperties": false, + "allOf": [{ + "if": { "properties": { "provider": { "const": "build.provider" } } }, + "then": { + "required": ["extensions"], + "properties": { + "execution": { "const": "application" }, + "command": false, + "args": { "maxItems": 0 }, + "env": { "maxProperties": 0 }, + "toolchains": { "maxProperties": 0 }, + "debug": false, + "extensions": { + "required": ["build"], + "properties": { "build": { "$ref": "#/$defs/buildProviderReference" } } + } + } + } + }] } } } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index f687838814..8656cc36b6 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -952,6 +952,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `editor.lineEdit` | Apply a deterministic line-level text transform and return the replacement plus selection to restore | | `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | | `workspace.snapshot` | Enumerate visible workspace nodes and relative file paths; `nestedWorktrees` lists linked worktrees checked out inside their own repository, which are left out of the tree, files, and search index ; `includeTree: false` returns only `files` and a childless `root`; `files` (and the tree, when included) keep the lowercase traversal order that bounded consumers such as Windows Quick Open rely on, not the natural display order of `workspace.listDirectories` | +| `workspace.filterChanges` | Filter an array of workspace-relative paths against Core's default build/cache exclusions; preserve order, spelling, and the empty root path. Native event loss bypasses filtering. Hosts relativize to the most specific explicit watch root and retain explicitly watched files. | | `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `shared/fixtures/workspace/file-name-order-v1.json`) | | `workspace.repositories` | Discover deterministic Git repository roots for an opened workspace | | `workspace.search` | Search visible file names and UTF-8 text files | @@ -968,6 +969,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `history.delete` | Delete one history entry and its snapshot | | `maven.scan` | Parse a Maven project descriptor and recursively return modules/profiles | | `maven.launchPlan` | Produce a deterministic Maven invocation from a versioned project context | +| `execution.resolveEnvironment` | Merge native `inherited` string values, selected `javaHome`/`mavenJavaHome` for the current `toolchain`, and `overrides` into `{ environment }`. `caseInsensitive: true` canonicalizes Windows keys to uppercase. Maven uses Maven JDK with project-JDK fallback; Java uses project JDK. String overrides win; structured `{ toolchain, property: "home" }` bindings use the current step's home for JAVA_HOME, and the named toolchain for other variables. Unknown bindings are omitted. | | `execution.planLaunchCommand` | Move oversized Java path-list options into a JDK argument file | | `processLifecycle.resolve` | Decide how each process or resource of one Lithe instance ends | | `processLifecycle.inventory` | Group a process snapshot of one Lithe instance into terminals, Agent tasks and background services | @@ -1111,10 +1113,17 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.detect` | Find every build-system instance the enabled plugins declare in a workspace, with models that need no process | | `build.invocationPlan` | List a build system's executable candidates, runtime dependencies, and version probe for one platform | | `build.checkToolVersion` | Decide whether a build tool's or runtime's version output meets the declared minimum | +| `build.resolveModelInputs` | Validate enabled producer facts and project a bounded public model snapshot for a declared consumer | | `build.resolveOptions` | Check stored build options against the declaration and return effective values, choices, and the fingerprint | | `build.modelPlan` | Expand the command that prints a command-sourced build model, or the request a build provider answers | | `build.taskPlan` | Expand one declared or model task for a workspace, project, or target | -| `build.testPlan` | Expand a test run for a scope, suite, or case and say where its reports will be | +| `build.testPlan` | Plan a test run for a scope, suite, or case and say where its reports will be | +| `build.testDiscoveryPlan` | Plan opt-in Provider test discovery for an instance | +| `build.decodeTestDiscovery` | Validate, deduplicate and order Provider test selectors | +| `build.prepareRunPlan` | Plan Provider-owned application preparation from a model target | +| `build.decodeRunPreparation` | Validate portable launch data before the native Run host starts a process | +| `build.prepareDebugPlan` | Plan same-plugin native debug preparation (Provider v5) | +| `build.decodeDebugPreparation` | Validate an existing instance-contained native debug program and bounded process facts | | `build.dependencyPlan` | Expand the command that prints a project's dependency tree, or the request a build provider answers | | `build.decodeModel` | Decode model output or a provider result into projects, targets, tasks, content roots, option values, and run entries | | `build.decodeTestResults` | Decode a test run's reports and output into per-case results and a summary | @@ -2727,17 +2736,28 @@ and virtual-location representation as ordinary navigation. `lsp.buildRequest` accepts `{ sessionId, operationId?, method, params, timeoutMilliseconds? }` and returns `{ operationId }`. `method` and `params` -are the `provider` field of a `build.modelPlan` or `build.dependencyPlan` -result; only `lithe/build/model` and `lithe/build/dependencies` are accepted, -and anything else is `invalid_request`. Every session offers -`capabilities.experimental.litheBuild: { versions: [1] }` in `initialize`; a +are the `provider` field of a `build.modelPlan`, `build.dependencyPlan` or +provider-sourced `build.taskPlan`/`build.testPlan`, `build.testDiscoveryPlan`, or +`build.prepareRunPlan` result. Version 1 accepts +`lithe/build/model` and `lithe/build/dependencies`; version 2 adds +`lithe/build/execute`, version 3 adds `lithe/build/test` with the same bounded +execution lifecycle and `lithe/build/discoverTests` with the raw result lifecycle. +Version 4 adds `lithe/build/prepareRun`, with execution progress but a raw launch +plan passed to `build.decodeRunPreparation` before native process startup. Version 5 adds `lithe/build/prepareDebug`: raw native executable facts pass through `build.decodeDebugPreparation` and the existing DebugAdapter Registry, not private functions or a second DAP engine. +Other methods are `invalid_request`; tasks on version 1 +and tests on versions 1/2 or run preparation on versions 1/2/3 are `not_supported`. +Every session offers +`capabilities.experimental.litheBuild: { versions: [4, 3, 2, 1] }` in `initialize`; a session whose process did not answer one of those versions rejects the request with `not_supported`, so a shared provider that does not speak the protocol still serves its language. `timeoutMilliseconds` replaces the session's request timeout for this request and is capped at one hour. The `requestCompleted` event carries the provider's reply unchanged as `result.value`, which the host passes to `build.decodeModel` or `build.decodeDependencies` as `result`; -cancellation and late results follow `lsp.cancelOperation`. +cancellation and late results follow `lsp.cancelOperation`. Execution replies +are validated as `{exitCode: Int32, output: string}` and progress is mapped +from a Core-owned wire request ID to `requestProgress` for the still-pending +operation. See the [provider execution contract](plugins/v1/README.md#provider-task-execution-buildproviders-2). `lsp.vcsRequest` accepts `{ sessionId, operationId?, vcs, operation, root, tool?, arguments?, timeoutMilliseconds? }` and returns `{ operationId }`. @@ -3053,6 +3073,22 @@ the sources of generated Java entries by content (`sha256:` in editing a class body is not a staleness. When the stored inputs no longer reproduce the stored fingerprint, the document came from another generator revision and the diagnostic says so instead of listing modified files. +Automatic run discovery, fingerprint traversal, Node manifest discovery and the +Windows Java source list use Core's shared scan policy. Known caches/build outputs +are pruned before descending; other hidden source directories remain visible and +symlinks are not followed. Failure to read the workspace root is an error. An +unreadable optional child or fingerprint input is recorded in +`generator.unreadablePaths`; generation retains previous configurations and input +hashes beneath those unknown paths. Inspection returns `incompleteProjectScan` +instead of claiming fingerprint freshness or inferred removals when either the +current scan or saved baseline is incomplete. The persisted diagnostic also appears +with `checkFingerprint: false`; a successful complete regeneration clears it. +This does not replace declared build-system modules or JDT's entrypoint facts. +The bounded Java source list stops opening further directories once its source +limit is reached, retains only collected Java paths, and returns them sorted. +Full fingerprint scans remain uncapped by this list limit and keep reporting +unreadable inputs independently. + `runConfig.inspect` also accepts optional schema-versioned `javaEntrypoints` from the current `lsp.request` result, independent of `checkFingerprint`. Core then compares JDT's `(sourcePath, mainClass)` pairs, with a `module/` prefix @@ -3149,6 +3185,21 @@ object in the local layer (e.g. provides defaults for every configuration's `extensions.java.*`. A non-empty per-configuration toolchain path overrides the corresponding project default. +Provider applications can be explicitly saved with `runConfig.createUserConfiguration` +using `{root,scope:"local",name,type:"buildProvider",build:{pluginID,buildSystemID, +root,run,projectID,targetID,userArguments?}}`. Core upserts the existing local +reference by selector identity and retains its `user:` ID, unrelated configurations +and project toolchain defaults. `extensions.build` in Run v2 holds the typed +reference; process commands, top-level args/environment/toolchains/debug are +refused for `build.provider`. Current model membership is revalidated by the +native build workflow before every preparation. No generated document is needed: +`runConfig.inspect` recognizes explicit saved local references and `runConfig.resolve` +merges them against an empty generated layer. A workspace without generated data +or an explicit saved Provider reference retains the existing missing state. +The platform guards the source snapshot, atomically writes only the local layer, +then re-reads the result. `shared/fixtures/run-configuration/build-provider.json` +is the canonical persistence example. + `runConfig.updateOptions` and `runConfig.createUserConfiguration` are pure document transformations. They validate scope, paths, supported types, stable IDs, main classes, modules, and argument parsing, then return UTF-8 JSON in the @@ -3555,11 +3606,13 @@ has one build workflow and never branches on a build-system ID: Core plans each process, the host runs it with the discovered executable, and Core decodes what it printed or wrote. -A build system with a `provider` also requires `buildProviders: 1`. Core supports -validation and planning for that capability; a host advertises it only after -implementing Provider lifecycle and requests. macOS does not yet advertise it, -so its marketplace and install review reject Provider plugins while accepting -compatible declarative build systems. +A build system with a `provider` requires `buildProviders: 1`, version 2 when +a task uses `source: "provider"`, version 3 when tests do, or version 4 for +provider application run preparation, version 5 for native debug preparation, and +version 6 for optional read-only model inputs. Core validates and plans all six versions; +a host advertises each only after implementing its lifecycle and requests. +macOS advertises all six and routes provider tasks, tests and run preparation through +the existing workspace session. Prepared applications use the existing native Run host. Windows does not yet advertise Provider lifecycle support. Except `build.detect`, every command accepts `{ manifest, buildSystemID, request }`, revalidates the manifest, and answers @@ -3588,7 +3641,7 @@ Paths in requests are workspace-relative with `/` unless named absolute. `root` is the instance root from detection. Selected entities are `{ name, path?, tags?, execution?, properties? }` as the decoded model returned them. Plan requests share `{ root, options?, pluginDirectory?, -workspaceStateDirectory? }`; `options` are the stored values by option ID, +workspaceStateDirectory?, modelInputs? }`; `options` are the stored values by option ID, and the directories are absolute host paths used only as substitution text. | Command | Request | Result | @@ -3596,10 +3649,17 @@ and the directories are absolute host paths used only as substitution text. | `build.detect` | `{ workspaceRoot, plugins: [{ manifest }], options?: { : { : value } } }` | `detection`: `{ instances, rejected: [{ index, error }], shadowed: [{ pluginID, buildSystemID, by }] }`; each instance is `{ id, pluginID, buildSystemID, displayName, root, markers, modelSource, optionFingerprint }` plus `model` or `modelError` for `static` and `hostIntegration` sources; a `provider` source has neither | | `build.invocationPlan` | `{ root, platform: "macos" \| "linux" \| "windows" }` | `invocation`: `{ buildSystemID, root, executables, environmentInputs, runtimeDependencies, version, minimumVersion }` with project-file paths joined to `root`; every runtime dependency with a known probe, host-managed ones included, carries `version: { arguments }` | | `build.checkToolVersion` | `{ runtime?, output }` | `version`: `{ satisfied, version? }`; `runtime` checks that runtime dependency instead of the tool | +| `build.resolveModelInputs` | `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, model?, options?}]}]}` from installed enabled current-workspace sources | `modelInputs`: version 1 public snapshot with exact optional producer slots, state, normalized projects/targets/content roots, configuration fingerprint and explicitly shared options; no tasks/run/settings/private functions; see the [plugin contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/plugins/v1/README.md#optional-read-only-model-inputs-buildmodelinputs-1) | | `build.resolveOptions` | `{ options, modelValues? }` | `options`: `{ options: [{ id, displayName, kind, value, source, choices, allowCustom }], dropped, fingerprint }` | | `build.modelPlan`, `build.dependencyPlan` | plan fields plus `project?` (dependencies), `outputFile?`, and for provider sources `refresh?` (boolean) and `tool?: { executable }` (absolute path) | `plan`: `{ buildSystemID, root, arguments, environment, workingDirectory, execution, optionFingerprint, output, decoder, executesProjectCode, mayUseNetwork }`; for a `provider` source `{ buildSystemID, root, provider: { method, params }, decoder, executesProjectCode, mayUseNetwork, optionFingerprint }`, where `method` is `lithe/build/model` or `lithe/build/dependencies` and `params` is `{ buildSystemID, root, options, optionArguments, environment, project?, refresh?, tool? }`; `refresh` appears only when `true` and `tool` only when the host sent it | -| `build.taskPlan` | plan fields plus `task`, `project?`, `target?`, `modelTask?`, `userArguments?` | `plan` with `arguments`, `environment`, `workingDirectory`, `execution`, and `optionFingerprint` | -| `build.testPlan` | plan fields plus `project?`, `target?`, `suite?`, `case?`, `userArguments?`, `projects?` (the model's project directories) | `plan` with the task-plan fields and `results`: `null`, `{ decoder, reports }` with workspace-relative globs (for the selected project, or for the root and every listed project when none is selected), or `{ decoder: "hostIntegration" }`. A `suite` or `case` the templates cannot place on the command line under this selection, such as a case without its suite when the tool only addresses `Suite#case`, is `plugin.build.invalidSelection` rather than a wider run | +| `build.taskPlan` | plan fields plus `task`, `project?`, `target?`, `modelTask?`, `userArguments?` | command `plan` with `arguments`, `environment`, `workingDirectory`, `execution`, and `optionFingerprint`; a provider task instead returns `provider: {method: "lithe/build/execute", params}` with effective options, selected task/entities, user arguments and effect metadata | +| `build.testPlan` | plan fields plus `project?`, `target?`, `suite?`, `case?`, `userArguments?`, `projects?` (the model's project directories) | command plan or `provider: {method: "lithe/build/test", params}` (buildProviders 3), plus `results`: `null`, `{ decoder, reports }` with workspace-relative globs (for the selected project, or for the root and every listed project when none is selected), or command-only `{ decoder: "hostIntegration" }`. Command filters that templates cannot place, such as a case without its suite when the tool only addresses `Suite#case`, are `plugin.build.invalidSelection`; Provider filters remain separate typed values and must be declared supported | +| `build.testDiscoveryPlan` | common plan fields plus `refresh?`, `tool?`; no project/output file | `plan.provider` for `lithe/build/discoverTests`, only with `test.discovery: true` (Provider v3), using the same effective options and effect metadata | +| `build.decodeTestDiscovery` | common plan fields plus canonical `result` | `discovery.tests` with validated `{id, suite, case, project?, target?}` selectors; bounded at 10,000 items/32 MiB, deterministic and exactly runnable; malformed replies reject in full | +| `build.prepareRunPlan` | common plan fields, `run`, required `project`/`target`, `userArguments?`; no output file | `plan.provider` for `lithe/build/prepareRun` (Provider v4), selected declaration/tags/paths/options validated | +| `build.decodeRunPreparation` | `workspaceRoot`, instance `root`, raw `result` | `launch` with portable `{executable:{kind,value},arguments,workingDirectory,environment}`; bounded strings and workspace directory containment, `plugin.build.invalidOutput` on invalid result | +| `build.prepareDebugPlan` | same selected fields as `build.prepareRunPlan`; run declares same-plugin `debug.adapter` | `plan.provider` for `lithe/build/prepareDebug` (v5); no cross-package reference | +| `build.decodeDebugPreparation` | `workspaceRoot`, instance `root`, selected `run`, raw `result` | `preparation` with declaration-owned `adapter`, normalized `root`, `program`, `arguments`, `workingDirectory`, `environment`; program is an existing regular file contained in the instance after canonicalization | | `build.decodeModel` | `{ workspaceRoot, root, output?, outputFile?, result?, options? }`; `result` is the provider's JSON answer | `model`: `{ projects, targets, tasks, contentRoots, optionValues, run }`; `run` entries carry `buildSystem: { id, root, projectID, targetID?, optionFingerprint }` | | `build.decodeTestResults` | `{ workspaceRoot, root, project?, projects?, output, notBeforeMillis?, suite? }`; without `project` the reports of the root and every listed model project are read | `results`: `{ tests: [{ suite, case, status, message?, durationMillis?, path?, line?, invocations }], summary: { total, passed, failed, errors, skipped }, success }`; repeated invocations of one case merge into the most severe status | | `build.decodeDiagnostics` | `{ workspaceRoot, root, output }` | `diagnostics`: `[{ path, line, column?, severity, message }]`, deduplicated, inside the workspace only | diff --git a/shared/fixtures/debug/adapter-selection-v1.json b/shared/fixtures/debug/adapter-selection-v1.json new file mode 100644 index 0000000000..b265a65b53 --- /dev/null +++ b/shared/fixtures/debug/adapter-selection-v1.json @@ -0,0 +1,118 @@ +{ + "format": "litheDebugAdapterSelection", + "version": 1, + "providers": [ + { + "id": "c", + "displayName": "Default C", + "fileExtensions": [ + "c" + ], + "selectsByFile": true, + "requests": [ + "launch", + "attach" + ] + }, + { + "id": "dev.example.native/lldb", + "displayName": "Native", + "fileExtensions": [ + "c" + ], + "selectsByFile": false, + "requests": [ + "launch" + ] + }, + { + "id": "dev.example.attach/native", + "displayName": "Attach", + "fileExtensions": [ + "c" + ], + "selectsByFile": false, + "requests": [ + "attach" + ] + } + ], + "cases": [ + { + "id": "file-default", + "target": "main.c", + "configuration": { + "name": "file-default", + "request": "launch", + "arguments": {} + }, + "expectedProviderID": "c" + }, + { + "id": "qualified-file", + "target": "main.c", + "configuration": { + "name": "qualified-file", + "request": "launch", + "arguments": {}, + "adapterProviderID": "dev.example.native/lldb" + }, + "expectedProviderID": "dev.example.native/lldb" + }, + { + "id": "qualified-project", + "target": ".", + "configuration": { + "name": "qualified-project", + "request": "launch", + "arguments": {}, + "adapterProviderID": "dev.example.native/lldb" + }, + "expectedProviderID": "dev.example.native/lldb" + }, + { + "id": "missing-qualified", + "target": "main.c", + "configuration": { + "name": "missing-qualified", + "request": "launch", + "arguments": {}, + "adapterProviderID": "dev.example.missing/native" + }, + "error": "unavailable" + }, + { + "id": "empty-explicit", + "target": "main.c", + "configuration": { + "name": "empty-explicit", + "request": "launch", + "arguments": {}, + "adapterProviderID": "" + }, + "error": "unavailable" + }, + { + "id": "unsupported-request", + "target": "main.c", + "configuration": { + "name": "unsupported-request", + "request": "launch", + "arguments": {}, + "adapterProviderID": "dev.example.attach/native" + }, + "error": "unsupported" + }, + { + "id": "explicit-attach", + "target": ".", + "configuration": { + "name": "explicit-attach", + "request": "attach", + "arguments": {}, + "adapterProviderID": "dev.example.attach/native" + }, + "expectedProviderID": "dev.example.attach/native" + } + ] +} diff --git a/shared/fixtures/debug/v1-baseline.json b/shared/fixtures/debug/v1-baseline.json index e706675c2d..463246dfce 100644 --- a/shared/fixtures/debug/v1-baseline.json +++ b/shared/fixtures/debug/v1-baseline.json @@ -504,6 +504,30 @@ } ], "gaps": [] + }, + { + "id": "explicit-adapter-routing", + "behavior": "host-selected adapter identities survive configuration transforms and retry; missing registrations and unsupported requests never fall back to a matching source-file adapter; fileless targets and concurrent matching adapters retain session ownership", + "status": "covered", + "evidence": [ + { + "platform": "macos", + "path": "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", + "test": "adapterSelectionMatchesTheSharedHostFixture", + "fixture": "shared/fixtures/debug/adapter-selection-v1.json" + }, + { + "platform": "macos", + "path": "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", + "test": "explicitFeatureRetryKeepsItsAdapterAndFailureCleansUpOnlyItsNewSession" + }, + { + "platform": "macos", + "path": "macos/Tests/LitheTests/PluginLiveActivationTests.swift", + "test": "qualifiedAdaptersKeepEveryPackageAndNeverBecomeFileDefaults" + } + ], + "gaps": [] } ] } diff --git a/shared/fixtures/execution/launch-environment-v1.json b/shared/fixtures/execution/launch-environment-v1.json new file mode 100644 index 0000000000..7d4df56a14 --- /dev/null +++ b/shared/fixtures/execution/launch-environment-v1.json @@ -0,0 +1,145 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "maven-project-selection", + "request": { + "toolchain": "project-maven", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": {}, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "jdk21" + } + } + }, + { + "name": "java-project-selection", + "request": { + "toolchain": "project-jdk", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": {}, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "jdk17" + } + } + }, + { + "name": "explicit-user-selection", + "request": { + "toolchain": "project-maven", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": { + "JAVA_HOME": "user-jdk" + }, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "user-jdk" + } + } + }, + { + "name": "maven-plan-binding", + "request": { + "toolchain": "project-maven", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": { + "JAVA_HOME": { + "toolchain": "project-maven", + "property": "home" + } + }, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "jdk21" + } + } + }, + { + "name": "windows-key-alias", + "request": { + "toolchain": "project-maven", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": { + "java_home": "user-jdk" + }, + "caseInsensitive": true + }, + "expected": { + "environment": { + "JAVA_HOME": "user-jdk" + } + } + }, + { + "name": "non-java-keeps-inherited", + "request": { + "toolchain": "project-node", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": {}, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "jdk8" + } + } + }, + { + "name": "maven-resource-step-inherits-java-application-binding", + "request": { + "toolchain": "project-maven", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": { + "JAVA_HOME": { + "toolchain": "project-jdk", + "property": "home" + } + }, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "jdk21" + } + } + } + ] +} diff --git a/shared/fixtures/run-configuration/build-provider.json b/shared/fixtures/run-configuration/build-provider.json new file mode 100644 index 0000000000..ab32e689cf --- /dev/null +++ b/shared/fixtures/run-configuration/build-provider.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "reference": { + "pluginID": "dev.example.gradle", + "buildSystemID": "gradle", + "root": "app", + "run": "application", + "projectID": "project-id", + "targetID": "target-id", + "userArguments": [ + "a b", + "--literal=one two" + ] + }, + "name": "Run Main", + "unrelated": { + "version": 2, + "toolchain": { + "java": { + "homePath": "/fixture/jdk" + } + }, + "configurations": [] + }, + "expected": { + "inspectionStatus": "ready", + "configurationCount": 1, + "provider": "build.provider" + } +} diff --git a/shared/fixtures/workspace/change-filter-v1.json b/shared/fixtures/workspace/change-filter-v1.json new file mode 100644 index 0000000000..0db0a6c594 --- /dev/null +++ b/shared/fixtures/workspace/change-filter-v1.json @@ -0,0 +1,25 @@ +{ + "paths": [ + "", + "src/Main.java", + "target/classes/Main.class", + "TARGET/classes/Main.class", + "module/build/generated.java", + ".gradle/cache.bin", + "src/targeted/Main.java", + ".sources/Main.java", + ".lithe/run/generated.json", + "pom.xml", + "module/target/X.class", + "module/src/main/resources/application.yml" + ], + "expected": [ + "", + "src/Main.java", + "src/targeted/Main.java", + ".sources/Main.java", + ".lithe/run/generated.json", + "pom.xml", + "module/src/main/resources/application.yml" + ] +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 83b6e5d2c4..ad17009a1b 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9438,7 +9438,23 @@ "debugger.memory.changedBytes": "{arg1} bytes changed", "debugger.step.overInstruction": "Step Over Instruction", "debugger.step.intoInstruction": "Step Into Instruction", - "debugger.disassembly.stepsByLine": "This adapter cannot step by instruction; steps still move by line" + "debugger.disassembly.stepsByLine": "This adapter cannot step by instruction; steps still move by line", + "plugins.java.installNotice": "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept.", + "plugins.java.installTemplates": "Install Java Support for Java, Maven and Spring templates…", + "run.incompleteProjectScanTitle": "Project scan is incomplete", + "run.incompleteProjectScanMessage": "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations.", + "native.theLanguageServerThisBuildSystemUsesIsNotAvailable": "The language server this build system uses is not available. Open a file of the language and try again.", + "native.loadModelBeforeWorkspaceTests": "Load or reload the project model before running all tests, so subproject reports are collected.", + "native.discoverTests": "Discover Tests", + "native.discoveredTests": "Discovered Tests", + "native.noTestsDiscovered": "No tests discovered.", + "native.invalidBuildProviderLaunchPlan": "The build provider returned an invalid launch plan.", + "native.saveProviderRunConfiguration": "Save provider run configuration to .lithe/run/local.json", + "native.providerRunSavingUnavailable": "Saving build provider run configurations is unavailable.", + "native.loadProviderModelBeforeRun": "Load the current build model and enable its provider before running this configuration.", + "native.providerRunSaveConflict": "Run configuration changed while saving. Reload and try again.", + "native.savedProviderRunUnavailable": "The saved provider run configuration is unavailable.", + "native.providerDebugUnavailable": "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 258c711857..611c8cf7f6 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9438,7 +9438,23 @@ "debugger.memory.changedBytes": "{arg1} 字节已变化", "debugger.step.overInstruction": "按指令单步跳过", "debugger.step.intoInstruction": "按指令单步进入", - "debugger.disassembly.stepsByLine": "该适配器不支持按指令单步,单步仍按源码行前进" + "debugger.disassembly.stepsByLine": "该适配器不支持按指令单步,单步仍按源码行前进", + "plugins.java.installNotice": "Java Support 现已独立安装。请从插件市场安装,或使用“从磁盘安装”导入离线包。原有 Java 设置会保留。", + "plugins.java.installTemplates": "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…", + "run.incompleteProjectScanTitle": "项目扫描不完整", + "run.incompleteProjectScanMessage": "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。", + "native.theLanguageServerThisBuildSystemUsesIsNotAvailable": "此构建系统使用的语言服务器不可用。请打开该语言的文件后重试。", + "native.loadModelBeforeWorkspaceTests": "运行全部测试前,请加载或重新加载项目模型,以便收集子项目的测试报告。", + "native.discoverTests": "发现测试", + "native.discoveredTests": "已发现的测试", + "native.noTestsDiscovered": "未发现测试。", + "native.invalidBuildProviderLaunchPlan": "构建提供方返回了无效的启动计划。", + "native.saveProviderRunConfiguration": "将提供方运行配置保存到 .lithe/run/local.json", + "native.providerRunSavingUnavailable": "当前无法保存构建提供方运行配置。", + "native.loadProviderModelBeforeRun": "请先加载当前构建模型并启用对应提供方,再运行此配置。", + "native.providerRunSaveConflict": "保存期间运行配置已更改。请重新加载后再试。", + "native.savedProviderRunUnavailable": "已保存的提供方运行配置不可用。", + "native.providerDebugUnavailable": "构建插件的调试程序或适配器不可用。请重新构建,并检查插件是否已启用。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 2901e6bcc8..fc16d1b78f 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -89944,6 +89944,184 @@ "key": "This adapter cannot step by instruction; steps still move by line", "arguments": [] } + }, + { + "id": "plugins.java.installNotice", + "description": "macOS Java Support independent distribution installation entry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept.", + "arguments": [] + } + }, + { + "id": "plugins.java.installTemplates", + "description": "macOS Java Support independent distribution installation entry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install Java Support for Java, Maven and Spring templates…", + "arguments": [] + } + }, + { + "id": "run.incompleteProjectScanTitle", + "description": "Run: incomplete workspace scan warning", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Project scan is incomplete", + "arguments": [] + } + }, + { + "id": "run.incompleteProjectScanMessage", + "description": "Run: incomplete workspace scan warning", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations.", + "arguments": [] + } + }, + { + "id": "native.theLanguageServerThisBuildSystemUsesIsNotAvailable", + "description": "Native UI: The language server this build system uses is not available. Open a file of the language and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The language server this build system uses is not available. Open a file of the language and try again.", + "arguments": [] + } + }, + { + "id": "native.loadModelBeforeWorkspaceTests", + "description": "Native UI: Load or reload the project model before running all tests, so subproject reports are collected.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load or reload the project model before running all tests, so subproject reports are collected.", + "arguments": [] + } + }, + { + "id": "native.discoverTests", + "description": "Native UI: Discover Tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discover Tests", + "arguments": [] + } + }, + { + "id": "native.discoveredTests", + "description": "Native UI: Discovered Tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discovered Tests", + "arguments": [] + } + }, + { + "id": "native.noTestsDiscovered", + "description": "Native UI: No tests discovered.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No tests discovered.", + "arguments": [] + } + }, + { + "id": "native.invalidBuildProviderLaunchPlan", + "description": "Native UI: Invalid build provider launch plan", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build provider returned an invalid launch plan.", + "arguments": [] + } + }, + { + "id": "native.saveProviderRunConfiguration", + "description": "Native UI: Save provider run configuration to .lithe/run/local.json", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save provider run configuration to .lithe/run/local.json", + "arguments": [] + } + }, + { + "id": "native.providerRunSavingUnavailable", + "description": "Native UI: Saving build provider run configurations is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saving build provider run configurations is unavailable.", + "arguments": [] + } + }, + { + "id": "native.loadProviderModelBeforeRun", + "description": "Native UI: Load the current build model and enable its provider before running this configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load the current build model and enable its provider before running this configuration.", + "arguments": [] + } + }, + { + "id": "native.providerRunSaveConflict", + "description": "Native UI: Provider run save conflict", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run configuration changed while saving. Reload and try again.", + "arguments": [] + } + }, + { + "id": "native.savedProviderRunUnavailable", + "description": "Native UI: Saved provider selection unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "The saved provider run configuration is unavailable.", + "arguments": [] + } + }, + { + "id": "native.providerDebugUnavailable", + "description": "Native UI: Provider debug target unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json b/shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json new file mode 100644 index 0000000000..76e865d7f3 --- /dev/null +++ b/shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json @@ -0,0 +1,30 @@ +{ + "id": "build-provider-readonly-model-inputs", + "area": "扩展", + "group": "插件与主题", + "capability": "已启用的构建 Provider 可显式发布只读项目、目标和内容目录及选定普通构建选项;消费者声明精确可选来源,由宿主/Core 提供有界快照;配置、模型、插件或工作区变化撤销旧输入,并拒绝旧回复,协议 6 独立准入", + "macos": { + "evidence": [ + "rust/lithe-core/src/build/model_inputs.rs", + "rust/lithe-core/src/tests/build_systems.rs", + "shared/contracts/plugins/v1/fixtures/build-model-producer.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-model-consumer.plugin.json", + "macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift", + "macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift", + "macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift", + "macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/build/model_inputs.rs", + "shared/contracts/plugins/v1/plugin-manifest.schema.json" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Extensions", + "verification": "本地运行 Core model_inputs 测试、BuildSystemServiceTests 和 RustBuildSystemOperationsTests:核对精确来源、只读字段投影、选项指纹、不可用/过期状态、路径/大小边界、协议 5 拒绝、协议 6 接收,以及生产者重载/选项 ABA/停用/构建文件修改/工作区替换后忽略取消的旧结果不能发布。在 lithe-plugin-docs 使用当前工作区快照执行 Node/Java 两个真实后端的模型共享端到端场景,核对插件包字节不变。手动验收仍需安装一对正式插件,在原生窗口加载生产者与消费者,再修改选项、禁用生产者并确认旧模型不能启动任务;当前 Java/Maven 插件尚未迁移,Windows 宿主缺失,均不视为通过实机验收。 并行生产者回归:同时暂停 A/B 模型回复,按两种顺序释放,确认二者均非过期且消费者收到两个 ready 模型;生产者自身选项 ABA 仍使旧回复 stale。" +} diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index b2bc106b4f..1388c75971 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -2,7 +2,7 @@ "id": "build-system-plugins", "area": "扩展", "group": "插件与主题", - "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退", + "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退;buildProviders 2/3 原生任务与测试执行复用工作区后端会话,带进度、取消、结果与配置失效隔离;协议 3 可按声明发现测试并在现有构建树按精确选择器运行,源码/设置变化撤销旧清单;协议 4 可为应用目标准备启动计划,Core 校验后交给原生 Run 管理进程;Provider 应用可保存到本地 Run v2 配置并重新准备后重启;插件自有原生程序经 Provider v5 构建并准备 Debug,Build 树和保存的 Run 配置复用原生调试会话", "macos": { "evidence": [ "rust/lithe-core/src/build", @@ -28,7 +28,33 @@ "rust/lithe-core/src/plugins/executables.rs", "macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift", "macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift", - "macos/Tests/LitheTests/MacBuildToolResolverTests.swift" + "macos/Tests/LitheTests/MacBuildToolResolverTests.swift", + "shared/contracts/plugins/v1/fixtures/build-provider-execution.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-execution-protocol.json", + "macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift", + ".agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md", + "shared/contracts/plugins/v1/fixtures/build-provider-tests.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-test-protocol.json", + "rust/lithe-core/src/build/test_discovery.rs", + "shared/contracts/plugins/v1/fixtures/build-test-discovery.json", + "rust/lithe-core/src/build/run_preparation.rs", + "shared/contracts/plugins/v1/fixtures/build-provider-run-preparation.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-run-preparation.json", + "macos/Sources/LitheExecutionModule/Services/RunService.swift", + "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift", + "shared/fixtures/run-configuration/build-provider.json", + "shared/contracts/run-configuration-v2.schema.json", + "macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift", + "macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift", + "macos/Tests/LitheTests/IdeCapabilitiesTests.swift", + "rust/lithe-core/src/build/debug_preparation.rs", + "shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json", + "macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift", + "macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift", + "macos/Tests/LitheTests/MacNativeProviderDebugIntegrationTests.swift", + "macos/Tests/LitheTests/ProviderWorkflowPresentationTests.swift", + "docs/assets/provider-workflow-review/README.md" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -43,5 +69,5 @@ "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实 Maven 模块编译与单方法测试已有生产 macOS Provider 会话证据;Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。 原生 Debug:运行声明引用同包适配器,点击 Build 树 Debug 或保存配置的 Debug 后先保存并构建,验证程序路径/参数/环境交给注册表;停止、源码或配置变化、模型重载、插件停用与工作区切换均丢弃过期结果,解码后的文件别名变化和不可执行文件在平台交接前拒绝。Node/Java SDK 进程的准备协议与 Core 解码有端到端覆盖;真实适配器、Java/JDT、跨插件协作和 Windows 尚未验收。 Maven 独立插件的本地增量已经删除生命周期/插件目标/自定义目标/测试的命令模板,改由 Provider 3 执行;真实 Core C ABI + Java 后端 + Maven 三模块工程验证模块编译、单方法 Surefire 报告和模型共享,受控进程回归覆盖取消、非零退出和输出超限。插件与新版 SDK 已分别提交 Draft PR #7/#4,尚未发布,正式 SDK 锁定仍待更新;生产 macOS Provider 会话已经执行模型、编译和单方法测试,包 SHA-256/权限保持不变;显式 SDK CI 在 Java 8 上通过当前与 3.6.3 Maven 的 55 项开发检查(各自包括 3 项打包);旧 MavenService、Agent/设置/Run/Java 迁移、正式包、完整 macOS UI 与 Windows 验收未完成,保持 partial/pending。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 Build 树应用从文档保存阶段起持有同一可取消运行 ID;保存 gate 下启动新任务/测试或停止、切换工作区、选项/源码/模型/插件失效后,旧保存返回不得启动 Provider、替换新运行或清空新输出;保存失败不启动,成功后才准备。 原生视图隔离回归用真实 Core 与受控 Provider 数据检查深浅主题的测试发现、Debug 入口和 Run 输出;截图仅验证该视图状态,不代替完整发行包或 Windows 实机验收。" } diff --git a/shared/platform-feature-matrix/features/file-explorer.json b/shared/platform-feature-matrix/features/file-explorer.json index de083479d0..a472e5c927 100644 --- a/shared/platform-feature-matrix/features/file-explorer.json +++ b/shared/platform-feature-matrix/features/file-explorer.json @@ -27,7 +27,8 @@ "macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift", "macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift", "shared/fixtures/workspace/file-name-order-v1.json", - ".agents/notes/implemented/bug-fix/2026-10-22-project-tree-natural-name-order.md" + ".agents/notes/implemented/bug-fix/2026-10-22-project-tree-natural-name-order.md", + "shared/fixtures/workspace/change-filter-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -47,12 +48,14 @@ "windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx", "windows/tauri/src/features/file-explorer/components/file-explorer-pane.tsx", "windows/tauri/src/features/file-explorer/hooks/use-file-explorer-drag-drop.test.tsx", - "windows/tauri/src/features/sidebar/utils/tree-selection.test.ts" + "windows/tauri/src/features/sidebar/utils/tree-selection.test.ts", + "rust/lithe-core/src/project/files.rs", + "shared/fixtures/workspace/change-filter-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Workspace", - "verification": "浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 macOS 文件树、标签页与面包屑共用 TOML 文件图标:亮色使用 IDEA New UI 官方浅底蓝色 T,深色使用深底蓝色 T;检查明暗主题切换后选择对应 SVG,保留原始 16pt 几何与颜色。 点击目录名称后上下键按可见行移动选择,右键展开目录并再次右键进入首个子项,左键折叠或返回显示父项;双击目录名称展开/折叠,单击与修饰键多选保持独立。验证压缩 Java 包、列表首尾、Shift 上下范围选择与自动滚动;方向键不送到编辑器,点击编辑器或转移到搜索输入框后恢复其按键处理。 Windows Project 文件树滚轮与 Git Log 共用 Community Windows 默认 200ms 惯性过渡;核对连续同向、反向、Shift 横向滚动、顶底边界、键盘定位与滑块拖动取消惯性、卸载帧清理和减少动态效果偏好;虚拟树保持可见区呈现,真实 WebView2 实机验收 pending。 macOS 项目文件和目录选中行共用 LitheTheme.Tree 的主题聚焦色与灰色失焦色;点击编辑器、其他工具窗口后失焦,键盘转移到搜索输入框尚未输入时失焦,重新点击树恢复,整个窗口失焦时保持灰色。深浅主题、非默认主题、多选、上下键和右键菜单均需核对;组件像素和焦点回归不能替代完整应用验收。 Windows 在默认及舒适密度下比较 Project 与 Dependencies 树正文,再修改界面字号确认同步;深浅主题检查刷新图标为循环箭头,点击仍刷新目录。 Windows 打开深层文件并折叠祖先,点击定位当前文件,检查展开后滚动到目标行;滚动离开后重复点击同一文件仍定位;编辑器切换页签不得让树自动滚动。延迟目录加载完成后再滚动,旧请求完成不改变新位置;树内搜索和 Dependencies 视图下定位应回到完整 Project 树。 Windows 搜索首次命中和 Enter 切换后复制/剪切应使用当前命中;同时选择父目录、后代与其他文件后右键后代,复制/删除仍处理整个选择且父子只操作一次。原生移动等待期间关闭 Project 或切换工作区,成功项同步原工作区并停止后续项。受控 WSL/远程/POSIX 路径用例保留大小写不同的文件与目录,Windows 本地/普通 UNC 同名目标继续拒绝覆盖;这些用例不代表已开放 WSL/远程功能,真实 WebView2 工作流仍 pending。 Windows 编辑器保持 README 打开时点击目录名称,通过真实 FileExplorerPane、侧栏与缓冲区 store 验证路径同步往返后目录仍被选中,复制/剪切、键盘焦点和 Shift 范围锚点仍属于目录;实际切换编辑文件时单选跟随,明确多选保留。 Windows 批量拖动分别在目录预检、原生移动和原工作区同步等待期间切换 A→B→A,确认旧批次不再派发剩余项,已完成项仍同步原工作区;普通重渲染继续当前批次,旧移动失败不清除新拖动或显示过期错误。 macOS 打开含 .claude/worktrees 等嵌套 Git linked worktree 与 .artifacts 的仓库:项目树、文件列表与搜索索引不包含这些目录,子模块和位于主检出之外的工作树仍显示;嵌套工作树内的文件变化不触发刷新。外部新增/删除普通文件时树原地增删、不重新扫描整个工作区,新目录、压缩包结构变化时回退完整快照;定位当前文件按路径逐级查找,侧栏渲染不再遍历全部文件。 macOS 项目树按需列目录:快照只返回文件清单,目录展开时才列出下一层;检查首次打开大仓库只显示根层,展开、折叠后再展开、源码根下压缩 Java 包与全量构建一致;外部改动未展开目录下的文件不改变树,展开后可见;重新扫描后已展开目录保持展开并显示最新内容;定位当前文件会逐级加载祖先后滚动到目标行;新增或删除文件后先展开目录再处理监听事件,确认搜索等文件库存仍同步;压缩包重列期间发生其它文件增删,完成后库存和树都保留最新结果。 macOS 项目树同级排序对齐 IDEA 项目视图:目录先于文件,名称按自然顺序(数字按数值、忽略大小写、'-' 与 '_' 相邻);打开含 docs/releases/v0.1.4.md、v0.1.12.md、v0.1.14.md 的仓库,确认 v0.1.4 在 v0.1.12 之前,外部新建 v0.1.5.md 后原地插入到二者之间,与重新扫描的顺序一致。", + "verification": "浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 macOS 文件树、标签页与面包屑共用 TOML 文件图标:亮色使用 IDEA New UI 官方浅底蓝色 T,深色使用深底蓝色 T;检查明暗主题切换后选择对应 SVG,保留原始 16pt 几何与颜色。 点击目录名称后上下键按可见行移动选择,右键展开目录并再次右键进入首个子项,左键折叠或返回显示父项;双击目录名称展开/折叠,单击与修饰键多选保持独立。验证压缩 Java 包、列表首尾、Shift 上下范围选择与自动滚动;方向键不送到编辑器,点击编辑器或转移到搜索输入框后恢复其按键处理。 Windows Project 文件树滚轮与 Git Log 共用 Community Windows 默认 200ms 惯性过渡;核对连续同向、反向、Shift 横向滚动、顶底边界、键盘定位与滑块拖动取消惯性、卸载帧清理和减少动态效果偏好;虚拟树保持可见区呈现,真实 WebView2 实机验收 pending。 macOS 项目文件和目录选中行共用 LitheTheme.Tree 的主题聚焦色与灰色失焦色;点击编辑器、其他工具窗口后失焦,键盘转移到搜索输入框尚未输入时失焦,重新点击树恢复,整个窗口失焦时保持灰色。深浅主题、非默认主题、多选、上下键和右键菜单均需核对;组件像素和焦点回归不能替代完整应用验收。 Windows 在默认及舒适密度下比较 Project 与 Dependencies 树正文,再修改界面字号确认同步;深浅主题检查刷新图标为循环箭头,点击仍刷新目录。 Windows 打开深层文件并折叠祖先,点击定位当前文件,检查展开后滚动到目标行;滚动离开后重复点击同一文件仍定位;编辑器切换页签不得让树自动滚动。延迟目录加载完成后再滚动,旧请求完成不改变新位置;树内搜索和 Dependencies 视图下定位应回到完整 Project 树。 Windows 搜索首次命中和 Enter 切换后复制/剪切应使用当前命中;同时选择父目录、后代与其他文件后右键后代,复制/删除仍处理整个选择且父子只操作一次。原生移动等待期间关闭 Project 或切换工作区,成功项同步原工作区并停止后续项。受控 WSL/远程/POSIX 路径用例保留大小写不同的文件与目录,Windows 本地/普通 UNC 同名目标继续拒绝覆盖;这些用例不代表已开放 WSL/远程功能,真实 WebView2 工作流仍 pending。 Windows 编辑器保持 README 打开时点击目录名称,通过真实 FileExplorerPane、侧栏与缓冲区 store 验证路径同步往返后目录仍被选中,复制/剪切、键盘焦点和 Shift 范围锚点仍属于目录;实际切换编辑文件时单选跟随,明确多选保留。 Windows 批量拖动分别在目录预检、原生移动和原工作区同步等待期间切换 A→B→A,确认旧批次不再派发剩余项,已完成项仍同步原工作区;普通重渲染继续当前批次,旧移动失败不清除新拖动或显示过期错误。 macOS 打开含 .claude/worktrees 等嵌套 Git linked worktree 与 .artifacts 的仓库:项目树、文件列表与搜索索引不包含这些目录,子模块和位于主检出之外的工作树仍显示;嵌套工作树内的文件变化不触发刷新。外部新增/删除普通文件时树原地增删、不重新扫描整个工作区,新目录、压缩包结构变化时回退完整快照;定位当前文件按路径逐级查找,侧栏渲染不再遍历全部文件。 macOS 项目树按需列目录:快照只返回文件清单,目录展开时才列出下一层;检查首次打开大仓库只显示根层,展开、折叠后再展开、源码根下压缩 Java 包与全量构建一致;外部改动未展开目录下的文件不改变树,展开后可见;重新扫描后已展开目录保持展开并显示最新内容;定位当前文件会逐级加载祖先后滚动到目标行;新增或删除文件后先展开目录再处理监听事件,确认搜索等文件库存仍同步;压缩包重列期间发生其它文件增删,完成后库存和树都保留最新结果。 macOS 项目树同级排序对齐 IDEA 项目视图:目录先于文件,名称按自然顺序(数字按数值、忽略大小写、'-' 与 '_' 相邻);打开含 docs/releases/v0.1.4.md、v0.1.12.md、v0.1.14.md 的仓库,确认 v0.1.4 在 v0.1.12 之前,外部新建 v0.1.5.md 后原地插入到二者之间,与重新扫描的顺序一致。 外部构建产生超过 512 条 target/build 事件且混有源码变化时,只转发有效事件,不触发输出洪峰导致的整项目重扫;显式文件/生成源码监听、项目根名为 build、跨排除边界重命名均保留,系统丢失事件始终触发恢复重扫。", "notes": "Windows 补齐 Ctrl/Shift 多选、批量复制/剪切/删除、已选集合拖动、Project/Dependencies 切换、定位当前文件、刷新和隐藏。目录名称单击选择,双击或箭头展开;长名称水平滚动。目录标记按工作区持久化,并随移动、重命名和删除迁移;本地 Windows/UNC 按不区分大小写处理,POSIX、WSL 和远程标识保留大小写并隔离工作区。批量移动预检父子重复及重名目标,原生发布拒绝覆盖;真实 WebView2 的拖动与焦点验收 pending。 Windows 树正文统一使用 ui-text-sm(默认 13px),随界面字号设置变化,舒适密度不单独放大 Dependencies 等树行;Project 刷新按钮使用 IDEA refresh.svg。手动定位使用有代次的展开后滚动请求;编辑器切换页签不再自动跟随滚动(autoRevealActiveFileInFileTree 设置已退役);定位清除树内搜索并切回 Project,保持虚拟列表。 搜索导航同步选择与锚点,右键成员判断保留原始选择集合,批量目标再去除父子重复;已完成移动不因面板卸载或工作区切换而漏掉原工作区同步。路径去重与拖动预检按文件系统大小写语义比较,包含 WSL UNC 别名。编辑器活动文件变化时单选同步高亮、复制/剪切目标及范围锚点,明确多选保留;使用同步 Hook 的真实编辑器目标判断切换,选择目录引发的共享侧栏路径往返不改变选择;真实面板与 store 回归覆盖此链路。 批量移动按每次工作区访问识别生命周期,相同根路径的再次访问不能复活旧批次;预检、移动、同步和失败回调共用该守卫。 macOS 快照跳过嵌套在自身仓库中的 linked worktree(.git 文件指向 <主仓库>/.git/worktrees/*)并内置隐藏 .artifacts;项目树由 workspace.listDirectories 按需逐层列出,children 为 nil 表示目录未加载;平铺文件清单 projectFiles 仍完整保留供搜索等功能使用。 macOS 项目树同级排序由 Rust Core 的 visible_children 与 Swift 的 NaturalNameOrder 共同实现 IDEA FileNameComparator(共享 fixture file-name-order-v1.json);快照 files 与搜索索引按 ChildOrder::Traversal 保持原有的小写排序(Windows 快速打开取前 100 项依赖它),只有 workspace.listDirectories 使用自然顺序。Windows 项目文件树仍使用 localeCompare,Git 文件树已使用同源的 compareGitFileNames,Windows 项目树对齐待后续处理。" } diff --git a/shared/platform-feature-matrix/features/java-support-plugin.json b/shared/platform-feature-matrix/features/java-support-plugin.json index aad20b01e1..c75b843faa 100644 --- a/shared/platform-feature-matrix/features/java-support-plugin.json +++ b/shared/platform-feature-matrix/features/java-support-plugin.json @@ -2,7 +2,7 @@ "id": "java-support-plugin", "area": "语言支持", "group": "Java", - "capability": "Java 语言服务器作为随应用发行的声明式插件:JDT LS、Lombok、调试与测试扩展及其 JDK 由插件包拥有,可卸载和恢复;运行、测试、调试仍由宿主内置实现提供,由插件在 jdt 集成的 capabilities 中声明", + "capability": "Java 语言服务器作为随应用发行的声明式插件:JDT LS、Lombok、调试与测试扩展及其 JDK 由插件包拥有,可卸载和恢复;运行、测试、调试仍由宿主内置实现提供,由插件在 jdt 集成的 capabilities 中声明;支持 packageTrust 1 的宿主按来源校验内置副本与发布者签名更新,安装时验证自带运行时和入口;双架构独立发布配置已准备但尚未启用上架;同 ID 安装保留默认启用和显式禁用,卸载不重新启用内置副本;缺包按需提示与模板安装入口;完整双架构索引上架后构建自动省去内置 JDT/JDK", "macos": { "evidence": [ "Plugins/mac/Official/JavaSupport/plugin.json", @@ -15,7 +15,18 @@ "shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json", "macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift", "macos/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift", - "macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift" + "macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift", + "macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift", + "shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json", + "scripts/official-plugin-releases.mjs", + "Plugins/marketplace/official-plugins.json", + "macos/Sources/Lithe/Application/Features/JavaPluginTransition.swift", + "scripts/official-plugin-distribution.mjs", + "scripts/verify-macos-package.sh", + "macos/Tests/LitheTests/PluginLiveActivationTests.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift", + "macos/Tests/LitheTests/PluginInstallationGuidancePresentationTests.swift", + "docs/assets/java-installation-guidance/README.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -29,5 +40,5 @@ "verificationStatus": "not-applicable" }, "owner": "Java Support", - "verification": "macOS:全新安装后打开插件管理,确认 Java Support 已安装且默认启用;打开 Maven 项目并点开 .java 文件,确认语言服务器启动,补全、诊断、跳转到 JDK 与依赖 jar 中的类(jdt://)、Lombok 生成的方法、运行标记可用;在打包出的应用里逐项确认宿主能力:工具栏调试能停在断点并继续,测试面板列出并能运行 Java 测试,“当前文件”与 Java main 运行配置能启动(Spring Boot 等 Maven 配置走另一条路径,不能替代这一项);用 lsof 或进程参数确认 JDT LS 及其 java 进程来自 Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support,且应用内不存在 Contents/Resources/LanguageServers。卸载 Java Support 并重启:打开 .java 文件时提示需要安装或恢复该插件,语法高亮、Maven 面板和其他语言不受影响,/Lithe/PluginState 下该插件的目录被删除,app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。点击恢复并重启后 Java 功能恢复。从旧版本升级:首次打开 Java 项目会重建一次索引,之后正常。Windows:JDT LS 与 JDK 仍随应用安装目录分发,尚未接入该插件契约。" + "verification": "macOS:全新安装后打开插件管理,确认 Java Support 已安装且默认启用;打开 Maven 项目并点开 .java 文件,确认语言服务器启动,补全、诊断、跳转到 JDK 与依赖 jar 中的类(jdt://)、Lombok 生成的方法、运行标记可用;在打包出的应用里逐项确认宿主能力:工具栏调试能停在断点并继续,测试面板列出并能运行 Java 测试,“当前文件”与 Java main 运行配置能启动(Spring Boot 等 Maven 配置走另一条路径,不能替代这一项);用 lsof 或进程参数确认 JDT LS 及其 java 进程来自 Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support,且应用内不存在 Contents/Resources/LanguageServers。卸载 Java Support 并重启:打开 .java 文件时提示需要安装或恢复该插件,语法高亮、Maven 面板和其他语言不受影响,/Lithe/PluginState 下该插件的目录被删除,app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。点击恢复并重启后 Java 功能恢复。从旧版本升级:首次打开 Java 项目会重建一次索引,之后正常。Windows:JDT LS 与 JDK 仍随应用安装目录分发,尚未接入该插件契约。 独立分发第一阶段:PluginBundledPublisherTrustTests 使用真实 Core 和注入测试公钥覆盖内置副本、已签名更新、用户目录伪造 bundled 来源、缺失运行时与篡改拒绝;官方发布索引回归验证双架构哈希与 tag 对应 API/requires。正式发布包、Intel 真机和完整产品下载迁移仍待验收,releaseEnabled 保持 false。 后续迁移回归覆盖默认启用/显式禁用、跳过过渡版的安装、旧移除标记不清理新状态、卸载更新不恢复内置副本;缺包提示不触发自动安装。verify-macos-package.sh 的默认与 --slim 模式分别验证空索引保留资源及完整 fixture 索引省去资源;fixture 不代表真实市场已经上线。 安装入口与实时插件激活共用同一安装流程:普通、市场、磁盘安装各覆盖默认启用和显式禁用选择,成功后迁移并保留包字节,失败不写偏好;不改变其他插件的实时激活/撤回规则,Java 宿主集成仍等待重启。 缺包判断按安装状态而非快照 ID:真实安装→卸载→prepareForLaunch→重新扫描后,.removed 内置快照不再抑制 Java 文件和项目提示;已安装(含禁用)、暂存更新及仍待重启的卸载继续使用原管理流程。状态矩阵覆盖两种启用选择与四种 Java/Maven/Gradle 文件;深浅主题原生视图验证缺包通知与新建项目安装按钮,只在用户点击后发出市场搜索,不自动安装或执行工具。" } diff --git a/shared/platform-feature-matrix/features/plugin-debug-adapters.json b/shared/platform-feature-matrix/features/plugin-debug-adapters.json index cf6edf017c..e6fedd48e6 100644 --- a/shared/platform-feature-matrix/features/plugin-debug-adapters.json +++ b/shared/platform-feature-matrix/features/plugin-debug-adapters.json @@ -2,7 +2,7 @@ "id": "plugin-debug-adapters", "area": "运行与调试", "group": "调试器", - "capability": "插件提供调试适配器:声明式 debugAdapters 经共享 Core 校验,宿主按已安装插件拉起适配器、启动与附加调试、调试控制台提示符与补全,安装、更新、启停、卸载免重启生效", + "capability": "插件提供调试适配器:声明式 debugAdapters 经共享 Core 校验,宿主按已安装插件拉起适配器、启动与附加调试、调试控制台提示符与补全,安装、更新、启停、卸载免重启生效;原生启动配置保留明确适配器身份,支持无源文件目标及同语言多适配器的会话路由;debugAdapters v2 绑定受限的目标参数与环境,并接收同包 Provider 原生调试准备", "macos": { "evidence": [ "shared/contracts/plugins/v1/fixtures/debug-adapter.plugin.json", @@ -25,8 +25,14 @@ "macos/Tests/LitheTests/PluginDebugAdapterTests.swift", "macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift", "macos/Tests/LitheTests/DebugExecutableResolverTests.swift", + "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", + "shared/fixtures/debug/adapter-selection-v1.json", + "macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift", + "macos/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift", + "rust/lithe-core/src/build/debug_preparation.rs", + "macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift", "macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift", - "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift" + "macos/Tests/LitheTests/MacNativeProviderDebugIntegrationTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -42,6 +48,6 @@ "verificationStatus": "not-applicable" }, "owner": "Debug", - "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。声明 tcpLanguageServer 传输的适配器(调试服务随同一插件的语言服务器运行,如 Java Debug Server 之于 JDT LS)不由宿主拉起进程:宿主启动该插件声明的语言服务器,用 endpoint.command 向它要端口并连接 127.0.0.1,端口无效、语言服务器不属于该插件或不可用时会话以原因结束。从保存配置直接启动且未打开源码时也会激活、登记语言服务器并探测工具;停止调试取消端口命令而不停止共享语言服务器,拒绝工作区或会话替换后的旧端口。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 在迁入 Java Support 插件前仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。远程 attach 目前只有 Core 契约:attach 配置可声明 targetBindings.connection,Core 校验主机、端口与路径映射并按适配器自己的参数路径写入启动参数,非 localhost、127.0.0.0/8、::1 的端点返回 remote:true;macOS 宿主尚未接入 attach 与“附加到远程”对话框,因此本项暂不能在应用里验证远程附加,接入后补充:连接新的非回环端点前必须弹确认且默认不记住,附加结束只断开不结束远端程序。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。", + "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。声明 tcpLanguageServer 传输的适配器(调试服务随同一插件的语言服务器运行,如 Java Debug Server 之于 JDT LS)不由宿主拉起进程:宿主启动该插件声明的语言服务器,用 endpoint.command 向它要端口并连接 127.0.0.1,端口无效、语言服务器不属于该插件或不可用时会话以原因结束。从保存配置直接启动且未打开源码时也会激活、登记语言服务器并探测工具;停止调试取消端口命令而不停止共享语言服务器,拒绝工作区或会话替换后的旧端口。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 在迁入 Java Support 插件前仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。远程 attach 目前只有 Core 契约:attach 配置可声明 targetBindings.connection,Core 校验主机、端口与路径映射并按适配器自己的参数路径写入启动参数,非 localhost、127.0.0.0/8、::1 的端点返回 remote:true;macOS 宿主尚未接入 attach 与“附加到远程”对话框,因此本项暂不能在应用里验证远程附加,接入后补充:连接新的非回环端点前必须弹确认且默认不记住,附加结束只断开不结束远端程序。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。 原生宿主路由回归:使用 shared/fixtures/debug/adapter-selection-v1.json 验证自动文件选择、明确身份、无源文件目标、空身份、已撤回身份和 launch/attach 不匹配;同语言多个适配器分别收到断点,重试保持身份,附加会话启动失败保留原会话。这里只验证宿主交接与受控会话,Provider 原生程序准备 RPC 有受控流程与真实 SDK 进程覆盖;真实适配器的完整工作流与 Windows 接入仍未验收。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 与显式 DAP 端点同时使用时,先按当前 Registry 身份校验请求能力,再调用端点工厂;launch/attach 与默认/明确身份的组合回归覆盖不会回退到本地适配器,配置编码与步进变换保留两个字段。", "notes": "普通 TCP 生命周期与用户显式 DAP 端点入口见 debug-tcp-transport;不放宽插件 tcpLanguageServer 回环约束,tcpPort/tcpAnnounced 托管模式仍拒绝。" } diff --git a/shared/platform-feature-matrix/features/run-discovery.json b/shared/platform-feature-matrix/features/run-discovery.json index bb8eedbe17..db07f98587 100644 --- a/shared/platform-feature-matrix/features/run-discovery.json +++ b/shared/platform-feature-matrix/features/run-discovery.json @@ -12,7 +12,9 @@ "macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift", "macos/Tests/LitheTests/RunEntryPointTests.swift", "macos/Sources/LitheExecutionModule/Services/RunService.swift", - "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift" + "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift", + "rust/lithe-core/src/execution/workspace_scan.rs", + "rust/lithe-core/src/execution/configuration.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -38,12 +40,14 @@ "shared/fixtures/run-configuration", "windows/tauri/src/features/run/services/java-context-entrypoint.ts", "windows/tauri/src/features/run/services/java-main-launch.test.ts", - "rust/lithe-core/src/lsp/interface/engine.rs" + "rust/lithe-core/src/lsp/interface/engine.rs", + "rust/lithe-core/src/execution/workspace_scan.rs", + "rust/lithe-core/src/execution/configuration.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Run", - "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向 primary,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。", + "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向 primary,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。 缓存目录不可访问时仍可发现兄弟目录和隐藏源码;可选子目录不可读时保留其旧配置并显示 incompleteProjectScan,不误报删除或新鲜;根目录不可读仍失败,完整重新生成后解除未知基线。 两端 Run 面板显示扫描不完整的共享中英文提示;恢复访问权限后重新识别。 Java 文件枚举达到 8,000 条上限后不再访问后续目录,返回顺序稳定;不可读兄弟目录不阻断结果,完整指纹扫描仍继续遍历。", "notes": " Windows普通Java文件的main入口在工作区搜索遗漏时,先保存并让JDT对当前文件确认,再生成命名运行配置;拒绝过期入口、准备失败和工作区切换结果。Java入口源码按容错UTF-8读取(非法字节替换为占位符),GBK项目不再因单个非UTF-8文件导致整个运行列表生成失败,Spring Boot注解等ASCII分类标记不受影响。 macOS 运行就绪要求匹配快照且工具链和配置选择加载完成,加载中的清单不可作为启动许可;受控工具链与快照测试验证完成前后状态,以及 Current File 不被迟到的默认配置覆盖。" } diff --git a/shared/platform-feature-matrix/features/run-save-toolchain.json b/shared/platform-feature-matrix/features/run-save-toolchain.json index b6b4422589..4b57c01c2f 100644 --- a/shared/platform-feature-matrix/features/run-save-toolchain.json +++ b/shared/platform-feature-matrix/features/run-save-toolchain.json @@ -12,7 +12,9 @@ "macos/Sources/LitheExecutionModule/Services/RunService.swift", "macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift", "macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift", - "rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs" + "rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs", + "rust/lithe-core/src/execution/environment.rs", + "shared/fixtures/execution/launch-environment-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -26,11 +28,13 @@ "windows/tauri/src-tauri/src/run.rs", "windows/tauri/src/platform/lsp-core-adapter.ts", "windows/tauri/src/platform/lsp-core-adapter.test.ts", - "rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs" + "rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs", + "rust/lithe-core/src/execution/environment.rs", + "shared/fixtures/execution/launch-environment-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Run", - "verification": "修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断;Maven Java Main 与 Spring Boot 直启在 JDT 构建后、JVM 启动前执行 Maven 原生资源处理,覆盖模块依赖、Profile、settings、测试资源和自定义 cwd;资源处理失败或超过十分钟期限不得启动 JVM,超时提示“Pre-launch step timed out after 600 seconds.”。 Issue #379:分别从 Run 面板和编辑器启动 Maven 主源码及测试源码中的 main(含默认包 MainTests、自定义源码/测试输出目录、test scope 依赖与多模块),确认 JDT 自动选择 classpath/module-path,测试入口包含测试输出和依赖,主源码入口排除测试输出;Run、Debug 与服务更新路径校验采用相同的自动 scope。 运行配置与工具链完成解析后才发布快照就绪状态;重叠加载不得保存临时 Current File 选择,已完成加载后的用户选择须保留。 打开运行配置编辑对话框,确认标题栏显示运行时图标、类型与生效来源胶囊,保存范围和 Maven 测试项使用共享分段控件,项目环境入口整行可点击;保存范围、继承/覆盖说明和环境变量格式默认收进 ⓘ,路径输入、环境变量编辑器以及保存/取消按钮与设置页样式一致;窄窗口下内容仍可滚动且底部按钮可见。" + "verification": "修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断;Maven Java Main 与 Spring Boot 直启在 JDT 构建后、JVM 启动前执行 Maven 原生资源处理,覆盖模块依赖、Profile、settings、测试资源和自定义 cwd;资源处理失败或超过十分钟期限不得启动 JVM,超时提示“Pre-launch step timed out after 600 seconds.”。 Issue #379:分别从 Run 面板和编辑器启动 Maven 主源码及测试源码中的 main(含默认包 MainTests、自定义源码/测试输出目录、test scope 依赖与多模块),确认 JDT 自动选择 classpath/module-path,测试入口包含测试输出和依赖,主源码入口排除测试输出;Run、Debug 与服务更新路径校验采用相同的自动 scope。 运行配置与工具链完成解析后才发布快照就绪状态;重叠加载不得保存临时 Current File 选择,已完成加载后的用户选择须保留。 打开运行配置编辑对话框,确认标题栏显示运行时图标、类型与生效来源胶囊,保存范围和 Maven 测试项使用共享分段控件,项目环境入口整行可点击;保存范围、继承/覆盖说明和环境变量格式默认收进 ⓘ,路径输入、环境变量编辑器以及保存/取消按钮与设置页样式一致;窄窗口下内容仍可滚动且底部按钮可见。 项目 JDK 为 8、Maven JDK 为 21 时,Maven goal 和资源前置步骤使用 21,Java 主程序使用 8;显式 JAVA_HOME 优先于选择,Windows 不同大小写环境键不产生冲突。" } diff --git a/windows/tauri/crates/project/Cargo.toml b/windows/tauri/crates/project/Cargo.toml index 2788ccf9ff..bed644a027 100644 --- a/windows/tauri/crates/project/Cargo.toml +++ b/windows/tauri/crates/project/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +lithe-core = { path = "../../../../rust/lithe-core" } anyhow = "1.0" encoding_rs = "0.8" sha2 = "0.10" diff --git a/windows/tauri/crates/project/src/lib.rs b/windows/tauri/crates/project/src/lib.rs index f3f48a2456..3f886c3722 100644 --- a/windows/tauri/crates/project/src/lib.rs +++ b/windows/tauri/crates/project/src/lib.rs @@ -111,20 +111,18 @@ impl FileWatcher { Ok(new_debouncer( Duration::from_millis(300), None, - move |result: DebounceEventResult| { - match result { - Ok(events) => Self::handle_events( - events, - emitter.as_ref(), - &watched_paths, - &watched_directories, - ), - Err(errors) => { - for error in &errors { - log::warn!("[FileWatcher] Native watcher error: {error}"); - } - Self::emit_rescans(emitter.as_ref(), &watched_directories); + move |result: DebounceEventResult| match result { + Ok(events) => Self::handle_events( + events, + emitter.as_ref(), + &watched_paths, + &watched_directories, + ), + Err(errors) => { + for error in &errors { + log::warn!("[FileWatcher] Native watcher error: {error}"); } + Self::emit_rescans(emitter.as_ref(), &watched_directories); } }, )?) @@ -141,38 +139,46 @@ impl FileWatcher { return; } - if events.len() > MAX_DEBOUNCED_EVENTS_PER_BATCH { - log::warn!( - "[FileWatcher] Collapsing {} debounced events into a bounded rescan", - events.len() - ); + let watched_paths = watched_paths.lock().unwrap(); + let watched_dirs = watched_directories.lock().unwrap(); + // Classify rename pairs before filtering either endpoint. A move from an + // excluded directory into source still creates a visible source event. + let changes = events + .iter() + .flat_map(|event| Self::classify_event(&event.event)) + .filter(|(path, _)| Self::is_relevant_change(path, &watched_paths, &watched_dirs)) + .collect::>(); + drop(watched_dirs); + drop(watched_paths); + if changes.len() > MAX_DEBOUNCED_EVENTS_PER_BATCH { Self::emit_rescans(emitter, watched_directories); return; } + for (path, event_type) in changes { + emitter.emit_file_change(&FileChangeEvent { + path: path.to_string_lossy().to_string(), + event_type, + }); + } + } - let watched_paths = watched_paths.lock().unwrap(); - let watched_dirs = watched_directories.lock().unwrap(); - - for event in events { - let changes = Self::classify_event(&event.event); - for (path, event_type) in changes { - if !Self::is_path_watched(&path, &watched_paths, &watched_dirs) { - continue; - } - - let change_event = FileChangeEvent { - path: path.to_string_lossy().to_string(), - event_type, - }; - - log::debug!( - "[FileWatcher] Emitting file-changed event for: {} ({:?})", - change_event.path, - change_event.event_type - ); - emitter.emit_file_change(&change_event); - } + fn is_relevant_change( + path: &PathBuf, + watched_paths: &HashSet, + watched_dirs: &HashSet, + ) -> bool { + if watched_paths.contains(path) { + return true; } + let root = watched_dirs + .iter() + .filter(|root| path.starts_with(root)) + .max_by_key(|root| root.components().count()); + root + .and_then(|root| path.strip_prefix(root).ok()) + .is_some_and(|relative| { + lithe_core::is_workspace_event_relevant(&relative.to_string_lossy()) + }) } fn classify_event(event: &Event) -> Vec<(PathBuf, FileChangeType)> { @@ -260,14 +266,6 @@ impl FileWatcher { } } - fn is_path_watched( - path: &PathBuf, - watched_paths: &HashSet, - watched_dirs: &HashSet, - ) -> bool { - watched_paths.contains(path) || watched_dirs.iter().any(|dir| path.starts_with(dir)) - } - fn setup_path_watching( &self, path_buf: &PathBuf, @@ -407,17 +405,12 @@ mod tests { path.display() ); match receiver.recv_timeout(remaining) { - Ok(event) - if event.path == path.to_string_lossy() && event.event_type == event_type => - { + Ok(event) if event.path == path.to_string_lossy() && event.event_type == event_type => { return event; } Ok(_) => continue, Err(RecvTimeoutError::Timeout) => { - panic!( - "timed out waiting for {event_type:?} at {}", - path.display() - ) + panic!("timed out waiting for {event_type:?} at {}", path.display()) } Err(RecvTimeoutError::Disconnected) => { panic!("file watcher event channel disconnected") @@ -501,12 +494,7 @@ mod tests { let watched_paths = Arc::new(Mutex::new(HashSet::from([root.clone()]))); let watched_directories = Arc::new(Mutex::new(HashSet::from([root.clone()]))); - FileWatcher::handle_events( - events, - &emitter, - &watched_paths, - &watched_directories, - ); + FileWatcher::handle_events(events, &emitter, &watched_paths, &watched_directories); assert_eq!( receiver.try_recv().expect("rescan event should be emitted"), @@ -517,4 +505,99 @@ mod tests { ); assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); } + #[test] + fn native_event_loss_recovers_even_when_only_output_paths_remain() { + let root = PathBuf::from("fixture"); + let event = Event::new(EventKind::Other) + .add_path(root.join("target/classes/Main.class")) + .set_flag(notify::event::Flag::Rescan); + let (sender, receiver) = mpsc::channel(); + FileWatcher::handle_events( + vec![DebouncedEvent::new(event, Instant::now())], + &ChannelEmitter(sender), + &Arc::new(Mutex::new(HashSet::from([root.clone()]))), + &Arc::new(Mutex::new(HashSet::from([root.clone()]))), + ); + assert_eq!( + receiver.try_iter().collect::>(), + vec![FileChangeEvent { + path: root.to_string_lossy().into_owned(), + event_type: FileChangeType::Rescan, + }] + ); + } + + #[test] + fn build_output_flood_preserves_source_and_explicit_watch_events() { + let root = PathBuf::from("fixture/build"); + let generated = root.join("target/generated-sources"); + let retained = root.join("target/explicit.txt"); + let source = root.join("src/Main.java"); + let mut events = (0..1024) + .map(|index| { + DebouncedEvent::new( + Event::new(EventKind::Create(notify::event::CreateKind::File)) + .add_path(root.join(format!("target/classes/{index}.class"))), + Instant::now(), + ) + }) + .collect::>(); + for path in [&source, &retained, &generated.join("Generated.java")] { + events.push(DebouncedEvent::new( + Event::new(EventKind::Modify(ModifyKind::Any)).add_path(path.clone()), + Instant::now(), + )); + } + let (sender, receiver) = mpsc::channel(); + FileWatcher::handle_events( + events, + &ChannelEmitter(sender), + &Arc::new(Mutex::new(HashSet::from([root.clone(), retained.clone()]))), + &Arc::new(Mutex::new(HashSet::from([root, generated.clone()]))), + ); + let actual = receiver.try_iter().collect::>(); + assert_eq!(actual.len(), 3); + assert!( + actual + .iter() + .all(|event| event.event_type != FileChangeType::Rescan) + ); + assert_eq!( + actual + .iter() + .map(|event| event.path.clone()) + .collect::>(), + [source, retained, generated.join("Generated.java")] + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect() + ); + } + + #[test] + fn rename_from_output_to_source_retains_only_visible_endpoint() { + let root = PathBuf::from("fixture"); + let source = root.join("src/Generated.java"); + let events = vec![DebouncedEvent::new( + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::Both))) + .add_path(root.join("target/Generated.java")) + .add_path(source.clone()), + Instant::now(), + )]; + let (sender, receiver) = mpsc::channel(); + FileWatcher::handle_events( + events, + &ChannelEmitter(sender), + &Arc::new(Mutex::new(HashSet::from([root.clone()]))), + &Arc::new(Mutex::new(HashSet::from([root]))), + ); + let actual = receiver.try_iter().collect::>(); + assert_eq!( + actual, + vec![FileChangeEvent { + path: source.to_string_lossy().to_string(), + event_type: FileChangeType::Opened + }] + ); + } } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 2508fb28c0..cc7a4db7d7 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2896,6 +2896,8 @@ dependencies = [ "lithe-agent-host", "lithe-git-host", "lithe-ide-host", + "lithe-remote-client", + "lithe-remote-protocol", "quick-xml 0.37.5", "rand 0.8.7", "regex", @@ -2945,6 +2947,7 @@ version = "0.1.0" dependencies = [ "anyhow", "encoding_rs", + "lithe-core", "log", "notify", "notify-debouncer-full", @@ -2955,6 +2958,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "lithe-remote-client" +version = "0.1.0" +dependencies = [ + "lithe-remote-protocol", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "lithe-remote-protocol" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "lithe-terminal" version = "0.1.0" diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 7c7b1fe8d4..dfbe8fa82b 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -37,24 +37,7 @@ const RUN_OUTPUT_QUEUE_CAPACITY_CHUNKS: usize = 64; const RUN_STOP_TIMEOUT: Duration = Duration::from_secs(10); // The exit code `ProcessTree` gives every process it ends. const STOPPED_EXIT_CODE: i32 = 1; -const SKIPPED_DIRECTORIES: &[&str] = &[ - "target", - "node_modules", - ".git", - "build", - "dist", - "out", - ".idea", - ".lithe", - "bin", - "obj", - "__pycache__", - ".gradle", - "vendor", - "coverage", - ".svn", - ".hg", -]; + const MAX_JAVA_SOURCES: usize = 8_000; const LITHE_GITIGNORE_ENTRIES: &[&str] = &[ "run/local.json", @@ -549,10 +532,8 @@ pub struct PreLaunchOutcome { #[tauri::command] pub fn run_list_java_sources(root: PathBuf) -> Result, String> { let root = existing_directory(&root)?; - let mut paths = Vec::new(); - collect_java_sources(&root, &root, &mut paths)?; - paths.sort(); - Ok(paths) + lithe_core::execution::list_run_java_sources(&root, MAX_JAVA_SOURCES) + .map_err(|error| error.message) } #[tauri::command] @@ -689,20 +670,19 @@ pub fn run_resolve_launch(args: ResolveLaunchArgs) -> Result>(); - if let Some(home) = &java_home { - environment.insert("JAVA_HOME".into(), home.clone()); - } - for (key, value) in args.environment { - if let Some(text) = - resolve_environment_value(&value, java_home.as_deref(), maven_java_home.as_deref()) - { - environment.insert(key, text); - } - } - if let Some(home) = maven_java_home.or(java_home) { - environment.entry("JAVA_HOME".into()).or_insert(home); - } + let mut environment = lithe_core::execution::resolve_launch_environment( + lithe_core::execution::LaunchEnvironmentRequest { + toolchain: args.executable.toolchain.clone(), + java_home, + maven_java_home, + inherited: std::env::vars().collect(), + overrides: args.environment.into_iter().collect(), + case_insensitive: cfg!(windows), + }, + ) + .environment + .into_iter() + .collect(); prepend_runtime_paths( &mut environment, &args.runtime_executable_paths, @@ -1245,46 +1225,6 @@ fn write_generated_documents( atomic_write(&generated_path, pretty_json(generated)?.as_bytes()) } -fn collect_java_sources( - root: &Path, - directory: &Path, - paths: &mut Vec, -) -> Result<(), String> { - if paths.len() >= MAX_JAVA_SOURCES { - return Ok(()); - } - let entries = fs::read_dir(directory).map_err(|error| error.to_string())?; - for entry in entries { - let entry = entry.map_err(|error| error.to_string())?; - let path = entry.path(); - let file_type = entry.file_type().map_err(|error| error.to_string())?; - if file_type.is_dir() { - let name = entry.file_name().to_string_lossy().to_string(); - if is_skipped_directory(&name) { - continue; - } - collect_java_sources(root, &path, paths)?; - continue; - } - if file_type.is_file() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.to_ascii_lowercase().ends_with(".java") { - if let Some(relative) = workspace_relative(root, &path) { - paths.push(relative); - } - } - } - } - Ok(()) -} - -fn is_skipped_directory(name: &str) -> bool { - name.starts_with('.') - || SKIPPED_DIRECTORIES - .iter() - .any(|value| value.eq_ignore_ascii_case(name)) -} - fn workspace_relative(root: &Path, path: &Path) -> Option { path.strip_prefix(root) .ok() @@ -2165,30 +2105,6 @@ fn resolve_working_directory(root: &Path, relative: &str) -> Result, - maven_java_home: Option<&str>, -) -> Option { - if let Some(text) = value.as_str() { - return Some(text.to_string()); - } - let object = value.as_object()?; - let toolchain = object.get("toolchain")?.as_str()?; - let property = object - .get("property") - .and_then(Value::as_str) - .unwrap_or("home"); - if property != "home" { - return None; - } - match toolchain { - "project-jdk" => java_home.map(str::to_string), - "project-maven" => maven_java_home.map(str::to_string), - _ => java_home.map(str::to_string), - } -} - fn lookup_on_path(name: &str) -> Option { let path = std::env::var_os("PATH")?; for directory in std::env::split_paths(&path) { @@ -2934,10 +2850,18 @@ mod tests { #[test] fn skipped_directories_include_build_outputs() { - assert!(is_skipped_directory("target")); - assert!(is_skipped_directory(".git")); - assert!(is_skipped_directory("node_modules")); - assert!(!is_skipped_directory("src")); + assert!(lithe_core::execution::is_run_scan_excluded_directory( + "target" + )); + assert!(lithe_core::execution::is_run_scan_excluded_directory( + ".git" + )); + assert!(lithe_core::execution::is_run_scan_excluded_directory( + "node_modules" + )); + assert!(!lithe_core::execution::is_run_scan_excluded_directory( + "src" + )); } #[test] @@ -3422,6 +3346,53 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn maven_launch_uses_selected_maven_jdk_in_real_host_resolution() { + let root = temp_project(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + let _cleanup = Cleanup(root.clone()); + let java = root.join("jdk8"); + let maven_java = root.join("jdk21"); + for home in [&java, &maven_java] { + fs::create_dir_all(home.join("bin")).unwrap(); + fs::write( + home.join(if cfg!(windows) { + "bin/java.exe" + } else { + "bin/java" + }), + b"fixture", + ) + .unwrap(); + } + let maven = root.join("mvn.cmd"); + fs::write(&maven, b"fixture").unwrap(); + let resolved = run_resolve_launch(ResolveLaunchArgs { + root: root.clone(), + executable: LaunchExecutable { + toolchain: Some("project-maven".into()), + command: None, + tool: None, + }, + working_directory: ".".into(), + java_home_path: java.to_string_lossy().into_owned(), + maven_java_home_path: maven_java.to_string_lossy().into_owned(), + maven_executable_path: maven.to_string_lossy().into_owned(), + runtime_executable_paths: HashMap::new(), + environment: Map::new(), + }) + .unwrap(); + assert_eq!( + resolved.environment.get("JAVA_HOME"), + Some(&normalize_path(&maven_java).to_string_lossy().into_owned()) + ); + } + #[test] fn selected_runtime_directories_are_prepended_to_launch_path() { let root = temp_project(); @@ -3455,20 +3426,6 @@ mod tests { fs::remove_dir_all(root).ok(); } - #[test] - fn launch_environment_resolves_java_home_from_toolchain_reference() { - let home = resolve_environment_value( - &json!({ "toolchain": "project-jdk", "property": "home" }), - Some(r"C:\jdk"), - Some(r"C:\maven-jdk"), - ); - assert_eq!(home.as_deref(), Some(r"C:\jdk")); - assert_eq!( - resolve_environment_value(&json!("debug"), Some(r"C:\jdk"), None).as_deref(), - Some("debug") - ); - } - #[test] fn batch_command_line_keeps_maven_goals_inside_one_cmd_string() { let line = batch_command_line( diff --git a/windows/tauri/src/features/run/components/run-pane.tsx b/windows/tauri/src/features/run/components/run-pane.tsx index 797a4e5573..de8731652e 100644 --- a/windows/tauri/src/features/run/components/run-pane.tsx +++ b/windows/tauri/src/features/run/components/run-pane.tsx @@ -163,6 +163,7 @@ export default function RunPane() { const freshnessDiagnostic = diagnostics.find((diagnostic) => diagnostic.code === "staleFingerprint" || diagnostic.code === "fingerprintCheckFailed" || + diagnostic.code === "incompleteProjectScan" || diagnostic.code === "staleJavaEntrypoint"); const isSelectedRunning = selection.isRunning; const output = selection.output; @@ -320,11 +321,13 @@ export default function RunPane() {
{blockingDiagnostic ? t("run.toolchainNeedsAttention") : - freshnessDiagnostic?.code === "fingerprintCheckFailed" ? t("run.freshnessCheckFailed") : + freshnessDiagnostic?.code === "incompleteProjectScan" ? t("run.incompleteProjectScanTitle") : + freshnessDiagnostic?.code === "fingerprintCheckFailed" ? t("run.freshnessCheckFailed") : t("run.staleConfigurations")}
- {blockingDiagnostic?.message ?? freshnessDiagnostic?.message} + {blockingDiagnostic?.message ?? (freshnessDiagnostic?.code === "incompleteProjectScan" + ? t("run.incompleteProjectScanMessage") : freshnessDiagnostic?.message)}
{blockingDiagnostic ? ( diff --git a/windows/tauri/src/features/run/utils/run-configuration.test.ts b/windows/tauri/src/features/run/utils/run-configuration.test.ts index d9c4018bad..48c24ccdad 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.test.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.test.ts @@ -272,6 +272,13 @@ describe("run configuration mapping", () => { }); }); + test("explicit user JAVA_HOME overrides a generated toolchain binding", () => { + expect(mergeLaunchEnvironment({ JAVA_HOME: "fixture/user-jdk" }, { + environment: { JAVA_HOME: { toolchain: "project-jdk", property: "home" } }, + env: { JAVA_HOME: "fixture/plan-jdk" }, + }).JAVA_HOME).toBe("fixture/user-jdk"); + }); + test("blocks only the configuration named by a toolchain diagnostic", () => { const diagnostics = [ { diff --git a/windows/tauri/src/features/run/utils/run-configuration.ts b/windows/tauri/src/features/run/utils/run-configuration.ts index 34ec624621..7495b62169 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.ts @@ -317,9 +317,9 @@ export function mergeLaunchEnvironment( plan: { environment?: Record; env?: Record }, ): Record { return { - ...configurationEnv, - ...(plan.env ?? {}), ...(plan.environment ?? {}), + ...(plan.env ?? {}), + ...configurationEnv, }; } diff --git a/windows/tauri/src/i18n/shared.generated.ts b/windows/tauri/src/i18n/shared.generated.ts index 4868d92be9..e3090dc26a 100644 --- a/windows/tauri/src/i18n/shared.generated.ts +++ b/windows/tauri/src/i18n/shared.generated.ts @@ -3724,6 +3724,8 @@ export const sharedCatalogs = { "run.identifyAgain": "Identify Again", "run.identifyAndGenerate": "Identify and Generate", "run.identifying": "Identifying project…", + "run.incompleteProjectScanMessage": "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations.", + "run.incompleteProjectScanTitle": "Project scan is incomplete", "run.infrastructure": "Docker services", "run.invalidTitle": "Project run configuration is invalid", "run.javaBuildAlwaysContinue": "Always Continue in This Workspace", @@ -8660,6 +8662,8 @@ export const sharedCatalogs = { "run.identifyAgain": "重新识别", "run.identifyAndGenerate": "识别并生成", "run.identifying": "正在识别项目…", + "run.incompleteProjectScanMessage": "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。", + "run.incompleteProjectScanTitle": "项目扫描不完整", "run.infrastructure": "Docker 服务", "run.invalidTitle": "项目运行配置无效", "run.javaBuildAlwaysContinue": "对此工作区始终继续",