Skip to content

Commit be779d2

Browse files
authored
Merge pull request #4 from Lithe-IDEA/codex/issue-1495-build-provider-sdk
feat(sdk): 原生构建、测试、运行调试准备与只读模型输入
2 parents aa9c443 + 82e602c commit be779d2

28 files changed

Lines changed: 2348 additions & 71 deletions

File tree

‎.github/workflows/verify.yml‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ on:
1212
schedule:
1313
- cron: "41 3 * * 1"
1414
workflow_dispatch:
15+
inputs:
16+
host-ref:
17+
description: 'Optional explicit Lithe host commit to validate native workflows before merge'
18+
required: false
19+
type: string
1520

1621
permissions:
1722
contents: read
@@ -59,6 +64,10 @@ jobs:
5964

6065
- name: Test
6166
run: node --test sdk/node/test/*.test.mjs
67+
- name: Check TypeScript consumers
68+
run: |
69+
npm ci --prefix sdk/node --ignore-scripts --no-audit --no-fund
70+
npm run --prefix sdk/node test:types
6271
6372
sdk-java:
6473
name: Java SDK
@@ -142,7 +151,14 @@ jobs:
142151
run: node scripts/sdk-e2e.mjs
143152

144153
- name: Run against Lithe's preview branch
145-
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
154+
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.host-ref == '')
155+
env:
156+
LITHE_PLUGIN_SDK_JAVA_RUNTIME: ${{ steps.java8.outputs.path }}/bin/java
157+
run: node scripts/sdk-e2e.mjs --ref preview --build-workflows
158+
159+
- name: Run native workflows against the explicitly selected host revision
160+
if: github.event_name == 'workflow_dispatch' && inputs.host-ref != ''
146161
env:
147162
LITHE_PLUGIN_SDK_JAVA_RUNTIME: ${{ steps.java8.outputs.path }}/bin/java
148-
run: node scripts/sdk-e2e.mjs --ref preview
163+
HOST_REF: ${{ inputs.host-ref }}
164+
run: node scripts/sdk-e2e.mjs --ref "$HOST_REF" --build-workflows

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ SDK 的源码都在本仓库的 `sdk/` 下,发布在[本仓库的 Releases](ht
128128
| 教程 | 内容 |
129129
| --- | --- |
130130
| [LSP 插件完整生命周期](guides/lsp-plugin-lifecycle.md) | 从 Lua 模板开始:清单、服务器打包、激活、状态目录、停止、升级与卸载 |
131+
| [原生构建工作流(开发中)](guides/native-build-workflows.md) | Node/Java SDK 的构建执行、精确测试、运行准备,以及未提交源码的真实 Core 验证 |
131132
| [构建、运行与测试接入](guides/build-tool-integration.md) | 可用的执行提供者示例、工程后端边界,以及开发中的 API 3 状态 |
132133
| [编写调试适配器插件](guides/debug-adapter-plugin.md) | API 4:从最小清单到启动方式、先编译再调试的 `executable`、调试控制台补全,以及预发布与转正 |
133134

‎guides/native-build-workflows.md‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# 原生构建、测试与运行准备(开发中)
2+
3+
插件可以只提供工作区后端,不创建网页。用户从原生工具窗口执行构建、发现与运行测试、准备应用启动;宿主继续管理工作区、取消、输出和应用进程。
4+
5+
本文对应尚未发布的 Node/Java SDK 源码,以及 Lithe #1495 的宿主改动。发布的 SDK 0.1.0 资产不包含这些新入口。宿主改动合并后,按仓库规则同步 `contract/v1/`,再一起发布、锁定新 SDK;不要手改契约副本或向旧版本宿主隐瞒 `requires.buildProviders`。
6+
7+
## 选择入口
8+
9+
| 工作 | Node SDK | Java SDK | 最低 `buildProviders` / 进程协议 |
10+
| --- | --- | --- | --- |
11+
| 执行有界任务 | `backend.build.execute` | `onExecute` | 2 |
12+
| 运行精确测试 | `backend.build.test` | `onTest` | 3 |
13+
| 发现可运行的用例 | `backend.build.discoverTests` | `onDiscoverTests` | 3 |
14+
| 准备应用启动 | `backend.build.prepareRun` | `onPrepareRun` | 4 |
15+
| 准备原生程序调试 | `backend.build.prepareDebug` | `onPrepareDebug` | 5 |
16+
17+
安装时检查清单的能力版本,启动时 SDK 从宿主提供的版本中选最高的共同版本。没有对应协议时,新方法在进入处理器之前被拒绝;不会改发私有函数或展开成另一条宿主命令。Node 用 `backend.buildVersion`、Java 用 `backend.negotiated().buildVersion()` 查看结果。
18+
19+
处理器必须在 `listen` 前注册。模型、依赖和测试发现回复由 SDK 补齐对应 `format` 和 `version: 1`;执行回复是 `{ exitCode, output }`,启动准备回复是完整的可移植计划。Core 继续检查选择器、工作区路径和大小限制。
20+
21+
## 所有权与清理
22+
23+
插件从其选定的上游引擎读取当前工程、测试和运行事实。发现结果中的每个用例都必须能通过同一套件、用例、工程与目标选择器精确执行;处理器重新核对选择,不把失效用例当成“运行全部”。不要在宿主或 SDK 里再扫描 Java 注解、拼 classpath 或解释 Maven 模型。
24+
25+
构建与测试处理器使用调用上下文的 `run` 启动工具,取消或调用结束会结束它拥有的工具树。工具正常返回非零码仍是执行结果;异常属于协议/准备失败。不要启动脱离调用的进程,或用一条私有函数代替这些领域入口。
26+
27+
运行准备先等待上游同步和必要构建,再返回 `executable`、原始 `arguments` 数组、工作区相对 `workingDirectory` 和 `environment`。`executable.kind` 只能是 `command`(宿主解析的裸命令名)或 `toolchain`(宿主工具链标识)。处理器不启动长期运行的应用;宿主校验后启动、停止并显示应用输出。构建失败、取消或旧结果失效时不能启动应用。
28+
29+
执行/测试请求的后端拒绝由 Core 报告为 `plugin.build.failed`。运行准备的 RPC 错误保留通用 LSP `serverError` 分类,不是带退出码的执行结果,也不使用私有函数的 `pluginErrorCode`。宿主在两种失败下都不能启动应用或改走旧命令。
30+
31+
执行、测试、运行与调试准备上下文携带 Core 分配的 `operationID`,进度自动带上它。Node 使用 `context.progress({ message, percentage, output })`;Java 使用 `context.progress(message, percentage, output)`。消息上限 4096 UTF-8 字节,单次输出上限 64 KiB,百分比限制在 0–100;取消或结束后的报告被丢弃。模型、依赖和发现不提供执行进度。
32+
33+
状态写入宿主提供的可写目录;安装后的插件包只读。`prepareRun` 的结果不能变成跨重启缓存的 classpath 或命令:宿主保存入口选择器,重启重新准备。
34+
35+
## 可运行示例
36+
37+
两套示例使用相同的虚构构建引擎,工程事实来自工作区的 `lithe-sample.json`。这是协议教学用的引擎,不是 Maven/Java 迁移成果。
38+
39+
- [Node 示例](../sdk/node/examples/sample-native-build/backend/main.mjs)及其[清单](../sdk/node/examples/sample-native-build/plugin.json)。开发时从源码引入 SDK;分发时将 `src/` 放入 `backend/vendor/`,把导入改为 `./vendor/index.mjs`。端到端测试按这种结构打包验证。
40+
- [Java 示例](../sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java)及其[清单](../sdk/java/examples/sample-native-build/plugin.json)。运行 `mvn -B -ntp -f sdk/java/pom.xml package`,将 `sample-native-build/target/sample-native-build.jar` 放到包内 `backend/`。SDK 已 shade 进同一 JAR。
41+
42+
示例工程包含构建命令、精确用例及应用参数,例如:
43+
44+
```json
45+
{
46+
"projects": [{
47+
"name": "app", "path": "app",
48+
"build": ["node", "-e", "console.log('built')"],
49+
"tests": [{ "suite": "Suite", "case": "exact case",
50+
"command": ["node", "-e", "console.log('passed')", "--"] }],
51+
"run": { "command": "node",
52+
"arguments": ["-e", "console.log(process.argv.slice(1))", "--"] }
53+
}]
54+
}
55+
```
56+
57+
模型的路径相对构建实例,后续请求的工程/目标选择器相对整个工作区;示例显式转换这两种表示,不把嵌套实例当成根工程。
58+
59+
`app` 目录需存在,测试工具需可执行。示例重新读取配置、核对工程/目标和测试,并解析真实路径以拒绝工作区外目录;实际插件应调用其上游工程引擎,而非复制这份虚构引擎。
60+
61+
## 验证
62+
63+
```sh
64+
node --test sdk/node/test/*.test.mjs
65+
npm ci --prefix sdk/node --ignore-scripts
66+
npm run --prefix sdk/node test:types
67+
mvn -B -ntp -f sdk/java/pom.xml verify
68+
node --test scripts/*.test.mjs
69+
70+
# 锁定契约对应的兼容场景。
71+
node scripts/sdk-e2e.mjs
72+
73+
# 宿主分支已提交,但尚未合并时。
74+
node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --ref branch --build-workflows
75+
76+
# 显式使用尚未提交的宿主源码;不可和 --ref 混用。
77+
node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --working-tree --build-workflows
78+
```
79+
80+
`--working-tree` 只把 `rust/`、`shared/` 的暂存、未暂存和未跟踪源码复制到一次性 Core 检出,不修改源仓库,也不复制忽略的构建缓存。该模式报告基线 commit 加工作区改动,不能把输出里的基线 SHA 当成完整已提交源码。
81+
82+
`--build-workflows` 显式增加两套新协议场景,供合并前验证;契约镜像同步到包含 `buildProviders: 5` 后,默认验证也自动包含它们。每周的 preview 检查显式运行新场景。新的测试通过真实 Core 规划与解码,覆盖构建非零退出码、精确筛选、运行前构建、准备计划、进度、取消后的工具清理、并行工作区隔离,以及包含 SDK/JAR 的完整包文件不变。它不代替原生界面、真实 Maven/JDT 工程或 Windows 实机验证。
83+
84+
若复用预构建 Java 示例,分别设置 `LITHE_PLUGIN_SDK_JAVA_SAMPLE` 与 `LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE`。编译使用 JDK 21,产物面向 Java 8;可用 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 指定实际运行时。
85+
86+
调试适配器、长期服务和跨插件协作有各自的宿主契约;这些处理器不开放跨包私有函数或反向 `lithe/*` 调用。
87+
88+
89+
## 原生程序调试准备
90+
91+
运行声明加 `debug: {"adapter":"native"}`,同一插件提供 `native` 适配器;manifest 要求 `buildProviders: 5` 与 `debugAdapters: 2`。适配器声明 launch/executable,使用 program、cwd、args、env 的目标绑定。不要用跨包 ID,也不要在后端返回 DAP 进程、端口或任意调试参数。
92+
93+
`prepareDebug` 先完成构建,再返回 `{program, arguments, workingDirectory, environment}`。程序是当前实例内的已有文件,路径从工作区起算;宿主校验路径和可执行权限,再复用现有 DebugAdapter Registry。取消、错误和过期结果都不能启动调试。Node 和 Java 的 sample-native-build 从虚构引擎的 `run.debugProgram` 读取程序,参数使用当前请求的 userArguments;示例的 `example-dap` 是占位适配器命令,真实插件应打包符合其许可的成熟适配器。
94+
95+
端到端示例验证两个 SDK 的真实进程、运行前构建、原生调试准备前构建、Core 路径解码、进度及失败不返回目标。它不启动真实调试器,也不代表 Java/JDT、跨插件协作或 Windows 已完成。

‎scripts/sdk-e2e.mjs‎

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Runs the SDK end-to-end scenario in sdk/e2e against Lithe's Rust Core.
22
//
33
// node scripts/sdk-e2e.mjs [--ref <commit|branch>] [--source <Lithe git URL or path>]
4+
// node scripts/sdk-e2e.mjs --source <local Lithe root> --working-tree --build-workflows
45
//
56
// Core lives in the Lithe repository, so the scenario is copied into
67
// rust/lithe-core/tests/ of a disposable Lithe checkout in .artifacts/lithe
@@ -15,7 +16,8 @@
1516
// The Rust sample backend is built with Cargo unless
1617
// LITHE_PLUGIN_SDK_RUST_SAMPLE names a built sample-backend executable.
1718
import { spawn } from 'node:child_process';
18-
import { access, copyFile, mkdir, readdir } from 'node:fs/promises';
19+
import { access, copyFile, mkdir, readdir, cp, realpath, readFile } from 'node:fs/promises';
20+
import { fileURLToPath } from 'node:url';
1921
import { join, resolve } from 'node:path';
2022
import { parseArgs } from 'node:util';
2123
import { readLock, repository } from './contract.mjs';
@@ -24,6 +26,15 @@ const work = join(repository, '.artifacts', 'lithe');
2426
const scenario = join(repository, 'sdk', 'e2e');
2527
const tests = ['plugin_sdk_node', 'plugin_sdk_java', 'plugin_sdk_rust'];
2628

29+
/** Once the contract mirror includes protocol 6, the current native workflows are mandatory. */
30+
export function includesNativeWorkflows(schema, requested = false) {
31+
const versions = schema?.properties?.requires?.properties?.buildProviders?.enum;
32+
if (!Array.isArray(versions) || versions.some(version => !Number.isInteger(version) || version < 1)) {
33+
throw new Error('The pinned contract must enumerate buildProviders versions.');
34+
}
35+
return requested || versions.includes(6);
36+
}
37+
2738
function run(command, args, options = {}) {
2839
return new Promise((resolvePromise, reject) => {
2940
const child = spawn(command, args, { stdio: 'inherit', ...options });
@@ -35,14 +46,15 @@ function run(command, args, options = {}) {
3546
});
3647
}
3748

38-
function output(command, args) {
49+
function output(command, args, trim = true) {
3950
return new Promise((resolvePromise, reject) => {
40-
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'inherit'] });
51+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'inherit'],
52+
timeout: 15000, killSignal: 'SIGKILL', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
4153
let text = '';
4254
child.stdout.on('data', chunk => { text += chunk; });
4355
child.on('error', reject);
4456
child.on('close', code => code === 0
45-
? resolvePromise(text.trim())
57+
? resolvePromise(trim ? text.trim() : text)
4658
: reject(new Error(`${command} ${args.join(' ')} failed (exit ${code})`)));
4759
});
4860
}
@@ -64,12 +76,37 @@ async function checkoutCore(source, ref) {
6476
await run('git', ['-C', work, 'config', 'remote.origin.url', source]);
6577
await run('git', ['-C', work, 'fetch', '-q', '--depth', '1', '--filter=blob:none', 'origin', ref]);
6678
await run('git', ['-C', work, 'checkout', '-q', '--force', '--detach', 'FETCH_HEAD']);
67-
// Scenario files copied by an earlier run are untracked; drop them so a file
68-
// removed from sdk/e2e does not linger.
69-
await run('git', ['-C', work, 'clean', '-q', '-f', '-d', '--', 'rust/lithe-core/tests']);
79+
// The checkout is disposable. Drop prior scenario/working-tree inputs so a
80+
// removed source cannot linger; ignored Cargo caches are left alone.
81+
await run('git', ['-C', work, 'clean', '-q', '-f', '-d', '--', 'rust', 'shared']);
7082
return output('git', ['-C', work, 'rev-parse', 'HEAD']);
7183
}
7284

