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 diff --git a/guides/native-build-workflows.md b/guides/native-build-workflows.md index 2faf785..58f6c97 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,50 @@ 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 解析或凭据传输。 + +## 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. + +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 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. diff --git a/scripts/sdk-e2e.mjs b/scripts/sdk-e2e.mjs index 800be89..59f09d0 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,13 +140,16 @@ 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' }, '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.'); } 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 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 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`; @@ -165,6 +170,9 @@ 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, LITHE_PLUGIN_SDK_JAVA: java, LITHE_PLUGIN_SDK_JAVA_SAMPLE: jar, 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 f7a05c9..3430a8e 100644 --- a/sdk/e2e/plugin_sdk/native_build.rs +++ b/sdk/e2e/plugin_sdk/native_build.rs @@ -2,12 +2,18 @@ //! 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; 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"], @@ -20,7 +26,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 +36,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 = 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, + "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(); let launch = core( @@ -60,6 +76,65 @@ 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!(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})); + 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 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 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); let model = build( @@ -87,7 +162,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":{ @@ -144,6 +219,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!([]); @@ -304,6 +392,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 c214a7d..c5ff07d 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -79,8 +79,10 @@ 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. | +| `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–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. | | `negotiated().functionsVersion()` | Highest common plugin-function version (1–3), or zero. | @@ -273,7 +275,87 @@ producer changes. Protocol 1–5 handlers reject requests containing model input 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. +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. + +## 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. + +## Declarative build preparation (development buildSystems 4) + +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) + +`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. + + +## 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) + +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. ## Native terminal API (unreleased) 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..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 @@ -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; @@ -24,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) -> { @@ -49,6 +55,39 @@ 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.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); @@ -70,7 +109,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") || 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."); 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."); @@ -87,8 +133,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 { @@ -104,6 +152,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)) { @@ -112,11 +161,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 9ef2992..541837e 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 @@ -126,6 +126,8 @@ 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"); + formats.put(Protocol.BUILD_EDIT_CONFIGURATION, "litheConfigurationEdit"); BUILD_FORMATS = Collections.unmodifiableMap(formats); } @@ -220,6 +222,16 @@ public List functionIds() { } } + /** 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); @@ -839,8 +851,37 @@ 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 (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; + } + 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), null); + run(id, null, null, context -> handler.handle(arguments, context), result -> buildResult(method, result, negotiated.buildVersion()), null); return; } Object wireId = arguments.get("operationID"); @@ -867,7 +908,7 @@ public void report(String text, Double percentage, String output) { } }; run(id, null, (String) wireId, null, 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 { @@ -1142,7 +1183,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."); @@ -1171,6 +1214,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 c4e2ff8..0922555 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,8 @@ 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; @@ -12,9 +14,9 @@ * when registering a handler instead of as a refused session at runtime. */ public final class Protocol { - /** Protocol versions this SDK can answer: the highest of each. */ + /** Protocol versions this SDK can answer. */ public static final int FUNCTIONS_VERSION = 3; - public static final int BUILD_VERSION = 6; + public static final int BUILD_VERSION = 11; /** First plugin-function version with host services (credentials). */ public static final int HOST_SERVICES_VERSION = 2; @@ -30,6 +32,8 @@ 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_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"; @@ -111,6 +115,7 @@ public final class Protocol { /** Minimum negotiated version; unknown methods can never pass admission. */ static int buildMethodVersion(String method) { + 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; @@ -227,4 +232,55 @@ 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", "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); + 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("debug")) { + Object rawDebug = value.get("debug"); + if (!"service".equals(value.get("execution")) || !(rawDebug instanceof Map)) return false; + Map debug = (Map) rawDebug; + 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; + 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 e93e24a..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 @@ -30,20 +30,46 @@ 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"); - int[] minimum = {2, 3, 3, 4, 5}; + 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())); - 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()), 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)) .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)) + .onEditConfiguration((params, context) -> replies.get(6)); TestHost host = new TestHost(backend); try { Map result = Json.object(host.initialize(offered(version, 99)).get("result")); @@ -57,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) { expected.put("format", "litheTestDiscovery"); 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")); } } @@ -65,11 +91,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}) { + 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")); @@ -219,4 +265,64 @@ 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(); } + } + } + + + @Test + 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; }); + 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("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 { + 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"), + 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)))); + } + assertEquals(1, calls.get()); + } + } finally { host.close(); } + } + } + } diff --git a/sdk/node/README.md b/sdk/node/README.md index 2ddcf60..8c53174 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -125,8 +125,10 @@ 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.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–6), or `null`; methods newer than that version are refused before their handler runs. | +| `backend.buildVersion` | Highest common negotiated build version (1–11), or `null`; methods newer than that version are refused before their handler runs. | | `backend.functionsVersion` | Highest common negotiated plugin-function version (1–3), or `null`. | | `backend.host` | [Host services](#host-services) (`lithe/host/*`), usable once the session is ready. | | `backend.onCommand(commandID, handler)` | Protocol 3: one of the plugin's commands ran; the handler gets `{ commandID, workspaceContext }`. | @@ -333,7 +335,76 @@ producer changes. Protocol 1–5 handlers reject requests containing model input 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. +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. + +## 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. + + +## 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. + + +## 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) + +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. ## Native terminal API (unreleased) diff --git a/sdk/node/examples/sample-native-build/backend/main.mjs b/sdk/node/examples/sample-native-build/backend/main.mjs index 55ecd67..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' }); @@ -15,6 +17,41 @@ 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: [] }; +}); + +// 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.'); @@ -35,12 +72,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)); @@ -48,7 +88,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.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.'); + } + 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.'); } @@ -60,8 +106,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 dd3a94e..7c2a190 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -22,6 +22,7 @@ import { HOST_NOTIFICATIONS, isContextReference, isFunctionId, + isBuildLaunchOptions, isReservedMethod, isVcsId, LIMITS, @@ -34,6 +35,8 @@ import { /** Canonical result format of each typed build request. */ const BUILD_FORMATS = Object.freeze({ [METHODS.buildModel]: 'litheBuildModel', + [METHODS.buildConfiguration]: 'litheBuildConfiguration', + [METHODS.buildEditConfiguration]: 'litheConfigurationEdit', [METHODS.buildDependencies]: 'litheDependencies', [METHODS.buildDiscoverTests]: 'litheTestDiscovery', }); @@ -114,6 +117,9 @@ 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), + 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. */ @@ -594,10 +600,33 @@ 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.' }); + 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; } + 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; + } + 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')) { @@ -617,7 +646,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, context: workspaceContext }) { @@ -869,7 +898,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.`); @@ -889,6 +918,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 a2cbc50..c2905f4 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -362,6 +362,11 @@ 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; target?: BuildSelection | null; suite?: string; @@ -370,7 +375,43 @@ 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'; + runtimeArguments: string[]; + arguments: string[]; + environment: Record; + workingDirectory?: string; + entrypoint?: string; + /** 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 { + launchOptions?: BuildLaunchOptions; run: string; project: BuildSelection; target: BuildSelection; @@ -390,7 +431,15 @@ 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; + /** 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 }[]; +} +export interface BuildExecutionResult { exitCode: number; output: string; testResults?: BuildTestResultsDocument; } export interface BuildTestDiscovery { format?: 'litheTestDiscovery'; version?: 1; @@ -480,7 +529,7 @@ export interface ListenOptions { export interface Backend { readonly negotiated: Negotiated; readonly initializeParams: JsonObject | null; - /** Highest common version (1-6), or null before/without negotiation. */ + /** Highest common version (1-11), or null before/without negotiation. */ readonly buildVersion: number | null; /** Highest common plugin-function version (1-3), or null before/without negotiation. */ readonly functionsVersion: number | null; @@ -488,6 +537,10 @@ export interface Backend { /** Host services; usable once the session is ready. */ readonly host: Host; readonly build: { + /** Non-sensitive source-backed settings; requires build protocol 7. */ + 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 4cf8e2b..2f5ed11 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -4,9 +4,9 @@ // same rules locally so a plugin author sees a mistake when registering a // handler instead of as a refused session at runtime. -/** Protocol versions this SDK can answer: the highest of each. */ +/** Protocol versions this SDK can answer. */ export const FUNCTIONS_VERSION = 3; -export const BUILD_VERSION = 6; +export const BUILD_VERSION = 11; export const VCS_VERSION = 1; /** First plugin-function version with host services (credentials). */ @@ -25,6 +25,8 @@ export const METHODS = Object.freeze({ functionUnregister: 'lithe/function/unregister', 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', @@ -112,6 +114,8 @@ export const HOST_ERRORS = 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, @@ -260,3 +264,37 @@ 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', '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'); + 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, 'debug')) { + const debug = value.debug; + if (value.execution !== 'service' || !debug || typeof debug !== 'object' || Array.isArray(debug) + || 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')) { + 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 080eea2..6196142 100644 --- a/sdk/node/test/build.test.mjs +++ b/sdk/node/test/build.test.mjs @@ -13,9 +13,11 @@ 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: [] }], + ['editConfiguration', 7, { sourceID: 'user', revision: 'missing', changed: false }], ]; -for (const version of [1, 2, 3, 4, 5, 6]) { +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 = []; @@ -32,7 +34,9 @@ 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 } : method === 'editConfiguration' + ? { ...reply, format: 'litheConfigurationEdit', version: 1 } : reply); } } }); @@ -96,7 +100,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 +113,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 +184,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 +196,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, 8]) { const seen = []; const backend = createBackend(); backend.build.model((params) => { seen.push(params.modelInputs); return { projects: [], targets: [], tasks: [] }; }); @@ -205,3 +209,103 @@ 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); +}); + +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); + } + }); +} + + +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); + } +}); + + +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: { 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); } + 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' }, + ...[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); + assert.equal(calls, 1); + } + } +}); diff --git a/sdk/node/test/types/build.mts b/sdk/node/test/types/build.mts index dfa0ffd..b1f58c0 100644 --- a/sdk/node/test/types/build.mts +++ b/sdk/node/test/types/build.mts @@ -12,8 +12,14 @@ 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 }); - 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 } }], @@ -62,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 }));