From 0704e6bf2dcc079f7ed94d4d8bb0894b78e0ed6d Mon Sep 17 00:00:00 2001 From: wgw Date: Wed, 28 Oct 2026 15:03:35 +0800 Subject: [PATCH 01/12] feat(sdk): support native configuration reads in protocol 7 --- guides/native-build-workflows.md | 17 +++++++++ scripts/sdk-e2e.mjs | 6 ++- sdk/e2e/plugin_sdk/native_build.rs | 38 ++++++++++++++++++- sdk/java/README.md | 3 +- .../samplenativebuild/SampleNativeBuild.java | 9 +++++ .../github/litheidea/plugin/sdk/Backend.java | 6 +++ .../github/litheidea/plugin/sdk/Protocol.java | 4 +- .../litheidea/plugin/sdk/BuildTest.java | 15 ++++---- sdk/node/README.md | 3 +- .../sample-native-build/backend/main.mjs | 7 ++++ sdk/node/src/backend.mjs | 3 ++ sdk/node/src/index.d.ts | 4 +- sdk/node/src/protocol.mjs | 4 +- sdk/node/test/build.test.mjs | 14 ++++--- 14 files changed, 112 insertions(+), 21 deletions(-) diff --git a/guides/native-build-workflows.md b/guides/native-build-workflows.md index 969e602..6317fc1 100644 --- a/guides/native-build-workflows.md +++ b/guides/native-build-workflows.md @@ -13,6 +13,7 @@ | 发现可运行的用例 | `backend.build.discoverTests` | `onDiscoverTests` | 3 | | 准备应用启动 | `backend.build.prepareRun` | `onPrepareRun` | 4 | | 准备原生程序调试 | `backend.build.prepareDebug` | `onPrepareDebug` | 5 | +| 读取原生配置 | `backend.build.configuration` | `onConfiguration` | 7 | 安装时检查清单的能力版本,启动时 SDK 从宿主提供的版本中选最高的共同版本。没有对应协议时,新方法在进入处理器之前被拒绝;不会改发私有函数或展开成另一条宿主命令。Node 用 `backend.buildVersion`、Java 用 `backend.negotiated().buildVersion()` 查看结果。 @@ -93,3 +94,19 @@ node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --working-tree --build-wor `prepareDebug` 先完成构建,再返回 `{program, arguments, workingDirectory, environment}`。程序是当前实例内的已有文件,路径从工作区起算;宿主校验路径和可执行权限,再复用现有 DebugAdapter Registry。取消、错误和过期结果都不能启动调试。Node 和 Java 的 sample-native-build 从虚构引擎的 `run.debugProgram` 读取程序,参数使用当前请求的 userArguments;示例的 `example-dap` 是占位适配器命令,真实插件应打包符合其许可的成熟适配器。 端到端示例验证两个 SDK 的真实进程、运行前构建、原生调试准备前构建、Core 路径解码、进度及失败不返回目标。它不启动真实调试器,也不代表 Java/JDT、跨插件协作或 Windows 已完成。 + +## 原生配置读取与确认保存(协议 7,开发中) + +构建系统声明 `configuration: {"source":"provider"}`,要求 `buildProviders: 7`,并使用自己的工作区 Provider 进程。宿主以同一组工具选择、选项和环境发送 `lithe/build/configuration`;这是读取,不传编辑值或完整原生文件。Node 注册 `backend.build.configuration(handler)`,Java 注册 `onConfiguration(handler)`。SDK 补齐 `format: "litheBuildConfiguration"`、`version: 1`;旧协商版本在进入处理器前拒绝。 + +处理器从选中的成熟上游获得实际来源与生效值,返回 `sources`。每个来源包含 `id`、`scope`(installation/user/workspace)、文件 URI、原始字节的 SHA-256 revision(缺失文件为 missing)、writable、XML 根名称与命名空间,以及非敏感字段。字段区分 value(原值,未设置为 null)、effectiveValue 和 effectiveSource,并声明 path(根以下的直接 XML 元素路径);stringList 还声明 itemElement。参考契约合并并同步后的 `contract/v1/fixtures/build-configuration.json`。当前镜像在宿主合并前保持原样,不能把待合并字段写进旧契约副本。 + +例如 Maven 插件观察其所选 Maven 的原始 `SettingsBuildingRequest` 与 `SettingsBuildingResult`,在 reactor 导入前取得源文件与合并结果;会话执行请求提供最终命令行覆盖值。不要在宿主、SDK 或插件里重写 Maven 配置优先级,不要把会话 SettingsAdapter 当成未覆盖的原始设置。配置探针需在返回前确认实际来源和哈希仍匹配,文件更改时明确失败。 + +宿主在通用设置页显示来源、原值、继承/覆盖和生效值。输入只改草稿;保存明确列出的目标来源后,平台适配器以原始快照进行条件替换,再调用同一 Provider 读取实际生效值。取消、打开、失焦和离页不写配置。安装来源始终只读;多个作用域选择同一文件时,只能显示完全一致的只读别名。保存失效或冲突保留草稿,不把提交值当成生效值。 + +完整 XML 可能含凭据,只能在宿主文件 adapter 与 Core 的纯编辑命令间流动。不要通过配置回复、日志、插件函数、网页或模型共享传整份文件。宿主仅编辑获准的非敏感节点并保留其他内容、注释和权限;Provider 只读真实事实,不接收或代写宿主确认过的原生文档。探针目录、输出和带源哈希的模型缓存属于隔离的工作区状态,调用结束清理,不能写包或跨工作树复用。 + +Maven 的真实集成场景覆盖损坏 POM 前读取、用户/安装来源、自定义路径、命令行覆盖、敏感内容保留、Core 编辑后回读、旧版本拒绝、清理与包清单不变。SDK 单元测试分别验证 1–7 协商与配置格式;宿主合并前用 `node scripts/sdk-e2e.mjs --source --working-tree --build-workflows --native-configuration` 验证 Node/Java 的端到端示例及协议 7 配置投影、Core 内部纯 XML 编辑、敏感内容保留与读取无写入。源码通过不表示已发版;Java SDK/JAR 的正式版本与哈希仍需发布流程固定。 + +`--native-configuration` 在宿主合并前显式启用协议 7 场景;契约镜像同步到包含 7 的 preview 提交后默认启用。Node/Java 的无页面样例仍以其虚构引擎 JSON 供应非敏感配置投影,不增加 XML 解析或凭据传输。 diff --git a/scripts/sdk-e2e.mjs b/scripts/sdk-e2e.mjs index 800be89..f6d065c 100644 --- a/scripts/sdk-e2e.mjs +++ b/scripts/sdk-e2e.mjs @@ -138,13 +138,14 @@ async function rustSample() { async function main() { const { values } = parseArgs({ options: { ref: { type: 'string' }, source: { type: 'string' }, - 'working-tree': { type: 'boolean' }, 'build-workflows': { type: 'boolean' } } }); + 'working-tree': { type: 'boolean' }, 'build-workflows': { type: 'boolean' }, 'native-configuration': { type: 'boolean' } } }); if (values['working-tree'] && (!values.source || values.ref)) { throw new Error('--working-tree requires a local --source and cannot be combined with --ref.'); } const lock = await readLock(); const schema = JSON.parse(await readFile(join(repository, 'contract/v1/plugin-manifest.schema.json'), 'utf8')); - const buildWorkflows = includesNativeWorkflows(schema, values['build-workflows']); + const configurationWorkflows = values['native-configuration'] || schema.properties.requires.properties.buildProviders.enum.includes(7); + const buildWorkflows = includesNativeWorkflows(schema, values['build-workflows'] || configurationWorkflows); const ref = values['working-tree'] ? 'HEAD' : values.ref ?? lock.source.revision; const source = values.source ?? `${lock.source.repository}.git`; @@ -165,6 +166,7 @@ async function main() { env: { ...process.env, LITHE_PLUGIN_SDK_ROOT: repository, + LITHE_PLUGIN_SDK_CONFIGURATION_WORKFLOWS: configurationWorkflows ? '1' : '0', LITHE_PLUGIN_SDK_NODE: process.execPath, LITHE_PLUGIN_SDK_JAVA: java, LITHE_PLUGIN_SDK_JAVA_SAMPLE: jar, diff --git a/sdk/e2e/plugin_sdk/native_build.rs b/sdk/e2e/plugin_sdk/native_build.rs index f7a05c9..3238645 100644 --- a/sdk/e2e/plugin_sdk/native_build.rs +++ b/sdk/e2e/plugin_sdk/native_build.rs @@ -2,6 +2,7 @@ //! private function. The fictional engine owns all build/test/run facts. use super::{core, listing, Fixture, Sample, Session, BUILD_SYSTEM, WAIT}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use std::fs; use std::time::Instant; @@ -20,7 +21,7 @@ pub fn run(sample: &Sample) { fs::create_dir_all(instance.join("app")).unwrap(); fs::create_dir_all(instance.join("broken")).unwrap(); fs::create_dir_all(instance.join("slow")).unwrap(); - let config = json!({"projects":[ + let mut config = json!({"projects":[ {"name":"app","path":"app","build":[sample.node,"-e","const fs=require('fs'); const n=fs.existsSync('build-count')?Number(fs.readFileSync('build-count','utf8')):0; fs.writeFileSync('build-count',String(n+1)); console.log('native build')"], "tests":[ {"suite":"Suite","case":"exact # [case]","command":[sample.node,"-e","console.log(JSON.stringify(process.argv.slice(1)))"]}, @@ -30,6 +31,16 @@ pub fn run(sample: &Sample) { "run":{"command":"node","arguments":["-e","process.exit(99)"]}}, {"name":"slow","path":"slow","build":[sample.node,"-e","require('fs').writeFileSync('started', String(process.pid)); setInterval(()=>{}, 1000)"],"run":{"command":"node","arguments":[],"debugProgram":"native-program"}} ]}); + let native_document = "/raw/cachesdk-native-secret-sentinel"; + let native_file = instance.join("settings.xml"); + if std::env::var("LITHE_PLUGIN_SDK_CONFIGURATION_WORKFLOWS").as_deref() == Ok("1") { + fs::write(&native_file, native_document).unwrap(); + config["configuration"] = json!({"sources":[{"id":"user","scope":"user", + "uri":url::Url::from_file_path(&native_file).unwrap().as_str(), + "revision":format!("sha256:{:x}", Sha256::digest(native_document.as_bytes())),"writable":true, + "rootElement":{"name":"settings","namespace":""},"fields":[{"id":"cacheDirectory","kind":"directory", + "value":"/raw/cache","effectiveValue":"/selected/cache","effectiveSource":"commandLine","path":["cacheDirectory"]}]}]}); + } fs::write(instance.join("lithe-sample.json"), config.to_string()).unwrap(); fs::write(instance.join("app/native-program"), "native debugger target fixture").unwrap(); let launch = core( @@ -60,6 +71,31 @@ pub fn run(sample: &Sample) { ) }; let app = json!({"name":"app","path":"nested/app"}); + if std::env::var("LITHE_PLUGIN_SDK_CONFIGURATION_WORKFLOWS").as_deref() == Ok("1") { + let mut native = manifest.clone(); + native["requires"]["buildProviders"] = json!(7); + native["buildSystems"][0]["configuration"] = json!({"source":"provider"}); + let request = |command: &str, payload: Value| core(command, + json!({"manifest":native,"buildSystemID":BUILD_SYSTEM,"request":payload})); + let planned = request("build.configurationPlan", json!({"root":root})); + assert_eq!(planned["status"], "ok", "{planned}"); + let reply = session.build_request("native-configuration", &planned["plan"]); + assert_eq!(reply["format"], "litheBuildConfiguration"); + assert_eq!(reply["version"], 1); + assert!(!reply.to_string().contains("sdk-native-secret-sentinel")); + let decoded = request("build.decodeConfiguration", json!({"result":reply})); + assert_eq!(decoded["status"], "ok", "{decoded}"); + assert_eq!(decoded["configuration"]["sources"][0]["fields"][0]["effectiveValue"], "/selected/cache"); + let edited = request("build.editConfigurationDocument", json!({"snapshot":decoded["configuration"], + "edits":{"sourceID":"user","revision":reply["sources"][0]["revision"],"values":{"cacheDirectory":"/new/cache"}}, + "document":native_document})); + assert_eq!(edited["status"], "ok", "{edited}"); + let document = edited["edit"]["document"].as_str().unwrap(); + assert!(document.contains("/new/cache")); + assert!(document.contains("")); + assert!(document.contains("sdk-native-secret-sentinel")); + assert_eq!(fs::read_to_string(&native_file).unwrap(), native_document, "Core editing alone performs no native IO"); + } let plan = build("build.modelPlan", json!({"root":root}))["plan"].clone(); let result = session.build_request("model", &plan); let model = build( diff --git a/sdk/java/README.md b/sdk/java/README.md index c9df996..ad84601 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -76,8 +76,9 @@ JAR it starts must hold everything it loads. | `onModel(handler)`, `onDependencies(handler)` | Implement the typed build requests. The SDK stamps `format` and `version`. Core validates the document. `litheBuild` is answered only when a handler exists. | | `onExecute(handler)` | Protocol 2+: a bounded provider-owned task, returning `{ exitCode, output }`. | | `onTest(handler)`, `onDiscoverTests(handler)` | Protocol 3+: exact execution and runnable selectors; discovery format/version are stamped. | +| `onConfiguration(handler)` | Protocol 7+: read-only non-sensitive raw/effective native configuration; stamps `litheBuildConfiguration`, version 1. The host owns explicit native-file saves. | | `onPrepareRun(handler)` | Protocol 4: prepare a portable plan; the host owns the application. | -| `negotiated().buildVersion()` | Highest common build version (1–6), or zero; newer methods are refused before their handler runs. | +| `negotiated().buildVersion()` | Highest common build version (1–7), or zero; newer methods are refused before their handler runs. | | `function(id, handler)` | Declares a function offered in `initialize`. | | `registerFunction(id, handler)`, `unregisterFunction(id)` | Add or withdraw a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list. Later calls send `lithe/function/register` or `lithe/function/unregister`, in order. Both return a `CompletableFuture`. Withdrawing a function revokes its running calls. | | `onInitialize(hook)`, `onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | diff --git a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java index 4773db1..e2b5c72 100644 --- a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java +++ b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java @@ -49,6 +49,15 @@ private void serve() { return object("projects", model, "targets", targets); }); backend.onDependencies((params, context) -> object("dependencies", Collections.emptyList())); + // The fictional engine supplies only the non-sensitive projection; + // complete native documents are never sent through the SDK. + backend.onConfiguration((params, context) -> { + Path base = inside(Json.string(params, "root")); + Map engine = Json.object(Json.parse(new String( + Files.readAllBytes(base.resolve("lithe-sample.json")), StandardCharsets.UTF_8))); + return engine.get("configuration") instanceof Map ? Json.object(engine.get("configuration")) + : object("sources", Collections.emptyList()); + }); backend.onExecute((params, context) -> { if (!"build".equals(params.get("task"))) throw new PluginError("sample.task", "Unknown build task."); return build(select(params), strings(params.get("userArguments")), context); diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java index ca06223..2b0d443 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -103,6 +103,7 @@ private enum Phase { IDLE, LISTENING, INITIALIZING, READY, SHUTTING_DOWN, EXITED formats.put(Protocol.BUILD_MODEL, "litheBuildModel"); formats.put(Protocol.BUILD_DEPENDENCIES, "litheDependencies"); formats.put(Protocol.BUILD_DISCOVER_TESTS, "litheTestDiscovery"); + formats.put(Protocol.BUILD_CONFIGURATION, "litheBuildConfiguration"); BUILD_FORMATS = Collections.unmodifiableMap(formats); } @@ -183,6 +184,11 @@ public List functionIds() { } } + /** Returns non-sensitive native source facts and generic XML edit bindings; protocol 7. */ + public Backend onConfiguration(Handler handler) { + return declareBuild(Protocol.BUILD_CONFIGURATION, handler); + } + /** Answers {@code lithe/build/model} with a {@code litheBuildModel} document. */ public Backend onModel(Handler handler) { return declareBuild(Protocol.BUILD_MODEL, handler); diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index 25ca884..149a2ed 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -13,7 +13,7 @@ public final class Protocol { /** Protocol versions this SDK can answer. */ public static final int FUNCTIONS_VERSION = 1; - public static final int BUILD_VERSION = 6; + public static final int BUILD_VERSION = 7; public static final String INITIALIZE = "initialize"; public static final String INITIALIZED = "initialized"; @@ -24,6 +24,7 @@ public final class Protocol { public static final String FUNCTION_REGISTER = "lithe/function/register"; public static final String FUNCTION_UNREGISTER = "lithe/function/unregister"; public static final String FUNCTION_PROGRESS = "lithe/function/progress"; + public static final String BUILD_CONFIGURATION = "lithe/build/configuration"; public static final String BUILD_MODEL = "lithe/build/model"; public static final String BUILD_DEPENDENCIES = "lithe/build/dependencies"; public static final String BUILD_EXECUTE = "lithe/build/execute"; @@ -54,6 +55,7 @@ public final class Protocol { /** Minimum negotiated version; unknown methods can never pass admission. */ static int buildMethodVersion(String method) { + if (BUILD_CONFIGURATION.equals(method)) return 7; if (BUILD_MODEL.equals(method) || BUILD_DEPENDENCIES.equals(method)) return 1; if (BUILD_EXECUTE.equals(method)) return 2; if (BUILD_TEST.equals(method) || BUILD_DISCOVER_TESTS.equals(method)) return 3; diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java index e93e24a..c79adaf 100644 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java @@ -32,18 +32,19 @@ private static Object errorCode(Map response) { @Test void eachVersionAdmitsOnlyItsTypedMethods() { - List methods = Arrays.asList("execute", "test", "discoverTests", "prepareRun", "prepareDebug"); - int[] minimum = {2, 3, 3, 4, 5}; + List methods = Arrays.asList("execute", "test", "discoverTests", "prepareRun", "prepareDebug", "configuration"); + int[] minimum = {2, 3, 3, 4, 5, 7}; List> replies = Arrays.asList(map("exitCode", 0, "output", "built\n"), map("exitCode", 2, "output", "test failed\n"), - map("tests", Arrays.asList(map("suite", "Suite", "case", "exact # [case]"))), LAUNCH, map("program", "app/bin/main", "arguments", Arrays.asList("a b"), "workingDirectory", "app", "environment", map())); - for (int version = 1; version <= 6; version++) { + map("tests", Arrays.asList(map("suite", "Suite", "case", "exact # [case]"))), LAUNCH, map("program", "app/bin/main", "arguments", Arrays.asList("a b"), "workingDirectory", "app", "environment", map()), map("sources", Collections.emptyList())); + for (int version = 1; version <= 7; version++) { Backend backend = Backend.create(null, null) .onExecute((params, context) -> replies.get(0)) .onTest((params, context) -> replies.get(1)) .onDiscoverTests((params, context) -> replies.get(2)) .onPrepareRun((params, context) -> replies.get(3)) - .onPrepareDebug((params, context) -> replies.get(4)); + .onPrepareDebug((params, context) -> replies.get(4)) + .onConfiguration((params, context) -> replies.get(5)); TestHost host = new TestHost(backend); try { Map result = Json.object(host.initialize(offered(version, 99)).get("result")); @@ -57,7 +58,7 @@ void eachVersionAdmitsOnlyItsTypedMethods() { } else { // Compare JSON-normalized numeric types, as seen on the wire. Map expected = Json.object(Json.parse(Json.stringify(replies.get(index)))); - if (index == 2) { expected.put("format", "litheTestDiscovery"); expected.put("version", 1L); } + if (index == 2 || index == 5) { expected.put("format", index == 2 ? "litheTestDiscovery" : "litheBuildConfiguration"); expected.put("version", 1L); } assertEquals(expected, response.get("result")); } } @@ -69,7 +70,7 @@ void eachVersionAdmitsOnlyItsTypedMethods() { void readOnlyModelInputsRequireVersionSixAndPreserveHostIdentity() { Map inputs = map("version", 1L, "revision", 17L, "inputs", Arrays.asList( map("pluginID", "dev.source", "buildSystemID", "source", "state", "unavailable", "instances", Collections.emptyList()))); - for (int version : new int[] {5, 6}) { + for (int version : new int[] {5, 6, 7}) { AtomicReference seen = new AtomicReference<>(); Backend backend = Backend.create(null, null).onModel((params, context) -> { seen.set(params.get("modelInputs")); diff --git a/sdk/node/README.md b/sdk/node/README.md index b5dd8de..75b0221 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -121,8 +121,9 @@ package. The SDK has no dependencies. | `backend.build.model(handler)`, `backend.build.dependencies(handler)` | Implements the typed build requests. The SDK stamps `format` and `version`; a handler returning another format or a version other than 1 fails. Core validates the document. `litheBuild` is answered only when a handler exists. | | `backend.build.execute(handler)` | Protocol 2+: provider-owned bounded task, returning `{ exitCode, output }`. | | `backend.build.test(handler)`, `backend.build.discoverTests(handler)` | Protocol 3+: exact test execution and runnable selectors; discovery gets `format: "litheTestDiscovery"`, `version: 1`. | +| `backend.build.configuration(handler)` | Protocol 7+: read-only non-sensitive raw/effective native configuration; stamps `litheBuildConfiguration`, version 1. The host owns explicit native-file saves. | | `backend.build.prepareRun(handler)` | Protocol 4: returns a portable launch plan after upstream synchronization/build; the host owns the application process. | -| `backend.buildVersion` | Highest common negotiated build version (1–6), or `null`; methods newer than that version are refused before their handler runs. | +| `backend.buildVersion` | Highest common negotiated build version (1–7), or `null`; methods newer than that version are refused before their handler runs. | | `backend.vcs(id, handlers, traits?)` | Implements the typed VCS requests; see [A VCS provider](#a-vcs-provider). `litheVcs` is answered only when the host offers it. | | `backend.function(id, handler)` | Declares a function offered in `initialize`. | | `backend.registerFunction(id, handler)`, `backend.unregisterFunction(id)` | Adds or withdraws a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list; later they send `lithe/function/register` / `unregister`. Withdrawing revokes running calls. | diff --git a/sdk/node/examples/sample-native-build/backend/main.mjs b/sdk/node/examples/sample-native-build/backend/main.mjs index 55ecd67..b322874 100644 --- a/sdk/node/examples/sample-native-build/backend/main.mjs +++ b/sdk/node/examples/sample-native-build/backend/main.mjs @@ -15,6 +15,13 @@ backend.build.model(async ({ root, modelInputs }) => { targets: all.filter(({ run }) => run).map(({ modelPath: path }) => ({ name: 'Main', path, project: path, tags: ['application'] })) }; }); backend.build.dependencies(() => ({ dependencies: [] })); +// The fictional engine supplies its non-sensitive native projection. Real +// plugins read these facts from their selected upstream configuration API. +backend.build.configuration(async ({ root }) => { + const base = await inside(root); + const configuration = JSON.parse(await readFile(join(base, 'lithe-sample.json'), 'utf8')).configuration; + return configuration ?? { sources: [] }; +}); backend.build.execute(async (params, context) => { if (params.task !== 'build') throw new PluginError('sample.task', 'Unknown build task.'); diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index dd20314..95a3b06 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -30,6 +30,7 @@ import { /** Canonical result format of each typed build request. */ const BUILD_FORMATS = Object.freeze({ [METHODS.buildModel]: 'litheBuildModel', + [METHODS.buildConfiguration]: 'litheBuildConfiguration', [METHODS.buildDependencies]: 'litheDependencies', [METHODS.buildDiscoverTests]: 'litheTestDiscovery', }); @@ -95,6 +96,8 @@ class Backend { this.#version = version === undefined ? undefined : String(version); this.#capabilities = capabilities; this.build = Object.freeze({ + /** Returns non-sensitive native settings; no native document bytes cross RPC. */ + configuration: (handler) => this.#declareBuild(METHODS.buildConfiguration, handler), /** Answers `lithe/build/model` with a `litheBuildModel` document. */ model: (handler) => this.#declareBuild(METHODS.buildModel, handler), /** Answers `lithe/build/dependencies` with a `litheDependencies` document. */ diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index d512f6d..39c48a3 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -225,10 +225,12 @@ export interface ListenOptions { export interface Backend { readonly negotiated: Negotiated; readonly initializeParams: JsonObject | null; - /** Highest common version (1-4), or null before/without negotiation. */ + /** Highest common version (1-7), or null before/without negotiation. */ readonly buildVersion: number | null; readonly functionIDs: string[]; readonly build: { + /** Non-sensitive source-backed settings; requires build protocol 7. */ + configuration(handler: BuildHandler): Backend; model(handler: BuildHandler): Backend; dependencies(handler: BuildHandler): Backend; execute(handler: BuildExecutionHandler): Backend; diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index d0a43dc..a1e30b9 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -6,7 +6,7 @@ /** Protocol versions this SDK can answer. */ export const FUNCTIONS_VERSION = 1; -export const BUILD_VERSION = 6; +export const BUILD_VERSION = 7; export const VCS_VERSION = 1; export const METHODS = Object.freeze({ @@ -20,6 +20,7 @@ export const METHODS = Object.freeze({ functionUnregister: 'lithe/function/unregister', functionProgress: 'lithe/function/progress', buildModel: 'lithe/build/model', + buildConfiguration: 'lithe/build/configuration', buildDependencies: 'lithe/build/dependencies', buildExecute: 'lithe/build/execute', buildTest: 'lithe/build/test', @@ -32,6 +33,7 @@ export const METHODS = Object.freeze({ /** Minimum negotiated version of each typed build operation. */ export const BUILD_METHOD_VERSIONS = Object.freeze({ + [METHODS.buildConfiguration]: 7, [METHODS.buildModel]: 1, [METHODS.buildDependencies]: 1, [METHODS.buildExecute]: 2, diff --git a/sdk/node/test/build.test.mjs b/sdk/node/test/build.test.mjs index 080eea2..c1ca912 100644 --- a/sdk/node/test/build.test.mjs +++ b/sdk/node/test/build.test.mjs @@ -13,9 +13,10 @@ const methods = [ ['discoverTests', 3, { tests: [{ suite: 'Suite', case: 'exact # [case]' }] }], ['prepareRun', 4, launch], ['prepareDebug', 5, { program: 'app/bin/main', arguments: [], workingDirectory: 'app', environment: {} }], + ['configuration', 7, { sources: [] }], ]; -for (const version of [1, 2, 3, 4, 5, 6]) { +for (const version of [1, 2, 3, 4, 5, 6, 7]) { test(`build protocol ${version} admits only its typed methods`, { timeout: 15000 }, async (t) => { const backend = createBackend(); const invoked = []; @@ -32,7 +33,8 @@ for (const version of [1, 2, 3, 4, 5, 6]) { assert.ok(!invoked.includes(method)); } else { assert.deepEqual(response.result, method === 'discoverTests' - ? { ...reply, format: 'litheTestDiscovery', version: 1 } : reply); + ? { ...reply, format: 'litheTestDiscovery', version: 1 } : method === 'configuration' + ? { ...reply, format: 'litheBuildConfiguration', version: 1 } : reply); } } }); @@ -96,7 +98,7 @@ test('native debug progress preserves wire identity, UTF-8 limits and call owner context.progress({ message: '😀'.repeat(2000), output: '界'.repeat(30000), percentage: 120 }); entered.open(); await release.opened; - return methods.at(-1)[2]; + return methods.find(([name]) => name === 'prepareDebug')[2]; }); const host = new TestHost(backend); t.after(async () => { release.open(); await host.close(); }); @@ -109,7 +111,7 @@ test('native debug progress preserves wire identity, UTF-8 limits and call owner assert.equal(Buffer.byteLength(report.output), 65535); assert.equal(report.percentage, 100); release.open(); - assert.deepEqual((await pending).result, methods.at(-1)[2]); + assert.deepEqual((await pending).result, methods.find(([name]) => name === 'prepareDebug')[2]); late({ message: 'late output', output: 'late' }); assert.equal(host.messages.filter((message) => message.method === 'lithe/build/progress').length, 1); }); @@ -180,7 +182,7 @@ test('build parameters need a wire operation ID and backend errors keep their ca test('native debug refuses adapter, process and arbitrary DAP result fields', { timeout: 15000 }, async (t) => { for (const field of ['adapter', 'launch', 'dapArguments']) { const backend = createBackend(); - backend.build.prepareDebug(() => ({ ...methods.at(-1)[2], [field]: 'untrusted' })); + backend.build.prepareDebug(() => ({ ...methods.find(([name]) => name === 'prepareDebug')[2], [field]: 'untrusted' })); const host = new TestHost(backend); t.after(() => host.close()); await host.initialize({ litheBuild: { versions: [5] } }); @@ -192,7 +194,7 @@ test('read-only model inputs require protocol 6 and preserve host identity', { t const modelInputs = { version: 1, revision: 17, inputs: [ { pluginID: 'dev.source', buildSystemID: 'source', state: 'unavailable', instances: [] }, ] }; - for (const version of [5, 6]) { + for (const version of [5, 6, 7]) { const seen = []; const backend = createBackend(); backend.build.model((params) => { seen.push(params.modelInputs); return { projects: [], targets: [], tasks: [] }; }); From 71667af404acb50360ea93b238f669784f75e94f Mon Sep 17 00:00:00 2001 From: wgw Date: Wed, 28 Oct 2026 22:12:55 +0800 Subject: [PATCH 02/12] feat(sdk): support provider-owned execution test reports --- scripts/sdk-e2e.mjs | 8 +++-- scripts/sdk-e2e.test.mjs | 6 +++- sdk/e2e/plugin_sdk/native_build.rs | 24 ++++++++++++-- sdk/java/README.md | 13 ++++++++ .../samplenativebuild/SampleNativeBuild.java | 12 +++++-- .../github/litheidea/plugin/sdk/Backend.java | 24 ++++++++++++-- .../github/litheidea/plugin/sdk/Protocol.java | 2 +- .../litheidea/plugin/sdk/BuildTest.java | 24 ++++++++++++-- sdk/node/README.md | 13 ++++++++ .../sample-native-build/backend/main.mjs | 7 +++-- sdk/node/src/backend.mjs | 17 ++++++++-- sdk/node/src/index.d.ts | 12 +++++-- sdk/node/src/protocol.mjs | 2 +- sdk/node/test/build.test.mjs | 31 +++++++++++++++++-- sdk/node/test/types/build.mts | 5 ++- 15 files changed, 176 insertions(+), 24 deletions(-) diff --git a/scripts/sdk-e2e.mjs b/scripts/sdk-e2e.mjs index f6d065c..fc91fe3 100644 --- a/scripts/sdk-e2e.mjs +++ b/scripts/sdk-e2e.mjs @@ -51,6 +51,8 @@ function output(command, args, trim = true) { const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'inherit'], timeout: 15000, killSignal: 'SIGKILL', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }); let text = ''; + // StringDecoder preserves UTF-8 characters when Git splits a large diff across chunks. + child.stdout.setEncoding('utf8'); child.stdout.on('data', chunk => { text += chunk; }); child.on('error', reject); child.on('close', code => code === 0 @@ -138,14 +140,15 @@ async function rustSample() { async function main() { const { values } = parseArgs({ options: { ref: { type: 'string' }, source: { type: 'string' }, - 'working-tree': { type: 'boolean' }, 'build-workflows': { type: 'boolean' }, 'native-configuration': { type: 'boolean' } } }); + 'working-tree': { type: 'boolean' }, 'build-workflows': { type: 'boolean' }, 'native-configuration': { type: 'boolean' }, 'provider-reports': { type: 'boolean' } } }); if (values['working-tree'] && (!values.source || values.ref)) { throw new Error('--working-tree requires a local --source and cannot be combined with --ref.'); } const lock = await readLock(); const schema = JSON.parse(await readFile(join(repository, 'contract/v1/plugin-manifest.schema.json'), 'utf8')); + const reportWorkflows = values['provider-reports'] || schema.properties.requires.properties.buildProviders.enum.includes(8); const configurationWorkflows = values['native-configuration'] || schema.properties.requires.properties.buildProviders.enum.includes(7); - const buildWorkflows = includesNativeWorkflows(schema, values['build-workflows'] || configurationWorkflows); + const buildWorkflows = includesNativeWorkflows(schema, values['build-workflows'] || configurationWorkflows || reportWorkflows); const ref = values['working-tree'] ? 'HEAD' : values.ref ?? lock.source.revision; const source = values.source ?? `${lock.source.repository}.git`; @@ -166,6 +169,7 @@ async function main() { env: { ...process.env, LITHE_PLUGIN_SDK_ROOT: repository, + LITHE_PLUGIN_SDK_REPORT_WORKFLOWS: reportWorkflows ? '1' : '0', LITHE_PLUGIN_SDK_CONFIGURATION_WORKFLOWS: configurationWorkflows ? '1' : '0', LITHE_PLUGIN_SDK_NODE: process.execPath, LITHE_PLUGIN_SDK_JAVA: java, diff --git a/scripts/sdk-e2e.test.mjs b/scripts/sdk-e2e.test.mjs index 646214b..6d006b7 100644 --- a/scripts/sdk-e2e.test.mjs +++ b/scripts/sdk-e2e.test.mjs @@ -32,7 +32,8 @@ test('explicit working-tree snapshots include staged, unstaged and untracked inp await writeFile(join(source, '.gitignore'), '/rust/target/\n'); await writeFile(join(source, 'rust/input.rs'), 'original\n'); await writeFile(join(source, 'rust/deleted.rs'), 'removed\n'); - await writeFile(join(source, 'shared/fixture.json'), '{}\n'); + const originalUnicode = JSON.stringify({ text: '构建配置验证与迁移'.repeat(20000) }) + '\n'; + await writeFile(join(source, 'shared/fixture.json'), originalUnicode); await writeFile(join(source, 'outside.txt'), 'original\n'); git(source, 'init', '-q'); git(source, 'config', 'core.autocrlf', 'false'); @@ -48,10 +49,13 @@ test('explicit working-tree snapshots include staged, unstaged and untracked inp await writeFile(join(source, 'outside.txt'), 'unrelated\n'); await mkdir(join(source, 'rust/target')); await writeFile(join(source, 'rust/target/cache'), 'not source\n'); + const changedUnicode = JSON.stringify({ text: '报告路径和失败结果'.repeat(20000) }) + '\n'; + await writeFile(join(source, 'shared/fixture.json'), changedUnicode); const indexBefore = await readFile(join(source, '.git/index')); const before = git(source, 'status', '--porcelain'); await copyWorkingTree(source, destination); assert.equal(await readFile(join(destination, 'rust/input.rs'), 'utf8'), 'unstaged final\n'); + assert.equal(await readFile(join(destination, 'shared/fixture.json'), 'utf8'), changedUnicode); assert.equal(await readFile(join(destination, 'shared/new fixture.json'), 'utf8'), '{"new":true}\n'); await assert.rejects(readFile(join(destination, 'rust/deleted.rs')), { code: 'ENOENT' }); await assert.rejects(readFile(join(destination, 'rust/target/cache')), { code: 'ENOENT' }); diff --git a/sdk/e2e/plugin_sdk/native_build.rs b/sdk/e2e/plugin_sdk/native_build.rs index 3238645..90bf561 100644 --- a/sdk/e2e/plugin_sdk/native_build.rs +++ b/sdk/e2e/plugin_sdk/native_build.rs @@ -7,8 +7,13 @@ use std::fs; use std::time::Instant; pub fn run(sample: &Sample) { - let manifest: Value = + let mut manifest: Value = serde_json::from_slice(&fs::read(sample.package.join("plugin.json")).unwrap()).unwrap(); + let reports = std::env::var("LITHE_PLUGIN_SDK_REPORT_WORKFLOWS").as_deref() == Ok("1"); + if reports { + manifest["requires"]["buildProviders"] = json!(8); + manifest["buildSystems"][0]["test"]["results"] = json!({"decoder":"provider"}); + } let before = listing(&sample.package); assert_eq!( core("plugins.declarative.validate", json!({"manifest":manifest}))["status"], @@ -73,7 +78,7 @@ pub fn run(sample: &Sample) { let app = json!({"name":"app","path":"nested/app"}); if std::env::var("LITHE_PLUGIN_SDK_CONFIGURATION_WORKFLOWS").as_deref() == Ok("1") { let mut native = manifest.clone(); - native["requires"]["buildProviders"] = json!(7); + native["requires"]["buildProviders"] = json!(if reports {8} else {7}); native["buildSystems"][0]["configuration"] = json!({"source":"provider"}); let request = |command: &str, payload: Value| core(command, json!({"manifest":native,"buildSystemID":BUILD_SYSTEM,"request":payload})); @@ -123,7 +128,7 @@ pub fn run(sample: &Sample) { "request":{"workspaceRoot":workspace,"root":root,"result":source_reply}})); assert_eq!(source_model["status"], "ok", "{source_model}"); let mut consumer = manifest.clone(); - consumer["requires"]["buildProviders"] = json!(6); + consumer["requires"]["buildProviders"] = json!(if reports {8} else {6}); consumer["requires"]["buildModelInputs"] = json!(1); consumer["buildSystems"][0]["modelInputs"] = json!([{"pluginID":producer["id"],"buildSystemID":"shared-source"}]); let resolved = core("build.resolveModelInputs", json!({"manifest":consumer,"buildSystemID":BUILD_SYSTEM,"request":{ @@ -180,6 +185,19 @@ pub fn run(sample: &Sample) { assert_eq!(tested["exitCode"], 0, "{tested}"); assert_eq!(tested["output"], "[\"a b\",\"x#y\"]\n"); + if reports { + let decoded = build("build.decodeTestResults", json!({"workspaceRoot":workspace,"root":root,"result":tested["testResults"]})); + assert_eq!(decoded["status"], "ok", "{decoded}"); + assert_eq!(decoded["results"]["summary"]["passed"], 1); + assert_eq!(decoded["results"]["tests"][0]["project"], "nested/app"); + let plan = build("build.testPlan", json!({"root":root,"project":app,"suite":"Suite","case":"other","userArguments":[]}))["plan"].clone(); + let failed = session.build_request("test-failure-with-report", &plan); + assert_eq!(failed["exitCode"], 7); + let decoded = build("build.decodeTestResults", json!({"workspaceRoot":workspace,"root":root,"result":failed["testResults"]})); + assert_eq!(decoded["results"]["summary"]["failed"], 1); + assert_eq!(decoded["results"]["tests"][0]["case"], "other"); + } + // An externally removed selector cannot silently widen to all tests. let mut changed = config.clone(); changed["projects"][0]["tests"] = json!([]); diff --git a/sdk/java/README.md b/sdk/java/README.md index ad84601..d1468bd 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -191,3 +191,16 @@ The two native workflow samples demonstrate independent producer and consumer backends in the Core end-to-end tests. Their engine is fictional; this does not migrate Maven, JDT, LLDB or existing plugin business logic. The contract mirror must be synced from the merged Lithe revision before these changes are released. + +## Inline test reports (development protocol 8) + +A test declared with `results: { "decoder": "provider" }` requires buildProviders 8. +Core sends `testResults: true`; answer with `testResults: { format: "litheTestResults", +version: 1, tests: [...] }` beside `exitCode` and `output`. Cases carry suite/case/status +and optional project/source paths, message, durationMillis and invocations. A failed +test still returns its nonzero exit code and current reports. Report discovery and +freshness belong to the provider's upstream engine; never return old files or silently +empty results after a read failure. Core validates 10,000 cases/8 MiB and keeps equal +suite/case names in distinct projects separate. Earlier negotiation refuses the new +request field. Ordinary execute replies may include reports for build phases that run +tests. These SDK sources are development inputs pending a coordinated host/SDK release. diff --git a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java index e2b5c72..ed66f09 100644 --- a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java +++ b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java @@ -113,6 +113,7 @@ private Map build(List> projects, List params, CallContext context) throws IOException { int matched = 0; + List results = new ArrayList<>(); StringBuilder output = new StringBuilder(); for (Map project : select(params)) { for (Map test : tests(project)) { @@ -121,11 +122,18 @@ private Object test(Map params, CallContext context) throws IOEx matched++; Processes.Result result = run(test.get("command"), project, strings(params.get("userArguments")), context); output.append(result.stdout()).append(result.stderr()); - if (result.exitCode() != 0) return object("exitCode", result.exitCode(), "output", output.toString()); + results.add(object("project", project.get("path"), "suite", test.get("suite"), "case", test.get("case"), "status", result.exitCode() == 0 ? "passed" : "failed")); + if (result.exitCode() != 0) return testReply(params, result.exitCode(), output.toString(), results); } } if (matched == 0 && (params.containsKey("suite") || params.containsKey("case"))) throw new PluginError("sample.test", "The selected test no longer exists."); - return object("exitCode", 0, "output", output.toString()); + return testReply(params, 0, output.toString(), results); + } + + private Object testReply(Map params, int exit, String output, List tests) { + Map reply = object("exitCode", exit, "output", output); + if (Boolean.TRUE.equals(params.get("testResults"))) reply.put("testResults", object("format", "litheTestResults", "version", 1, "tests", tests)); + return reply; } private Processes.Result run(Object declared, Map project, List arguments, CallContext context) throws IOException { diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java index 2b0d443..c1ca809 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -695,8 +695,13 @@ private void invokeBuild(Object id, String method, Map arguments respond(id, null, error(Protocol.INVALID_PARAMS, "Model inputs require build protocol 6.")); return; } + if (arguments.containsKey("testResults") && (negotiated.buildVersion() < 8 + || !Protocol.BUILD_TEST.equals(method) || !Boolean.TRUE.equals(arguments.get("testResults")))) { + respond(id, null, error(Protocol.INVALID_PARAMS, "Inline test reports require build protocol 8 and a test request.")); + return; + } if (!Protocol.buildProgress(method)) { - run(id, null, null, context -> handler.handle(arguments, context), result -> buildResult(method, result), null); + run(id, null, null, context -> handler.handle(arguments, context), result -> buildResult(method, result, negotiated.buildVersion()), null); return; } Object wireId = arguments.get("operationID"); @@ -723,7 +728,7 @@ public void report(String text, Double percentage, String output) { } }; run(id, null, (String) wireId, context -> handler.handle(arguments, context), - result -> buildResult(method, result), () -> reporting.set(false), progress); + result -> buildResult(method, result, negotiated.buildVersion()), () -> reporting.set(false), progress); } private interface Invocation { @@ -998,7 +1003,9 @@ static Map toResponseError(Throwable failure, Cancellation cance * plain document. A result in another format is a backend bug: Core would * refuse it as {@code plugin.build.invalidOutput}, so it fails here with a log. */ - static Object buildResult(String method, Object result) { + static Object buildResult(String method, Object result) { return buildResult(method, result, Protocol.BUILD_VERSION); } + + private static Object buildResult(String method, Object result, int protocolVersion) { String expected = BUILD_FORMATS.get(method); if (!(result instanceof Map)) { throw new PluginSdkError("sdk.build.invalidResult", method + " must return a reply object."); @@ -1027,6 +1034,17 @@ static Object buildResult(String method, Object result) { } document.put("exitCode", ((Number) exit).longValue()); } + if (document.containsKey("testResults")) { + Object value = document.get("testResults"); + Map report = value instanceof Map ? Json.object(value) : Collections.emptyMap(); + Object tests = report.get("tests"); + if (protocolVersion < 8 || !(Protocol.BUILD_TEST.equals(method) || Protocol.BUILD_EXECUTE.equals(method)) + || !"litheTestResults".equals(report.get("format")) || !(report.get("version") instanceof Number) + || ((Number) report.get("version")).doubleValue() != 1 || !(tests instanceof List) + || ((List) tests).size() > 10000 || Protocol.utf8Length(Json.stringify(report)) > 8 * 1024 * 1024) { + throw new PluginSdkError("sdk.build.invalidResult", "Inline test reports need protocol 8 and a bounded litheTestResults document."); + } + } if (Protocol.BUILD_PREPARE_DEBUG.equals(method)) { if (!(document.get("program") instanceof String) || !(document.get("workingDirectory") instanceof String) || !stringList(document.get("arguments")) || !stringMap(document.get("environment")) diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index 149a2ed..532a26b 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -13,7 +13,7 @@ public final class Protocol { /** Protocol versions this SDK can answer. */ public static final int FUNCTIONS_VERSION = 1; - public static final int BUILD_VERSION = 7; + public static final int BUILD_VERSION = 8; public static final String INITIALIZE = "initialize"; public static final String INITIALIZED = "initialized"; diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java index c79adaf..7644371 100644 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java @@ -37,7 +37,7 @@ void eachVersionAdmitsOnlyItsTypedMethods() { List> replies = Arrays.asList(map("exitCode", 0, "output", "built\n"), map("exitCode", 2, "output", "test failed\n"), map("tests", Arrays.asList(map("suite", "Suite", "case", "exact # [case]"))), LAUNCH, map("program", "app/bin/main", "arguments", Arrays.asList("a b"), "workingDirectory", "app", "environment", map()), map("sources", Collections.emptyList())); - for (int version = 1; version <= 7; version++) { + for (int version = 1; version <= 8; version++) { Backend backend = Backend.create(null, null) .onExecute((params, context) -> replies.get(0)) .onTest((params, context) -> replies.get(1)) @@ -66,11 +66,31 @@ void eachVersionAdmitsOnlyItsTypedMethods() { } } + @Test + void inlineReportsNeedVersionEightAndRemainBoundToTheCompletingTest() { + for (int version : new int[] {7, 8}) { + java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger(); + Map report = map("format", "litheTestResults", "version", 1L, "tests", Arrays.asList( + map("project", "a", "suite", "Same", "case", "checks", "status", "failed"), + map("project", "b", "suite", "Same", "case", "checks", "status", "passed"))); + Backend backend = Backend.create(null, null).onTest((params, context) -> { + calls.incrementAndGet(); return map("exitCode", 1, "output", "failed", "testResults", report); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(version)); + Map response = host.request("lithe/build/test", map("operationID", "wire-reports", "testResults", true)); + if (version == 7) { assertEquals(-32602L, errorCode(response)); assertEquals(0, calls.get()); } + else { assertEquals(report, Json.object(response.get("result")).get("testResults")); assertEquals(1, calls.get()); } + } finally { host.close(); } + } + } + @Test void readOnlyModelInputsRequireVersionSixAndPreserveHostIdentity() { Map inputs = map("version", 1L, "revision", 17L, "inputs", Arrays.asList( map("pluginID", "dev.source", "buildSystemID", "source", "state", "unavailable", "instances", Collections.emptyList()))); - for (int version : new int[] {5, 6, 7}) { + for (int version : new int[] {5, 6, 7, 8}) { AtomicReference seen = new AtomicReference<>(); Backend backend = Backend.create(null, null).onModel((params, context) -> { seen.set(params.get("modelInputs")); diff --git a/sdk/node/README.md b/sdk/node/README.md index 75b0221..e9735a3 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -253,3 +253,16 @@ The two native workflow samples demonstrate independent producer and consumer backends in the Core end-to-end tests. Their engine is fictional; this does not migrate Maven, JDT, LLDB or existing plugin business logic. The contract mirror must be synced from the merged Lithe revision before these changes are released. + +## Inline test reports (development protocol 8) + +A test declared with `results: { "decoder": "provider" }` requires buildProviders 8. +Core sends `testResults: true`; answer with `testResults: { format: "litheTestResults", +version: 1, tests: [...] }` beside `exitCode` and `output`. Cases carry suite/case/status +and optional project/source paths, message, durationMillis and invocations. A failed +test still returns its nonzero exit code and current reports. Report discovery and +freshness belong to the provider's upstream engine; never return old files or silently +empty results after a read failure. Core validates 10,000 cases/8 MiB and keeps equal +suite/case names in distinct projects separate. Earlier negotiation refuses the new +request field. Ordinary execute replies may include reports for build phases that run +tests. These SDK sources are development inputs pending a coordinated host/SDK release. diff --git a/sdk/node/examples/sample-native-build/backend/main.mjs b/sdk/node/examples/sample-native-build/backend/main.mjs index b322874..a8c6e3a 100644 --- a/sdk/node/examples/sample-native-build/backend/main.mjs +++ b/sdk/node/examples/sample-native-build/backend/main.mjs @@ -42,12 +42,15 @@ backend.build.test(async (params, context) => { throw new PluginError('sample.test', 'The selected test no longer exists.'); } let output = ''; + const tests = []; + const finish = exitCode => ({ exitCode, output, ...(params.testResults ? { testResults: { format: 'litheTestResults', version: 1, tests } } : {}) }); for (const { project, test } of selected) { const result = await run(test.command, project, params.userArguments, context); output += result.stdout + result.stderr; - if (result.exitCode !== 0) return { exitCode: result.exitCode, output }; + tests.push({ project: project.path, suite: test.suite, case: test.case, status: result.exitCode === 0 ? 'passed' : 'failed' }); + if (result.exitCode !== 0) return finish(result.exitCode); } - return { exitCode: 0, output }; + return finish(0); }); backend.build.prepareRun((params, context) => prepare(params, context, false)); diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index 95a3b06..bfbed6d 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -494,6 +494,11 @@ class Backend { this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Model inputs require build protocol 6.' }); return; } + if (Object.hasOwn(params ?? {}, 'testResults') + && (this.#buildVersion < 8 || method !== METHODS.buildTest || params.testResults !== true)) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Inline test reports require build protocol 8 and a test request.' }); + return; + } const progressing = [METHODS.buildExecute, METHODS.buildTest, METHODS.buildPrepareRun, METHODS.buildPrepareDebug].includes(method); if (!params || typeof params !== 'object' || Array.isArray(params) || (progressing && typeof params.operationID !== 'string')) { @@ -513,7 +518,7 @@ class Backend { if (Number.isFinite(percentage)) report.percentage = Math.min(100, Math.max(0, Math.round(percentage))); this.#write({ jsonrpc: '2.0', method: METHODS.buildProgress, params: report }); }, - } : context), (result) => buildResult(method, result), () => { reporting = false; }); + } : context), (result) => buildResult(method, result, this.#buildVersion), () => { reporting = false; }); } #invokeFunction(id, { functionID, operationID, params }) { @@ -753,7 +758,7 @@ function requestKey(id) { * plain document. A result in another format is a backend bug: Core would * refuse it as `plugin.build.invalidOutput`, so it fails here with a clear log. */ -function buildResult(method, result) { +function buildResult(method, result, version) { const expected = BUILD_FORMATS[method]; if (!isObject(result)) { throw new PluginSdkError('sdk.build.invalidResult', `${method} must return a reply object.`); @@ -773,6 +778,14 @@ function buildResult(method, result) { || typeof result.output !== 'string' || Buffer.byteLength(result.output) > LIMITS.maxBuildResultBytes)) { throw new PluginSdkError('sdk.build.invalidResult', `${method} needs an int32 exitCode and bounded output.`); } + if (Object.hasOwn(result, 'testResults')) { + const report = result.testResults; + if (version < 8 || ![METHODS.buildExecute, METHODS.buildTest].includes(method) || !isObject(report) + || report.format !== 'litheTestResults' || report.version !== 1 || !Array.isArray(report.tests) + || report.tests.length > 10000 || Buffer.byteLength(JSON.stringify(report)) > 8 * 1024 * 1024) { + throw new PluginSdkError('sdk.build.invalidResult', 'Inline test reports need protocol 8 and a bounded litheTestResults document.'); + } + } if (method === METHODS.buildPrepareRun && (!isObject(result.executable) || !['toolchain', 'command'].includes(result.executable.kind) || typeof result.executable.value !== 'string' || typeof result.workingDirectory !== 'string' diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index 39c48a3..2bb8845 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -107,6 +107,8 @@ export interface BuildExecutionParams extends BuildRequestParams { } export interface BuildTestParams extends BuildRequestParams { + /** Requires build protocol 8 and an inline report in the reply. */ + testResults?: true; project?: BuildSelection | null; target?: BuildSelection | null; suite?: string; @@ -135,7 +137,13 @@ export interface BuildContext extends RequestContext { progress(report: BuildProgressReport): void; } -export interface BuildExecutionResult { exitCode: number; output: string; } +/** Results belong to this operation; the host does not discover report files. */ +export interface BuildTestResultsDocument { + format: 'litheTestResults'; version: 1; + tests: { project?: string; suite: string; case: string; status: 'passed' | 'failed' | 'error' | 'skipped'; + message?: string; durationMillis?: number; path?: string; line?: number; invocations?: number }[]; +} +export interface BuildExecutionResult { exitCode: number; output: string; testResults?: BuildTestResultsDocument; } export interface BuildTestDiscovery { format?: 'litheTestDiscovery'; version?: 1; @@ -225,7 +233,7 @@ export interface ListenOptions { export interface Backend { readonly negotiated: Negotiated; readonly initializeParams: JsonObject | null; - /** Highest common version (1-7), or null before/without negotiation. */ + /** Highest common version (1-8), or null before/without negotiation. */ readonly buildVersion: number | null; readonly functionIDs: string[]; readonly build: { diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index a1e30b9..ce88311 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -6,7 +6,7 @@ /** Protocol versions this SDK can answer. */ export const FUNCTIONS_VERSION = 1; -export const BUILD_VERSION = 7; +export const BUILD_VERSION = 8; export const VCS_VERSION = 1; export const METHODS = Object.freeze({ diff --git a/sdk/node/test/build.test.mjs b/sdk/node/test/build.test.mjs index c1ca912..01fe38e 100644 --- a/sdk/node/test/build.test.mjs +++ b/sdk/node/test/build.test.mjs @@ -16,7 +16,7 @@ const methods = [ ['configuration', 7, { sources: [] }], ]; -for (const version of [1, 2, 3, 4, 5, 6, 7]) { +for (const version of [1, 2, 3, 4, 5, 6, 7, 8]) { test(`build protocol ${version} admits only its typed methods`, { timeout: 15000 }, async (t) => { const backend = createBackend(); const invoked = []; @@ -194,7 +194,7 @@ test('read-only model inputs require protocol 6 and preserve host identity', { t const modelInputs = { version: 1, revision: 17, inputs: [ { pluginID: 'dev.source', buildSystemID: 'source', state: 'unavailable', instances: [] }, ] }; - for (const version of [5, 6, 7]) { + for (const version of [5, 6, 7, 8]) { const seen = []; const backend = createBackend(); backend.build.model((params) => { seen.push(params.modelInputs); return { projects: [], targets: [], tasks: [] }; }); @@ -207,3 +207,30 @@ test('read-only model inputs require protocol 6 and preserve host identity', { t await host.close(); } }); + +for (const version of [7, 8]) { + test(`inline test reports require protocol 8 (peer ${version})`, { timeout: 15000 }, async t => { + let calls = 0; + const report = { format: 'litheTestResults', version: 1, tests: [ + { project: 'a', suite: 'Same', case: 'checks', status: 'failed' }, + { project: 'b', suite: 'Same', case: 'checks', status: 'passed' }, + ] }; + const backend = createBackend(); + backend.build.test(() => { calls++; return { exitCode: 1, output: 'failed', testResults: report }; }); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [version] } }); + const response = await host.request('lithe/build/test', { operationID: 'wire-reports', testResults: true }); + if (version === 7) { assert.equal(response.error.code, -32602); assert.equal(calls, 0); } + else { assert.deepEqual(response.result.testResults, report); assert.equal(response.result.exitCode, 1); } + }); +} + +test('malformed inline report cannot become an empty successful outcome', { timeout: 15000 }, async t => { + const backend = createBackend(); + backend.build.test(() => ({ exitCode: 0, output: '', testResults: { format: 'wrong', version: 1, tests: [] } })); + const host = new TestHost(backend); t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [8] } }); + const response = await host.request('lithe/build/test', { operationID: 'wire-invalid', testResults: true }); + assert.ok(response.error); assert.equal(response.result, undefined); +}); diff --git a/sdk/node/test/types/build.mts b/sdk/node/test/types/build.mts index dfa0ffd..a99dd3a 100644 --- a/sdk/node/test/types/build.mts +++ b/sdk/node/test/types/build.mts @@ -13,7 +13,10 @@ backend.build.execute((params, context) => { backend.build.test(async (params, context) => { const suite: string | undefined = params.suite; context.progress({ output: suite }); - return { exitCode: 2, output: 'test failure' }; + const wantsReport: true | undefined = params.testResults; + return { exitCode: 2, output: 'test failure', ...(wantsReport ? { testResults: { + format: 'litheTestResults' as const, version: 1 as const, tests: [{ project: 'app', suite: 'Suite', case: 'checks', status: 'failed' as const }], + } } : {}) }; }); backend.build.discoverTests(async (params: BuildRequestParams) => ({ tests: [{ suite: 'Suite', case: 'exact # [case]', project: { name: 'app', path: params.root } }], From 0d003342975b44e93dbdc707de46395741ca6237 Mon Sep 17 00:00:00 2001 From: wgw Date: Thu, 29 Oct 2026 13:36:20 +0800 Subject: [PATCH 03/12] feat(sdk): negotiate opaque semantic test selectors in build protocol 9 --- sdk/java/README.md | 9 +++++++ .../github/litheidea/plugin/sdk/Backend.java | 16 ++++++++++++ .../github/litheidea/plugin/sdk/Protocol.java | 2 +- .../litheidea/plugin/sdk/BuildTest.java | 26 ++++++++++++++++++- sdk/node/README.md | 12 ++++++++- sdk/node/src/backend.mjs | 9 +++++++ sdk/node/src/index.d.ts | 5 ++++ sdk/node/src/protocol.mjs | 2 +- sdk/node/test/build.test.mjs | 22 +++++++++++++++- sdk/node/test/types/build.mts | 3 +++ 10 files changed, 101 insertions(+), 5 deletions(-) diff --git a/sdk/java/README.md b/sdk/java/README.md index d1468bd..a530f9f 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -204,3 +204,12 @@ empty results after a read failure. Core validates 10,000 cases/8 MiB and keeps suite/case names in distinct projects separate. Earlier negotiation refuses the new request field. Ordinary execute replies may include reports for build phases that run tests. These SDK sources are development inputs pending a coordinated host/SDK release. + +## Opaque test selectors (development protocol 9) + +`onTest` can receive `selectorKind` and `selectors` for a namespace declared by +`test.selectorKinds`. The SDK admits them only after protocol 9 negotiation and +rejects conflicts with suite/case, more than 64 values, empty/control-containing +values and values exceeding 4096 UTF-8 bytes. An empty list selects the normal +workspace/project scope. Preserve language-engine identities until mapping them +to the upstream runner, and reject selections that cannot be represented exactly. diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java index c1ca809..179ee03 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -700,6 +700,22 @@ private void invokeBuild(Object id, String method, Map arguments respond(id, null, error(Protocol.INVALID_PARAMS, "Inline test reports require build protocol 8 and a test request.")); return; } + if (arguments.containsKey("selectorKind") || arguments.containsKey("selectors")) { + Object kind = arguments.get("selectorKind"), selectors = arguments.get("selectors"); + boolean valid = negotiated.buildVersion() >= 9 && Protocol.BUILD_TEST.equals(method) + && kind instanceof String && ((String) kind).matches("[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}") + && selectors instanceof List && ((List) selectors).size() <= 64 + && !arguments.containsKey("suite") && !arguments.containsKey("case"); + if (valid) for (Object selector : (List) selectors) { + if (!(selector instanceof String) || ((String) selector).isEmpty() + || ((String) selector).getBytes(StandardCharsets.UTF_8).length > 4096 + || ((String) selector).chars().anyMatch(Character::isISOControl)) { valid = false; break; } + } + if (!valid) { + respond(id, null, error(Protocol.INVALID_PARAMS, "Opaque selectors require build protocol 9 and a valid test selection.")); + return; + } + } if (!Protocol.buildProgress(method)) { run(id, null, null, context -> handler.handle(arguments, context), result -> buildResult(method, result, negotiated.buildVersion()), null); return; diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index 532a26b..e3a048a 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -13,7 +13,7 @@ public final class Protocol { /** Protocol versions this SDK can answer. */ public static final int FUNCTIONS_VERSION = 1; - public static final int BUILD_VERSION = 8; + public static final int BUILD_VERSION = 9; public static final String INITIALIZE = "initialize"; public static final String INITIALIZED = "initialized"; diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java index 7644371..d36df21 100644 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java @@ -30,6 +30,30 @@ private static Object errorCode(Map response) { return Json.object(response.get("error")).get("code"); } + @Test + void opaqueSelectorsCannotBeIgnoredByAnOlderBackend() { + for (int version : new int[] {8, 9}) { + AtomicReference seen = new AtomicReference<>(); + Backend backend = Backend.create(null, null).onTest((params, context) -> { + seen.set(params.get("selectors")); return map("exitCode", 0, "output", ""); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(version)); + Map params = map("operationID", "wire-selectors", "selectorKind", "org.example.semantic", + "selectors", Arrays.asList("opaque # [case] () 中文")); + Map result = host.request(Protocol.BUILD_TEST, params); + if (version == 8) { assertEquals(-32602L, errorCode(result)); assertEquals(null, seen.get()); } + else { + assertTrue(result.containsKey("result")); assertEquals(params.get("selectors"), seen.get()); + params.put("suite", "Suite"); assertEquals(-32602L, errorCode(host.request(Protocol.BUILD_TEST, params))); + params.remove("suite"); params.put("selectors", Arrays.asList("bad\nvalue")); + assertEquals(-32602L, errorCode(host.request(Protocol.BUILD_TEST, params))); + } + } finally { host.close(); } + } + } + @Test void eachVersionAdmitsOnlyItsTypedMethods() { List methods = Arrays.asList("execute", "test", "discoverTests", "prepareRun", "prepareDebug", "configuration"); @@ -37,7 +61,7 @@ void eachVersionAdmitsOnlyItsTypedMethods() { List> replies = Arrays.asList(map("exitCode", 0, "output", "built\n"), map("exitCode", 2, "output", "test failed\n"), map("tests", Arrays.asList(map("suite", "Suite", "case", "exact # [case]"))), LAUNCH, map("program", "app/bin/main", "arguments", Arrays.asList("a b"), "workingDirectory", "app", "environment", map()), map("sources", Collections.emptyList())); - for (int version = 1; version <= 8; version++) { + for (int version = 1; version <= 9; version++) { Backend backend = Backend.create(null, null) .onExecute((params, context) -> replies.get(0)) .onTest((params, context) -> replies.get(1)) diff --git a/sdk/node/README.md b/sdk/node/README.md index e9735a3..814ffb3 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -123,7 +123,7 @@ package. The SDK has no dependencies. | `backend.build.test(handler)`, `backend.build.discoverTests(handler)` | Protocol 3+: exact test execution and runnable selectors; discovery gets `format: "litheTestDiscovery"`, `version: 1`. | | `backend.build.configuration(handler)` | Protocol 7+: read-only non-sensitive raw/effective native configuration; stamps `litheBuildConfiguration`, version 1. The host owns explicit native-file saves. | | `backend.build.prepareRun(handler)` | Protocol 4: returns a portable launch plan after upstream synchronization/build; the host owns the application process. | -| `backend.buildVersion` | Highest common negotiated build version (1–7), or `null`; methods newer than that version are refused before their handler runs. | +| `backend.buildVersion` | Highest common negotiated build version (1–9), or `null`; methods newer than that version are refused before their handler runs. | | `backend.vcs(id, handlers, traits?)` | Implements the typed VCS requests; see [A VCS provider](#a-vcs-provider). `litheVcs` is answered only when the host offers it. | | `backend.function(id, handler)` | Declares a function offered in `initialize`. | | `backend.registerFunction(id, handler)`, `backend.unregisterFunction(id)` | Adds or withdraws a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list; later they send `lithe/function/register` / `unregister`. Withdrawing revokes running calls. | @@ -266,3 +266,13 @@ empty results after a read failure. Core validates 10,000 cases/8 MiB and keeps suite/case names in distinct projects separate. Earlier negotiation refuses the new request field. Ordinary execute replies may include reports for build phases that run tests. These SDK sources are development inputs pending a coordinated host/SDK release. + +## Opaque test selectors (development protocol 9) + +A provider test may declare public `selectorKinds` in its manifest. Protocol 9 +passes `selectorKind` and `selectors` unchanged from the owning language engine; +they are mutually exclusive with `suite` and `case`. An empty list selects the +normal workspace/project scope. The SDK rejects these fields before the handler +on older negotiated versions, invalid identifiers, more than 64 selectors, +empty/control-containing values, or values larger than 4096 UTF-8 bytes. The +backend must reject identities its upstream runner cannot represent exactly. diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index bfbed6d..20d205c 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -499,6 +499,15 @@ class Backend { this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Inline test reports require build protocol 8 and a test request.' }); return; } + if ((Object.hasOwn(params ?? {}, 'selectorKind') || Object.hasOwn(params ?? {}, 'selectors')) + && (this.#buildVersion < 9 || method !== METHODS.buildTest + || typeof params.selectorKind !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$/.test(params.selectorKind) + || !Array.isArray(params.selectors) || params.selectors.length > 64 + || params.selectors.some(value => typeof value !== 'string' || !value || Buffer.byteLength(value) > 4096 || /[\x00-\x1f\x7f-\x9f]/.test(value)) + || Object.hasOwn(params, 'suite') || Object.hasOwn(params, 'case'))) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Opaque selectors require build protocol 9 and a valid test selection.' }); + return; + } const progressing = [METHODS.buildExecute, METHODS.buildTest, METHODS.buildPrepareRun, METHODS.buildPrepareDebug].includes(method); if (!params || typeof params !== 'object' || Array.isArray(params) || (progressing && typeof params.operationID !== 'string')) { diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index 2bb8845..1eefaba 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -107,6 +107,9 @@ export interface BuildExecutionParams extends BuildRequestParams { } export interface BuildTestParams extends BuildRequestParams { + /** Protocol 9: public semantic namespace and upstream-owned opaque identities. */ + selectorKind?: string; + selectors?: string[]; /** Requires build protocol 8 and an inline report in the reply. */ testResults?: true; project?: BuildSelection | null; @@ -140,6 +143,8 @@ export interface BuildContext extends RequestContext { /** Results belong to this operation; the host does not discover report files. */ export interface BuildTestResultsDocument { format: 'litheTestResults'; version: 1; + /** Completed project/suite/case scopes; omitted means only returned cases are replaced. */ + coverage?: { project: string; suite?: string; case?: string }[]; tests: { project?: string; suite: string; case: string; status: 'passed' | 'failed' | 'error' | 'skipped'; message?: string; durationMillis?: number; path?: string; line?: number; invocations?: number }[]; } diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index ce88311..961d438 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -6,7 +6,7 @@ /** Protocol versions this SDK can answer. */ export const FUNCTIONS_VERSION = 1; -export const BUILD_VERSION = 8; +export const BUILD_VERSION = 9; export const VCS_VERSION = 1; export const METHODS = Object.freeze({ diff --git a/sdk/node/test/build.test.mjs b/sdk/node/test/build.test.mjs index 01fe38e..7bb2dc9 100644 --- a/sdk/node/test/build.test.mjs +++ b/sdk/node/test/build.test.mjs @@ -16,7 +16,7 @@ const methods = [ ['configuration', 7, { sources: [] }], ]; -for (const version of [1, 2, 3, 4, 5, 6, 7, 8]) { +for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9]) { test(`build protocol ${version} admits only its typed methods`, { timeout: 15000 }, async (t) => { const backend = createBackend(); const invoked = []; @@ -234,3 +234,23 @@ test('malformed inline report cannot become an empty successful outcome', { time const response = await host.request('lithe/build/test', { operationID: 'wire-invalid', testResults: true }); assert.ok(response.error); assert.equal(response.result, undefined); }); + +for (const version of [8, 9]) { + test(`opaque selectors are forwarded only after protocol 9 agreement (peer ${version})`, { timeout: 15000 }, async t => { + const seen = []; + const backend = createBackend(); + backend.build.test(params => { seen.push(params); return { exitCode: 0, output: '' }; }); + const host = new TestHost(backend); t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [version] } }); + const params = { operationID: 'wire-selectors', selectorKind: 'org.example.semantic', selectors: ['opaque # [case] () 中文'] }; + const response = await host.request('lithe/build/test', params); + if (version < 9) { assert.equal(response.error.code, -32602); assert.deepEqual(seen, []); } + else { + assert.ok(response.result); assert.deepEqual(seen, [params]); + for (const edits of [{ suite: 'Suite' }, { case: 'method' }, { selectors: [''] }, { selectors: ['bad\nvalue'] }, { selectors: Array(65).fill('test') }, { selectorKind: '' }]) { + assert.equal((await host.request('lithe/build/test', { ...params, ...edits })).error.code, -32602); + } + assert.equal(seen.length, 1); + } + }); +} diff --git a/sdk/node/test/types/build.mts b/sdk/node/test/types/build.mts index a99dd3a..9297d51 100644 --- a/sdk/node/test/types/build.mts +++ b/sdk/node/test/types/build.mts @@ -12,6 +12,9 @@ backend.build.execute((params, context) => { }); backend.build.test(async (params, context) => { const suite: string | undefined = params.suite; + const selectorKind: string | undefined = params.selectorKind; + const selectors: string[] | undefined = params.selectors; + void selectorKind; void selectors; context.progress({ output: suite }); const wantsReport: true | undefined = params.testResults; return { exitCode: 2, output: 'test failure', ...(wantsReport ? { testResults: { From ff9524ff16bb2ce9611069722b56066d56a9e657 Mon Sep 17 00:00:00 2001 From: wgw Date: Thu, 29 Oct 2026 14:29:48 +0800 Subject: [PATCH 04/12] docs(sdk): describe public Java build preparation task convention --- sdk/java/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sdk/java/README.md b/sdk/java/README.md index a530f9f..22d886a 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -213,3 +213,14 @@ rejects conflicts with suite/case, more than 64 values, empty/control-containing values and values exceeding 4096 UTF-8 bytes. An empty list selects the normal workspace/project scope. Preserve language-engine identities until mapping them to the upstream runner, and reject selections that cannot be represented exactly. + +## Java build preparation tasks (development convention) + +A build provider can declare project tasks `java-prepare-run-main` and +`java-prepare-run-test` and handle them with `onExecute`. The first prepares +main resources, and the second main plus test resources. The language workflow +uses current model source roles, awaits successful completion, then asks JDT for +runtime paths. The backend owns upstream filtering and reactor semantics. +These are ordinary public task IDs; no Java source parsing, private function or +new backend method is involved. Per-run options/tools are ephemeral plan inputs; +never overwrite saved build defaults while preparing an application. From 3981616c9d8bf990cb9867e6ef7da0035970573b Mon Sep 17 00:00:00 2001 From: wgw Date: Fri, 30 Oct 2026 08:48:52 +0800 Subject: [PATCH 05/12] docs(build): describe effective Maven framework model metadata --- guides/native-build-workflows.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/guides/native-build-workflows.md b/guides/native-build-workflows.md index 6317fc1..1bfdcd5 100644 --- a/guides/native-build-workflows.md +++ b/guides/native-build-workflows.md @@ -110,3 +110,17 @@ node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --working-tree --build-wor Maven 的真实集成场景覆盖损坏 POM 前读取、用户/安装来源、自定义路径、命令行覆盖、敏感内容保留、Core 编辑后回读、旧版本拒绝、清理与包清单不变。SDK 单元测试分别验证 1–7 协商与配置格式;宿主合并前用 `node scripts/sdk-e2e.mjs --source --working-tree --build-workflows --native-configuration` 验证 Node/Java 的端到端示例及协议 7 配置投影、Core 内部纯 XML 编辑、敏感内容保留与读取无写入。源码通过不表示已发版;Java SDK/JAR 的正式版本与哈希仍需发布流程固定。 `--native-configuration` 在宿主合并前显式启用协议 7 场景;契约镜像同步到包含 7 的 preview 提交后默认启用。Node/Java 的无页面样例仍以其虚构引擎 JSON 供应非敏感配置投影,不增加 XML 解析或凭据传输。 + +## Maven framework model properties + +Maven's backend publishes `run.modelVersion: "1"` on each canonical model +project. For non-aggregator projects it may also publish `run.provider` with +`spring-boot.maven`, `quarkus.maven`, or `micronaut.maven`. The selection comes +from Maven's effective applied build plugins, including active Profiles and +inheritance; plugin management alone is not application. These are scalar model +properties, not a new SDK RPC or launch recipe. The host forwards the current +project name, reactor and selection to Run generation; Core validates and +projects existing service identities. A host requiring this model rejects an +older Maven plugin without the version property and asks for an update rather +than parsing POMs itself. Existing framework launch options remain in their +current Run configuration layer until launch planning is migrated. From 19fbda5d7eabec6f6a69fa8d79963e11e3e78238 Mon Sep 17 00:00:00 2001 From: wgw Date: Fri, 30 Oct 2026 09:01:05 +0800 Subject: [PATCH 06/12] docs(build): document upstream compiler requirement metadata --- guides/native-build-workflows.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/guides/native-build-workflows.md b/guides/native-build-workflows.md index 1bfdcd5..341b90e 100644 --- a/guides/native-build-workflows.md +++ b/guides/native-build-workflows.md @@ -124,3 +124,12 @@ projects existing service identities. A host requiring this model rejects an older Maven plugin without the version property and asks for an update rather than parsing POMs itself. Existing framework launch options remain in their current Run configuration layer until launch planning is migrated. + +The optional scalar project property `java.minimumVersion` contains a normalized +Java feature version from the configured compiler execution model. Maven's +backend calculates its own lifecycle plan without executing goals, evaluates +compiler parameters with Maven's evaluator, and accounts for main/test and +custom executions. Missing means unknown (including an unavailable descriptor +or unresolved expression). Hosts must not fill the gap by parsing POM text. +The highest known floor applies to project toolchain selection; platform Java +version preferences must not lower it. From afcc133dc4c975ecbbdc8c5d7cc16809d9658241 Mon Sep 17 00:00:00 2001 From: wgw Date: Fri, 30 Oct 2026 09:22:15 +0800 Subject: [PATCH 07/12] docs(build): describe native Wrapper model facts and invalidation --- guides/native-build-workflows.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/guides/native-build-workflows.md b/guides/native-build-workflows.md index 341b90e..926015b 100644 --- a/guides/native-build-workflows.md +++ b/guides/native-build-workflows.md @@ -130,6 +130,14 @@ Java feature version from the configured compiler execution model. Maven's backend calculates its own lifecycle plan without executing goals, evaluates compiler parameters with Maven's evaluator, and accounts for main/test and custom executions. Missing means unknown (including an unavailable descriptor -or unresolved expression). Hosts must not fill the gap by parsing POM text. +or unsupported evaluated version). Hosts must not fill the gap by parsing POM text. The highest known floor applies to project toolchain selection; platform Java version preferences must not lower it. + +Maven also publishes optional scalar `maven.wrapper` (`mvnw` or `mvnw.cmd`, +relative to the instance root) and `maven.minimumVersion` properties, read from +that instance's native script/Wrapper configuration. Hosts project the script +into a workspace-relative path. Java Properties semantics own comments and URL +escaping; unknown distribution filenames stay unknown, and model reading never +executes the Wrapper. Script and properties changes invalidate the model. +Multiple independent reactors must not share one global Wrapper selection. From af13e3c8d9cd1de540c2e909520d945fe2f845ee Mon Sep 17 00:00:00 2001 From: wgw Date: Fri, 30 Oct 2026 14:58:32 +0800 Subject: [PATCH 08/12] feat(sdk): negotiate typed service and task run choices --- scripts/sdk-e2e.mjs | 6 ++- sdk/e2e/plugin_sdk/native_build.rs | 20 ++++++++++ sdk/java/README.md | 18 +++++++++ .../samplenativebuild/SampleNativeBuild.java | 14 ++++++- .../github/litheidea/plugin/sdk/Backend.java | 4 ++ .../github/litheidea/plugin/sdk/Protocol.java | 38 ++++++++++++++++++- .../litheidea/plugin/sdk/BuildTest.java | 31 ++++++++++++++- sdk/node/README.md | 21 +++++++++- .../sample-native-build/backend/main.mjs | 12 ++++-- sdk/node/src/backend.mjs | 6 +++ sdk/node/src/index.d.ts | 11 ++++++ sdk/node/src/protocol.mjs | 25 +++++++++++- sdk/node/test/build.test.mjs | 27 ++++++++++++- 13 files changed, 221 insertions(+), 12 deletions(-) diff --git a/scripts/sdk-e2e.mjs b/scripts/sdk-e2e.mjs index fc91fe3..59f09d0 100644 --- a/scripts/sdk-e2e.mjs +++ b/scripts/sdk-e2e.mjs @@ -140,7 +140,7 @@ async function rustSample() { async function main() { const { values } = parseArgs({ options: { ref: { type: 'string' }, source: { type: 'string' }, - 'working-tree': { type: 'boolean' }, 'build-workflows': { type: 'boolean' }, 'native-configuration': { type: 'boolean' }, 'provider-reports': { type: 'boolean' } } }); + 'working-tree': { type: 'boolean' }, 'build-workflows': { type: 'boolean' }, 'native-configuration': { type: 'boolean' }, 'provider-reports': { type: 'boolean' }, 'provider-run-options': { type: 'boolean' } } }); if (values['working-tree'] && (!values.source || values.ref)) { throw new Error('--working-tree requires a local --source and cannot be combined with --ref.'); } @@ -148,7 +148,8 @@ async function main() { const schema = JSON.parse(await readFile(join(repository, 'contract/v1/plugin-manifest.schema.json'), 'utf8')); const reportWorkflows = values['provider-reports'] || schema.properties.requires.properties.buildProviders.enum.includes(8); const configurationWorkflows = values['native-configuration'] || schema.properties.requires.properties.buildProviders.enum.includes(7); - const buildWorkflows = includesNativeWorkflows(schema, values['build-workflows'] || configurationWorkflows || reportWorkflows); + const runOptionsWorkflows = values['provider-run-options'] || schema.properties.requires.properties.buildProviders.enum.includes(10); + const buildWorkflows = includesNativeWorkflows(schema, values['build-workflows'] || configurationWorkflows || reportWorkflows || runOptionsWorkflows); const ref = values['working-tree'] ? 'HEAD' : values.ref ?? lock.source.revision; const source = values.source ?? `${lock.source.repository}.git`; @@ -169,6 +170,7 @@ async function main() { env: { ...process.env, LITHE_PLUGIN_SDK_ROOT: repository, + LITHE_PLUGIN_SDK_RUN_OPTIONS_WORKFLOWS: runOptionsWorkflows ? '1' : '0', LITHE_PLUGIN_SDK_REPORT_WORKFLOWS: reportWorkflows ? '1' : '0', LITHE_PLUGIN_SDK_CONFIGURATION_WORKFLOWS: configurationWorkflows ? '1' : '0', LITHE_PLUGIN_SDK_NODE: process.execPath, diff --git a/sdk/e2e/plugin_sdk/native_build.rs b/sdk/e2e/plugin_sdk/native_build.rs index 90bf561..572699c 100644 --- a/sdk/e2e/plugin_sdk/native_build.rs +++ b/sdk/e2e/plugin_sdk/native_build.rs @@ -358,6 +358,26 @@ pub fn run(sample: &Sample) { "other workspace\n" ); other.stop(); + if std::env::var("LITHE_PLUGIN_SDK_RUN_OPTIONS_WORKFLOWS").as_deref() == Ok("1") { + fs::write(instance.join("lithe-sample.json"), config.to_string()).unwrap(); + let mut native = manifest.clone(); + native["requires"]["buildProviders"] = json!(10); + for execution in ["service", "task"] { + native["buildSystems"][0]["run"].as_array_mut().unwrap().push(json!({"id":execution,"source":"provider", "execution":execution,"fromTargets":{"tags":["application"]}})); + let options = json!({"execution":execution,"runtimeArguments":[],"arguments":["a b","雪",""],"environment":{"EXAMPLE":"typed value"},"workingDirectory":"."}); + let plan = core("build.prepareRunPlan", json!({"manifest":native,"buildSystemID":BUILD_SYSTEM,"request":{ + "root":root,"workspaceRoot":workspace,"run":execution,"project":app,"target":{"name":target["name"],"path":target["path"],"tags":target["tags"]},"launchOptions":options}})); + assert_eq!(plan["status"], "ok", "{plan}"); + let result = session.build_request(&format!("prepare-{execution}"), &plan["plan"]); + let decoded = core("build.decodeRunPreparation", json!({"manifest":native,"buildSystemID":BUILD_SYSTEM, + "request":{"root":root,"workspaceRoot":workspace,"launchOptions":options,"result":result}})); + assert_eq!(decoded["status"], "ok", "{decoded}"); + assert_eq!(decoded["launch"]["workingDirectory"], "."); + assert_eq!(decoded["launch"]["environment"]["EXAMPLE"], "typed value"); + let args = decoded["launch"]["arguments"].as_array().unwrap(); + assert_eq!(&args[args.len()-3..], json!(["a b","雪",""]).as_array().unwrap()); + } + } session.stop(); drop(other); drop(session); diff --git a/sdk/java/README.md b/sdk/java/README.md index 22d886a..f34ee00 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -224,3 +224,21 @@ runtime paths. The backend owns upstream filtering and reactor semantics. These are ordinary public task IDs; no Java source parsing, private function or new backend method is involved. Per-run options/tools are ephemeral plan inputs; never overwrite saved build defaults while preparing an application. + + +## Typed run choices (development protocol 10) + +`onPrepareRun` receives optional `launchOptions` after version-10 negotiation. +Its execution kind, runtime/program argv arrays, environment and optional +workspace-relative working directory/opaque entrypoint are resolved user choices. +The SDK rejects malformed options or older sessions before invoking the handler. +Service/task declarations require protocol 10 and these options; their process +still belongs to the host after preparation. Preserve empty argv values, spaces, +Unicode and literal backslashes. Reject unsupported runtime or entrypoint choices; +the native example consumes supported arguments, environment and cwd directly. +No new runtime dependency or Java baseline is introduced. + +Test an unmerged host with +`node scripts/sdk-e2e.mjs --source --working-tree --build-workflows --provider-run-options`. +The contract mirror and released SDK remain pinned until the corresponding +preview commit is merged and synchronized. diff --git a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java index ed66f09..79a9a3d 100644 --- a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java +++ b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java @@ -12,6 +12,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; @@ -79,7 +80,14 @@ private void serve() { private Object prepare(Map params, CallContext context, boolean debug) throws IOException { List> selected = select(params); Map target = Json.object(params.get("target")); - if (selected.size() != 1 || !"application".equals(params.get("run"))) throw new PluginError("sample.target", "Select one application."); + Map options = params.get("launchOptions") instanceof Map ? Json.object(params.get("launchOptions")) : null; + String execution = String.valueOf(params.get("run")); + if (!Arrays.asList("application", "service", "task").contains(execution) + || (!"application".equals(execution) && options == null) + || (options != null && (!strings(options.get("runtimeArguments")).isEmpty() || options.containsKey("entrypoint") || !execution.equals(options.get("execution"))))) { + throw new PluginError("sample.launchOptions", "This engine does not support the requested runtime or entrypoint options."); + } + if (selected.size() != 1) throw new PluginError("sample.target", "Select one application."); Map project = selected.get(0); if (!(project.get("run") instanceof Map) || !"Main".equals(target.get("name")) || !project.get("path").equals(target.get("path"))) { throw new PluginError("sample.target", "The selected application no longer exists."); @@ -96,8 +104,10 @@ private Object prepare(Map params, CallContext context, boolean } List arguments = strings(run.get("arguments")); arguments.addAll(strings(params.get("userArguments"))); + if (options != null) arguments.addAll(strings(options.get("arguments"))); return object("executable", object("kind", "command", "value", run.get("command")), "arguments", arguments, - "workingDirectory", project.get("path"), "environment", object()); + "workingDirectory", options != null && options.containsKey("workingDirectory") ? options.get("workingDirectory") : project.get("path"), + "environment", options == null ? object() : options.get("environment")); } private Map build(List> projects, List arguments, CallContext context) throws IOException { diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java index 179ee03..d5bcf16 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -700,6 +700,10 @@ private void invokeBuild(Object id, String method, Map arguments respond(id, null, error(Protocol.INVALID_PARAMS, "Inline test reports require build protocol 8 and a test request.")); return; } + if (arguments.containsKey("launchOptions") && (negotiated.buildVersion() < 10 || !Protocol.BUILD_PREPARE_RUN.equals(method) || !Protocol.validBuildLaunchOptions(arguments.get("launchOptions")))) { + respond(id, null, error(Protocol.INVALID_PARAMS, "Typed launch options require build protocol 10 and valid run preparation options.")); + return; + } if (arguments.containsKey("selectorKind") || arguments.containsKey("selectors")) { Object kind = arguments.get("selectorKind"), selectors = arguments.get("selectors"); boolean valid = negotiated.buildVersion() >= 9 && Protocol.BUILD_TEST.equals(method) diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index e3a048a..145d4ea 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -1,6 +1,9 @@ package io.github.litheidea.plugin.sdk; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Map; import java.util.regex.Pattern; /** @@ -13,7 +16,7 @@ public final class Protocol { /** Protocol versions this SDK can answer. */ public static final int FUNCTIONS_VERSION = 1; - public static final int BUILD_VERSION = 9; + public static final int BUILD_VERSION = 10; public static final String INITIALIZE = "initialize"; public static final String INITIALIZED = "initialized"; @@ -132,4 +135,37 @@ public static boolean isReservedMethod(String method) { static int utf8Length(String value) { return value.getBytes(StandardCharsets.UTF_8).length; } + /** Strict portable user choices accepted by build protocol 10. */ + static boolean validBuildLaunchOptions(Object raw) { + if (!(raw instanceof Map)) return false; + Map value = (Map) raw; + List allowed = Arrays.asList("execution", "runtimeArguments", "arguments", "environment", "workingDirectory", "entrypoint"); + if (!allowed.containsAll(value.keySet()) || !Arrays.asList("application", "service", "task").contains(value.get("execution"))) return false; + for (String name : Arrays.asList("runtimeArguments", "arguments")) { + Object arguments = value.get(name); + if (!(arguments instanceof List) || ((List) arguments).size() > 1024) return false; + for (Object argument : (List) arguments) if (!launchText(argument, 65536)) return false; + } + Object environment = value.get("environment"); + if (!(environment instanceof Map) || ((Map) environment).size() > 64) return false; + for (Map.Entry entry : ((Map) environment).entrySet()) { + if (!(entry.getKey() instanceof String) || !((String) entry.getKey()).matches("[A-Za-z_][A-Za-z0-9_]{0,127}") || !launchText(entry.getValue(), 65536)) return false; + } + if (value.containsKey("entrypoint") && (!launchText(value.get("entrypoint"), 4096) || ((String) value.get("entrypoint")).isEmpty())) return false; + if (value.containsKey("workingDirectory")) { + Object rawPath = value.get("workingDirectory"); + if (!launchText(rawPath, 4096)) return false; + String path = (String) rawPath; + if (path.isEmpty() || path.indexOf('\\') >= 0 || path.matches("^[A-Za-z]:.*")) return false; + if (!".".equals(path)) for (String part : path.split("/", -1)) { + if (part.isEmpty() || ".".equals(part) || "..".equals(part)) return false; + } + } + return true; + } + + private static boolean launchText(Object value, int maximum) { + return value instanceof String && ((String) value).getBytes(StandardCharsets.UTF_8).length <= maximum && ((String) value).indexOf(0) < 0; + } + } diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java index d36df21..6947288 100644 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java @@ -61,7 +61,7 @@ void eachVersionAdmitsOnlyItsTypedMethods() { List> replies = Arrays.asList(map("exitCode", 0, "output", "built\n"), map("exitCode", 2, "output", "test failed\n"), map("tests", Arrays.asList(map("suite", "Suite", "case", "exact # [case]"))), LAUNCH, map("program", "app/bin/main", "arguments", Arrays.asList("a b"), "workingDirectory", "app", "environment", map()), map("sources", Collections.emptyList())); - for (int version = 1; version <= 9; version++) { + for (int version = 1; version <= 10; version++) { Backend backend = Backend.create(null, null) .onExecute((params, context) -> replies.get(0)) .onTest((params, context) -> replies.get(1)) @@ -264,4 +264,33 @@ private static String repeat(String text, int count) { for (int index = 0; index < count; index++) result.append(text); return result.toString(); } + @Test + void typedLaunchOptionsRequireVersionTenAndPreserveArgumentBoundaries() { + Map options = map("execution", "service", "runtimeArguments", Arrays.asList("-Dmessage=hello world"), + "arguments", Arrays.asList("雪", "", "a b", "C:\\path"), "environment", map("EXAMPLE", "x"), + "workingDirectory", ".", "entrypoint", "example.Main"); + for (int version : new int[] {9, 10}) { + AtomicReference seen = new AtomicReference<>(); + Backend backend = Backend.create(null, null).onPrepareRun((params, context) -> { seen.set(params.get("launchOptions")); return LAUNCH; }) + .onModel((params, context) -> map("projects", Collections.emptyList())); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(version)); + Map response = host.request(Protocol.BUILD_PREPARE_RUN, map("operationID", "wire-options", "launchOptions", options)); + if (version == 9) { assertEquals(-32602L, errorCode(response)); assertEquals(null, seen.get()); continue; } + assertEquals(options, seen.get()); assertEquals(LAUNCH, response.get("result")); + for (Map patch : Arrays.asList(map("execution", "unknown"), map("arguments", Arrays.asList("bad\u0000")), + map("runtimeArguments", Collections.nCopies(1025, "x")), map("environment", map("BAD=NAME", "x")), + map("environment", map("A", "bad\u0000")), map("workingDirectory", "../escape"), map("workingDirectory", "/absolute"), + map("workingDirectory", "C:relative"), map("workingDirectory", "a//b"), map("workingDirectory", "a/./b"), + map("workingDirectory", "a\\b"), map("entrypoint", ""), map("entrypoint", null), map("unknown", true))) { + Map invalid = new java.util.LinkedHashMap<>(options); invalid.putAll(patch); seen.set(null); + assertEquals(-32602L, errorCode(host.request(Protocol.BUILD_PREPARE_RUN, map("operationID", "wire-options", "launchOptions", invalid))), patch.toString()); + assertEquals(null, seen.get()); + } + assertEquals(-32602L, errorCode(host.request(Protocol.BUILD_MODEL, map("launchOptions", options)))); + } finally { host.close(); } + } + } + } diff --git a/sdk/node/README.md b/sdk/node/README.md index 814ffb3..bbc761b 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -123,7 +123,7 @@ package. The SDK has no dependencies. | `backend.build.test(handler)`, `backend.build.discoverTests(handler)` | Protocol 3+: exact test execution and runnable selectors; discovery gets `format: "litheTestDiscovery"`, `version: 1`. | | `backend.build.configuration(handler)` | Protocol 7+: read-only non-sensitive raw/effective native configuration; stamps `litheBuildConfiguration`, version 1. The host owns explicit native-file saves. | | `backend.build.prepareRun(handler)` | Protocol 4: returns a portable launch plan after upstream synchronization/build; the host owns the application process. | -| `backend.buildVersion` | Highest common negotiated build version (1–9), or `null`; methods newer than that version are refused before their handler runs. | +| `backend.buildVersion` | Highest common negotiated build version (1–10), or `null`; methods newer than that version are refused before their handler runs. | | `backend.vcs(id, handlers, traits?)` | Implements the typed VCS requests; see [A VCS provider](#a-vcs-provider). `litheVcs` is answered only when the host offers it. | | `backend.function(id, handler)` | Declares a function offered in `initialize`. | | `backend.registerFunction(id, handler)`, `backend.unregisterFunction(id)` | Adds or withdraws a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list; later they send `lithe/function/register` / `unregister`. Withdrawing revokes running calls. | @@ -276,3 +276,22 @@ normal workspace/project scope. The SDK rejects these fields before the handler on older negotiated versions, invalid identifiers, more than 64 selectors, empty/control-containing values, or values larger than 4096 UTF-8 bytes. The backend must reject identities its upstream runner cannot represent exactly. + + +## Typed run choices (development protocol 10) + +`backend.build.prepareRun` receives optional `params.launchOptions` only with +protocol 10: an execution kind, separate runtime/program argv arrays, environment, +and optional workspace-relative working directory and opaque entrypoint. Service +and task declarations require version 10 and these options; preparation still +finishes before the host starts and owns the process. Both SDK and Core refuse +old sessions or malformed fields before calling the handler. Empty argv values, +spaces, Unicode and literal backslashes remain separate values. Reject choices +your engine cannot implement; do not silently drop runtime or entrypoint choices. +The native sample demonstrates that refusal and consumes supported arguments, +environment and cwd without parsing a shell string. + +Validate the development host combination with +`node scripts/sdk-e2e.mjs --source --working-tree --build-workflows --provider-run-options`. +This flag explicitly tests unmerged Core work. The contract mirror and released +SDK stay pinned until the corresponding preview commit is merged and synchronized. diff --git a/sdk/node/examples/sample-native-build/backend/main.mjs b/sdk/node/examples/sample-native-build/backend/main.mjs index a8c6e3a..02341e8 100644 --- a/sdk/node/examples/sample-native-build/backend/main.mjs +++ b/sdk/node/examples/sample-native-build/backend/main.mjs @@ -58,7 +58,13 @@ backend.build.prepareDebug((params, context) => prepare(params, context, true)); async function prepare(params, context, debug) { const selected = await select(params); const project = selected[0]; - if (selected.length !== 1 || params.run !== 'application' || !project.run + const options = params.launchOptions; + const execution = params.run === 'application' ? 'application' : params.run; + if ((options && (options.runtimeArguments.length || options.entrypoint || options.execution !== execution)) + || (!['application', 'service', 'task'].includes(params.run)) || (params.run !== 'application' && !options)) { + throw new PluginError('sample.launchOptions', 'This engine does not support the requested runtime or entrypoint options.'); + } + if (selected.length !== 1 || !project.run || params.target?.name !== 'Main' || params.target?.path !== project.path) { throw new PluginError('sample.target', 'The selected application no longer exists.'); } @@ -70,8 +76,8 @@ async function prepare(params, context, debug) { workingDirectory: project.path, environment: {} }; } return { executable: { kind: 'command', value: project.run.command }, - arguments: [...project.run.arguments, ...params.userArguments], - workingDirectory: project.path, environment: {} }; + arguments: [...project.run.arguments, ...params.userArguments, ...(options?.arguments ?? [])], + workingDirectory: options?.workingDirectory ?? project.path, environment: options?.environment ?? {} }; } async function build(selected, arguments_, context) { diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index 20d205c..b6d7d59 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -18,6 +18,7 @@ import { ERROR_CODES, FUNCTIONS_VERSION, isFunctionId, + isBuildLaunchOptions, isReservedMethod, isVcsId, LIMITS, @@ -490,6 +491,11 @@ class Backend { } #invokeBuild(id, method, params, handler) { + if (Object.hasOwn(params ?? {}, 'launchOptions') + && (this.#buildVersion < 10 || method !== METHODS.buildPrepareRun || !isBuildLaunchOptions(params.launchOptions))) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Typed launch options require build protocol 10 and valid run preparation options.' }); + return; + } if (Object.hasOwn(params ?? {}, 'modelInputs') && this.#buildVersion < 6) { this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Model inputs require build protocol 6.' }); return; diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index 1eefaba..488b0a9 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -120,7 +120,18 @@ export interface BuildTestParams extends BuildRequestParams { operationID: string; } +/** Version 10. These are resolved user choices, not shell command strings. */ +export interface BuildLaunchOptions { + execution: 'application' | 'service' | 'task'; + runtimeArguments: string[]; + arguments: string[]; + environment: Record; + workingDirectory?: string; + entrypoint?: string; +} + export interface BuildRunParams extends BuildRequestParams { + launchOptions?: BuildLaunchOptions; run: string; project: BuildSelection; target: BuildSelection; diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index 961d438..cd33c06 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -6,7 +6,7 @@ /** Protocol versions this SDK can answer. */ export const FUNCTIONS_VERSION = 1; -export const BUILD_VERSION = 9; +export const BUILD_VERSION = 10; export const VCS_VERSION = 1; export const METHODS = Object.freeze({ @@ -158,3 +158,26 @@ export function isReservedMethod(method) { method.startsWith('lithe/') ); } + +/** Version-10 user choices remain argv arrays until the owning tool consumes them. */ +export function isBuildLaunchOptions(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const allowed = ['execution', 'runtimeArguments', 'arguments', 'environment', 'workingDirectory', 'entrypoint']; + if (Object.keys(value).some(key => !allowed.includes(key)) + || !['application', 'service', 'task'].includes(value.execution)) return false; + const text = (item, maximum) => typeof item === 'string' && Buffer.byteLength(item) <= maximum && !item.includes('\0'); + for (const arguments_ of [value.runtimeArguments, value.arguments]) { + if (!Array.isArray(arguments_) || arguments_.length > 1024 || arguments_.some(item => !text(item, 65536))) return false; + } + const environment = value.environment; + if (!environment || typeof environment !== 'object' || Array.isArray(environment) + || Object.keys(environment).length > 64 || Object.entries(environment).some(([key, item]) => + !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key) || !text(item, 65536))) return false; + if (Object.hasOwn(value, 'entrypoint') && (!text(value.entrypoint, 4096) || !value.entrypoint)) return false; + if (Object.hasOwn(value, 'workingDirectory')) { + const path = value.workingDirectory; + if (!text(path, 4096) || !path || path.includes('\\') || /^[A-Za-z]:/.test(path) + || (path !== '.' && path.split('/').some(part => !part || part === '.' || part === '..'))) return false; + } + return true; +} diff --git a/sdk/node/test/build.test.mjs b/sdk/node/test/build.test.mjs index 7bb2dc9..db10d90 100644 --- a/sdk/node/test/build.test.mjs +++ b/sdk/node/test/build.test.mjs @@ -16,7 +16,7 @@ const methods = [ ['configuration', 7, { sources: [] }], ]; -for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9]) { +for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { test(`build protocol ${version} admits only its typed methods`, { timeout: 15000 }, async (t) => { const backend = createBackend(); const invoked = []; @@ -254,3 +254,28 @@ for (const version of [8, 9]) { } }); } + + +test('typed launch options preserve argv only after version 10 agreement', { timeout: 15000 }, async (t) => { + const options = { execution: 'service', runtimeArguments: ['-Dmessage=hello world'], arguments: ['雪', '', 'a b', 'C:\\path'], environment: { EXAMPLE: 'x' }, workingDirectory: '.', entrypoint: 'example.Main' }; + for (const version of [9, 10]) { + let seen; + const backend = createBackend(); + backend.build.prepareRun(params => { seen = params.launchOptions; return launch; }); + backend.build.model(() => ({ projects: [] })); + const host = new TestHost(backend); t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [version] } }); + const request = launchOptions => host.request('lithe/build/prepareRun', { operationID: 'wire-options', launchOptions }); + const response = await request(options); + if (version === 9) { assert.equal(response.error.code, -32602); assert.equal(seen, undefined); continue; } + assert.deepEqual(seen, options); assert.deepEqual(response.result, launch); + for (const patch of [{ execution: 'unknown' }, { arguments: ['bad\0'] }, { runtimeArguments: Array(1025).fill('x') }, + { environment: { 'BAD=NAME': 'x' } }, { environment: { A: 'bad\0' } }, { workingDirectory: '../escape' }, + { workingDirectory: '/absolute' }, { workingDirectory: 'C:relative' }, { workingDirectory: 'a//b' }, + { workingDirectory: 'a/./b' }, { workingDirectory: 'a\\b' }, { entrypoint: '' }, { entrypoint: null }, { unknown: true }]) { + seen = undefined; assert.equal((await request({ ...options, ...patch })).error.code, -32602, JSON.stringify(patch)); + assert.equal(seen, undefined); + } + assert.equal((await host.request('lithe/build/model', { launchOptions: options })).error.code, -32602); + } +}); From 4886c9072b1ccb8de9fd4a58e7f706d4ee958f6e Mon Sep 17 00:00:00 2001 From: wgw Date: Fri, 30 Oct 2026 17:01:45 +0800 Subject: [PATCH 09/12] feat(sdk): negotiate JVM service debug choices --- sdk/java/README.md | 12 ++++++++ .../samplenativebuild/SampleNativeBuild.java | 2 +- .../github/litheidea/plugin/sdk/Backend.java | 4 +++ .../github/litheidea/plugin/sdk/Protocol.java | 13 +++++++-- .../litheidea/plugin/sdk/BuildTest.java | 28 +++++++++++++++++++ sdk/node/README.md | 12 ++++++++ .../sample-native-build/backend/main.mjs | 2 +- sdk/node/src/backend.mjs | 4 +++ sdk/node/src/index.d.ts | 2 ++ sdk/node/src/protocol.mjs | 10 +++++-- sdk/node/test/build.test.mjs | 27 +++++++++++++++++- 11 files changed, 109 insertions(+), 7 deletions(-) diff --git a/sdk/java/README.md b/sdk/java/README.md index f34ee00..bc0f0cd 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -242,3 +242,15 @@ Test an unmerged host with `node scripts/sdk-e2e.mjs --source --working-tree --build-workflows --provider-run-options`. The contract mirror and released SDK remain pinned until the corresponding preview commit is merged and synchronized. + + +## JVM service debug choices (development protocol 11) + +A service preparation may receive `launchOptions.debug = { runtime: 'jvm', port: 51235 }`. +This is a host-selected loopback port, with a suspended JVM and exactly one agent; +it is not permission to choose an endpoint, adapter or DAP arguments. Reject the +choice if your engine cannot honor it. SDKs refuse it on protocol 10 or older. +The portable process result is unchanged, and the host owns process lifetime, +readiness, attachment and cleanup. The native-build examples explicitly reject +this JVM choice rather than silently ignoring it. No release version or lock is +changed by this development protocol increment. diff --git a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java index 79a9a3d..ca17c7e 100644 --- a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java +++ b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java @@ -84,7 +84,7 @@ private Object prepare(Map params, CallContext context, boolean String execution = String.valueOf(params.get("run")); if (!Arrays.asList("application", "service", "task").contains(execution) || (!"application".equals(execution) && options == null) - || (options != null && (!strings(options.get("runtimeArguments")).isEmpty() || options.containsKey("entrypoint") || !execution.equals(options.get("execution"))))) { + || (options != null && (!strings(options.get("runtimeArguments")).isEmpty() || options.containsKey("entrypoint") || options.containsKey("debug") || !execution.equals(options.get("execution"))))) { throw new PluginError("sample.launchOptions", "This engine does not support the requested runtime or entrypoint options."); } if (selected.size() != 1) throw new PluginError("sample.target", "Select one application."); diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java index d5bcf16..dc48db9 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -700,6 +700,10 @@ private void invokeBuild(Object id, String method, Map arguments respond(id, null, error(Protocol.INVALID_PARAMS, "Inline test reports require build protocol 8 and a test request.")); return; } + if (arguments.get("launchOptions") instanceof Map && Json.object(arguments.get("launchOptions")).containsKey("debug") && negotiated.buildVersion() < 11) { + respond(id, null, error(Protocol.INVALID_PARAMS, "Runtime debug options require build protocol 11.")); + return; + } if (arguments.containsKey("launchOptions") && (negotiated.buildVersion() < 10 || !Protocol.BUILD_PREPARE_RUN.equals(method) || !Protocol.validBuildLaunchOptions(arguments.get("launchOptions")))) { respond(id, null, error(Protocol.INVALID_PARAMS, "Typed launch options require build protocol 10 and valid run preparation options.")); return; diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index 145d4ea..68d741b 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -16,7 +16,7 @@ public final class Protocol { /** Protocol versions this SDK can answer. */ public static final int FUNCTIONS_VERSION = 1; - public static final int BUILD_VERSION = 10; + public static final int BUILD_VERSION = 11; public static final String INITIALIZE = "initialize"; public static final String INITIALIZED = "initialized"; @@ -139,7 +139,7 @@ static int utf8Length(String value) { static boolean validBuildLaunchOptions(Object raw) { if (!(raw instanceof Map)) return false; Map value = (Map) raw; - List allowed = Arrays.asList("execution", "runtimeArguments", "arguments", "environment", "workingDirectory", "entrypoint"); + List allowed = Arrays.asList("execution", "runtimeArguments", "arguments", "environment", "workingDirectory", "entrypoint", "debug"); if (!allowed.containsAll(value.keySet()) || !Arrays.asList("application", "service", "task").contains(value.get("execution"))) return false; for (String name : Arrays.asList("runtimeArguments", "arguments")) { Object arguments = value.get(name); @@ -151,6 +151,15 @@ static boolean validBuildLaunchOptions(Object raw) { for (Map.Entry entry : ((Map) environment).entrySet()) { if (!(entry.getKey() instanceof String) || !((String) entry.getKey()).matches("[A-Za-z_][A-Za-z0-9_]{0,127}") || !launchText(entry.getValue(), 65536)) return false; } + if (value.containsKey("debug")) { + Object rawDebug = value.get("debug"); + if (!"service".equals(value.get("execution")) || !(rawDebug instanceof Map)) return false; + Map debug = (Map) rawDebug; + Object port = debug.get("port"); + if (debug.size() != 2 || !"jvm".equals(debug.get("runtime")) || !(port instanceof Number) + || ((Number) port).doubleValue() != ((Number) port).longValue() + || ((Number) port).longValue() < 1 || ((Number) port).longValue() > 65535) return false; + } if (value.containsKey("entrypoint") && (!launchText(value.get("entrypoint"), 4096) || ((String) value.get("entrypoint")).isEmpty())) return false; if (value.containsKey("workingDirectory")) { Object rawPath = value.get("workingDirectory"); diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java index 6947288..f372c44 100644 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java @@ -293,4 +293,32 @@ void typedLaunchOptionsRequireVersionTenAndPreserveArgumentBoundaries() { } } + + @Test + void jvmDebugRequiresVersionElevenAndAHostSelectedEndpoint() { + for (int version : new int[] {10, 11}) { + java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger(); + Backend backend = Backend.create(null, null).onPrepareRun((params, context) -> { calls.incrementAndGet(); return LAUNCH; }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(version)); + Map options = map("execution", "service", "runtimeArguments", Collections.emptyList(), + "arguments", Collections.emptyList(), "environment", map(), "debug", map("runtime", "jvm", "port", 51235)); + Map response = host.request(Protocol.BUILD_PREPARE_RUN, map("operationID", "jvm-debug", "launchOptions", options)); + if (version == 10) { assertEquals(-32602L, errorCode(response)); assertEquals(0, calls.get()); } + else { + assertEquals(LAUNCH, response.get("result")); + for (Object debug : Arrays.asList(null, map(), map("runtime", "native", "port", 51235), + map("runtime", "jvm", "port", 0), map("runtime", "jvm", "port", 65536), + map("runtime", "jvm", "port", 1.5), map("runtime", "jvm", "port", true), + map("runtime", "jvm", "port", 51235, "host", "remote"))) { + options.put("debug", debug); + assertEquals(-32602L, errorCode(host.request(Protocol.BUILD_PREPARE_RUN, map("operationID", "jvm-debug", "launchOptions", options)))); + } + assertEquals(1, calls.get()); + } + } finally { host.close(); } + } + } + } diff --git a/sdk/node/README.md b/sdk/node/README.md index bbc761b..1ba8a40 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -295,3 +295,15 @@ Validate the development host combination with `node scripts/sdk-e2e.mjs --source --working-tree --build-workflows --provider-run-options`. This flag explicitly tests unmerged Core work. The contract mirror and released SDK stay pinned until the corresponding preview commit is merged and synchronized. + + +## JVM service debug choices (development protocol 11) + +A service preparation may receive `launchOptions.debug = { runtime: 'jvm', port: 51235 }`. +This is a host-selected loopback port, with a suspended JVM and exactly one agent; +it is not permission to choose an endpoint, adapter or DAP arguments. Reject the +choice if your engine cannot honor it. SDKs refuse it on protocol 10 or older. +The portable process result is unchanged, and the host owns process lifetime, +readiness, attachment and cleanup. The native-build examples explicitly reject +this JVM choice rather than silently ignoring it. No release version or lock is +changed by this development protocol increment. diff --git a/sdk/node/examples/sample-native-build/backend/main.mjs b/sdk/node/examples/sample-native-build/backend/main.mjs index 02341e8..203b4e1 100644 --- a/sdk/node/examples/sample-native-build/backend/main.mjs +++ b/sdk/node/examples/sample-native-build/backend/main.mjs @@ -60,7 +60,7 @@ async function prepare(params, context, debug) { const project = selected[0]; const options = params.launchOptions; const execution = params.run === 'application' ? 'application' : params.run; - if ((options && (options.runtimeArguments.length || options.entrypoint || options.execution !== execution)) + if ((options && (options.runtimeArguments.length || options.entrypoint || options.debug || options.execution !== execution)) || (!['application', 'service', 'task'].includes(params.run)) || (params.run !== 'application' && !options)) { throw new PluginError('sample.launchOptions', 'This engine does not support the requested runtime or entrypoint options.'); } diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index b6d7d59..f148e55 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -491,6 +491,10 @@ class Backend { } #invokeBuild(id, method, params, handler) { + if (Object.hasOwn(params?.launchOptions ?? {}, 'debug') && this.#buildVersion < 11) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Runtime debug options require build protocol 11.' }); + return; + } if (Object.hasOwn(params ?? {}, 'launchOptions') && (this.#buildVersion < 10 || method !== METHODS.buildPrepareRun || !isBuildLaunchOptions(params.launchOptions))) { this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Typed launch options require build protocol 10 and valid run preparation options.' }); diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index 488b0a9..4b0aacc 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -128,6 +128,8 @@ export interface BuildLaunchOptions { environment: Record; workingDirectory?: string; entrypoint?: string; + /** Version 11. Suspend the JVM on a host-selected loopback endpoint. */ + debug?: { runtime: 'jvm'; port: number }; } export interface BuildRunParams extends BuildRequestParams { diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index cd33c06..4c918a5 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -6,7 +6,7 @@ /** Protocol versions this SDK can answer. */ export const FUNCTIONS_VERSION = 1; -export const BUILD_VERSION = 10; +export const BUILD_VERSION = 11; export const VCS_VERSION = 1; export const METHODS = Object.freeze({ @@ -162,7 +162,7 @@ export function isReservedMethod(method) { /** Version-10 user choices remain argv arrays until the owning tool consumes them. */ export function isBuildLaunchOptions(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const allowed = ['execution', 'runtimeArguments', 'arguments', 'environment', 'workingDirectory', 'entrypoint']; + const allowed = ['execution', 'runtimeArguments', 'arguments', 'environment', 'workingDirectory', 'entrypoint', 'debug']; if (Object.keys(value).some(key => !allowed.includes(key)) || !['application', 'service', 'task'].includes(value.execution)) return false; const text = (item, maximum) => typeof item === 'string' && Buffer.byteLength(item) <= maximum && !item.includes('\0'); @@ -173,6 +173,12 @@ export function isBuildLaunchOptions(value) { if (!environment || typeof environment !== 'object' || Array.isArray(environment) || Object.keys(environment).length > 64 || Object.entries(environment).some(([key, item]) => !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key) || !text(item, 65536))) return false; + if (Object.hasOwn(value, 'debug')) { + const debug = value.debug; + if (value.execution !== 'service' || !debug || typeof debug !== 'object' || Array.isArray(debug) + || Object.keys(debug).length !== 2 || debug.runtime !== 'jvm' + || !Number.isInteger(debug.port) || debug.port < 1 || debug.port > 65535) return false; + } if (Object.hasOwn(value, 'entrypoint') && (!text(value.entrypoint, 4096) || !value.entrypoint)) return false; if (Object.hasOwn(value, 'workingDirectory')) { const path = value.workingDirectory; diff --git a/sdk/node/test/build.test.mjs b/sdk/node/test/build.test.mjs index db10d90..86fa052 100644 --- a/sdk/node/test/build.test.mjs +++ b/sdk/node/test/build.test.mjs @@ -16,7 +16,7 @@ const methods = [ ['configuration', 7, { sources: [] }], ]; -for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { +for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) { test(`build protocol ${version} admits only its typed methods`, { timeout: 15000 }, async (t) => { const backend = createBackend(); const invoked = []; @@ -279,3 +279,28 @@ test('typed launch options preserve argv only after version 10 agreement', { tim assert.equal((await host.request('lithe/build/model', { launchOptions: options })).error.code, -32602); } }); + + +test('JVM debug options require protocol 11 and an exact host-selected endpoint', { timeout: 15000 }, async t => { + for (const version of [10, 11]) { + let calls = 0; + const backend = createBackend(); + backend.build.prepareRun(() => { calls++; return launch; }); + const host = new TestHost(backend); t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [version] } }); + const options = { execution: 'service', runtimeArguments: [], arguments: [], environment: {}, debug: { runtime: 'jvm', port: 51235 } }; + const request = launchOptions => host.request('lithe/build/prepareRun', { operationID: 'jvm-debug', launchOptions }); + const response = await request(options); + if (version === 10) { assert.equal(response.error.code, -32602); assert.equal(calls, 0); } + else { + assert.deepEqual(response.result, launch); + for (const debug of [null, {}, { runtime: 'native', port: 51235 }, { runtime: 'jvm', port: 0 }, + { runtime: 'jvm', port: 65536 }, { runtime: 'jvm', port: 1.5 }, { runtime: 'jvm', port: true }, + { runtime: 'jvm', port: 51235, host: 'remote' }]) { + assert.equal((await request({ ...options, debug })).error.code, -32602); + } + assert.equal((await request({ ...options, execution: 'task' })).error.code, -32602); + assert.equal(calls, 1); + } + } +}); From 1bd97f2ec0e4f697accbd06181f0ecb468a3c092 Mon Sep 17 00:00:00 2001 From: wgw Date: Sat, 31 Oct 2026 18:24:38 +0800 Subject: [PATCH 10/12] fix(sdk): support format agnostic native configuration edits --- sdk/e2e/plugin_sdk/native_build.rs | 58 +++++++++++++++---- sdk/java/README.md | 35 ++++++++--- .../samplenativebuild/SampleNativeBuild.java | 29 ++++++++++ .../github/litheidea/plugin/sdk/Backend.java | 8 ++- .../github/litheidea/plugin/sdk/Protocol.java | 3 +- .../litheidea/plugin/sdk/BuildTest.java | 11 ++-- sdk/node/README.md | 15 +++++ .../sample-native-build/backend/main.mjs | 32 +++++++++- sdk/node/src/backend.mjs | 2 + sdk/node/src/index.d.ts | 29 +++++++++- sdk/node/src/protocol.mjs | 2 + sdk/node/test/build.test.mjs | 4 +- sdk/node/test/types/build.mts | 13 +++++ 13 files changed, 209 insertions(+), 32 deletions(-) diff --git a/sdk/e2e/plugin_sdk/native_build.rs b/sdk/e2e/plugin_sdk/native_build.rs index 572699c..3430a8e 100644 --- a/sdk/e2e/plugin_sdk/native_build.rs +++ b/sdk/e2e/plugin_sdk/native_build.rs @@ -36,15 +36,15 @@ pub fn run(sample: &Sample) { "run":{"command":"node","arguments":["-e","process.exit(99)"]}}, {"name":"slow","path":"slow","build":[sample.node,"-e","require('fs').writeFileSync('started', String(process.pid)); setInterval(()=>{}, 1000)"],"run":{"command":"node","arguments":[],"debugProgram":"native-program"}} ]}); - let native_document = "/raw/cachesdk-native-secret-sentinel"; - let native_file = instance.join("settings.xml"); + let native_document = r#"{"cacheDirectory":"/raw/cache","_comment":"keep","password":"sdk-native-secret-sentinel"}"#; + let native_file = instance.join("settings.json"); if std::env::var("LITHE_PLUGIN_SDK_CONFIGURATION_WORKFLOWS").as_deref() == Ok("1") { fs::write(&native_file, native_document).unwrap(); config["configuration"] = json!({"sources":[{"id":"user","scope":"user", "uri":url::Url::from_file_path(&native_file).unwrap().as_str(), "revision":format!("sha256:{:x}", Sha256::digest(native_document.as_bytes())),"writable":true, - "rootElement":{"name":"settings","namespace":""},"fields":[{"id":"cacheDirectory","kind":"directory", - "value":"/raw/cache","effectiveValue":"/selected/cache","effectiveSource":"commandLine","path":["cacheDirectory"]}]}]}); + "fields":[{"id":"cacheDirectory","kind":"directory", + "value":"/raw/cache","effectiveValue":"/selected/cache","effectiveSource":"commandLine"}]}]}); } fs::write(instance.join("lithe-sample.json"), config.to_string()).unwrap(); fs::write(instance.join("app/native-program"), "native debugger target fixture").unwrap(); @@ -91,15 +91,49 @@ pub fn run(sample: &Sample) { let decoded = request("build.decodeConfiguration", json!({"result":reply})); assert_eq!(decoded["status"], "ok", "{decoded}"); assert_eq!(decoded["configuration"]["sources"][0]["fields"][0]["effectiveValue"], "/selected/cache"); - let edited = request("build.editConfigurationDocument", json!({"snapshot":decoded["configuration"], - "edits":{"sourceID":"user","revision":reply["sources"][0]["revision"],"values":{"cacheDirectory":"/new/cache"}}, - "document":native_document})); + let state = fixture.root.join("workspace-state"); + fs::create_dir_all(&state).unwrap(); + let output = state.join("native-edit.json"); + fs::write(&output, b"").unwrap(); + let edits = json!({"sourceID":"user","revision":reply["sources"][0]["revision"],"values":{"cacheDirectory":"/new/cache"}}); + let planned = request("build.configurationEditPlan", json!({"root":root,"workspaceStateDirectory":state,"outputFile":output, + "snapshot":decoded["configuration"],"edits":edits})); + assert_eq!(planned["status"], "ok", "{planned}"); + let receipt = session.build_request("native-edit", &planned["plan"]); + assert!(!receipt.to_string().contains("sdk-native-secret-sentinel")); + let edited = request("build.decodeConfigurationEdit", json!({"snapshot":decoded["configuration"],"edits":edits,"result":receipt})); assert_eq!(edited["status"], "ok", "{edited}"); - let document = edited["edit"]["document"].as_str().unwrap(); - assert!(document.contains("/new/cache")); - assert!(document.contains("")); - assert!(document.contains("sdk-native-secret-sentinel")); - assert_eq!(fs::read_to_string(&native_file).unwrap(), native_document, "Core editing alone performs no native IO"); + let replacement = fs::read(&output).unwrap(); + let document: Value = serde_json::from_slice(&replacement).unwrap(); + assert_eq!(document["cacheDirectory"], "/new/cache"); + assert_eq!(document["_comment"], "keep"); + assert_eq!(document["password"], "sdk-native-secret-sentinel"); + assert_eq!(edited["edit"]["revision"], format!("sha256:{:x}", Sha256::digest(&replacement))); + assert_eq!(fs::read_to_string(&native_file).unwrap(), native_document, "Provider editing only writes the private staging file"); + session.send_build_request("nonempty-output", &planned["plan"]); + let rejected = session.completion("nonempty-output"); + assert!(rejected.get("error").is_some(), "Providers cannot truncate an existing nonempty file"); + assert_eq!(fs::read(&output).unwrap(), replacement); + assert_eq!(fs::read_to_string(&native_file).unwrap(), native_document); + fs::remove_file(&output).unwrap(); + session.send_build_request("cancelled-output", &planned["plan"]); + let rejected = session.completion("cancelled-output"); + assert!(rejected.get("error").is_some(), "An absent host staging file cannot be recreated"); + assert!(!output.exists()); + let malformed = r#"{"password":"sdk-native-secret-sentinel","#; + fs::write(&native_file, malformed).unwrap(); + fs::write(&output, b"").unwrap(); + let revision = format!("sha256:{:x}", Sha256::digest(malformed.as_bytes())); + let mut broken = planned["plan"].clone(); + broken["provider"]["params"]["snapshot"]["sources"][0]["revision"] = json!(revision); + broken["provider"]["params"]["edits"]["revision"] = json!(revision); + session.send_build_request("invalid-native-json", &broken); + let rejected = session.completion("invalid-native-json"); + assert!(rejected.get("error").is_some()); + assert!(!rejected.to_string().contains("sdk-native-secret-sentinel")); + assert_eq!(fs::read(&output).unwrap(), b""); + assert_eq!(fs::read_to_string(&native_file).unwrap(), malformed); + fs::remove_file(&output).unwrap(); } let plan = build("build.modelPlan", json!({"root":root}))["plan"].clone(); let result = session.build_request("model", &plan); diff --git a/sdk/java/README.md b/sdk/java/README.md index bc0f0cd..0f0ff61 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -77,6 +77,7 @@ JAR it starts must hold everything it loads. | `onExecute(handler)` | Protocol 2+: a bounded provider-owned task, returning `{ exitCode, output }`. | | `onTest(handler)`, `onDiscoverTests(handler)` | Protocol 3+: exact execution and runnable selectors; discovery format/version are stamped. | | `onConfiguration(handler)` | Protocol 7+: read-only non-sensitive raw/effective native configuration; stamps `litheBuildConfiguration`, version 1. The host owns explicit native-file saves. | +| `onEditConfiguration(handler)` | Protocol 7+: format-owned replacement to an existing private host output file; returns only source ID, SHA-256 and changed status, stamped `litheConfigurationEdit` v1. Never CREATE the output or write the original source. | | `onPrepareRun(handler)` | Protocol 4: prepare a portable plan; the host owns the application. | | `negotiated().buildVersion()` | Highest common build version (1–7), or zero; newer methods are refused before their handler runs. | | `function(id, handler)` | Declares a function offered in `initialize`. | @@ -214,16 +215,18 @@ values and values exceeding 4096 UTF-8 bytes. An empty list selects the normal workspace/project scope. Preserve language-engine identities until mapping them to the upstream runner, and reject selections that cannot be represented exactly. -## Java build preparation tasks (development convention) +## Declarative build preparation (development buildSystems 4) -A build provider can declare project tasks `java-prepare-run-main` and -`java-prepare-run-test` and handle them with `onExecute`. The first prepares -main resources, and the second main plus test resources. The language workflow -uses current model source roles, awaits successful completion, then asks JDT for -runtime paths. The backend owns upstream filtering and reactor semantics. -These are ordinary public task IDs; no Java source parsing, private function or -new backend method is involved. Per-run options/tools are ephemeral plan inputs; -never overwrite saved build defaults while preparing an application. +A provider task may declare `preparation: { "purpose": "run", "sourceRoles": +["source", "generatedSource"] }` in its manifest and require `buildSystems: 4`. +Declare a separate task for test source roles when needed. Task IDs are private +to the provider; hosts find the purpose/role binding in the current model. +`onExecute` receives the selected ID through the existing method, so no SDK +handler or build RPC version changes. Missing roles/declarations fail explicitly. +Preparation tasks must be project scoped, run without prompts and have unique +purpose/role bindings. The provider owns filtering and upstream build semantics; +the language workflow awaits completion before resolving launch paths. +Per-run options/tools are ephemeral inputs and never overwrite saved defaults. ## Typed run choices (development protocol 10) @@ -254,3 +257,17 @@ The portable process result is unchanged, and the host owns process lifetime, readiness, attachment and cleanup. The native-build examples explicitly reject this JVM choice rather than silently ignoring it. No release version or lock is changed by this development protocol increment. + +### Native configuration editing (development protocol 7) + +Field IDs are opaque to the host; XML element bindings and host XML editing have +been removed. The sample native engine uses JSON while Maven owns its XML reader +and writer. Read only allowlisted non-sensitive facts, check the exact source +revision, write replacement bytes to the existing host staging file without +CREATE, and return a receipt only. Preserve unknown fields/comments supported by +the native format and private values locally. The host verifies the output hash, +rechecks operation/source ownership, publishes atomically and re-reads upstream. +On cancellation the host removes staging; never recreate it or return document +bytes. Protocols 8–10 retain their existing version numbers and handlers. Protocol +7 is being revised in the unmerged development PR; contract mirrors are synced +only after the host change merges. No SDK release or lock update is implied. diff --git a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java index ca17c7e..fa284a5 100644 --- a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java +++ b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java @@ -25,6 +25,11 @@ public final class SampleNativeBuild { public static void main(String[] arguments) { new SampleNativeBuild().serve(); } + private static String digest(byte[] bytes) throws Exception { + StringBuilder text = new StringBuilder("sha256:"); + for (byte value : java.security.MessageDigest.getInstance("SHA-256").digest(bytes)) text.append(String.format("%02x", value & 255)); + return text.toString(); + } private void serve() { backend.onInitialize((params, negotiated) -> workspace = Paths.get(URI.create(Json.string(params, "rootUri"))).toRealPath()); backend.onModel((params, context) -> { @@ -59,6 +64,30 @@ private void serve() { return engine.get("configuration") instanceof Map ? Json.object(engine.get("configuration")) : object("sources", Collections.emptyList()); }); + backend.onEditConfiguration((params, context) -> { + Map snapshot = Json.object(params.get("snapshot")), edits = Json.object(params.get("edits")); + Map source = null; + for (Object value : (List) snapshot.get("sources")) if (Json.object(value).get("id").equals(edits.get("sourceID"))) source = Json.object(value); + if (source == null || !Boolean.TRUE.equals(source.get("writable")) || !source.get("revision").equals(edits.get("revision"))) throw new PluginError("sample.configuration", "Invalid native edit."); + byte[] bytes = Files.readAllBytes(Paths.get(new URI(Json.string(source, "uri")))); + if (!digest(bytes).equals(edits.get("revision"))) throw new PluginError("sample.configuration", "Native source changed."); + Map document; + try { document = Json.object(Json.parse(new String(bytes, StandardCharsets.UTF_8))); } + catch (Exception error) { throw new PluginError("sample.configuration", "Native configuration could not be parsed."); } + for (Map.Entry edit : Json.object(edits.get("values")).entrySet()) { + if (!"cacheDirectory".equals(edit.getKey())) throw new PluginError("sample.configuration", "Unknown field."); + if (edit.getValue() == null) document.remove(edit.getKey()); else document.put(edit.getKey(), edit.getValue()); + } + context.cancellation().throwIfCancelled(); + byte[] replacement = Json.stringify(document).getBytes(StandardCharsets.UTF_8); + try (java.nio.channels.SeekableByteChannel output = Files.newByteChannel(Paths.get(Json.string(params, "outputFile")), + java.nio.file.StandardOpenOption.WRITE, java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + if (output.size() != 0) throw new PluginError("sample.configuration", "Invalid staging file."); + java.nio.ByteBuffer buffer = java.nio.ByteBuffer.wrap(replacement); + while (buffer.hasRemaining()) { context.cancellation().throwIfCancelled(); output.write(buffer); } + } + return object("sourceID", source.get("id"), "changed", true, "revision", digest(replacement)); + }); backend.onExecute((params, context) -> { if (!"build".equals(params.get("task"))) throw new PluginError("sample.task", "Unknown build task."); return build(select(params), strings(params.get("userArguments")), context); diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java index dc48db9..497c65a 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -104,6 +104,7 @@ private enum Phase { IDLE, LISTENING, INITIALIZING, READY, SHUTTING_DOWN, EXITED formats.put(Protocol.BUILD_DEPENDENCIES, "litheDependencies"); formats.put(Protocol.BUILD_DISCOVER_TESTS, "litheTestDiscovery"); formats.put(Protocol.BUILD_CONFIGURATION, "litheBuildConfiguration"); + formats.put(Protocol.BUILD_EDIT_CONFIGURATION, "litheConfigurationEdit"); BUILD_FORMATS = Collections.unmodifiableMap(formats); } @@ -184,11 +185,16 @@ public List functionIds() { } } - /** Returns non-sensitive native source facts and generic XML edit bindings; protocol 7. */ + /** Returns non-sensitive, format-independent native source facts; protocol 7. */ public Backend onConfiguration(Handler handler) { return declareBuild(Protocol.BUILD_CONFIGURATION, handler); } + /** Prepares native replacement bytes in an existing host staging file; returns only a receipt (v7). */ + public Backend onEditConfiguration(Handler handler) { + return declareBuild(Protocol.BUILD_EDIT_CONFIGURATION, handler); + } + /** Answers {@code lithe/build/model} with a {@code litheBuildModel} document. */ public Backend onModel(Handler handler) { return declareBuild(Protocol.BUILD_MODEL, handler); diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index 68d741b..bf9a14c 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -28,6 +28,7 @@ public final class Protocol { public static final String FUNCTION_UNREGISTER = "lithe/function/unregister"; public static final String FUNCTION_PROGRESS = "lithe/function/progress"; public static final String BUILD_CONFIGURATION = "lithe/build/configuration"; + public static final String BUILD_EDIT_CONFIGURATION = "lithe/build/editConfiguration"; public static final String BUILD_MODEL = "lithe/build/model"; public static final String BUILD_DEPENDENCIES = "lithe/build/dependencies"; public static final String BUILD_EXECUTE = "lithe/build/execute"; @@ -58,7 +59,7 @@ public final class Protocol { /** Minimum negotiated version; unknown methods can never pass admission. */ static int buildMethodVersion(String method) { - if (BUILD_CONFIGURATION.equals(method)) return 7; + if (BUILD_CONFIGURATION.equals(method) || BUILD_EDIT_CONFIGURATION.equals(method)) return 7; if (BUILD_MODEL.equals(method) || BUILD_DEPENDENCIES.equals(method)) return 1; if (BUILD_EXECUTE.equals(method)) return 2; if (BUILD_TEST.equals(method) || BUILD_DISCOVER_TESTS.equals(method)) return 3; diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java index f372c44..fd6a83f 100644 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java @@ -56,11 +56,11 @@ void opaqueSelectorsCannotBeIgnoredByAnOlderBackend() { @Test void eachVersionAdmitsOnlyItsTypedMethods() { - List methods = Arrays.asList("execute", "test", "discoverTests", "prepareRun", "prepareDebug", "configuration"); - int[] minimum = {2, 3, 3, 4, 5, 7}; + List methods = Arrays.asList("execute", "test", "discoverTests", "prepareRun", "prepareDebug", "configuration", "editConfiguration"); + int[] minimum = {2, 3, 3, 4, 5, 7, 7}; List> replies = Arrays.asList(map("exitCode", 0, "output", "built\n"), map("exitCode", 2, "output", "test failed\n"), - map("tests", Arrays.asList(map("suite", "Suite", "case", "exact # [case]"))), LAUNCH, map("program", "app/bin/main", "arguments", Arrays.asList("a b"), "workingDirectory", "app", "environment", map()), map("sources", Collections.emptyList())); + map("tests", Arrays.asList(map("suite", "Suite", "case", "exact # [case]"))), LAUNCH, map("program", "app/bin/main", "arguments", Arrays.asList("a b"), "workingDirectory", "app", "environment", map()), map("sources", Collections.emptyList()), map("sourceID", "user", "revision", "missing", "changed", false)); for (int version = 1; version <= 10; version++) { Backend backend = Backend.create(null, null) .onExecute((params, context) -> replies.get(0)) @@ -68,7 +68,8 @@ void eachVersionAdmitsOnlyItsTypedMethods() { .onDiscoverTests((params, context) -> replies.get(2)) .onPrepareRun((params, context) -> replies.get(3)) .onPrepareDebug((params, context) -> replies.get(4)) - .onConfiguration((params, context) -> replies.get(5)); + .onConfiguration((params, context) -> replies.get(5)) + .onEditConfiguration((params, context) -> replies.get(6)); TestHost host = new TestHost(backend); try { Map result = Json.object(host.initialize(offered(version, 99)).get("result")); @@ -82,7 +83,7 @@ void eachVersionAdmitsOnlyItsTypedMethods() { } else { // Compare JSON-normalized numeric types, as seen on the wire. Map expected = Json.object(Json.parse(Json.stringify(replies.get(index)))); - if (index == 2 || index == 5) { expected.put("format", index == 2 ? "litheTestDiscovery" : "litheBuildConfiguration"); expected.put("version", 1L); } + if (index == 2 || index == 5 || index == 6) { expected.put("format", index == 2 ? "litheTestDiscovery" : index == 5 ? "litheBuildConfiguration" : "litheConfigurationEdit"); expected.put("version", 1L); } assertEquals(expected, response.get("result")); } } diff --git a/sdk/node/README.md b/sdk/node/README.md index 1ba8a40..4d297b8 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -122,6 +122,7 @@ package. The SDK has no dependencies. | `backend.build.execute(handler)` | Protocol 2+: provider-owned bounded task, returning `{ exitCode, output }`. | | `backend.build.test(handler)`, `backend.build.discoverTests(handler)` | Protocol 3+: exact test execution and runnable selectors; discovery gets `format: "litheTestDiscovery"`, `version: 1`. | | `backend.build.configuration(handler)` | Protocol 7+: read-only non-sensitive raw/effective native configuration; stamps `litheBuildConfiguration`, version 1. The host owns explicit native-file saves. | +| `backend.build.editConfiguration(handler)` | Protocol 7+: format-owned replacement to an existing private host output file; returns only source ID, SHA-256 and changed status, stamped `litheConfigurationEdit` v1. Never CREATE the output or write the original source. | | `backend.build.prepareRun(handler)` | Protocol 4: returns a portable launch plan after upstream synchronization/build; the host owns the application process. | | `backend.buildVersion` | Highest common negotiated build version (1–10), or `null`; methods newer than that version are refused before their handler runs. | | `backend.vcs(id, handlers, traits?)` | Implements the typed VCS requests; see [A VCS provider](#a-vcs-provider). `litheVcs` is answered only when the host offers it. | @@ -307,3 +308,17 @@ The portable process result is unchanged, and the host owns process lifetime, readiness, attachment and cleanup. The native-build examples explicitly reject this JVM choice rather than silently ignoring it. No release version or lock is changed by this development protocol increment. + +### Native configuration editing (development protocol 7) + +Field IDs are opaque to the host; XML element bindings and host XML editing have +been removed. The sample native engine uses JSON while Maven owns its XML reader +and writer. Read only allowlisted non-sensitive facts, check the exact source +revision, write replacement bytes to the existing host staging file without +CREATE, and return a receipt only. Preserve unknown fields/comments supported by +the native format and private values locally. The host verifies the output hash, +rechecks operation/source ownership, publishes atomically and re-reads upstream. +On cancellation the host removes staging; never recreate it or return document +bytes. Protocols 8–10 retain their existing version numbers and handlers. Protocol +7 is being revised in the unmerged development PR; contract mirrors are synced +only after the host change merges. No SDK release or lock update is implied. diff --git a/sdk/node/examples/sample-native-build/backend/main.mjs b/sdk/node/examples/sample-native-build/backend/main.mjs index 203b4e1..1c4bf0c 100644 --- a/sdk/node/examples/sample-native-build/backend/main.mjs +++ b/sdk/node/examples/sample-native-build/backend/main.mjs @@ -1,8 +1,10 @@ // A fictional build engine whose facts are supplied by lithe-sample.json. // Native IDE operations use typed handlers; this plugin needs no web page. -import { readFile, realpath } from 'node:fs/promises'; +import { readFile, realpath, open } from 'node:fs/promises'; +import { constants } from 'node:fs'; import { join, isAbsolute, resolve, sep, relative, posix } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; import { createBackend, PluginError } from '../../../src/index.mjs'; const backend = createBackend({ name: 'sample-native-build' }); @@ -23,6 +25,34 @@ backend.build.configuration(async ({ root }) => { return configuration ?? { sources: [] }; }); +// This fictional engine uses JSON, independently of Maven's XML format. +backend.build.editConfiguration(async ({ snapshot, edits, outputFile }, context) => { + const source = snapshot.sources.find(source => source.id === edits.sourceID); + if (!source?.writable || source.revision !== edits.revision || Object.keys(edits.values).some(id => id !== 'cacheDirectory')) { + throw new PluginError('sample.configuration', 'Invalid native edit.'); + } + const bytes = await readFile(fileURLToPath(source.uri)); + const revision = 'sha256:' + createHash('sha256').update(bytes).digest('hex'); + if (revision !== edits.revision) throw new PluginError('sample.configuration', 'Native source changed.'); + let document; + try { document = JSON.parse(bytes.toString('utf8')); } + catch { throw new PluginError('sample.configuration', 'Native configuration could not be parsed.'); } + for (const [id, value] of Object.entries(edits.values)) { + if (value === null) delete document[id]; else document[id] = value; + } + context.signal.throwIfAborted(); + const replacement = Buffer.from(JSON.stringify(document)); + const output = await open(outputFile, constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const metadata = await output.stat(); + if (!metadata.isFile() || metadata.size !== 0 || metadata.nlink > 1) throw new PluginError('sample.configuration', 'Invalid staging file.'); + context.signal.throwIfAborted(); + await output.writeFile(replacement); + } + finally { await output.close(); } + return { sourceID: source.id, changed: true, revision: 'sha256:' + createHash('sha256').update(replacement).digest('hex') }; +}); + backend.build.execute(async (params, context) => { if (params.task !== 'build') throw new PluginError('sample.task', 'Unknown build task.'); return build(await select(params), params.userArguments, context); diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index f148e55..670d4b0 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -32,6 +32,7 @@ import { const BUILD_FORMATS = Object.freeze({ [METHODS.buildModel]: 'litheBuildModel', [METHODS.buildConfiguration]: 'litheBuildConfiguration', + [METHODS.buildEditConfiguration]: 'litheConfigurationEdit', [METHODS.buildDependencies]: 'litheDependencies', [METHODS.buildDiscoverTests]: 'litheTestDiscovery', }); @@ -99,6 +100,7 @@ class Backend { this.build = Object.freeze({ /** Returns non-sensitive native settings; no native document bytes cross RPC. */ configuration: (handler) => this.#declareBuild(METHODS.buildConfiguration, handler), + editConfiguration: (handler) => this.#declareBuild(METHODS.buildEditConfiguration, handler), /** Answers `lithe/build/model` with a `litheBuildModel` document. */ model: (handler) => this.#declareBuild(METHODS.buildModel, handler), /** Answers `lithe/build/dependencies` with a `litheDependencies` document. */ diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index 4b0aacc..24e37e9 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -120,6 +120,29 @@ export interface BuildTestParams extends BuildRequestParams { operationID: string; } +/** Protocol 7: non-sensitive native facts; bindings are private to the provider. */ +export type BuildConfigurationValue = string | boolean | string[] | null; +export interface BuildConfigurationDocument { + format?: 'litheBuildConfiguration'; version?: 1; + sources: { id: string; scope: 'installation' | 'user' | 'workspace'; uri: string; + revision: string; writable: boolean; + fields: { id: string; kind: 'text' | 'file' | 'directory' | 'boolean' | 'stringList'; + value: BuildConfigurationValue; effectiveValue: BuildConfigurationValue; effectiveSource: string }[]; + }[]; +} +export interface BuildConfigurationEditParams extends BuildRequestParams { + snapshot: BuildConfigurationDocument; + edits: { sourceID: string; revision: string; values: Record }; + /** Existing private host file; open without CREATE and never write the source. */ + outputFile: string; +} +export interface BuildConfigurationEditReceipt { + format?: 'litheConfigurationEdit'; version?: 1; + sourceID: string; revision: string; changed: boolean; +} +export type BuildConfigurationHandler = (params: BuildRequestParams, context: RequestContext) => BuildConfigurationDocument | Promise; +export type BuildConfigurationEditHandler = (params: BuildConfigurationEditParams, context: RequestContext) => BuildConfigurationEditReceipt | Promise; + /** Version 10. These are resolved user choices, not shell command strings. */ export interface BuildLaunchOptions { execution: 'application' | 'service' | 'task'; @@ -251,12 +274,14 @@ export interface ListenOptions { export interface Backend { readonly negotiated: Negotiated; readonly initializeParams: JsonObject | null; - /** Highest common version (1-8), or null before/without negotiation. */ + /** Highest common version (1-11), or null before/without negotiation. */ readonly buildVersion: number | null; readonly functionIDs: string[]; readonly build: { /** Non-sensitive source-backed settings; requires build protocol 7. */ - configuration(handler: BuildHandler): Backend; + configuration(handler: BuildConfigurationHandler): Backend; + /** Format-independent edit to a host-owned staging file; returns a receipt only (v7). */ + editConfiguration(handler: BuildConfigurationEditHandler): Backend; model(handler: BuildHandler): Backend; dependencies(handler: BuildHandler): Backend; execute(handler: BuildExecutionHandler): Backend; diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index 4c918a5..d336bfa 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -21,6 +21,7 @@ export const METHODS = Object.freeze({ functionProgress: 'lithe/function/progress', buildModel: 'lithe/build/model', buildConfiguration: 'lithe/build/configuration', + buildEditConfiguration: 'lithe/build/editConfiguration', buildDependencies: 'lithe/build/dependencies', buildExecute: 'lithe/build/execute', buildTest: 'lithe/build/test', @@ -34,6 +35,7 @@ export const METHODS = Object.freeze({ /** Minimum negotiated version of each typed build operation. */ export const BUILD_METHOD_VERSIONS = Object.freeze({ [METHODS.buildConfiguration]: 7, + [METHODS.buildEditConfiguration]: 7, [METHODS.buildModel]: 1, [METHODS.buildDependencies]: 1, [METHODS.buildExecute]: 2, diff --git a/sdk/node/test/build.test.mjs b/sdk/node/test/build.test.mjs index 86fa052..bdbe37f 100644 --- a/sdk/node/test/build.test.mjs +++ b/sdk/node/test/build.test.mjs @@ -14,6 +14,7 @@ const methods = [ ['prepareRun', 4, launch], ['prepareDebug', 5, { program: 'app/bin/main', arguments: [], workingDirectory: 'app', environment: {} }], ['configuration', 7, { sources: [] }], + ['editConfiguration', 7, { sourceID: 'user', revision: 'missing', changed: false }], ]; for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) { @@ -34,7 +35,8 @@ for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) { } else { assert.deepEqual(response.result, method === 'discoverTests' ? { ...reply, format: 'litheTestDiscovery', version: 1 } : method === 'configuration' - ? { ...reply, format: 'litheBuildConfiguration', version: 1 } : reply); + ? { ...reply, format: 'litheBuildConfiguration', version: 1 } : method === 'editConfiguration' + ? { ...reply, format: 'litheConfigurationEdit', version: 1 } : reply); } } }); diff --git a/sdk/node/test/types/build.mts b/sdk/node/test/types/build.mts index 9297d51..b1f58c0 100644 --- a/sdk/node/test/types/build.mts +++ b/sdk/node/test/types/build.mts @@ -68,3 +68,16 @@ backend.build.model((params) => { } return { projects: [] }; }); + +backend.build.configuration(() => ({ sources: [] })); +backend.build.editConfiguration((params, context) => { + context.throwIfCancelled(); + const output: string = params.outputFile; + const values: Record = params.edits.values; + void output; void values; + // @ts-expect-error Native bindings do not expose XML roots or native documents. + void params.snapshot.sources[0]!.rootElement; + return { sourceID: params.edits.sourceID, revision: params.edits.revision, changed: false }; +}); +// @ts-expect-error Edit receipts require a source identity and a revision. +backend.build.editConfiguration(() => ({ changed: true })); From 23e455aa462f279f0aab01a88fc93455837ff157 Mon Sep 17 00:00:00 2001 From: wgw Date: Sun, 1 Nov 2026 09:24:19 +0800 Subject: [PATCH 11/12] fix(sdk): make service debugging use registry connection targets --- sdk/java/README.md | 20 +++++++++---------- .../github/litheidea/plugin/sdk/Protocol.java | 15 +++++++++++--- .../litheidea/plugin/sdk/BuildTest.java | 9 ++++++--- sdk/node/README.md | 20 +++++++++---------- sdk/node/src/index.d.ts | 4 ++-- sdk/node/src/protocol.mjs | 9 +++++++-- sdk/node/test/build.test.mjs | 9 ++++++--- 7 files changed, 53 insertions(+), 33 deletions(-) diff --git a/sdk/java/README.md b/sdk/java/README.md index 0f0ff61..44ecc45 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -247,16 +247,16 @@ The contract mirror and released SDK remain pinned until the corresponding preview commit is merged and synchronized. -## JVM service debug choices (development protocol 11) - -A service preparation may receive `launchOptions.debug = { runtime: 'jvm', port: 51235 }`. -This is a host-selected loopback port, with a suspended JVM and exactly one agent; -it is not permission to choose an endpoint, adapter or DAP arguments. Reject the -choice if your engine cannot honor it. SDKs refuse it on protocol 10 or older. -The portable process result is unchanged, and the host owns process lifetime, -readiness, attachment and cleanup. The native-build examples explicitly reject -this JVM choice rather than silently ignoring it. No release version or lock is -changed by this development protocol increment. +## Registry connection debug choices (development protocol 11) + +A service preparation may receive `launchOptions.debug = { adapterID: 'dev.example/debug', configurationType: 'example', target: { kind: 'connection', host: '127.0.0.1', port: 51235 } }`. +The host selects a current registered adapter and loopback port. The provider +owns engine-specific suspension flags and rejects a choice it cannot honor. +It cannot select an endpoint, adapter or DAP arguments. SDKs reject the field +on protocol 10 or older. The portable process result is unchanged; the host +owns readiness, attachment and cleanup. The native-build examples reject this +unsupported connection choice. This revises unpublished v11 only; 8–10 and +release versions/locks are unchanged. ### Native configuration editing (development protocol 7) diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index bf9a14c..88c1a10 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -156,9 +156,18 @@ static boolean validBuildLaunchOptions(Object raw) { Object rawDebug = value.get("debug"); if (!"service".equals(value.get("execution")) || !(rawDebug instanceof Map)) return false; Map debug = (Map) rawDebug; - Object port = debug.get("port"); - if (debug.size() != 2 || !"jvm".equals(debug.get("runtime")) || !(port instanceof Number) - || ((Number) port).doubleValue() != ((Number) port).longValue() + if (debug.size() != 3 || !(debug.get("adapterID") instanceof String) + || !((String) debug.get("adapterID")).matches("[a-z0-9.-]{1,128}/[a-z0-9-]{1,64}") + || Arrays.asList(".", "..").contains(((String) debug.get("adapterID")).split("/")[0]) + || !(debug.get("configurationType") instanceof String) + || ((String) debug.get("configurationType")).trim().isEmpty() + || ((String) debug.get("configurationType")).getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 256 + || ((String) debug.get("configurationType")).chars().anyMatch(Character::isISOControl) + || !(debug.get("target") instanceof Map)) return false; + Map target = (Map) debug.get("target"); + Object port = target.get("port"); + if (target.size() != 3 || !"connection".equals(target.get("kind")) || !"127.0.0.1".equals(target.get("host")) + || !(port instanceof Number) || ((Number) port).doubleValue() != ((Number) port).longValue() || ((Number) port).longValue() < 1 || ((Number) port).longValue() > 65535) return false; } if (value.containsKey("entrypoint") && (!launchText(value.get("entrypoint"), 4096) || ((String) value.get("entrypoint")).isEmpty())) return false; diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java index fd6a83f..ff9cae0 100644 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java @@ -296,7 +296,7 @@ void typedLaunchOptionsRequireVersionTenAndPreserveArgumentBoundaries() { @Test - void jvmDebugRequiresVersionElevenAndAHostSelectedEndpoint() { + void connectionDebugRequiresVersionElevenAndAHostSelectedEndpoint() { for (int version : new int[] {10, 11}) { java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger(); Backend backend = Backend.create(null, null).onPrepareRun((params, context) -> { calls.incrementAndGet(); return LAUNCH; }); @@ -304,7 +304,7 @@ void jvmDebugRequiresVersionElevenAndAHostSelectedEndpoint() { try { host.initialize(offered(version)); Map options = map("execution", "service", "runtimeArguments", Collections.emptyList(), - "arguments", Collections.emptyList(), "environment", map(), "debug", map("runtime", "jvm", "port", 51235)); + "arguments", Collections.emptyList(), "environment", map(), "debug", map("adapterID", "dev.example/debug", "configurationType", "example", "target", map("kind", "connection", "host", "127.0.0.1", "port", 51235))); Map response = host.request(Protocol.BUILD_PREPARE_RUN, map("operationID", "jvm-debug", "launchOptions", options)); if (version == 10) { assertEquals(-32602L, errorCode(response)); assertEquals(0, calls.get()); } else { @@ -312,7 +312,10 @@ void jvmDebugRequiresVersionElevenAndAHostSelectedEndpoint() { for (Object debug : Arrays.asList(null, map(), map("runtime", "native", "port", 51235), map("runtime", "jvm", "port", 0), map("runtime", "jvm", "port", 65536), map("runtime", "jvm", "port", 1.5), map("runtime", "jvm", "port", true), - map("runtime", "jvm", "port", 51235, "host", "remote"))) { + map("runtime", "jvm", "port", 51235, "host", "remote"), + map("adapterID", "java", "configurationType", "java", "target", map("kind", "connection", "host", "127.0.0.1", "port", 51235)), + map("adapterID", "dev.example/debug", "configurationType", "example", "target", map("kind", "connection", "host", "remote", "port", 51235)), + map("adapterID", "dev.example/debug", "configurationType", "example", "target", map("kind", "connection", "host", "127.0.0.1", "port", 0)))) { options.put("debug", debug); assertEquals(-32602L, errorCode(host.request(Protocol.BUILD_PREPARE_RUN, map("operationID", "jvm-debug", "launchOptions", options)))); } diff --git a/sdk/node/README.md b/sdk/node/README.md index 4d297b8..53c5b1c 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -298,16 +298,16 @@ This flag explicitly tests unmerged Core work. The contract mirror and released SDK stay pinned until the corresponding preview commit is merged and synchronized. -## JVM service debug choices (development protocol 11) - -A service preparation may receive `launchOptions.debug = { runtime: 'jvm', port: 51235 }`. -This is a host-selected loopback port, with a suspended JVM and exactly one agent; -it is not permission to choose an endpoint, adapter or DAP arguments. Reject the -choice if your engine cannot honor it. SDKs refuse it on protocol 10 or older. -The portable process result is unchanged, and the host owns process lifetime, -readiness, attachment and cleanup. The native-build examples explicitly reject -this JVM choice rather than silently ignoring it. No release version or lock is -changed by this development protocol increment. +## Registry connection debug choices (development protocol 11) + +A service preparation may receive `launchOptions.debug = { adapterID: 'dev.example/debug', configurationType: 'example', target: { kind: 'connection', host: '127.0.0.1', port: 51235 } }`. +The host selects a current registered adapter and loopback port. The provider +owns engine-specific suspension flags and rejects a choice it cannot honor. +It cannot select an endpoint, adapter or DAP arguments. SDKs reject the field +on protocol 10 or older. The portable process result is unchanged; the host +owns readiness, attachment and cleanup. The native-build examples reject this +unsupported connection choice. This revises unpublished v11 only; 8–10 and +release versions/locks are unchanged. ### Native configuration editing (development protocol 7) diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index 24e37e9..65f0fa3 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -151,8 +151,8 @@ export interface BuildLaunchOptions { environment: Record; workingDirectory?: string; entrypoint?: string; - /** Version 11. Suspend the JVM on a host-selected loopback endpoint. */ - debug?: { runtime: 'jvm'; port: number }; + /** Version 11. Suspend the service for a registry-selected connection attach. */ + debug?: { adapterID: string; configurationType: string; target: { kind: 'connection'; host: '127.0.0.1'; port: number } }; } export interface BuildRunParams extends BuildRequestParams { diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index d336bfa..69037ae 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -178,8 +178,13 @@ export function isBuildLaunchOptions(value) { if (Object.hasOwn(value, 'debug')) { const debug = value.debug; if (value.execution !== 'service' || !debug || typeof debug !== 'object' || Array.isArray(debug) - || Object.keys(debug).length !== 2 || debug.runtime !== 'jvm' - || !Number.isInteger(debug.port) || debug.port < 1 || debug.port > 65535) return false; + || Object.keys(debug).length !== 3 + || typeof debug.adapterID !== 'string' || !/^[a-z0-9.-]{1,128}\/[a-z0-9-]{1,64}$/.test(debug.adapterID) || ['.', '..'].includes(debug.adapterID.split('/')[0]) + || typeof debug.configurationType !== 'string' || !debug.configurationType.trim() || Buffer.byteLength(debug.configurationType) > 256 || /[\x00-\x1f\x7f-\x9f]/.test(debug.configurationType)) return false; + const target = debug.target; + if (!target || typeof target !== 'object' || Array.isArray(target) || Object.keys(target).length !== 3 + || target.kind !== 'connection' || target.host !== '127.0.0.1' + || !Number.isInteger(target.port) || target.port < 1 || target.port > 65535) return false; } if (Object.hasOwn(value, 'entrypoint') && (!text(value.entrypoint, 4096) || !value.entrypoint)) return false; if (Object.hasOwn(value, 'workingDirectory')) { diff --git a/sdk/node/test/build.test.mjs b/sdk/node/test/build.test.mjs index bdbe37f..6196142 100644 --- a/sdk/node/test/build.test.mjs +++ b/sdk/node/test/build.test.mjs @@ -283,14 +283,14 @@ test('typed launch options preserve argv only after version 10 agreement', { tim }); -test('JVM debug options require protocol 11 and an exact host-selected endpoint', { timeout: 15000 }, async t => { +test('Registry connection debug options require protocol 11 and an exact host-selected endpoint', { timeout: 15000 }, async t => { for (const version of [10, 11]) { let calls = 0; const backend = createBackend(); backend.build.prepareRun(() => { calls++; return launch; }); const host = new TestHost(backend); t.after(() => host.close()); await host.initialize({ litheBuild: { versions: [version] } }); - const options = { execution: 'service', runtimeArguments: [], arguments: [], environment: {}, debug: { runtime: 'jvm', port: 51235 } }; + const options = { execution: 'service', runtimeArguments: [], arguments: [], environment: {}, debug: { adapterID: 'dev.example/debug', configurationType: 'example', target: { kind: 'connection', host: '127.0.0.1', port: 51235 } } }; const request = launchOptions => host.request('lithe/build/prepareRun', { operationID: 'jvm-debug', launchOptions }); const response = await request(options); if (version === 10) { assert.equal(response.error.code, -32602); assert.equal(calls, 0); } @@ -298,7 +298,10 @@ test('JVM debug options require protocol 11 and an exact host-selected endpoint' assert.deepEqual(response.result, launch); for (const debug of [null, {}, { runtime: 'native', port: 51235 }, { runtime: 'jvm', port: 0 }, { runtime: 'jvm', port: 65536 }, { runtime: 'jvm', port: 1.5 }, { runtime: 'jvm', port: true }, - { runtime: 'jvm', port: 51235, host: 'remote' }]) { + { runtime: 'jvm', port: 51235, host: 'remote' }, + ...[0, 65536, 1.5, true].map(port => ({ ...options.debug, target: { ...options.debug.target, port } })), + { ...options.debug, adapterID: 'java' }, + { ...options.debug, target: { ...options.debug.target, host: 'remote' } }]) { assert.equal((await request({ ...options, debug })).error.code, -32602); } assert.equal((await request({ ...options, execution: 'task' })).error.code, -32602); From 0ce83355d76f992f043b6a55a00d913454d01176 Mon Sep 17 00:00:00 2001 From: wgw Date: Sun, 1 Nov 2026 21:42:40 +0800 Subject: [PATCH 12/12] ci: include current native protocols in explicit host validation --- .github/workflows/verify.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 69486c5..a32025c 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -161,4 +161,6 @@ jobs: env: LITHE_PLUGIN_SDK_JAVA_RUNTIME: ${{ steps.java8.outputs.path }}/bin/java HOST_REF: ${{ inputs.host-ref }} - run: node scripts/sdk-e2e.mjs --ref "$HOST_REF" --build-workflows + run: >- + node scripts/sdk-e2e.mjs --ref "$HOST_REF" --build-workflows + --native-configuration --provider-reports --provider-run-options