85+
/** Replays only rust/ and shared/ from an explicitly selected local working tree. */
86+
export async function copyWorkingTree(source, destination) {
87+
const directory = await realpath(source);
88+
const root = await output('git', ['-C', directory, 'rev-parse', '--show-toplevel']);
89+
if (await realpath(root) !== directory) throw new Error('--working-tree source must be the repository root.');
90+
const patch = await output('git', ['-C', directory, 'diff', '--no-ext-diff', '--no-textconv', '--binary', 'HEAD', '--', 'rust', 'shared'], false);
91+
if (patch) {
92+
await new Promise((resolvePromise, reject) => {
93+
const child = spawn('git', ['-C', destination, 'apply', '--binary', '-'], {
94+
stdio: ['pipe', 'inherit', 'inherit'], timeout: 15000, killSignal: 'SIGKILL' });
95+
child.on('error', reject);
96+
child.stdin.on('error', reject);
97+
child.on('close', code => code === 0 ? resolvePromise() : reject(new Error('Cannot replay working-tree changes.')));
98+
child.stdin.end(patch);
99+
});
100+
}
101+
const files = (await output('git', ['-C', directory, 'ls-files', '--others', '--exclude-standard', '-z', '--', 'rust', 'shared'], false))
102+
.split('\0').filter(Boolean);
103+
for (const file of files) {
104+
const target = join(destination, file);
105+
await mkdir(resolve(target, '..'), { recursive: true });
106+
await cp(join(directory, file), target, { verbatimSymlinks: true });
107+
}
108+
}
109+
73110
async function copyScenario(destination) {
74111
for (const entry of await readdir(scenario, { recursive: true, withFileTypes: true })) {
75112
if (!entry.isFile()) continue;
@@ -100,32 +137,43 @@ async function rustSample() {
100137
}
101138

102139
async function main() {
103-
const { values } = parseArgs({ options: { ref: { type: 'string' }, source: { type: 'string' } } });
140+
const { values } = parseArgs({ options: { ref: { type: 'string' }, source: { type: 'string' },
141+
'working-tree': { type: 'boolean' }, 'build-workflows': { type: 'boolean' } } });
142+
if (values['working-tree'] && (!values.source || values.ref)) {
143+
throw new Error('--working-tree requires a local --source and cannot be combined with --ref.');
144+
}
104145
const lock = await readLock();
105-
const ref = values.ref ?? lock.source.revision;
146+
const schema = JSON.parse(await readFile(join(repository, 'contract/v1/plugin-manifest.schema.json'), 'utf8'));
147+
const buildWorkflows = includesNativeWorkflows(schema, values['build-workflows']);
148+
const ref = values['working-tree'] ? 'HEAD' : values.ref ?? lock.source.revision;
106149
const source = values.source ?? `${lock.source.repository}.git`;
107150

108151
const java = process.env.LITHE_PLUGIN_SDK_JAVA_RUNTIME || await which('java');
109152
const jar = await javaSample();
153+
const nativeJar = process.env.LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE
154+
? resolve(process.env.LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE)
155+
: join(repository, 'sdk/java/examples/sample-native-build/target/sample-native-build.jar');
156+
if (buildWorkflows) await access(nativeJar);
110157
const rust = await rustSample();
111158
const revision = await checkoutCore(source, ref);
112-
console.log(`Running the SDK end-to-end scenario against Lithe ${revision}`);
159+
if (values['working-tree']) await copyWorkingTree(source, work);
160+
console.log(`Running the SDK end-to-end scenario against Lithe ${revision}${values['working-tree'] ? ' plus explicit working-tree changes' : ''}`);
113161
await copyScenario(join(work, 'rust/lithe-core/tests'));
114162

115163
await run('cargo', ['test', '--locked', '--manifest-path', join(work, 'rust/Cargo.toml'), '-p', 'lithe-core',
116-
...tests.flatMap(test => ['--test', test])], {
164+
...[...tests, ...(buildWorkflows ? ['plugin_sdk_build'] : [])].flatMap(test => ['--test', test])], {
117165
env: {
118166
...process.env,
119167
LITHE_PLUGIN_SDK_ROOT: repository,
120168
LITHE_PLUGIN_SDK_NODE: process.execPath,
121169
LITHE_PLUGIN_SDK_JAVA: java,
122170
LITHE_PLUGIN_SDK_JAVA_SAMPLE: jar,
171+
LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE: nativeJar,
123172
LITHE_PLUGIN_SDK_RUST_SAMPLE: rust,
124173
},
125174
});
126175
}
127176

128-
main().catch(error => {
129-
console.error(error.message);
130-
process.exitCode = 1;
131-
});
177+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
178+
main().catch(error => { console.error(error.message); process.exitCode = 1; });
179+
}

0 commit comments

Comments
 (0)