@agile-team/wl-skills-ui 1.20.1 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +36 -13
- package/bin/__tests__/dependency-security.test.mjs +78 -0
- package/bin/__tests__/protocol-cli.test.mjs +3 -2
- package/bin/__tests__/protocol-execute.test.mjs +168 -0
- package/bin/executor-cli.mjs +13 -0
- package/bin/executor-invoke.cjs +129 -0
- package/bin/executor-process.cjs +51 -0
- package/bin/executors.json +1 -0
- package/bin/integration-protocol.cjs +50 -5
- package/bin/protocol-cli.mjs +11 -2
- package/bin/shared-support-check.cjs +40 -0
- package/bin/shared-support-manifest.json +25 -0
- package/bin/task-observability.cjs +26 -2
- package/bin/wl-ui.js +1 -1
- package/docs/maintenance-contract.md +46 -0
- package/docs/task-observability.md +10 -0
- package/package.json +9 -4
- package/scanner/engine.mjs +17 -8
- package/scanner/fix.mjs +2 -2
- package/scanner/index.mjs +2 -2
- package/scanner/rule-selector.mjs +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.21.0] — 2026-10-11
|
|
4
|
+
|
|
5
|
+
- 协议参数校验运行依赖固定升级 Ajv 8.20.0,消除 GHSA-2g4f-4pwh-qvx6 已知版本风险;保留共享 Schema 与未受影响的依赖版本。
|
|
6
|
+
- 定向修复 fast-uri 与开发工具传递依赖的已知公告;开发验证 Vue 链更新为 3.5.42 并保留生产 peer 范围,tmp 兼容性独立验证,主 CI 增加运行依赖审计门禁。
|
|
7
|
+
- CI 兼容矩阵先冻结安装依赖并生成运行产物,再打包候选;验证与发布工作流显式固定 pnpm 版本,避免全新 checkout 缺少构建或包管理器版本。
|
|
8
|
+
- 增加 executor-invoke-v1 结构化检查入口,复用本包原执行器并保留旧五操作。
|
|
9
|
+
- 完整参数 Schema、任务准入和路径边界校验;共享快照来源及离线防漂移门禁。
|
|
10
|
+
- 更新独立维护、Harness 兼容与持续验收说明。
|
|
11
|
+
|
|
12
|
+
- 公开协议新增 `executor-invoke-v1` 与结构化 `execute`;UI 在包内维护 scan 参数映射,复用原 scanner CLI 和同 runId 回执,无兄弟包或 Harness 运行时依赖。
|
|
13
|
+
- 执行前核对本项目有效计划、动作许可、目标及规则子集与参数 Schema;非法输入、计划外范围、解释、过期和禁用请求不启动检查。业务失败保留真实非零退出,调用成功不冒充验证通过。
|
|
14
|
+
- execute 省略 rules 时只执行计划内 R 规则;CLI/MCP 的 only/skip 在扫描前选择实际回调,SFC_PARSE 保持独立且不可隐藏。
|
|
15
|
+
- 原 scanner/修复器读取 Vue 叶子前核验实际项目根,拒绝目录内外部文件链接;保留本项目内部合法链接。
|
|
16
|
+
- 异步执行直接复用 scanner,避免同步包装器形成二次子进程;协议等待检查结束,复用公共输出预算与进程树取消,并增加真实 R001 成功/违规及拒绝零记录回归。
|
|
17
|
+
|
|
3
18
|
## [1.20.1] - 2026-10-11
|
|
4
19
|
|
|
5
20
|
- 修复 task 在版本漂移后旧顶层 status/ready 与嵌套 decision、notice、公开协议准入不一致;从校正后的记录生成旧字段,gap 始终 ready=false。
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> 项目范围:只对目标自身已接入且平台适用的项目判定规则;父工作区安装不启用子项目。移动端与未接入开源项目不套用。详见 [精准触发与范围配置](docs/project-scope.md)。
|
|
4
4
|
|
|
5
|
-
**企业级 UI 一致性与 AI 精确治理工具包 v1.
|
|
5
|
+
**企业级 UI 一致性与 AI 精确治理工具包 v1.21.0** — 面向 Vue 3 + Element Plus 的设计令牌、能力 Profile、样式皮肤、页面骨架、运行时能力、静态扫描、确定性修复、AI Skill、MCP 与脱敏 UI 模板契约。
|
|
6
6
|
|
|
7
7
|
它把“视觉统一、问题识别、机械修复、项目适配和样例沉淀”拆成可验证、可组合的工程能力。脚本、规则和 MCP 先完成确定性工作,AI 只处理业务语义与人工决策,从而减少重复读源码、上下文 token 和误修改。
|
|
8
8
|
|
|
@@ -108,20 +108,41 @@ CLI、安装器和 MCP 无需安装其他 wl-skills 包或统一客户端即可
|
|
|
108
108
|
|
|
109
109
|
## 公开集成协议 v1
|
|
110
110
|
|
|
111
|
-
供 harness、客户端或其他编排层以统一 JSON
|
|
111
|
+
供 harness、客户端或其他编排层以统一 JSON 入口判定、查询并调用本包声明的业务只读检查器。业务执行复用本包原 CLI,参数映射由本包维护;不新增兄弟包或 Harness 运行时依赖。
|
|
112
112
|
|
|
113
113
|
```bash
|
|
114
|
-
wl-ui protocol describe --json #
|
|
114
|
+
wl-ui protocol describe --json # 能力目录、六操作映射、请求/响应 Schema、错误码
|
|
115
115
|
wl-ui protocol request --input-file request.json --json
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
- 操作:`route` / `explain`(只读判定)、`task`(判定并持久化计划,尚未执行)、`status`(回查本包执行回执)、`doctor-host
|
|
119
|
-
-
|
|
120
|
-
- 统一信封:`{ ok, result | error, diagnostics }
|
|
121
|
-
-
|
|
118
|
+
- 操作:`route` / `explain`(只读判定)、`task`(判定并持久化计划,尚未执行)、`status`(回查本包执行回执)、`doctor-host`(宿主入口静态诊断)、`execute`(按有效计划调用本包声明的业务只读检查器)。
|
|
119
|
+
- 判定与查询请求字段:`protocolVersion`(1)、`operation`、`requestId`、`runId`、`projectRoot`、`task`、`targets`、`skill`、`host`+ `profile`。
|
|
120
|
+
- 统一信封:`{ ok, result | error, diagnostics }`;机器结果与诊断分离;非法输入在触达执行器前拒绝(基础请求为 `invalid-input`,执行参数为 `invalid-executor-input`),不写入任何记录。
|
|
121
|
+
- 基础错误码:`unsupported-protocol` / `unknown-operation` / `missing-input` / `invalid-input` / `internal-error`;执行请求另区分计划、授权、范围与参数拒绝,完整错误码见 `describe.errorCodes`。请求失败(exit 2)时 stdout 仍为可解析 JSON 信封。
|
|
122
122
|
- **`ok=true` 只表示协议调用成功,不代表业务验证通过**;业务状态以 `result` 内 `validationStatus` / `executionStatus` 为准(仅计划时为 `unverified` / `not-executed`)。
|
|
123
|
-
-
|
|
124
|
-
- 协议实现为本包内置的同源快照 `integration-protocol.cjs
|
|
123
|
+
- 副作用:`task` 保存计划,`execute` 复用原执行器写本包自己的 runs 回执并输出报告;均不通过本接口修改业务文件。其他操作只读,见 describe 中各操作 `sideEffects`。
|
|
124
|
+
- 协议实现为本包内置的同源快照 `integration-protocol.cjs`,当前源码分发一致性由 conformance 验收。各包独立保留自己的快照与版本;已发布版本按协议、feature 和 Schema 判断兼容,不要求每次同步发版。
|
|
125
|
+
|
|
126
|
+
### 稳定执行入口
|
|
127
|
+
|
|
128
|
+
先用 `task` 创建当前项目的计划,核对 `result.integration.allowedExecutors`,再通过同一个 `protocol request` 发送:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"protocolVersion": 1,
|
|
133
|
+
"operation": "execute",
|
|
134
|
+
"projectRoot": "/absolute/path/to/adopted-pc-project",
|
|
135
|
+
"runId": "task-returned-run-id",
|
|
136
|
+
"executorId": "scan",
|
|
137
|
+
"parameters": {"targets": ["src/views/example/index.vue"], "rules": ["R001"]}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`projectRoot`、`runId`、`executorId`、`parameters` 必填;执行目标放在 `parameters.targets`,不接受顶层 `targets`。UI 的 `rules` 只接受本次计划内的规则子集;省略时执行当前计划的 R 规则,不扩大到全量规则。SFC 解析检查独立执行且不能被隐藏。未知执行器、非法参数、计划外目标/规则、未接入或不适用项目、禁止检查、过期或版本漂移的计划在启动业务 CLI 前拒绝。当前本包执行器为 `scan`;以 `describe.inventory.executors` 的实际目录与 Schema 为准。
|
|
142
|
+
|
|
143
|
+
返回 `result.execution` 的真实 `exitCode`、`signal`、stdout/stderr,并重新读取同 runId 的包内状态形成 `result.integration`。`ok=true` 表示调用已完成,即使业务检查退出非零仍保留真实失败;宿主须查看执行与验证状态、实际检查文件和未验证项。输出有界,取消会终止本次业务 CLI 及其进程树。协议不代替真实宿主加载、模型读取或界面展示证据。
|
|
144
|
+
|
|
145
|
+
适配器只发送执行器 ID 与结构化参数,无需维护本包 CLI argv。增加检查器时由本包更新目录、Schema 与原 CLI 映射;兼容接口下其他包与 Harness 不必同步发版。原 CLI/MCP 继续可独立使用。
|
|
125
146
|
|
|
126
147
|
## 能力地图
|
|
127
148
|
|
|
@@ -512,7 +533,7 @@ npx wl-ui doctor --project . --print-overrides
|
|
|
512
533
|
|
|
513
534
|
## 版本与进一步阅读
|
|
514
535
|
|
|
515
|
-
当前 v1.
|
|
536
|
+
当前 v1.21.0:公开集成协议 describe/request 统一信封(能力清单与路由语料经真实打包产物验证)、输入校验收口与 MCP 目录对齐真实 tools/list。v1.15.0:任务判定、真实执行/验证回执、实际检查范围和过期检测;Codex 原生薄入口按需加载 canonical Skill。确定性路由覆盖已发布目录与显式缺口,不保证识别所有自然语言。v1.14.0:明确独立能力边界,安装、更新、清理按本包贡献保护用户及其他包内容;共享文件采用单文件原子替换,支持历史路由与 `.clinerules` 目录迁移。v1.13.2:R044 审查多列表单超宽标签,R040 识别自定义 `stepper`,均供人工复核。v1.13.0:新增 `native-jh-ag` Profile、显式 Profile 兼容承诺与联邦 AG 依赖识别。
|
|
516
537
|
|
|
517
538
|
- [版本摘要](docs/version-history.md)
|
|
518
539
|
- [完整 Changelog](CHANGELOG.md)
|
|
@@ -539,13 +560,13 @@ npm run pack:dry
|
|
|
539
560
|
|
|
540
561
|
任务判定保留原始文本,并将当前请求、历史引用和明确否定分开。自然语言判定覆盖已公开语料与规则,不能承诺任意表述都自动命中;遇到歧义或缺口应补充目标/领域或显式指定本包能力,不能静默选择。编辑前显示真实 `notice`,包括版本、技能、规则、目标、runId、动作与待检查项。
|
|
541
562
|
|
|
542
|
-
`protocol describe --json` 声明 `task-intent-v1`、`result-core-v1`、`executor-catalog-v1`;`schemas.resultCore`
|
|
563
|
+
`protocol describe --json` 声明 `task-intent-v1`、`result-core-v1`、`executor-catalog-v1`、`executor-invoke-v1`;`schemas.resultCore` 与六操作 `schemas.results` 可供宿主验证。原有结果字段保留,新增 `result.integration`:项目适用范围、动作模式、规则身份、目标、就绪状态、允许执行器和真实执行/验证状态。`ok` 只表示调用成功。判定与查询的未声明字段进入 diagnostics,不会转成授权依据;execute 的额外字段按 Schema 拒绝。
|
|
543
564
|
|
|
544
565
|
`action.mode` 为 explain/plan/inspect/apply/unspecified;解释和规划不自动启动检查。`businessWritesAuthorized=false`,任务意图不能替代业务写入授权。`ruleRefs` 为包自有规则身份(包名、规则 ID、版本、来源、性质),自然语言约束有稳定 ID;原规则名称与列表保留。所有规则的机械/语义验证仍以实际回执为准。
|
|
545
566
|
|
|
546
|
-
`inventory.executors` 声明本包业务只读检查器、目标与参数 Schema;其适用条件由本包判定。外部适配器须核对同项目、同包、同版本、同 runId
|
|
567
|
+
`inventory.executors` 声明本包业务只读检查器、目标与参数 Schema;其适用条件由本包判定。外部适配器须核对同项目、同包、同版本、同 runId 的真实计划、新鲜度与允许动作,指定一个目标,不能扩大范围;支持规则子集的执行器不得选择计划外规则。当前执行入口一次只接受与本包计划一致的一个目标;多目标应先拆分计划,再逐个执行。五包和 Harness 均为可选组合,不新增兄弟包或 Harness 运行时依赖;卸下适配器后原 CLI/MCP 继续独立使用。
|
|
547
568
|
|
|
548
|
-
样式规则检查使用 `scan
|
|
569
|
+
样式规则检查使用 `scan`(支持计划内规则子集;`--only/--skip` 在扫描前选择实际规则回调);`check` 只核验接入完整性。技能触发词由本包 `bin/task-triggers.json` 维护并接受目录一致性检查。
|
|
549
570
|
|
|
550
571
|
`scan/audit/fix` 的未知参数、重复值选项、空值、多余位置参数和不存在的规则选择器在检查或写入前拒绝,机读输出返回 `invalid-input`,不会形成成功检查记录。CLI 与 MCP 共用注册规则选择器,支持单个 ID、逗号列表与三位编号范围(例如 `R031-R037`);用 `wl-ui rules list` 核对实际规则。Vue 解析错误仍不能被 `--only/--skip` 隐藏。`wl-ui scan --help` 只输出帮助,不启动扫描。
|
|
551
572
|
|
|
@@ -554,3 +575,5 @@ npm run pack:dry
|
|
|
554
575
|
编辑前展示实际 `notice` 的包版本、判定、动作、技能/规则、目标、runId、待检查项与版本对齐信息;解释/规划和明确禁止检查时不自动执行检查。`runtime=mismatch/invalid` 或 `planStale=true` 必须先同步本包或重新判定。只读 `inspect` 输入变化后重新 `task`;`apply` 完成正常业务修改后可用同 runId 做实际复扫,但规则、配置或版本变化仍需重新判定。
|
|
555
576
|
|
|
556
577
|
单包只读取自身公开能力;多包复用同 runId 并各存各的证据。`matched/ready/ok=true` 仅说明判定或调用成功,是否完成以实际 `executionStatus/validationStatus/checkedFiles` 为准。未接入或不适用的项目不借父目录规则;宿主发现、模型读取、反馈展示没有真实证据时继续标记未验证。
|
|
578
|
+
|
|
579
|
+
维护边界、结构化执行和防漂移验收:[维护契约](docs/maintenance-contract.md)。
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtempSync, writeFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
|
|
8
|
+
// Commitizen and cz-customizable still use external-editor 3.x. Verify the
|
|
9
|
+
// patched tmp dependency against their real sync, callback and cleanup APIs.
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
const commitizenRequire = createRequire(require.resolve('commitizen'))
|
|
12
|
+
const inquirerRequire = createRequire(commitizenRequire.resolve('inquirer'))
|
|
13
|
+
const editorEntry = inquirerRequire.resolve('external-editor')
|
|
14
|
+
const editorRequire = createRequire(editorEntry)
|
|
15
|
+
const { ExternalEditor, editAsync } = editorRequire(editorEntry)
|
|
16
|
+
const tmp = editorRequire('tmp')
|
|
17
|
+
|
|
18
|
+
function fixture() {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), 'wl-ui-editor-api-'))
|
|
20
|
+
const editor = join(root, 'editor.cjs')
|
|
21
|
+
writeFileSync(editor, "require('node:fs').writeFileSync(process.argv[2], 'edited value', 'utf8')\n")
|
|
22
|
+
return { root, editor, cleanup: () => rmSync(root, { recursive: true, force: true }) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('patched tmp keeps external-editor sync creation, editing and cleanup', () => {
|
|
26
|
+
const f = fixture()
|
|
27
|
+
try {
|
|
28
|
+
assert.equal(editorRequire('tmp/package.json').version, '0.2.7')
|
|
29
|
+
const editor = new ExternalEditor('initial value', { dir: f.root, prefix: 'input-' })
|
|
30
|
+
editor.editor = { bin: process.execPath, args: [f.editor] }
|
|
31
|
+
assert.equal(existsSync(editor.tempFile), true)
|
|
32
|
+
assert.equal(editor.run(), 'edited value')
|
|
33
|
+
assert.equal(editor.lastExitStatus, 0)
|
|
34
|
+
editor.cleanup()
|
|
35
|
+
assert.equal(existsSync(editor.tempFile), false)
|
|
36
|
+
} finally {
|
|
37
|
+
f.cleanup()
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('patched tmp keeps external-editor async callback and automatic cleanup', async () => {
|
|
42
|
+
const f = fixture()
|
|
43
|
+
const previousVisual = process.env.VISUAL
|
|
44
|
+
try {
|
|
45
|
+
process.env.VISUAL = `${process.execPath} ${f.editor}`
|
|
46
|
+
const value = await new Promise((resolve, reject) => {
|
|
47
|
+
editAsync('initial value', (error, result) => error ? reject(error) : resolve(result), {
|
|
48
|
+
dir: f.root,
|
|
49
|
+
prefix: 'input-',
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
assert.equal(value, 'edited value')
|
|
53
|
+
assert.deepEqual(readdirSync(f.root), ['editor.cjs'])
|
|
54
|
+
} finally {
|
|
55
|
+
if (previousVisual === undefined) delete process.env.VISUAL
|
|
56
|
+
else process.env.VISUAL = previousVisual
|
|
57
|
+
f.cleanup()
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('patched tmp file callback retains idempotent cleanup within the fixture', async () => {
|
|
62
|
+
const f = fixture()
|
|
63
|
+
try {
|
|
64
|
+
const result = await new Promise((resolve, reject) => {
|
|
65
|
+
tmp.file({ dir: f.root, discardDescriptor: true }, (error, name, fd, cleanup) => {
|
|
66
|
+
if (error) reject(error)
|
|
67
|
+
else resolve({ name, fd, cleanup })
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
assert.equal(existsSync(result.name), true)
|
|
71
|
+
assert.equal(result.fd, undefined)
|
|
72
|
+
await new Promise((resolve, reject) => result.cleanup(error => error ? reject(error) : resolve()))
|
|
73
|
+
result.cleanup()
|
|
74
|
+
assert.equal(existsSync(result.name), false)
|
|
75
|
+
} finally {
|
|
76
|
+
f.cleanup()
|
|
77
|
+
}
|
|
78
|
+
})
|
|
@@ -22,12 +22,13 @@ function tempRoot() {
|
|
|
22
22
|
return root;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
test("describe
|
|
25
|
+
test("describe 返回协议版本、能力目录与六个统一操作", () => {
|
|
26
26
|
const described = protocol.describe();
|
|
27
27
|
assert.equal(described.protocolVersion, 1);
|
|
28
28
|
assert.equal(described.package, pkg.name);
|
|
29
29
|
assert.ok(Array.isArray(described.capabilities) && described.capabilities.length > 0);
|
|
30
|
-
assert.deepEqual(described.operations.map((operation) => operation.id), ["route", "explain", "task", "status", "doctor-host"]);
|
|
30
|
+
assert.deepEqual(described.operations.map((operation) => operation.id), ["route", "explain", "task", "status", "doctor-host", "execute"]);
|
|
31
|
+
assert.ok(described.features.includes("executor-invoke-v1"));
|
|
31
32
|
assert.ok(described.errorCodes.includes("unsupported-protocol"));
|
|
32
33
|
});
|
|
33
34
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const repo = path.resolve(import.meta.dirname, "../..");
|
|
9
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repo, "package.json"), "utf8"));
|
|
10
|
+
const cli = path.join(repo, "bin/wl-ui.js");
|
|
11
|
+
const selected = "src/views/Selected/index.vue";
|
|
12
|
+
|
|
13
|
+
function fixture(callback) {
|
|
14
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wl-ui-public-execute-"));
|
|
15
|
+
try {
|
|
16
|
+
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ private: true, devDependencies: { [pkg.name]: pkg.version, vue: "^3.4.0" } }));
|
|
17
|
+
fs.mkdirSync(path.join(root, "node_modules/@agile-team"), { recursive: true });
|
|
18
|
+
fs.symlinkSync(repo, path.join(root, "node_modules", pkg.name), "dir");
|
|
19
|
+
fs.mkdirSync(path.dirname(path.join(root, selected)), { recursive: true });
|
|
20
|
+
fs.writeFileSync(path.join(root, selected), '<template><el-table><el-table-column prop="name" align="center"/></el-table></template>\n');
|
|
21
|
+
const installed = spawnSync(process.execPath, [cli, "init", "--editor", "all", "--profile", "native-element"], { cwd: root, encoding: "utf8", timeout: 30000 });
|
|
22
|
+
assert.equal(installed.status, 0, installed.stderr || installed.stdout);
|
|
23
|
+
callback(root);
|
|
24
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function request(root, input) {
|
|
28
|
+
const file = path.join(root, "request.json");
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify({ projectRoot: root, ...input }));
|
|
30
|
+
const called = spawnSync(process.execPath, [cli, "protocol", "request", "--input-file", file, "--json"], { cwd: root, encoding: "utf8", timeout: 30000 });
|
|
31
|
+
assert.equal(called.signal, null, called.stderr);
|
|
32
|
+
return { ...JSON.parse(called.stdout), cliExitCode: called.status };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function plan(root, runId, task = "检查当前 Vue 代码") {
|
|
36
|
+
const planned = request(root, { operation: "task", task, runId, targets: [selected] });
|
|
37
|
+
assert.equal(planned.ok, true);
|
|
38
|
+
return planned.result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function execute(root, runId, extra = {}) {
|
|
42
|
+
return request(root, { operation: "execute", runId, executorId: "scan", parameters: { targets: [selected], rules: ["R001"] }, ...extra });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("公开 execute 复用原 scan:规则子集与真实失败保留,同 runId 状态回查", () => fixture(root => {
|
|
46
|
+
const source = fs.readFileSync(path.join(root, selected));
|
|
47
|
+
assert.ok(plan(root, "passed").integration.allowedExecutors.includes("scan"));
|
|
48
|
+
const passed = execute(root, "passed");
|
|
49
|
+
assert.equal(passed.ok, true, JSON.stringify(passed));
|
|
50
|
+
assert.equal(passed.cliExitCode, 0);
|
|
51
|
+
assert.equal(passed.result.execution.exitCode, 0);
|
|
52
|
+
assert.equal(passed.result.execution.signal, null);
|
|
53
|
+
assert.equal(passed.result.integration.runId, "passed");
|
|
54
|
+
assert.equal(passed.result.integration.executionStatus, "completed");
|
|
55
|
+
assert.deepEqual(fs.readFileSync(path.join(root, selected)), source);
|
|
56
|
+
const report = JSON.parse(passed.result.execution.stdout);
|
|
57
|
+
assert.equal(report.summary.fileCount, 1);
|
|
58
|
+
assert.equal(report.issues.length, 0);
|
|
59
|
+
const status = request(root, { operation: "status", runId: "passed" });
|
|
60
|
+
assert.equal(status.result.integration.executionStatus, passed.result.integration.executionStatus);
|
|
61
|
+
assert.equal(status.result.tools[0].tool, "scan");
|
|
62
|
+
assert.deepEqual(status.result.tools[0].checkedFiles.map(file => file.path), [selected]);
|
|
63
|
+
assert.deepEqual(status.result.tools[0].summary.rules.map(rule => rule.id), ["R001"]);
|
|
64
|
+
fs.writeFileSync(path.join(root, selected), '<template><el-table><el-table-column prop="name"/></el-table></template>\n');
|
|
65
|
+
plan(root, "failed");
|
|
66
|
+
const failed = execute(root, "failed");
|
|
67
|
+
assert.equal(failed.ok, true, JSON.stringify(failed));
|
|
68
|
+
assert.equal(failed.result.execution.exitCode, 1);
|
|
69
|
+
assert.equal(failed.result.integration.executionStatus, "failed");
|
|
70
|
+
assert.equal(failed.result.integration.validationStatus, "failed");
|
|
71
|
+
assert.ok(JSON.parse(failed.result.execution.stdout).issues.some(issue => issue.rule === "R001"));
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
test("execute 省略 rules 沿用计划 R 子集,不调用或显示计划外 R038", () => fixture(root => {
|
|
75
|
+
fs.writeFileSync(path.join(root, selected), '<template><el-table><el-table-column prop="name" align="center"/></el-table><el-button>新增</el-button></template>\n');
|
|
76
|
+
const planned = plan(root, "default-plan-rules");
|
|
77
|
+
const expected = planned.integration.ruleRefs.map(rule => rule.legacyId).filter(id => /^R\d{3}$/.test(id)).sort();
|
|
78
|
+
assert.ok(expected.includes("R001"));
|
|
79
|
+
assert.equal(expected.includes("R038"), false);
|
|
80
|
+
const called = execute(root, "default-plan-rules", { parameters: { targets: [selected] } });
|
|
81
|
+
assert.equal(called.ok, true, JSON.stringify(called));
|
|
82
|
+
assert.equal(called.result.execution.exitCode, 0);
|
|
83
|
+
const report = JSON.parse(called.result.execution.stdout);
|
|
84
|
+
assert.equal(report.issues.some(issue => issue.rule === "R038"), false);
|
|
85
|
+
assert.deepEqual(called.result.tools[0].summary.rules.map(rule => rule.id).sort(), expected);
|
|
86
|
+
assert.equal(called.result.tools[0].checks.some(check => check.id === "R038"), false);
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
test("execute 未知执行器、计划外规则/目标与额外字段在原 scanner 前拒绝,计划不变", () => fixture(root => {
|
|
90
|
+
const planned = plan(root, "rejected");
|
|
91
|
+
const planFile = path.join(root, planned.recordPath);
|
|
92
|
+
const files = fs.readdirSync(path.dirname(planFile));
|
|
93
|
+
const before = fs.readFileSync(planFile);
|
|
94
|
+
const cases = [
|
|
95
|
+
[{ executorId: "unknown" }, "unsupported-executor"],
|
|
96
|
+
[{ parameters: { targets: [] } }, "invalid-executor-input"],
|
|
97
|
+
[{ parameters: { targets: [selected], rules: ["R999"] } }, "rule-mismatch"],
|
|
98
|
+
[{ parameters: { targets: [selected], rules: ["R038"] } }, "rule-mismatch"],
|
|
99
|
+
[{ parameters: { targets: ["src/views/Other/index.vue"] } }, "target-mismatch"],
|
|
100
|
+
[{ parameters: { targets: [selected], command: "fix" } }, "invalid-executor-input"],
|
|
101
|
+
[{ targets: [selected] }, "invalid-executor-input"],
|
|
102
|
+
];
|
|
103
|
+
for (const [input, code] of cases) {
|
|
104
|
+
const rejected = execute(root, "rejected", input);
|
|
105
|
+
assert.equal(rejected.ok, false, JSON.stringify(input));
|
|
106
|
+
assert.equal(rejected.cliExitCode, 2);
|
|
107
|
+
assert.equal(rejected.error.code, code);
|
|
108
|
+
assert.deepEqual(fs.readFileSync(planFile), before);
|
|
109
|
+
assert.deepEqual(fs.readdirSync(path.dirname(planFile)), files);
|
|
110
|
+
assert.equal(request(root, { operation: "status", runId: "rejected" }).result.executionStatus, "not-executed");
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
test("execute 需要本项目有效检查计划:解释、过期、禁用与未知 runId 均拒绝", () => fixture(root => {
|
|
115
|
+
plan(root, "explain", "解释当前 Vue 样式,不要检查");
|
|
116
|
+
assert.equal(execute(root, "explain").ok, false);
|
|
117
|
+
plan(root, "stale");
|
|
118
|
+
fs.appendFileSync(path.join(root, selected), "<!-- changed after inspect -->\n");
|
|
119
|
+
assert.equal(execute(root, "stale").ok, false);
|
|
120
|
+
assert.equal(request(root, { operation: "status", runId: "stale" }).result.executionStatus, "not-executed");
|
|
121
|
+
plan(root, "disabled");
|
|
122
|
+
fs.writeFileSync(path.join(root, ".wl-skills-scope.json"), JSON.stringify({ schemaVersion: 1, projectType: "pc", packages: { ui: false } }));
|
|
123
|
+
assert.equal(execute(root, "disabled").ok, false);
|
|
124
|
+
assert.equal(execute(root, "unknown-run").ok, false);
|
|
125
|
+
assert.equal(fs.existsSync(path.join(root, ".wl-skills-ui/runs/unknown-run")), false);
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
test("公开 execute 在原 scanner 前拒绝目录中越界的 Vue 叶子,任务回执零新增", () => fixture(root => {
|
|
129
|
+
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "wl-ui-external-source-"));
|
|
130
|
+
try {
|
|
131
|
+
const external = path.join(outsideRoot, "Outside.vue");
|
|
132
|
+
const source = '<template><el-table-column prop="external"/></template>';
|
|
133
|
+
fs.writeFileSync(external, source);
|
|
134
|
+
fs.symlinkSync(external, path.join(root, "src/views/Selected/Outside.vue"));
|
|
135
|
+
const target = "src/views/Selected";
|
|
136
|
+
const planned = request(root, { operation: "task", task: "检查当前 Vue 代码", targets: [target], runId: "external-leaf" });
|
|
137
|
+
assert.equal(planned.ok, true);
|
|
138
|
+
const record = path.join(root, planned.result.recordPath);
|
|
139
|
+
const bytes = fs.readFileSync(record);
|
|
140
|
+
const files = fs.readdirSync(path.dirname(record));
|
|
141
|
+
const rejected = execute(root, "external-leaf", { parameters: { targets: [target] } });
|
|
142
|
+
assert.equal(rejected.ok, false, JSON.stringify(rejected));
|
|
143
|
+
assert.equal(rejected.error.code, "invalid-executor-input");
|
|
144
|
+
assert.deepEqual(fs.readFileSync(record), bytes);
|
|
145
|
+
assert.deepEqual(fs.readdirSync(path.dirname(record)), files);
|
|
146
|
+
assert.equal(fs.readFileSync(external, "utf8"), source);
|
|
147
|
+
} finally { fs.rmSync(outsideRoot, { recursive: true, force: true }); }
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
test("复制项目不能重用原根任务计划:status 过期、execute 身份拒绝且零新回执", () => fixture(root => {
|
|
151
|
+
const planned = plan(root, "copied-plan");
|
|
152
|
+
const copy = fs.mkdtempSync(path.join(os.tmpdir(), "wl-ui-copied-project-"));
|
|
153
|
+
try {
|
|
154
|
+
fs.cpSync(root, copy, { recursive: true, verbatimSymlinks: true });
|
|
155
|
+
const record = path.join(copy, planned.recordPath);
|
|
156
|
+
const bytes = fs.readFileSync(record);
|
|
157
|
+
const files = fs.readdirSync(path.dirname(record));
|
|
158
|
+
const status = request(copy, { operation: "status", runId: "copied-plan" });
|
|
159
|
+
assert.equal(status.result.planStale, true);
|
|
160
|
+
assert.equal(status.result.ready, false);
|
|
161
|
+
assert.equal(status.result.integration.ready, false);
|
|
162
|
+
const rejected = execute(copy, "copied-plan");
|
|
163
|
+
assert.equal(rejected.ok, false);
|
|
164
|
+
assert.equal(rejected.error.code, "identity-mismatch");
|
|
165
|
+
assert.deepEqual(fs.readFileSync(record), bytes);
|
|
166
|
+
assert.deepEqual(fs.readdirSync(path.dirname(record)), files);
|
|
167
|
+
} finally { fs.rmSync(copy, { recursive: true, force: true }); }
|
|
168
|
+
}));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import processCore from "./executor-process.cjs";
|
|
3
|
+
|
|
4
|
+
// 复用原 scanner CLI,避免 wl-ui 包装器的同步二次子进程。
|
|
5
|
+
// 规则与参数归本包维护;共享协议先验证计划、规则子集与目标范围。
|
|
6
|
+
export function invokeExecutor({ executor, parameters, projectRoot, runId, plan }) {
|
|
7
|
+
if (executor.id !== "scan") throw new Error(`UI 未实现执行器:${executor.id}`);
|
|
8
|
+
const rules = parameters.rules || plan.ruleRefs.map(rule => rule.legacyId).filter(id => /^R\d{3}$/.test(id));
|
|
9
|
+
if (!rules.length) throw new Error("当前计划没有可执行的 UI R 规则,不能默认扩大扫描范围");
|
|
10
|
+
const args = ["scan", "--project", projectRoot, `--target=${parameters.targets[0]}`, "--run-id", runId, "--output", "json", "--fail-on-error"];
|
|
11
|
+
args.push("--only", rules.join(","));
|
|
12
|
+
return processCore.invokeCli({ cli: path.resolve(import.meta.dirname, "../scanner/index.mjs"), args, projectRoot });
|
|
13
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/** Public execution admission. Package schemas and recorded decisions remain the facts. */
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const Ajv2020 = require("ajv/dist/2020");
|
|
7
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true, addUsedSchema: false });
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
const STRING = { type: "string", minLength: 1, pattern: "\\S" };
|
|
10
|
+
const REQUEST_SCHEMA = {
|
|
11
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
12
|
+
type: "object", additionalProperties: false,
|
|
13
|
+
required: ["operation", "projectRoot", "runId", "executorId", "parameters"],
|
|
14
|
+
properties: {
|
|
15
|
+
operation: { const: "execute" }, protocolVersion: { const: 1 }, requestId: STRING,
|
|
16
|
+
projectRoot: STRING, runId: { ...STRING, pattern: "^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$" }, executorId: STRING,
|
|
17
|
+
parameters: { type: "object" },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const OPERATION = {
|
|
21
|
+
id: "execute", summary: "通过稳定结构化入口执行本包允许的业务只读检查器",
|
|
22
|
+
readOnly: false, required: ["projectRoot", "runId", "executorId", "parameters"], optional: [],
|
|
23
|
+
sideEffects: "业务只读;按执行器声明写本包任务记录或检查报告", mapping: "protocol request operation=execute (executor-invoke-v1)",
|
|
24
|
+
};
|
|
25
|
+
class ExecutionError extends Error {
|
|
26
|
+
constructor(code, message) { super(message); this.code = code; }
|
|
27
|
+
}
|
|
28
|
+
function refuse(code, message) { throw new ExecutionError(code, message); }
|
|
29
|
+
function validateSchema(schema, value) {
|
|
30
|
+
const key = JSON.stringify(schema);
|
|
31
|
+
let validate = cache.get(key);
|
|
32
|
+
try {
|
|
33
|
+
if (!validate) {
|
|
34
|
+
validate = ajv.compile(schema);
|
|
35
|
+
if (cache.size >= 64) cache.clear();
|
|
36
|
+
cache.set(key, validate);
|
|
37
|
+
}
|
|
38
|
+
} catch (error) { refuse("unsupported-contract", `不能解释执行参数 Schema:${error.message}`); }
|
|
39
|
+
if (!validate(value)) refuse("invalid-executor-input", ajv.errorsText(validate.errors, { separator: "; " }));
|
|
40
|
+
}
|
|
41
|
+
function realTarget(root, target) {
|
|
42
|
+
const full = path.resolve(root, target);
|
|
43
|
+
if (full !== root && !full.startsWith(root + path.sep)) refuse("invalid-executor-input", "执行目标越出项目根");
|
|
44
|
+
let existing = full;
|
|
45
|
+
while (!fs.existsSync(existing)) {
|
|
46
|
+
const parent = path.dirname(existing);
|
|
47
|
+
if (parent === existing) refuse("invalid-executor-input", "执行目标没有可核验祖先");
|
|
48
|
+
existing = parent;
|
|
49
|
+
}
|
|
50
|
+
const real = fs.realpathSync(existing);
|
|
51
|
+
if (real !== root && !real.startsWith(root + path.sep)) refuse("invalid-executor-input", "执行目标符号链接越出项目根");
|
|
52
|
+
if (!fs.existsSync(full)) return;
|
|
53
|
+
inspectTargetTree(root, full);
|
|
54
|
+
}
|
|
55
|
+
function inspectTargetTree(root, full) {
|
|
56
|
+
const ignored = new Set(["node_modules", ".git", ".wl-skills", ".wl-skills-ui", ".wl-skills-bd", ".wl-skills-design", ".wl-skills-test", "test-reports", "dist", "build", "target", "coverage"]);
|
|
57
|
+
const visited = new Set(), queue = [full];
|
|
58
|
+
let entries = 0;
|
|
59
|
+
while (queue.length) {
|
|
60
|
+
const current = queue.pop();
|
|
61
|
+
if (++entries > 50000) refuse("unsupported-executor-range", "范围超过路径预检预算,请缩小目标");
|
|
62
|
+
const resolved = fs.realpathSync(current);
|
|
63
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) refuse("invalid-executor-input", "目录内部符号链接越出项目根");
|
|
64
|
+
if (!fs.statSync(current).isDirectory() || visited.has(resolved)) continue;
|
|
65
|
+
visited.add(resolved);
|
|
66
|
+
for (const name of fs.readdirSync(current)) if (!ignored.has(name)) queue.push(path.join(current, name));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function identifyPlan(config, input, status, root) {
|
|
70
|
+
const core = status.integration;
|
|
71
|
+
let recordedRoot;
|
|
72
|
+
try { recordedRoot = core?.projectRoot && fs.realpathSync(core.projectRoot); }
|
|
73
|
+
catch { refuse("identity-mismatch", "计划项目根无法核验"); }
|
|
74
|
+
if (!core || core.packageName !== config.packageName || core.packageVersion !== config.packageVersion
|
|
75
|
+
|| recordedRoot !== root || core.runId !== input.runId) refuse("identity-mismatch", "任务身份、项目或包版本不一致");
|
|
76
|
+
const plan = (status.records || []).filter(record => record.kind === "task-decision").at(-1);
|
|
77
|
+
assertLatestPlan(config, input, plan, root);
|
|
78
|
+
return { core, plan };
|
|
79
|
+
}
|
|
80
|
+
function assertLatestPlan(config, input, plan, root) {
|
|
81
|
+
if (!plan || plan.runId !== input.runId || plan.packageName !== config.packageName || plan.packageVersion !== config.packageVersion) refuse("missing-plan", "没有本包同 runId 的真实任务计划");
|
|
82
|
+
assertPlanRoot(plan, root);
|
|
83
|
+
}
|
|
84
|
+
function assertPlanRoot(plan, root) {
|
|
85
|
+
const recorded = plan.decision?.scope?.projectRoot || plan.notice?.projectRoot;
|
|
86
|
+
let resolved;
|
|
87
|
+
try { resolved = typeof recorded === "string" && fs.realpathSync(recorded); }
|
|
88
|
+
catch { refuse("identity-mismatch", "任务计划原项目根无法核验,请重新建立任务"); }
|
|
89
|
+
if (resolved !== root) refuse("identity-mismatch", "任务计划属于另一项目或缺少原项目身份,请重新建立任务");
|
|
90
|
+
}
|
|
91
|
+
function assertReady(config, core) {
|
|
92
|
+
if (core.scope?.status !== "applicable" || core.scope.adopted !== true) refuse("not-applicable", "项目未接入或不适用本包");
|
|
93
|
+
assertCatalog(config);
|
|
94
|
+
if (!["matched", "baseline"].includes(core.decisionStatus) || core.ready !== true) refuse("unresolved-plan", "计划存在缺口、歧义或待确认输入");
|
|
95
|
+
if (core.action?.checksAllowed !== true) refuse("action-not-allowed", "本任务没有允许检查");
|
|
96
|
+
}
|
|
97
|
+
function assertCatalog(config) {
|
|
98
|
+
if (config.inventory?.loadErrors?.length) refuse("catalog-load-error", `能力目录加载异常:${config.inventory.loadErrors.join("; ")}`);
|
|
99
|
+
}
|
|
100
|
+
function allowsRecheck(status, core) {
|
|
101
|
+
return core.action?.mode === "apply" && core.planStale === false && status.outdatedChecker === false
|
|
102
|
+
&& !["invalid", "mismatch"].includes(core.runtime?.status);
|
|
103
|
+
}
|
|
104
|
+
function assertFresh(status, core, plan) {
|
|
105
|
+
const tools = (status.records || []).filter(record => record.kind === "tool-finished");
|
|
106
|
+
const replanned = tools.length > 0 && tools.every(record => record.recordedAt < plan.recordedAt);
|
|
107
|
+
const recheck = allowsRecheck(status, core);
|
|
108
|
+
if (core.planStale !== false || core.stale !== false && !replanned && !recheck) refuse("stale-plan", "计划或检查证据过期,需重新判定");
|
|
109
|
+
if (["invalid", "mismatch"].includes(core.runtime?.status)) refuse("identity-mismatch", "安装资产、本地依赖与运行版本未对齐");
|
|
110
|
+
}
|
|
111
|
+
function selectExecutor(config, input, core) {
|
|
112
|
+
const executor = config.executors.find(item => item.id === input.executorId);
|
|
113
|
+
if (!executor || executor.sideEffects?.business !== "none" || executor.authorization !== "read-only-business-check") refuse("unsupported-executor", "执行器不存在或不是业务只读检查器");
|
|
114
|
+
if (!core.allowedExecutors.includes(input.executorId)) refuse("executor-not-allowed", "计划未允许此执行器");
|
|
115
|
+
return executor;
|
|
116
|
+
}
|
|
117
|
+
function admit(config, input, status) {
|
|
118
|
+
const root = fs.realpathSync(input.projectRoot);
|
|
119
|
+
const { core, plan } = identifyPlan(config, input, status, root);
|
|
120
|
+
assertReady(config, core);
|
|
121
|
+
assertFresh(status, core, plan);
|
|
122
|
+
const executor = selectExecutor(config, input, core);
|
|
123
|
+
validateSchema(executor.parameters, input.parameters);
|
|
124
|
+
if (JSON.stringify(input.parameters.targets) !== JSON.stringify(core.targets)) refuse("target-mismatch", "执行目标必须与计划一致");
|
|
125
|
+
for (const target of input.parameters.targets) realTarget(root, target);
|
|
126
|
+
if (input.parameters.rules?.some(id => !core.ruleRefs.some(rule => rule.legacyId === id))) refuse("rule-mismatch", "规则超出任务计划");
|
|
127
|
+
return { executor, parameters: input.parameters, projectRoot: root, runId: input.runId };
|
|
128
|
+
}
|
|
129
|
+
module.exports = { REQUEST_SCHEMA, OPERATION, ExecutionError, validateSchema, admit, realTarget };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/** Reuse the package's original CLI without shell evaluation or orphaned cancellation. */
|
|
4
|
+
const { spawn } = require("node:child_process");
|
|
5
|
+
function invokeCli({ cli, args, projectRoot, maxOutputBytes = 4 * 1024 * 1024 }) {
|
|
6
|
+
if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 64 * 1024 * 1024) throw new Error("执行器输出预算非法");
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const grouped = process.platform !== "win32";
|
|
9
|
+
const child = spawn(process.execPath, [cli, ...args], { cwd: projectRoot, detached: grouped, stdio: ["ignore", "pipe", "pipe"] });
|
|
10
|
+
const stdout = [], stderr = [];
|
|
11
|
+
let bytes = 0, cancelled = false, limited = false;
|
|
12
|
+
const killTree = () => {
|
|
13
|
+
if (!child.pid) return;
|
|
14
|
+
if (grouped) { try { process.kill(-child.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") child.kill("SIGKILL"); } }
|
|
15
|
+
else {
|
|
16
|
+
const { spawnSync } = require("node:child_process");
|
|
17
|
+
spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { timeout: 5000, stdio: "ignore" });
|
|
18
|
+
child.kill("SIGKILL");
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const stop = () => {
|
|
22
|
+
cancelled = true;
|
|
23
|
+
// Cancellation must clean descendants before an outer host can force-kill this CLI.
|
|
24
|
+
killTree();
|
|
25
|
+
};
|
|
26
|
+
const onTerm = () => stop("SIGTERM");
|
|
27
|
+
const onInt = () => stop("SIGINT");
|
|
28
|
+
process.on("SIGTERM", onTerm); process.on("SIGINT", onInt);
|
|
29
|
+
const cleanup = () => {
|
|
30
|
+
process.removeListener("SIGTERM", onTerm); process.removeListener("SIGINT", onInt);
|
|
31
|
+
};
|
|
32
|
+
const collect = (chunk, channel) => {
|
|
33
|
+
const retained = chunk.subarray(0, Math.max(0, maxOutputBytes - bytes));
|
|
34
|
+
if (channel === "stdout") stdout.push(retained); else stderr.push(retained);
|
|
35
|
+
bytes += chunk.length;
|
|
36
|
+
if (bytes > maxOutputBytes && !limited) { limited = true; stop("SIGTERM"); }
|
|
37
|
+
};
|
|
38
|
+
child.stdout.on("data", chunk => collect(chunk, "stdout"));
|
|
39
|
+
child.stderr.on("data", chunk => collect(chunk, "stderr"));
|
|
40
|
+
child.once("error", error => {
|
|
41
|
+
cleanup(); error.code = "executor-start-failed"; error.processId = child.pid; reject(error);
|
|
42
|
+
});
|
|
43
|
+
child.once("exit", killTree);
|
|
44
|
+
child.once("close", (exitCode, signal) => {
|
|
45
|
+
cleanup();
|
|
46
|
+
if (limited) { const error = new Error("执行器输出超出预算,已终止,不能宣称检查完成"); error.code = "executor-output-limit"; error.processId = child.pid; reject(error); }
|
|
47
|
+
else resolve({ exitCode, signal, processId: child.pid, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8"), outputTruncated: false, cancelled });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
module.exports = { invokeCli };
|
package/bin/executors.json
CHANGED
|
@@ -12,13 +12,18 @@
|
|
|
12
12
|
* - request:所有字段在执行前完成类型校验;非法输入返回 invalid-input,不触发执行器、
|
|
13
13
|
* 不写入任何记录。非法的 operation/requestId 不回显到信封(保持 string|null)。
|
|
14
14
|
* - 信封结构互斥:ok=true 必有 result 且无 error;ok=false 必有 error 且无 result。
|
|
15
|
-
* -
|
|
15
|
+
* - executor-invoke-v1 增加结构化只读检查入口,复用包内原执行器;旧五操作保持兼容。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const PROTOCOL_VERSION = 1;
|
|
19
19
|
const resultCore = require("./integration-result.cjs");
|
|
20
|
+
const execution = require("./executor-invoke.cjs");
|
|
21
|
+
const observability = require("./task-observability.cjs");
|
|
20
22
|
const SUPPORTED_VERSIONS = [1];
|
|
21
|
-
const ERROR_CODES = ["unsupported-protocol", "unknown-operation", "missing-input", "invalid-input", "internal-error"
|
|
23
|
+
const ERROR_CODES = ["unsupported-protocol", "unknown-operation", "missing-input", "invalid-input", "internal-error",
|
|
24
|
+
"unsupported-contract", "invalid-executor-input", "identity-mismatch", "missing-plan", "not-applicable", "catalog-load-error",
|
|
25
|
+
"unresolved-plan", "action-not-allowed", "stale-plan", "unsupported-executor", "executor-not-allowed", "target-mismatch", "rule-mismatch",
|
|
26
|
+
"unsupported-executor-range", "executor-start-failed", "executor-output-limit"];
|
|
22
27
|
const OPTIONAL_STRING_FIELDS = ["requestId", "runId", "projectRoot", "skill", "host", "type", "domain", "profile"];
|
|
23
28
|
|
|
24
29
|
const STRING_FIELD_SCHEMA = { type: "string", minLength: 1, pattern: "\\S" };
|
|
@@ -101,8 +106,10 @@ function requestSchema(config) {
|
|
|
101
106
|
domain: STRING_FIELD_SCHEMA,
|
|
102
107
|
profile: STRING_FIELD_SCHEMA,
|
|
103
108
|
context: CONTEXT_FIELD_SCHEMA,
|
|
109
|
+
executorId: STRING_FIELD_SCHEMA,
|
|
110
|
+
parameters: { type: "object" },
|
|
104
111
|
},
|
|
105
|
-
...
|
|
112
|
+
allOf: [...conditional, { if: { properties: { operation: { const: "execute" } }, required: ["operation"] }, then: execution.REQUEST_SCHEMA }],
|
|
106
113
|
};
|
|
107
114
|
}
|
|
108
115
|
|
|
@@ -131,6 +138,10 @@ function assertValidConfig(config) {
|
|
|
131
138
|
|
|
132
139
|
function createProtocol(config) {
|
|
133
140
|
assertValidConfig(config);
|
|
141
|
+
config = { ...config, operations: [...config.operations] };
|
|
142
|
+
const executionSupported = Array.isArray(config.executors) && config.executors.length > 0;
|
|
143
|
+
if (executionSupported) config.operations.push({ ...execution.OPERATION, mapping: `${config.bin || "<package-bin>"} ${execution.OPERATION.mapping}` });
|
|
144
|
+
assertValidConfig(config);
|
|
134
145
|
|
|
135
146
|
function base(operation, requestId) {
|
|
136
147
|
return {
|
|
@@ -235,7 +246,7 @@ function validateFieldTypes(input) {
|
|
|
235
246
|
mcpTools: inventory.mcpTools || [],
|
|
236
247
|
loadErrors: inventory.loadErrors || [],
|
|
237
248
|
executors: config.executors || [],
|
|
238
|
-
note: "
|
|
249
|
+
note: "结构化 execute 复用本包原执行器;inventory 声明公开调用映射,不改变业务归属;loadErrors 非空阻止自动执行,需修复目录诊断",
|
|
239
250
|
};
|
|
240
251
|
}
|
|
241
252
|
|
|
@@ -248,6 +259,19 @@ function validateFieldTypes(input) {
|
|
|
248
259
|
};
|
|
249
260
|
}
|
|
250
261
|
|
|
262
|
+
async function invokeAndClose(input, admitted, before, original, invoke) {
|
|
263
|
+
const previousEventIds = new Set((original.records || []).map(record => record.eventId));
|
|
264
|
+
const interruptionOptions = { projectRoot: admitted.projectRoot, runId: input.runId, packageName: config.packageName, packageVersion: config.packageVersion };
|
|
265
|
+
let receipt;
|
|
266
|
+
try { receipt = await invoke({ ...admitted, plan: before.integration }); }
|
|
267
|
+
catch (error) {
|
|
268
|
+
observability.finishInterruptedExecutions(interruptionOptions, previousEventIds, error.processId, error.code || "executor-interrupted");
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
if (receipt.cancelled || receipt.exitCode !== 0) observability.finishInterruptedExecutions(interruptionOptions, previousEventIds, receipt.processId, receipt.cancelled ? "executor-cancelled" : "executor-interrupted");
|
|
272
|
+
return receipt;
|
|
273
|
+
}
|
|
274
|
+
|
|
251
275
|
return {
|
|
252
276
|
PROTOCOL_VERSION,
|
|
253
277
|
SUPPORTED_VERSIONS,
|
|
@@ -257,10 +281,12 @@ function validateFieldTypes(input) {
|
|
|
257
281
|
return {
|
|
258
282
|
...base(null, null),
|
|
259
283
|
capabilities: config.capabilities || [],
|
|
260
|
-
|
|
284
|
+
support: require("./shared-support-manifest.json"),
|
|
285
|
+
features: ["task-intent-v1", "result-core-v1", "executor-catalog-v1", ...(executionSupported ? ["executor-invoke-v1"] : [])],
|
|
261
286
|
operations: config.operations.map((operation) => ({ ...operation })),
|
|
262
287
|
inventory: buildInventory(config),
|
|
263
288
|
schemas: { request: requestSchema(config), envelope: envelopeSchema(config.packageName), resultCore: resultCore.SCHEMA,
|
|
289
|
+
...(executionSupported ? { execute: execution.REQUEST_SCHEMA } : {}),
|
|
264
290
|
results: Object.fromEntries(config.operations.map((operation) => [operation.id, { type: "object", required: ["integration"], properties: { integration: resultCore.SCHEMA }, additionalProperties: true }])) },
|
|
265
291
|
constraints: buildConstraints(config),
|
|
266
292
|
errorCodes: [...ERROR_CODES],
|
|
@@ -270,6 +296,7 @@ function validateFieldTypes(input) {
|
|
|
270
296
|
request(input, runOperation) {
|
|
271
297
|
const { operation, error } = validateRequest(input);
|
|
272
298
|
if (error) return error;
|
|
299
|
+
if (operation.id === "execute") return fail(operation.id, input.requestId, "invalid-input", "execute 必须通过异步 requestExecute 接线调用");
|
|
273
300
|
const diagnostics = [];
|
|
274
301
|
const supportedFields = new Set(["operation", "protocolVersion", "requestId", ...operation.required, ...(operation.requireAny || []), ...operation.optional]);
|
|
275
302
|
for (const field of Object.keys(input)) {
|
|
@@ -286,6 +313,24 @@ function validateFieldTypes(input) {
|
|
|
286
313
|
return envelope;
|
|
287
314
|
}
|
|
288
315
|
},
|
|
316
|
+
|
|
317
|
+
async requestExecute(input, { readStatus, invoke }) {
|
|
318
|
+
const checked = validateRequest(input);
|
|
319
|
+
if (checked.error) return checked.error;
|
|
320
|
+
if (checked.operation.id !== "execute") return fail(input.operation, input.requestId, "invalid-input", "requestExecute 仅处理 execute 操作");
|
|
321
|
+
try {
|
|
322
|
+
execution.validateSchema(execution.REQUEST_SCHEMA, input);
|
|
323
|
+
const original = await readStatus(input.projectRoot, input.runId);
|
|
324
|
+
const before = resultCore.projectResult(original, input, config);
|
|
325
|
+
const admitted = execution.admit(config, input, before);
|
|
326
|
+
const receipt = await invokeAndClose(input, admitted, before, original, invoke);
|
|
327
|
+
const after = await readStatus(admitted.projectRoot, input.runId);
|
|
328
|
+
return { ...base("execute", input.requestId), ok: true,
|
|
329
|
+
result: { ...resultCore.projectResult(after, input, config), executorId: input.executorId, execution: receipt }, diagnostics: [] };
|
|
330
|
+
} catch (error) {
|
|
331
|
+
return fail("execute", input.requestId, ERROR_CODES.includes(error.code) ? error.code : "internal-error", error.message || "执行接口异常");
|
|
332
|
+
}
|
|
333
|
+
},
|
|
289
334
|
};
|
|
290
335
|
}
|
|
291
336
|
|
package/bin/protocol-cli.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
10
|
import { runTaskAction, taskCatalog } from "./task-integration.mjs";
|
|
11
|
+
import { invokeExecutor } from "./executor-cli.mjs";
|
|
11
12
|
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const pkg = require("../package.json");
|
|
@@ -77,6 +78,7 @@ function buildInventory() {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
export const protocol = createProtocol({
|
|
81
|
+
bin: BIN,
|
|
80
82
|
packageName: pkg.name,
|
|
81
83
|
packageVersion: pkg.version,
|
|
82
84
|
capabilities: capabilitiesDocument.capabilities || [],
|
|
@@ -103,7 +105,14 @@ function envelopeError(code, message, field) {
|
|
|
103
105
|
return { protocolVersion: 1, package: pkg.name, packageVersion: pkg.version, operation: null, requestId: null, ok: false, error: { code, message, ...(field ? { field } : {}) }, diagnostics: [] };
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
async function requestEnvelope(input) {
|
|
109
|
+
if (input && input.operation === "execute") {
|
|
110
|
+
return protocol.requestExecute(input, { readStatus: (root, runId) => runOperation("status", { projectRoot: root, runId }), invoke: invokeExecutor });
|
|
111
|
+
}
|
|
112
|
+
return protocol.request(input, runOperation);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function runCli(argv) {
|
|
107
116
|
const sub = argv[0];
|
|
108
117
|
if (sub === "describe") {
|
|
109
118
|
console.log(JSON.stringify(protocol.describe(), null, 2));
|
|
@@ -124,7 +133,7 @@ export function runCli(argv) {
|
|
|
124
133
|
console.log(JSON.stringify(envelopeError("invalid-input", message, "input-file"), null, 2));
|
|
125
134
|
return 2;
|
|
126
135
|
}
|
|
127
|
-
const envelope =
|
|
136
|
+
const envelope = await requestEnvelope(input);
|
|
128
137
|
console.log(JSON.stringify(envelope, null, 2));
|
|
129
138
|
return envelope.ok ? 0 : 2;
|
|
130
139
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Packaged snapshots verify themselves offline, without sibling checkouts. */
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
const digest = bytes => crypto.createHash("sha256").update(bytes).digest("hex");
|
|
7
|
+
function validateManifest(manifest) {
|
|
8
|
+
if (manifest.schemaVersion !== 1 || !manifest.files || Object.keys(manifest.files).length < 10) throw new Error("共享快照清单结构不完整");
|
|
9
|
+
if (manifest.sourceRevision !== `sha256:${digest(JSON.stringify({ files: manifest.files, developmentFiles: manifest.developmentFiles }))}`) throw new Error("共享快照源 revision 与清单不一致");
|
|
10
|
+
}
|
|
11
|
+
function verifyRuntime(directory, files) {
|
|
12
|
+
for (const [name, expected] of Object.entries(files)) {
|
|
13
|
+
if (path.basename(name) !== name || !/^[a-f0-9]{64}$/.test(expected)) throw new Error("非法共享快照文件清单");
|
|
14
|
+
if (digest(fs.readFileSync(path.join(directory, name))) !== expected) throw new Error(`共享快照漂移:${name}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function verifyDevelopment(directory, files) {
|
|
18
|
+
let developmentFilesChecked = 0;
|
|
19
|
+
for (const [name, expected] of Object.entries(files || {})) {
|
|
20
|
+
if (name !== "scripts/compatibility-check.cjs" || !/^[a-f0-9]{64}$/.test(expected)) throw new Error("非法开发验收脚本清单");
|
|
21
|
+
const file = path.resolve(directory, "..", name);
|
|
22
|
+
// Runtime archives need no development script; repositories verify any distributed copy.
|
|
23
|
+
if (!fs.existsSync(file)) continue;
|
|
24
|
+
if (digest(fs.readFileSync(file)) !== expected) throw new Error(`开发验收脚本漂移:${name}`);
|
|
25
|
+
developmentFilesChecked++;
|
|
26
|
+
}
|
|
27
|
+
return developmentFilesChecked;
|
|
28
|
+
}
|
|
29
|
+
function verifySupport(directory = __dirname) {
|
|
30
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(directory, "shared-support-manifest.json"), "utf8"));
|
|
31
|
+
validateManifest(manifest);
|
|
32
|
+
verifyRuntime(directory, manifest.files);
|
|
33
|
+
const developmentFilesChecked = verifyDevelopment(directory, manifest.developmentFiles);
|
|
34
|
+
return { ok: true, sourceRevision: manifest.sourceRevision, contracts: manifest.contracts, files: Object.keys(manifest.files).length, developmentFilesChecked };
|
|
35
|
+
}
|
|
36
|
+
if (require.main === module) {
|
|
37
|
+
try { console.log(JSON.stringify(verifySupport())); }
|
|
38
|
+
catch (error) { console.error(JSON.stringify({ ok: false, error: error.message })); process.exitCode = 1; }
|
|
39
|
+
}
|
|
40
|
+
module.exports = { verifySupport };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"source": "conformance/support",
|
|
4
|
+
"sourceRevision": "sha256:01fd34623e8641d867d4f95eb4ef115ebc2be8900b3589736d9b0b01aff214e0",
|
|
5
|
+
"contracts": {
|
|
6
|
+
"protocol": 1,
|
|
7
|
+
"resultCore": 1,
|
|
8
|
+
"executorInvoke": 1
|
|
9
|
+
},
|
|
10
|
+
"files": {
|
|
11
|
+
"task-observability.cjs": "5a042f28604e5bff3f9c20ad0f69c225bfafc336c35bc95e49cc8311712d0376",
|
|
12
|
+
"task-observability.schema.json": "fd66fd710d529fadf1a523b9e57a8380bc5f3edfc9eb93479e55b3744fef55de",
|
|
13
|
+
"integration-protocol.cjs": "6856d674526121010bbc5a0348306f9f3374819ec3cb6bb75eb8f305484d535c",
|
|
14
|
+
"project-scope.cjs": "bf3d4448407ceb84bb33576438f65fce342477a975dcfcfde1963d47af7282c0",
|
|
15
|
+
"project-scope.schema.json": "05512ab16189a7beaafc12d6a85ebd1b4784f1c199888fff68d556becb15f42f",
|
|
16
|
+
"task-intent.cjs": "b603a5115fd9d2718b5d4edc7459c5b9c23eb9a95a0cd1089a242fd0e77e787a",
|
|
17
|
+
"integration-result.cjs": "56370e200bb78c7ec0ad577dcc76e28490f271d9cc78bfaa314dbd863fd0b57c",
|
|
18
|
+
"executor-invoke.cjs": "e06c71317f80e3c0647aa0c46ecc89a44f0b621a2b6320353e526f754f33104e",
|
|
19
|
+
"executor-process.cjs": "cceff60e2896947becbd86a364c96b94c4dab7c47a1bd90e6d4837a442e543ce",
|
|
20
|
+
"shared-support-check.cjs": "a3a6fae30ae93f8dbd0f6cab3f871cc0f61a6611a72c9c8d330423ab3f66dc66"
|
|
21
|
+
},
|
|
22
|
+
"developmentFiles": {
|
|
23
|
+
"scripts/compatibility-check.cjs": "3053e99c94f7af332791d4dede70c92b6937d1a0dc1a6caf1a14f514ee32dde2"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -316,7 +316,7 @@ function startTask(options) {
|
|
|
316
316
|
|
|
317
317
|
function beginExecution(options) {
|
|
318
318
|
const metadata = runMetadata(options);
|
|
319
|
-
const record = { ...baseRecord(metadata, "tool-started"), tool: safeText(options.tool, 128), targets: strings(options.targets), executionStatus: "started", validationStatus: "unverified", inputSnapshot: captureSnapshot(options), ruleSnapshot: contextSnapshot(options, "ruleFiles"), configSnapshot: contextSnapshot(options, "configFiles") };
|
|
319
|
+
const record = { ...baseRecord(metadata, "tool-started"), producer: { pid: process.pid }, tool: safeText(options.tool, 128), targets: strings(options.targets), executionStatus: "started", validationStatus: "unverified", inputSnapshot: captureSnapshot(options), ruleSnapshot: contextSnapshot(options, "ruleFiles"), configSnapshot: contextSnapshot(options, "configFiles") };
|
|
320
320
|
record.recordPath = appendRecord(options, record);
|
|
321
321
|
return { options: { ...options, runId: metadata.runId }, metadata, startedAt: Date.now(), record };
|
|
322
322
|
}
|
|
@@ -395,6 +395,21 @@ async function withExecution(options, fn) {
|
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
// Record a transport interruption only for starts created by this invocation.
|
|
399
|
+
// No rule, file coverage or successful business result is inferred.
|
|
400
|
+
function finishInterruptedExecutions(options, previousEventIds, processId, errorCode) {
|
|
401
|
+
if (!Number.isInteger(processId) || processId <= 0) return 0;
|
|
402
|
+
const records = allRecords(options).filter(record => record.runId === options.runId);
|
|
403
|
+
const finished = new Set(records.filter(record => record.kind === "tool-finished").map(record => record.executionId));
|
|
404
|
+
const pending = records.filter(record => record.kind === "tool-started" && record.producer?.pid === processId && !previousEventIds.has(record.eventId) && !finished.has(record.eventId));
|
|
405
|
+
for (const record of pending) {
|
|
406
|
+
const metadata = { packageName: options.packageName, packageVersion: options.packageVersion, runId: options.runId };
|
|
407
|
+
finishExecution({ options: { ...options, targets: record.targets }, metadata, record, startedAt: Date.parse(record.recordedAt) },
|
|
408
|
+
{ exitCode: 1, validationStatus: "unverified", checks: [], checkedFiles: [], errorCode, summary: { source: "execution-transport", interrupted: true } });
|
|
409
|
+
}
|
|
410
|
+
return pending.length;
|
|
411
|
+
}
|
|
412
|
+
|
|
398
413
|
function readRecord(file, expectedPackage) {
|
|
399
414
|
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
400
415
|
if (data.recordVersion !== RECORD_VERSION || data.packageName !== expectedPackage) throw new Error("Unknown or foreign WL execution record");
|
|
@@ -476,7 +491,16 @@ function validationContext(plan, tools, options) {
|
|
|
476
491
|
return { validationStatus: summarizeValidation(verifiers, pending, stale, unresolvedDecision || scopeGaps.length > 0 || executionOrderUnverified), pendingChecks: pending, stale, outdatedChecker, planStale, scopeGaps, unresolvedDecision, executionOrderUnverified,runtime };
|
|
477
492
|
}
|
|
478
493
|
|
|
494
|
+
function planRootFresh(plan, options) {
|
|
495
|
+
const recordedRoot = plan.decision.scope?.projectRoot || plan.notice?.projectRoot;
|
|
496
|
+
if (recordedRoot) {
|
|
497
|
+
try { if (fs.realpathSync(recordedRoot) !== fs.realpathSync(options.projectRoot || process.cwd())) return false; }
|
|
498
|
+
catch { return false; }
|
|
499
|
+
}
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
479
502
|
function planInputsFresh(plan, options) {
|
|
503
|
+
if (!planRootFresh(plan, options)) return false;
|
|
480
504
|
if(plan.decision.action?.mode === 'inspect' && !snapshotsFresh([plan.inputSnapshot],options))return false;
|
|
481
505
|
if (!snapshotsFresh([plan.ruleSnapshot, plan.configSnapshot], options)) return false;
|
|
482
506
|
if (!plan.decision.scope?.fingerprint) return true;
|
|
@@ -695,4 +719,4 @@ function aggregateStatus(statuses) {
|
|
|
695
719
|
return { recordVersion: RECORD_VERSION, runId: runIds.length === 1 ? runIds[0] : null, correlated: runIds.length === 1, packages: valid, mixedRuns: runIds.length > 1, hasFailure: valid.some((status) => status.validationStatus === "failed"), gaps: valid.flatMap((status) => status.gaps || []) };
|
|
696
720
|
}
|
|
697
721
|
|
|
698
|
-
module.exports = { RECORD_VERSION, STORAGE, evaluateTask, startTask, beginExecution, finishExecution, withExecution, captureSnapshot, readStatus, listGaps, doctorHost, inspectRuntime, attachNotice, formatDecision, formatStatus, aggregateStatus };
|
|
722
|
+
module.exports = { RECORD_VERSION, STORAGE, evaluateTask, startTask, beginExecution, finishExecution, finishInterruptedExecutions, withExecution, captureSnapshot, readStatus, listGaps, doctorHost, inspectRuntime, attachNotice, formatDecision, formatStatus, aggregateStatus };
|
package/bin/wl-ui.js
CHANGED
|
@@ -97,7 +97,7 @@ if (rawArgs.length > 0 && SUBCOMMANDS.has(rawArgs[0])) {
|
|
|
97
97
|
// 非 init 子命令:直接委托给 scanner/index.mjs
|
|
98
98
|
if (subcommand === "protocol") {
|
|
99
99
|
const { runCli } = await import("./protocol-cli.mjs");
|
|
100
|
-
process.exitCode = runCli(rawArgs);
|
|
100
|
+
process.exitCode = await runCli(rawArgs);
|
|
101
101
|
} else if (["task", "route", "explain", "status", "doctor-host"].includes(subcommand)) {
|
|
102
102
|
const { taskCli } = await import("./task-integration.mjs");
|
|
103
103
|
taskCli(subcommand, rawArgs);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# 独立包与 Harness 的维护契约
|
|
2
|
+
|
|
3
|
+
业务规则、Skill、模板、检查器和参数语义由本包维护。Harness 通过公开 `protocol` 调用,不复制这些实现。本包不依赖 Harness,也不要求安装兄弟包;PC 接入、移动端排除和项目边界判断仍以本包真实 scope 判定为准。
|
|
4
|
+
|
|
5
|
+
## 稳定结构化执行入口
|
|
6
|
+
|
|
7
|
+
先用 `protocol request` 的 `task` 创建计划,再读取 `status.result.integration` 中的 `allowedExecutors`、`targets`、`ruleRefs`、准入和新鲜度。选定已允许的执行器后请求:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"protocolVersion": 1,
|
|
12
|
+
"operation": "execute",
|
|
13
|
+
"projectRoot": "/absolute/project",
|
|
14
|
+
"runId": "same-task-id",
|
|
15
|
+
"executorId": "从本包允许目录选择",
|
|
16
|
+
"parameters": { "targets": ["使用计划返回的实际目标"] }
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
能力以本地 `protocol describe --json` 为准,特性为 `executor-invoke-v1`;参数遵守对应 `inventory.executors[].parameters` 的完整 JSON Schema。不能省略 projectRoot/runId,不能任意扩大目标、规则或执行权限。能力目录加载异常、缺口、歧义、只讨论任务、过期计划和版本不一致会拒绝执行。
|
|
21
|
+
|
|
22
|
+
`ok=true` 表示结构化调用完成,不代表检查通过。`result.execution` 保留原执行器 exitCode、signal 和有界 stdout/stderr;真正业务验证结论来自 `result.integration`、实际工具回执与未验证项。业务只读检查可以写本包任务记录和报告。
|
|
23
|
+
|
|
24
|
+
原 CLI 与结构化入口复用同一个原检查器。本包内部 CLI 参数调整只需修改本包映射;新版 Harness 不保存这些旗标。未提供此特性的旧包仍可独立使用原 CLI,宿主可以在明确提示 legacy 的情况下使用旧受控映射。
|
|
25
|
+
|
|
26
|
+
## 版本、快照与自动监督
|
|
27
|
+
|
|
28
|
+
- `npm run support:check` 依据本包自带的 sourceRevision、契约版本和 SHA256 清单离线检查共享核心,不要求兄弟源码。公共源修复统一分发,禁止人工单独编辑快照。
|
|
29
|
+
- 公共开发工具支持 `support:sync -- --packages <keys> --check` 与按选定包分发;只读检查不能修改文件。来源 revision 相同表示同源内容,不要求五包每次同步发版。
|
|
30
|
+
- `npm run compatibility:check` 对本包候选真实 tarball 做发布基线普通升级与已发布兄弟包共存验证;仅开发验收使用这些兄弟包,不构成运行依赖。
|
|
31
|
+
- 五包本包 CI 执行快照检查及本包门禁;兼容 CI 保存产物和验收证据。Harness 自身另有已发布包与候选 SDK 验收。工作流配置与远端执行回执分开记录。
|
|
32
|
+
- 本包主 CI 的 Ubuntu Node 22 分支执行 `pnpm audit --prod --audit-level=moderate`,监督锁定运行依赖的已知公告;该门禁不等同于任意输入都安全。
|
|
33
|
+
- 完整开发依赖审计另行保留回执。当前 `braces@3.0.3` 的 GHSA-vfj7-8cjw-p6xm 属 Sass、lint 与提交工具的 glob 解析链;官方公告尚无已发布修复,不能把 registry 中不存在的 3.0.4 当作补丁,也不宣称完整开发图零告警。
|
|
34
|
+
- 开发图另保留 `esbuild@0.27.7` 的低风险 GHSA-g7r4-m6w7-qqqr:公告针对 Windows 上 esbuild 自有 `servedir` 服务器;当前 tsup/Vite 构建使用编译 API,不启用该服务器。修复要求 0.28.1,超出现父依赖的 0.27 范围,本轮不以强制跨范围 override 替代兼容验证;未取得真实 Windows CI 回执前,不扩大平台验证结论。
|
|
35
|
+
- 包版本、协议版本、公共快照 revision、SDK 版本分别管理。版本不同允许存在;破坏性公开契约变化须声明版本和兼容迁移。
|
|
36
|
+
|
|
37
|
+
宿主可见回执应显示包及实际版本、技能/规则、目标、runId、准入与缺口、执行结果及证据新鲜度。宿主是否发现、读取或遵守规则必须有相应宿主证据,静态入口存在和模型自报不能代替。
|
|
38
|
+
|
|
39
|
+
## 单包能力保留
|
|
40
|
+
|
|
41
|
+
卸载 Harness 不改变本包 CLI/MCP、Skill 和检查器。未安装或未接入的兄弟项目不继承规则。公共契约检查不强制业务项目安装全部五包,也不会自动追随 npm latest。
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
取消与路径证据:POSIX 执行器拥有独立进程组,取消立即终止其进程树;只对本次子进程实际新写且未结束的开始记录补执行失败、验证未验证、零规则通过/零检查文件的终态。并行同 runId 的其他进程记录不被代写。UTF8 输出按完整字节解码,stdout/stderr共用4MiB预算;预算超限不能成为成功。Windows进程树处理有实现,真实Windows执行须由CI另行采证。
|
|
45
|
+
|
|
46
|
+
计划绑定原项目的真实路径;复制或移动项目后旧计划失效,需要重新建立任务。目录内外部符号链接不能扩大检查范围。快照清单同时记录可分发兼容脚本的哈希:源码中存在此脚本时离线验证其内容;运行时tarball可以不包含开发验收脚本,不因此依赖兄弟源码。实际编辑器加载、模型读取与远端CI运行记录仍由对应宿主提供。
|
|
@@ -25,3 +25,13 @@ UI scan 记录规则真实回调次数及 SFC_PARSE 错误;解析错误不受
|
|
|
25
25
|
当前 CLI 检查回执覆盖 scan / audit / check / fix / all 与 Vite 依赖检查,本包 MCP 记录实际调用;其他 CLI snapshot / drift / exempt / contract / 脚手架保留既有产物与日志,不自动等同于 UI 源码检查。未由执行器报告实际文件的操作,其检查范围明确未知;不能用输入目录快照或成功退出补造文件覆盖。
|
|
26
26
|
|
|
27
27
|
规则选择器来自实际注册表;未知 ID、格式错误和无界范围在扫描前拒绝。CLI 的未知参数、重复值选项、空值与额外位置参数同样不会启动检查或写入回执;JSON/summary/compact-v2 输出返回 `invalid-input` 与 `not-executed`。MCP 的错误调用返回 `isError=true` 与真实失败记录,保留同 runId,`checkedFiles` 与源码检查为空。已注册的规则选择和语法错误检查继续按原有契约执行。
|
|
28
|
+
|
|
29
|
+
## 稳定协议执行
|
|
30
|
+
|
|
31
|
+
编排层先调用 `protocol request` 的 `task`,再发送 `operation: "execute"`、当前项目绝对 `projectRoot`、同一 `runId`、`executorId: "scan"` 与 `parameters.targets`。UI 的 `rules` 只接受本次计划内的规则子集;省略时执行当前计划的 R 规则,不扩大到全量规则。SFC 解析检查独立执行且不能被隐藏。请求的执行器必须在当前状态的 `integration.allowedExecutors` 内;目标不能扩大,参数按目录 Schema 校验。解释、规划、禁止检查、未接入/移动项目、缺口、计划过期与已知版本漂移不会启动执行器。
|
|
32
|
+
|
|
33
|
+
执行接线只保留本包参数映射,复用原 scan CLI,不复制规则或检查器。业务文件只读,原执行器仍写本包真实回执;返回 `result.execution` 和重新回查的 `result.integration`。调用 `ok=true` 不等于检查通过,非零退出、取消信号、实际文件和未验证项须如实展示。稳定接口使本包新版本不要求适配器或兄弟包同步修改;宿主/模型的实际读取和展示仍需独立证据。
|
|
34
|
+
|
|
35
|
+
`--only/--skip` 在扫描前选择实际规则回调,CLI 与 MCP 使用同一选择器;回执的 ruleExecutions 只记录实际选中的规则。它们不只是过滤问题展示;SFC_PARSE 仍独立验证,不能被规则选择隐藏。
|
|
36
|
+
|
|
37
|
+
原 scanner 与修复器在读取 Vue 文件前核验实际路径属于本项目;目录目标中的外部符号链接叶子也明确拒绝,不能静默跳过后称检查通过。本项目内合法文件链接继续可读;公开 execute 在启动原 CLI 前另做目标边界预检。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agile-team/wl-skills-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "企业级 UI 风格对齐框架 — Vue + Element Plus 双模式、任务路由与真实检查回执(tokens / element / vendors / layouts / runtime / scanner / fixer / skills)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./es/index.js",
|
|
@@ -76,12 +76,14 @@
|
|
|
76
76
|
"lint": "eslint . --cache --cache-location .eslintcache --max-warnings 0",
|
|
77
77
|
"typecheck": "tsc -p tsconfig.check.json",
|
|
78
78
|
"verify:package": "node scripts/verify-package.mjs",
|
|
79
|
-
"verify": "npm run lint && npm run typecheck && npm run test:coverage && npm run check:scss && npm run check:compat && npm run docs:check && npm run build && npm run verify:package",
|
|
79
|
+
"verify": "npm run support:check && npm run lint && npm run typecheck && npm run test:coverage && npm run check:scss && npm run check:compat && npm run docs:check && npm run build && npm run verify:package",
|
|
80
80
|
"pack:dry": "npm pack --dry-run --ignore-scripts",
|
|
81
81
|
"release:check": "npm run verify && npm run test:visual:contracts && npm run pack:dry",
|
|
82
|
-
"prepublishOnly": "npm run release:check",
|
|
82
|
+
"prepublishOnly": "npm run support:check && npm run release:check",
|
|
83
83
|
"test:coverage": "node --test --experimental-test-coverage --test-coverage-exclude=bin/vendor/** --test-coverage-lines=70 --test-coverage-branches=65 --test-coverage-functions=70 scanner/__tests__/*.test.mjs runtime/__tests__/*.test.mjs bin/__tests__/*.test.mjs",
|
|
84
|
-
"benchmark:runtime": "node scripts/benchmark-observer-hub.mjs"
|
|
84
|
+
"benchmark:runtime": "node scripts/benchmark-observer-hub.mjs",
|
|
85
|
+
"support:check": "node bin/shared-support-check.cjs",
|
|
86
|
+
"compatibility:check": "node scripts/compatibility-check.cjs"
|
|
85
87
|
},
|
|
86
88
|
"files": [
|
|
87
89
|
"dist",
|
|
@@ -171,5 +173,8 @@
|
|
|
171
173
|
"{bin,mcp,runtime,scanner,scripts,standards}/**/*.{js,mjs,ts,mts,tsx,vue}": [
|
|
172
174
|
"eslint --fix --no-cache"
|
|
173
175
|
]
|
|
176
|
+
},
|
|
177
|
+
"dependencies": {
|
|
178
|
+
"ajv": "8.20.0"
|
|
174
179
|
}
|
|
175
180
|
}
|
package/scanner/engine.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { join, relative, resolve } from "node:path";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { getRules, normalizeIssue } from "./rules/index.mjs";
|
|
4
4
|
import {
|
|
5
5
|
createCoverageCollector,
|
|
@@ -10,7 +10,7 @@ import { loadExemptConfig } from "./exempt.mjs";
|
|
|
10
10
|
import { collectChangedVueFiles } from "./changed.mjs";
|
|
11
11
|
import { parseVueSfc, SFC_PARSER_MODES } from "./sfc-parser.mjs";
|
|
12
12
|
import { resolveProjectProfile } from "../standards/profiles-loader.mjs";
|
|
13
|
-
import { expandRuleSelector } from "./rule-selector.mjs";
|
|
13
|
+
import { expandRuleSelector, selectRuleCallbacks } from "./rule-selector.mjs";
|
|
14
14
|
|
|
15
15
|
export function expandRuleRange(input = "") {
|
|
16
16
|
return input === "" ? new Set() : expandRuleSelector(input, getRules().map(rule => rule.id));
|
|
@@ -20,9 +20,12 @@ export function expandRuleRange(input = "") {
|
|
|
20
20
|
* 递归收集 .vue 文件(目录名排序稳定遍历;target 为单个 .vue 文件时直接返回)。
|
|
21
21
|
* 扫描与修复共用,保证两边看到同一文件集合。
|
|
22
22
|
*/
|
|
23
|
-
export function* walkVue(target, excludes) {
|
|
23
|
+
export function* walkVue(target, excludes, projectRoot) {
|
|
24
24
|
if (!existsSync(target)) throw new Error(`扫描目标不存在:${target}`);
|
|
25
|
-
|
|
25
|
+
const file = statSync(target).isFile();
|
|
26
|
+
const root = realpathSync(resolve(projectRoot || (file ? dirname(target) : target)));
|
|
27
|
+
assertVueBoundary(root, target);
|
|
28
|
+
if (file) {
|
|
26
29
|
if (target.endsWith(".vue")) yield target;
|
|
27
30
|
return;
|
|
28
31
|
}
|
|
@@ -32,13 +35,19 @@ export function* walkVue(target, excludes) {
|
|
|
32
35
|
for (const entry of entries) {
|
|
33
36
|
const full = join(target, entry.name);
|
|
34
37
|
if (entry.isDirectory()) {
|
|
35
|
-
if (!excludes.includes(entry.name)) yield* walkVue(full, excludes);
|
|
38
|
+
if (!excludes.includes(entry.name)) yield* walkVue(full, excludes, root);
|
|
36
39
|
} else if (entry.name.endsWith(".vue")) {
|
|
40
|
+
assertVueBoundary(root, full);
|
|
37
41
|
yield full;
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
function assertVueBoundary(root, file) {
|
|
47
|
+
const actual = realpathSync(file);
|
|
48
|
+
if (actual !== root && !actual.startsWith(root + sep)) throw new Error(`Vue 文件或目录必须位于项目根目录内(含符号链接):${file}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
function executeRule(rule, method, text, relPath, offset, executions) {
|
|
43
52
|
const issues = rule[method](text, relPath, offset).map(normalizeIssue);
|
|
44
53
|
const record = executions.get(rule.id);
|
|
@@ -73,7 +82,7 @@ export function scanFiles({
|
|
|
73
82
|
const checkedFiles = [];
|
|
74
83
|
const executions = new Map(rules.map((rule) => [rule.id, { id: rule.id, calls: 0, issues: 0, errors: 0 }]));
|
|
75
84
|
|
|
76
|
-
for (const filePath of walkVue(targetDir, excludeDirs)) {
|
|
85
|
+
for (const filePath of walkVue(targetDir, excludeDirs, projectRoot)) {
|
|
77
86
|
if (fileFilter && !fileFilter.has(resolve(filePath))) continue;
|
|
78
87
|
fileCount++;
|
|
79
88
|
const content = readFileSync(filePath, "utf8");
|
|
@@ -193,7 +202,7 @@ export function scanProject(options = {}) {
|
|
|
193
202
|
mode: options.mode,
|
|
194
203
|
});
|
|
195
204
|
const profile = profileResolution.profile;
|
|
196
|
-
const rules = getRules({ profile: profile.id });
|
|
205
|
+
const rules = selectRuleCallbacks(getRules({ profile: profile.id }), options, getRules().map(rule => rule.id));
|
|
197
206
|
let fileFilter = null;
|
|
198
207
|
let changed = { requested: false, fallback: false };
|
|
199
208
|
if (options.changedOnly || options.changed) {
|
package/scanner/fix.mjs
CHANGED
|
@@ -371,8 +371,9 @@ export function runFix({
|
|
|
371
371
|
let totalFiles = 0;
|
|
372
372
|
|
|
373
373
|
// 第一遍:收集所有需要改动的文件及其内容
|
|
374
|
+
const root = projectRoot || resolve(target, "..");
|
|
374
375
|
const pending = [];
|
|
375
|
-
for (const filePath of walkVue(target, exclude)) {
|
|
376
|
+
for (const filePath of walkVue(target, exclude, root)) {
|
|
376
377
|
totalFiles++;
|
|
377
378
|
const original = readFileSync(filePath, "utf8");
|
|
378
379
|
let content = original;
|
|
@@ -414,7 +415,6 @@ export function runFix({
|
|
|
414
415
|
}
|
|
415
416
|
|
|
416
417
|
pending.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
417
|
-
const root = projectRoot || resolve(target, "..");
|
|
418
418
|
const planHash = createHash("sha256")
|
|
419
419
|
.update(
|
|
420
420
|
JSON.stringify({
|
package/scanner/index.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
29
29
|
import { join, resolve, relative } from "node:path";
|
|
30
30
|
import { parseScannerInput } from "./cli-input.mjs";
|
|
31
|
-
import { expandRuleSelector } from "./rule-selector.mjs";
|
|
31
|
+
import { expandRuleSelector, selectRuleCallbacks } from "./rule-selector.mjs";
|
|
32
32
|
import { getRules } from "./rules/index.mjs";
|
|
33
33
|
import { generateReport } from "./report.mjs";
|
|
34
34
|
import { checkIntegration } from "./integration.mjs";
|
|
@@ -136,7 +136,7 @@ if (!["error", "full"].includes(values["changed-fallback"])) {
|
|
|
136
136
|
process.exit(1);
|
|
137
137
|
}
|
|
138
138
|
const activeProfile = profileResolution.profile;
|
|
139
|
-
const rules = getRules({ profile: activeProfile.id });
|
|
139
|
+
const rules = selectRuleCallbacks(getRules({ profile: activeProfile.id }), values, REGISTERED_RULES);
|
|
140
140
|
|
|
141
141
|
function resolveTarget(projectRoot, target) {
|
|
142
142
|
return resolve(projectRoot, target || "src");
|
|
@@ -15,3 +15,10 @@ export function expandRuleSelector(input, registered) {
|
|
|
15
15
|
if (missing.length) throw new Error(`不存在或格式无效的规则:${missing.join("、") || "空规则"};请用 wl-ui rules list 核对规则 ID`);
|
|
16
16
|
return selected;
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
/** Select callbacks before scanning; output filtering alone is not rule scope. */
|
|
20
|
+
export function selectRuleCallbacks(rules, { only, skip } = {}, registered = rules.map(rule => rule.id)) {
|
|
21
|
+
const allow = only ? expandRuleSelector(only, registered) : null;
|
|
22
|
+
const deny = skip ? expandRuleSelector(skip, registered) : new Set();
|
|
23
|
+
return rules.filter(rule => (!allow || allow.has(rule.id)) && !deny.has(rule.id));
|
|
24
|
+
}
|