@agile-team/wl-skills-gate 0.9.1 → 0.10.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 CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.10.0] — 2026-10-15
2
+
3
+ - 新增可启动的质量接入菜单和跨系统启动器,首次接入逐阶段展示、确认、执行、复扫与HTML反馈,不再要求人工传递计划摘要。
4
+ - 新增已接项目工作副本清单与独立初始化:固定锁安装、恢复本机Hook、实际正反验收,保留项目自定义策略;IDE打开任务只读预检。
5
+ - 新增中央接线预检与受控启动,核对配置、来源、身份、凭据存在性、密钥/状态绑定与回流;默认不联网、不启动、不发送,独立Review签发保持分离。
6
+ - 新增组合发布输入生成器,从可信采集原件生成并固定实际字节摘要,确认前后及签发前复核漂移,不代替业务决策、Review授权或部署。
7
+
1
8
  ## [0.9.1] — 2026-10-15
2
9
 
3
10
  - 重写 README:按实际能力梳理定位、接入步骤、使用场景与验证入口,完整细节保留在对应手册。
package/README.md CHANGED
@@ -33,44 +33,33 @@ Gate 是质量执行与裁决层,复用项目已有检查器和适用的 WL
33
33
 
34
34
  ## 快速接入
35
35
 
36
- 由技术经理在**实际 Git 项目根目录**接入一次。准备 Node.js ≥22.12、项目固定的包管理器和原有构建工具;安装源使用团队批准的 HTTPS registry。
36
+ 技术经理拿到批准的 Gate 完整包后,打开 `templates/launchers/setup.command`(macOS)、`setup.cmd`(Windows)或 `setup.sh`(Linux),按菜单选择任务。先提供 Node.js ≥22.12、项目固定的包管理器及原有构建工具。
37
37
 
38
- ### 1. 预览项目接入计划
39
-
40
- ```bash
41
- npx --yes @agile-team/wl-skills-gate@latest onboard \
42
- --root . --complete --registry https://registry.npmjs.org
43
- ```
44
-
45
- 查看识别的项目类型、依赖版本、文件差异、Hook 和阻断项。脚本与模板随 Gate 包交付,无需从文档复制拼装。
46
-
47
- | 项目 | 依赖装在哪里 | 业务构建 |
38
+ | 任务 | 人只需做什么 | 脚本完成什么 |
48
39
  | --- | --- | --- |
49
- | PC Vue 管理端,如 `wl-ui-produce` | 项目 `package.json`:Gate / Kit / UI | 保留原前端构建 |
50
- | Java/Maven,如 `wl-produce` | `quality-tools/package.json`:Gate / BD | 保留 Maven,Node 工具不进入 jar |
51
-
52
- `quality-tools/` 只是 Java 仓库里的 Node 工具依赖目录。内置后端配套针对 `wl-produce` PL 模块;其他 Maven 项目先由负责人配置模块与必测。移动端、UniApp 不套用 PC 配套。
40
+ | 首次接入项目 | 选择 Git 项目根、批准安装源,确认各阶段差异 | 识别范围、安装固定依赖、同步规则、Hook、POM/锁、首扫及提交说明正反验证 |
41
+ | 新成员初始化 | 选择已接项目,在安装与激活计划上确认 | 按仓库已提交清单恢复本机环境,保留项目策略,再验真实 Hook |
42
+ | 运维接线 | 选择批准配置目录,预检后选择启动 | 核对配置/身份/凭据存在性/旧状态,启动中央采集与操作入口;消息默认预览 |
43
+ | 组合发布输入 | 选择批准配置,确认组件与输出 | 从真实采集原件生成输入,核验来源、SHA、制品与兼容证据 |
53
44
 
54
- ### 2. 确认并应用这份计划
45
+ 也可用一个命令启动相同交互流程;进入后无需复制计划摘要或拼接后续命令:
55
46
 
56
47
  ```bash
57
- npx --yes @agile-team/wl-skills-gate@latest onboard \
58
- --root . --complete --registry https://registry.npmjs.org \
59
- --apply --plan-hash "<本次返回的 planHash>" --confirm
48
+ npx --yes --ignore-scripts --registry https://registry.npmjs.org/ \
49
+ --@agile-team:registry=https://registry.npmjs.org/ \
50
+ @agile-team/wl-skills-gate@0.10.0 setup project
60
51
  ```
61
52
 
62
- 程序按计划安装固定依赖、同步规则、配置 Hook、执行首扫与提交说明正反验证。首次安装后如出现原生规则或 POM 新差异,会返回新的待确认阶段;用同一入口重新预览、确认后续跑。
53
+ | 项目 | 依赖装在哪里 | 业务构建 |
54
+ | --- | --- | --- |
55
+ | PC Vue 管理端,如 `wl-ui-produce` | 根 `package.json`:Gate / Kit / UI | 保留原前端构建 |
56
+ | Java/Maven,如 `wl-produce` | `quality-tools/package.json`:Gate / BD | Maven;Node 工具不进入 jar |
63
57
 
64
- ### 3. 验证本地与远端效果
58
+ `quality-tools/` 是 Java 仓库内的 Node 工具依赖目录。内置后端配套针对 `wl-produce` PL;其他 Maven 项目先由负责人配置模块与必测。移动端、UniApp 不套用 PC 配套。
65
59
 
66
- | 验收点 | 成功依据 |
67
- | --- | --- |
68
- | 本地接入 | 返回 `local-validated-awaiting-remote-acceptance`;首扫通过、合规提交说明通过、违规说明被拦截 |
69
- | 首份质量报告 | 打开 `quality-reports/quality-report.html`;结构化结果在 `quality-summary.json` |
70
- | 接入执行记录 | `quality-reports/onboarding/execution.json` 显示本次实际阶段与结果 |
71
- | 远端强制检查 | 按下节接线,在真实 MR / 构建中验证失败拒绝、通过放行与消息送达 |
60
+ 首次接入反馈在 `quality-reports/setup/<sessionId>/result.html`,质量报告在 `quality-reports/quality-report.html`。只有真实首扫及 Hook 正反验证通过,才返回 `local-validated-awaiting-remote-acceptance`。GitLab/Jenkins 强制检查、独立 Review、真实到群及部署另做现场验收。
72
61
 
73
- 本地接入通过后,再完成 CI、Review、通知和部署验收。完整步骤与状态说明见 [接入指南](./docs/17-onboarding.md)。
62
+ 接线配置、非交互任务参数与失败处理见 [接入指南](./docs/17-onboarding.md)。
74
63
 
75
64
  ## 日常怎么用
76
65
 
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline/promises";
3
+ import { runSetupCli } from "../lib/setup.js";
4
+
5
+ try {
6
+ let args = process.argv.slice(2);
7
+ if (!args.length) {
8
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("启动菜单需要交互终端;自动任务请明确指定 project/workspace/central/release-input 及其预览/确认参数");
9
+ console.log("\nWL · WORLD LEADING | 质量工程接入\n\n1 首次接入项目\n2 初始化已接项目的本机环境\n3 运维接线预检\n4 启动中央采集与操作入口(消息预览)\n5 生成组合发布输入\nEnter 退出\n");
10
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
11
+ const choice = (await prompt.question("请选择:")).trim(); prompt.close();
12
+ const modes = { "1": ["project"], "2": ["workspace"], "3": ["central"], "4": ["central", "--start", "--watch"], "5": ["release-input"] };
13
+ if (!choice) process.exit(0);
14
+ if (!modes[choice]) throw new Error("请选择1到5");
15
+ args = modes[choice];
16
+ }
17
+ process.exitCode = await runSetupCli(args);
18
+ } catch (error) { console.error(`[quality-setup] ${error.message}`); process.exitCode = 2; }
@@ -76,6 +76,9 @@ try {
76
76
  } else if (command === "onboard") {
77
77
  const { runOnboardCli } = await import("../lib/onboard.js");
78
78
  process.exitCode = await runOnboardCli(rest);
79
+ } else if (command === "setup") {
80
+ const { runSetupCli } = await import("../lib/setup.js");
81
+ process.exitCode = await runSetupCli(rest);
79
82
  } else if (command === "doctor") {
80
83
  const o = options(["--root", "--kind", "--out"]);
81
84
  if (o.positional.length) throw new Error("doctor 不接受位置参数");
@@ -293,5 +296,5 @@ try {
293
296
  if (rest.length) throw new Error("self-test 不接受额外参数");
294
297
  const { runAllFixtures } = await import("../scripts/run-fixtures.mjs");
295
298
  process.exitCode = await runAllFixtures();
296
- } else throw new Error("用法: wl-skills-gate <onboard|run|doctor|automation|control|collect-ci|review-facts|review-ci|review-collect|review-publish|lifecycle-build|lifecycle-review|governance|inspect|cost|fix|validate-report|commit-message|manifest|attest|attest-collected|release-set|deployment|review-draft|review-sign|verify-artifact|promote|promote-apply|self-test>,详见 docs/README.md");
299
+ } else throw new Error("用法: wl-skills-gate <setup|onboard|run|doctor|automation|control|collect-ci|review-facts|review-ci|review-collect|review-publish|lifecycle-build|lifecycle-review|governance|inspect|cost|fix|validate-report|commit-message|manifest|attest|attest-collected|release-set|deployment|review-draft|review-sign|verify-artifact|promote|promote-apply|self-test>,详见 docs/README.md");
297
300
  } catch (e) { console.error(`[${command || "wl-skills-gate"}] ${e.message}`); process.exitCode = 2; }
@@ -13,19 +13,28 @@
13
13
 
14
14
  ## 技术经理一次接入,开发者由 Hook 自动检查
15
15
 
16
- 完整接入使用 `--complete`,从已批准的固定 Gate 产物运行预览。下例 `$GATE_ROOT` 指该完整包目录,`$PROJECT_ROOT` 指实际 Git 仓库根;前后端使用同一入口。
16
+ 负责人从批准的完整包打开 `templates/launchers/setup.command` / `setup.cmd` / `setup.sh`,选择任务即可。首次项目接入使用 `setup project` 交互向导:选择真实 Git 根和批准 HTTPS 安装源,查看文件、依赖、Hook 与固定安装 argv,确认当前阶段。脚本管理本阶段计划摘要,出现安装后的新差异再展示确认。
17
17
 
18
- ```bash
19
- node "$GATE_ROOT/bin/wl-skills-gate.js" onboard --root "$PROJECT_ROOT" --complete --registry "$APPROVED_NPM_REGISTRY"
20
- ```
18
+ | 入口 | 自动执行 | 成功反馈 |
19
+ |---|---|---|
20
+ | `setup project` | 预检→批准配置/依赖安装→原生规则/POM/锁→doctor→真实Hook首扫和提交说明正反验证 | `quality-reports/setup/<sessionId>/result.html`;阶段原件与执行回执可追溯 |
21
+ | `setup workspace` | 按已提交 `quality/workspace.json` 与依赖锁安装,恢复本机 Hook,实际验收 | `quality-reports/onboarding/workspace-execution.json`;不覆盖项目策略 |
22
+ | `setup central` | 中央配置、来源、身份、凭据存在性、旧状态及部署回流预检 | 逐项状态、责任人和下一步;预检不联网、不启动、不发送 |
23
+ | `setup release-input` | 核验固定来源原件、签名、摘要、兼容证据,确认后生成组合输入 | `configHash` / `inputHash` 与实际输入文件;不代替授权或签发 |
21
24
 
22
- 接入界面可直接消费此 JSON,展示项目判定、每个文件的修改前后内容、依赖版本变化、Hook 激活、冲突与剩余验收。技术经理确认**这份具体计划**后,界面传回其实际摘要:
25
+ 非交互入口默认只读。`setup project --root PATH --registry URL --preview --json` 可用于预检;项目首次写入由交互逐阶段批准,自动任务可继续使用下文底层 `onboard` 契约。
23
26
 
24
- ```bash
25
- node "$GATE_ROOT/bin/wl-skills-gate.js" onboard --root "$PROJECT_ROOT" --complete --registry "$APPROVED_NPM_REGISTRY" --apply --plan-hash "$ONBOARD_PLAN_HASH" --confirm
26
- ```
27
+ ### 已接项目的新克隆
28
+
29
+ 首次接入完成后,将生成的工作副本清单、依赖锁、Hook、脚本与 IDE 任务一并审阅提交。新成员在 VS Code/Cursor 中选择 **wl-quality:初始化本机质量环境**。缺少本地 Gate 时入口先展示清单固定版本和批准源,确认后获取引导工具;随后展示项目初始化计划,确认后安装与验收。不得重跑首次接入来覆盖已经批准的项目策略。
30
+
31
+ **wl-quality:打开项目检查接入状态** 在允许自动任务的受信工作区打开时执行,只读、不联网、不安装。IDE 的 Workspace Trust 与自动任务许可由用户环境控制;Git clone 自身不会执行仓库脚本。首次初始化成功后,日常仍由 Hook 自动检查。
27
32
 
28
- 安装源必须是明确批准的 HTTPS URL;URL 不含凭据,认证由受控环境注入。完整接入计划依次展示固定 argv 安装(关闭生命周期脚本)、原生规则实际预览与同步、后端 POM 精确补丁、实际版本锁、doctor、真实 Git Hook 首扫、提交说明正反演练。两个入口命令只给接入负责人或接入界面使用。开发者正常提交/推送时,Hook 自动检查;IDE 提供“查看报告 / 预览机械修复 / 确认应用并复扫”操作,不要求输入质量命令。修复需要本人预览和确认。
33
+ ### 运维与发布输入
34
+
35
+ 中央交互先询问配置目录及独立Review签发是否分机,默认分机,不读取独立私钥。只预检时使用 `setup central`;经批准启动时使用 `setup central --start --watch`,消息仍只预览。真实发送另加 `--send`,启动确认会明确显示发送范围。非交互启动要求 `--config-dir DIR --start --watch --confirm --plan-hash HASH`;分机时加 `--separate-review-signer`。服务以前台受控任务运行,SIGINT/SIGTERM 清理本任务启动的服务,不安装系统服务、不改GitLab/Jenkins权限或密钥、不启动独立签发。
36
+
37
+ 发布负责人维护 `templates/platform/release-input-config.json` 的项目副本。`setup release-input --config FILE` 展示实际组件、SHA、作业和输出,确认后生成输入。非交互生成需 `--confirm --config-hash HASH --input-hash HASH`;原件变化会使旧确认失效。作者映射、兼容性结论、恢复方案仍由对应负责人批准。生成后的原件摘要在签发前再次复核。
29
38
 
30
39
  - 默认计划将尚未配置的本地 `core.hooksPath` 指向生成的 `.githooks`;应用后生效。已有仓库内 Hook 路径直接复用,`.husky/_` 映射到用户 `.husky` 文件。既有Husky9转发器通过计划中独立、固定资产的激活阶段生成,不执行整个自定义prepare脚本;未知版本或路径明确阻断。
31
40
  - 既有脚本与检查保留。发现旧 Hook 自动写源码或重复检查时列出迁移项;不会擅自删除或跳过。
@@ -33,7 +42,14 @@ node "$GATE_ROOT/bin/wl-skills-gate.js" onboard --root "$PROJECT_ROOT" --complet
33
42
  - 计划、项目 HEAD、文件、模板、接入实现或选择变化后,旧摘要失效。冲突、路径越界、符号链接均拒写;写入有备份、独占锁与失败回滚。
34
43
  - `--no-activate-hooks` 可显式只生成文件;此时不能登记自动检查已生效。预览与应用必须使用相同选项。
35
44
 
36
- ## 实际差异出现时,再确认同一入口
45
+ ## 底层自动任务契约与阶段确认
46
+
47
+ 交互向导自动调用以下同一底层契约;脚本集成者才需要管理摘要。
48
+
49
+ ```bash
50
+ node "$GATE_ROOT/bin/wl-skills-gate.js" onboard --root "$PROJECT_ROOT" --complete --registry "$APPROVED_NPM_REGISTRY"
51
+ node "$GATE_ROOT/bin/wl-skills-gate.js" onboard --root "$PROJECT_ROOT" --complete --registry "$APPROVED_NPM_REGISTRY" --apply --plan-hash "$ONBOARD_PLAN_HASH" --confirm
52
+ ```
37
53
 
38
54
  首次尚未安装固定包时,无法提前知道它们的真实规则差异。确认首份计划后只完成本地配置与固定清单安装,结果为 `native-approval-required`;接入界面重新调用同一预览,显示实际 Kit/UI/BD 差异和新摘要,负责人确认后继续。BD 刚同步而 POM 模板首次出现时,以 `pom-approval-required` 展示完整 before/after,再确认同一入口。后续不需要技术经理拼装多个 update、Maven、版本查询或 Hook 命令。
39
55
 
@@ -67,7 +83,7 @@ GitLab/Jenkins 权限、可信中央模板、候选/独立 Review、构建/部
67
83
 
68
84
  ## 维护单源
69
85
 
70
- - 唯一公共入口:`lib/onboard.js`;唯一配置引擎:`lib/onboard-config.js`;完整任务编排:`lib/onboard-complete.js`。
86
+ - 交互入口:`lib/setup.js` / `lib/setup-modes.js`;底层首次接入:`lib/onboard.js` / `lib/onboard-config.js` / `lib/onboard-complete.js`;新克隆:`lib/workspace-init.js`;中央预检:`lib/setup-central.js`;发布输入:`lib/release-input.js`。
71
87
  - 包内交付源:`templates/onboarding/{frontend,backend,automation}`。`templates/scripts-quality/run.mjs` 与 schema 同包核验。
72
88
  - 文档目录旧 `automation/configure.mjs` 仅兼容转发;不得再次复制实现。文档配套文件是审阅用样例副本,维护时与包内交付源核对摘要。
73
89
  - 本模块测试覆盖真实 Git Hook 拒绝、前后端位置、旧 Hook 复用、幂等、漂移拒绝、完整包独立交付;不把隔离测试计为业务项目或远端验收。
package/docs/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # wl-skills-gate 技术手册索引
2
2
 
3
- 当前版本 0.9.0;对照团队落地执行 V3.4。技术宣贯基线仍为 V3.1;团队具体操作单源见 [16 项目与运维入口](16-project-ci-integration.md),本索引提供工具契约与参数参考。
3
+ 当前版本 0.10.0;对照团队落地执行 V3.4。技术宣贯基线仍为 V3.1;团队具体操作单源见 [16 项目与运维入口](16-project-ci-integration.md),本索引提供工具契约与参数参考。
4
4
 
5
5
  授权声明见 [LICENSE](../LICENSE);公开获取不授予使用、复制、修改或再分发授权,申请入口为 [仓库 Issues](https://github.com/ChenyCHENYU/wl-skills-gate/issues)。
6
6
 
7
- 状态分开登记:**共享实现已验证 / 项目已接入 / 平台已验收 / 业务闭环已验收**。每个状态须有自己的实际证据;以下手册不将本地自测、模板或签名通过标成生产接线完成。
7
+ 状态分开登记:**共享实现已验证 / 项目已接入 / 远端接线已验收 / 业务闭环已验收**。每个状态须有自己的实际证据;以下手册不将本地自测、模板或签名通过标成生产接线完成。
8
8
 
9
9
  | 手册 | 对应要求 | 内容 |
10
10
  |---|---|---|
package/lib/automation.js CHANGED
@@ -241,7 +241,27 @@ export async function automationTick({ configFile, send = false, env = process.e
241
241
  }
242
242
 
243
243
  export function startAutomation({ configFile, send = false, onResult = (result) => process.stdout.write(JSON.stringify(result) + "\n"), ...options }) {
244
- const c = loadAutomationConfig(configFile); let stopped = false, timer;
245
- const next = async () => { try { onResult(await automationTick({ configFile, send, ...options })); } catch (error) { onResult({ status: "attention", stage: "scheduler", reason: error.message }); } if (!stopped) timer = setTimeout(next, c.intervalSeconds * 1000); };
246
- next(); return { stop() { stopped = true; clearTimeout(timer); } };
244
+ const c = loadAutomationConfig(configFile); let stopped = false, timer, inFlight = Promise.resolve();
245
+ const next = () => {
246
+ if (stopped) return inFlight;
247
+ inFlight = (async () => {
248
+ let result;
249
+ try { result = await automationTick({ configFile, send, ...options }); }
250
+ catch (error) { result = { status: "attention", stage: "scheduler", reason: error.message }; }
251
+ // The result handler can be asynchronous. Drain it together with the tick
252
+ // before acknowledging stop; a failed handler must not create an unhandled
253
+ // rejection or schedule more work whose result cannot be reported.
254
+ try { await onResult(result); }
255
+ catch { stopped = true; result = { status: "attention", stage: "result-handler", reason: "调度结果回调失败;已停止后续周期" }; }
256
+ if (!stopped) timer = setTimeout(next, c.intervalSeconds * 1000);
257
+ return result;
258
+ })();
259
+ return inFlight;
260
+ };
261
+ next();
262
+ return { stop() {
263
+ // Already-issued network requests and journaled actions cannot be undone.
264
+ // Stop new cycles immediately, then finish the current bounded transaction.
265
+ stopped = true; clearTimeout(timer); return inFlight;
266
+ } };
247
267
  }
@@ -124,7 +124,7 @@ function mergeEntries(original, desired, key, label) {
124
124
  }
125
125
 
126
126
  export function planConfiguration({ kind, root: requestedRoot, allowDependencyUpdates = false, gateTemplateRoot,
127
- assetRoot = EXAMPLES, sharedGateRoot = SHARED_GATE, activateHooks = false, projectName, migrateLegacyHooks = false }) {
127
+ assetRoot = EXAMPLES, sharedGateRoot = SHARED_GATE, activateHooks = false, projectName, migrateLegacyHooks = false, workspaceRegistry }) {
128
128
  if (!["frontend", "backend"].includes(kind)) throw new Error("--kind 必须是 frontend 或 backend");
129
129
  const root = rootPath(requestedRoot);
130
130
  const files = []; const sources = []; const hardConflicts = []; const gaps = []; const dependencyChanges = []; const legacyMigrations = [];
@@ -227,6 +227,8 @@ export function planConfiguration({ kind, root: requestedRoot, allowDependencyUp
227
227
  if (buckets.length > 1) throw new Error(`${name} 在多个依赖类别重复声明:${buckets.join(", ")};请先评审合并`);
228
228
  if (buckets.length === 1) {
229
229
  const bucket = buckets[0]; const previousVersion = pkg[bucket][name];
230
+ if (previous === null && previousVersion === version)
231
+ dependencyChanges.push({ name, bucket, from: null, to: version, approval: "selected-fixed-preset" });
230
232
  if (previousVersion !== version) {
231
233
  if (!allowDependencyUpdates) throw new Error(`${bucket}.${name}=${previousVersion},批准固定版本为 ${version};先评审升级,明确批准时可带 --allow-dependency-updates 重新预览`);
232
234
  if (typeof previousVersion !== "string" || !/^[~^]?\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(previousVersion))
@@ -306,6 +308,7 @@ export function planConfiguration({ kind, root: requestedRoot, allowDependencyUp
306
308
  propose("quality/review-evidence.plan.json", json(evidencePlan), undefined, true);
307
309
  });
308
310
  step("scripts/quality/project-actions.mjs", () => propose("scripts/quality/project-actions.mjs", fromExample("automation/project-actions.mjs"), undefined, true));
311
+ step("scripts/quality/workspace-setup.mjs", () => propose("scripts/quality/workspace-setup.mjs", fromExample("automation/workspace-setup.mjs"), undefined, true));
309
312
  step("scripts/quality/run.mjs", () => {
310
313
  if (kind === "backend") return propose("scripts/quality/run.mjs", fromExample("backend/run.mjs"), undefined, true);
311
314
  const source = templates();
@@ -368,12 +371,35 @@ export function planConfiguration({ kind, root: requestedRoot, allowDependencyUp
368
371
  addGap("HOOK-EXECUTION-UNVERIFIED", "Hook 文件配置不代表已实际触发;需在装好依赖后做正常/违规提交与推送验收");
369
372
  });
370
373
 
374
+ if (workspaceRegistry !== undefined) step("quality/workspace.json", () => {
375
+ let registry;
376
+ try { registry = new URL(workspaceRegistry); } catch { throw new Error("工作副本清单需要明确批准的HTTPS安装源"); }
377
+ if (registry.protocol !== "https:" || registry.username || registry.password || registry.search || registry.hash)
378
+ throw new Error("工作副本安装源必须为不带凭据或查询参数的HTTPS地址");
379
+ registry.pathname = registry.pathname.replace(/\/?$/, "/");
380
+ const pkg = JSON.parse(files.find((file) => file.path === packageTarget)?.afterContent || "{}");
381
+ if (kind === "frontend" && !/^pnpm@\d+\.\d+\.\d+$/.test(pkg.packageManager || ""))
382
+ throw new Error("PC项目缺少固定 packageManager(如 pnpm@10.34.5);由技术经理确认项目运行环境后写入,再接入,不能生成无法复现的新克隆清单");
383
+ const actions = kind === "frontend" ? ["pre-commit", "pre-push", "commit-msg"] : ["pre-push", "commit-msg"];
384
+ const hookFiles = actions.map((action) => {
385
+ const relative = path.join(hooks.destination, action).replaceAll(path.sep, "/");
386
+ const planned = files.find((file) => file.path.replaceAll(path.sep, "/") === relative);
387
+ if (!planned) throw new Error("缺少批准的 Hook 文件:" + relative);
388
+ return { path: relative, sha256: planned.afterHash };
389
+ });
390
+ propose("quality/workspace.json", json({ schemaVersion: 1, kind: "wl-quality-workspace", projectKind: kind,
391
+ gateVersion: VERSIONS.gate, registry: registry.href, dependencyDirectory: kind === "backend" ? "quality-tools" : ".",
392
+ packageManager: kind === "backend" ? "npm" : pkg.packageManager,
393
+ hooksPath: (hooks.activation?.after || hooks.configuredPath || hooks.destination).replaceAll(path.sep, "/"), hookFiles }), undefined, true);
394
+ });
395
+
371
396
  step(".gitignore", () => {
372
397
  const previous = readTarget(".gitignore") || "";
373
398
  const source = templates();
374
399
  const generated = readSource(path.join(source.root, "templates/quality/generated.gitignore"), source.origin);
375
400
  const required = [...generated.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#")),
376
- "/.quality-onboarding-backups/", "/.quality-onboarding.lock", "/.quality-onboarding-execution.lock"];
401
+ kind === "backend" ? "/quality-tools/node_modules/" : "/node_modules/",
402
+ "/.quality-onboarding-backups/", "/.quality-onboarding.lock", "/.quality-onboarding-execution.lock", "/.quality-workspace.lock"];
377
403
  const existing = new Set(previous.split(/\r?\n/).map((line) => line.trim()));
378
404
  const missing = required.filter((line) => !existing.has(line));
379
405
  const suffix = missing.length ? `${previous && !previous.endsWith("\n") ? "\n" : ""}\n# wl-quality-onboarding generated artifacts\n${missing.join("\n")}\n` : "";
package/lib/onboard.js CHANGED
@@ -56,6 +56,7 @@ function effectiveOptions(options) {
56
56
  return { detected, configuration: { kind: detected.kind, root: detected.root, assetRoot,
57
57
  sharedGateRoot: PACKAGE_ROOT, gateTemplateRoot: options.gateTemplateRoot || PACKAGE_ROOT,
58
58
  projectName: detected.kind === "frontend" ? detected.projectName : undefined, migrateLegacyHooks: true,
59
+ workspaceRegistry: options.registry,
59
60
  allowDependencyUpdates: options.allowDependencyUpdates === true, activateHooks: options.activateHooks !== false } };
60
61
  }
61
62
 
@@ -0,0 +1,114 @@
1
+ import path from "node:path";
2
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
3
+ import { ENVIRONMENTS } from "./evidence.js";
4
+ import { inspectCiObservation } from "./lifecycle-source.js";
5
+ import { controlledPath, releaseDigest, validateReleaseSetInput, writeReleaseJson } from "./release-set.js";
6
+
7
+ const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/, HASH = /^[a-f0-9]{64}$/;
8
+ const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
9
+ const assert = (ok, message) => { if (!ok) throw new Error(message); };
10
+ const json = (value) => JSON.stringify(value, null, 2) + "\n";
11
+ const relative = (value) => typeof value === "string" && value.length < 1024 && !path.isAbsolute(value) && !/[\\\u0000-\u001f]/.test(value) && value.split("/").every((part) => part && part !== "." && part !== "..");
12
+ function local(root, file) {
13
+ assert(relative(file), `发布输入必须使用受控根目录内的明确相对路径: ${file}`);
14
+ return controlledPath(path.join(root, file));
15
+ }
16
+ function original(file, limit = 16 * 1024 * 1024) {
17
+ file = controlledPath(file); const stat = lstatSync(file);
18
+ assert(stat.isFile() && stat.size >= 0 && stat.size <= limit, "发布输入原件必须为普通文件且不超过预算");
19
+ const bytes = readFileSync(file); return { file, bytes, sha256: releaseDigest(bytes) };
20
+ }
21
+ function keys(value, allowed, message) {
22
+ assert(value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => allowed.includes(key)), message);
23
+ }
24
+
25
+ // This builder never fetches URLs, runs project code, signs a release, or grants
26
+ // approval. It only verifies centrally collected originals and prepares the
27
+ // exact bytes that a separate release approval/signing step will consume.
28
+ export function buildReleaseInputPlan({ configFile, now = new Date() }) {
29
+ const configOriginal = original(configFile, 4 * 1024 * 1024), c = JSON.parse(configOriginal.bytes);
30
+ keys(c, ["schemaVersion", "kind", "releaseSetId", "environment", "evidenceRoot", "outputFile", "components", "compatibilityFile"], "发布输入配置含未知字段;不能提供脚本/命令或覆盖参数");
31
+ assert(c.schemaVersion === 1 && c.kind === "release-input-config" && ID.test(c.releaseSetId || "") && ENVIRONMENTS.includes(c.environment), "发布输入配置身份/环境无效");
32
+ const evidenceRoot = local(path.dirname(configOriginal.file), c.evidenceRoot);
33
+ assert(lstatSync(evidenceRoot).isDirectory(), "发布输入证据根目录不存在");
34
+ const inputFile = local(evidenceRoot, c.outputFile);
35
+ assert(!existsSync(inputFile), "发布输入输出文件已存在;不得覆盖已审阅的输入");
36
+ assert(Array.isArray(c.components) && c.components.length > 0 && c.components.length <= 50 && new Set(c.components.map((v) => v.project)).size === c.components.length, "发布输入组件为空、重复或过多");
37
+ const originals = new Map();
38
+ const read = (file, limit) => {
39
+ const saved = original(local(evidenceRoot, file), limit), previous = originals.get(file);
40
+ assert(!previous || previous.sha256 === saved.sha256, `发布输入原件在检查期间发生变化: ${file}`);
41
+ originals.set(file, { file, sha256: saved.sha256, bytes: saved.bytes.length }); return saved;
42
+ };
43
+ const components = c.components.map((entry) => {
44
+ keys(entry, ["project", "scope", "role", "sha", "authorId", "sourceConfigFile", "sourceConfigHash", "sourceFile", "proofFile", "publicKeyFile", "publicKeyHash", "manifestFile", "summaryFile", "artifactRoot", "expectIssuer", "expectPolicyHash", "expectRulesHash", "expectBaselineHash", "expectWaiversHash"], "发布输入组件含未知字段或执行入口");
45
+ assert(ID.test(entry.project || "") && ID.test(entry.scope || "") && ["frontend", "backend", "service"].includes(entry.role), "发布输入组件身份/范围/角色无效");
46
+ // Existing collected observations carry an authenticated mapping supplied by
47
+ // the central caller, not a signed author identity. Require that mapping to
48
+ // be independently fixed in the approved config; never infer an author
49
+ // from a Git commit name, email, report, or the CI trigger account.
50
+ assert(ID.test(entry.authorId || ""), "作者不能猜测;必须在批准配置中固定已认证 authorId");
51
+ assert(ID.test(entry.expectIssuer || ""), "组件必须固定批准的证明签发者");
52
+ for (const field of ["sourceConfigHash", "publicKeyHash", "expectPolicyHash", "expectRulesHash", "expectBaselineHash", "expectWaiversHash"]) assert(HASH.test(entry[field] || ""), `发布输入组件必须固定 ${field}`);
53
+ const sourceConfig = read(entry.sourceConfigFile), observation = read(entry.sourceFile);
54
+ assert(sourceConfig.sha256 === entry.sourceConfigHash, "组件采集配置不是批准的原始摘要");
55
+ const observed = JSON.parse(observation.bytes);
56
+ assert(SHA.test(observed.sha || "") && (entry.sha === undefined || (SHA.test(entry.sha) && entry.sha === observed.sha)), "组件实际SHA与批准候选不一致");
57
+ const { config: sourceConfigValue, value } = inspectCiObservation({ configFile: sourceConfig.file, sourceFile: observation.file, sha: observed.sha, now: now.getTime() });
58
+ assert(value.project === entry.project && value.scope === entry.scope && value.authorId === entry.authorId, "组件实际项目/范围/作者与批准配置不一致");
59
+ assert(value.status === "success" && value.jobStatus === "success" && value.requiredJobs.every((job) => job.status === "success"), "组件实际必需作业未全部成功");
60
+ const expectedRoot = path.posix.join(path.posix.dirname(entry.sourceFile), "artifacts");
61
+ assert(relative(entry.artifactRoot) && entry.artifactRoot === expectedRoot, "组件 artifactRoot 必须是实际采集目录的 artifacts");
62
+ const collected = (file) => {
63
+ const artifact = read(file, sourceConfigValue.maxArtifactBytes);
64
+ const rootPrefix = entry.artifactRoot + "/";
65
+ assert(file.startsWith(rootPrefix), "组件原件没有来自固定采集工件目录");
66
+ const relativeFile = file.slice(rootPrefix.length), record = value.artifacts.find((item) => item.path === relativeFile);
67
+ assert(record && record.bytes === artifact.bytes.length && record.sha256 === artifact.sha256 && sourceConfigValue.artifacts.paths.includes(relativeFile), `组件采集原件缺失或变化: ${relativeFile}`);
68
+ return artifact;
69
+ };
70
+ assert(entry.summaryFile === `${entry.artifactRoot}/${sourceConfigValue.qualitySummary}`, "组件质量报告不是采集配置固定的原件");
71
+ collected(entry.summaryFile);
72
+ const manifest = JSON.parse(collected(entry.manifestFile).bytes);
73
+ assert(Array.isArray(manifest.artifacts) && manifest.artifacts.length && Array.isArray(manifest.configFiles), "组件清单缺少真实制品/配置");
74
+ for (const artifact of [...manifest.artifacts, ...manifest.configFiles]) {
75
+ assert(relative(artifact.path), "组件制品/配置路径无效");
76
+ assert(collected(`${entry.artifactRoot}/${artifact.path}`).sha256 === artifact.sha256, `组件真实制品/配置与清单不符: ${artifact.path}`);
77
+ }
78
+ read(entry.proofFile);
79
+ assert(read(entry.publicKeyFile).sha256 === entry.publicKeyHash, "组件证明公钥不是批准的原始摘要");
80
+ return { project: entry.project, scope: entry.scope, role: entry.role, sha: value.sha, authorId: entry.authorId, summaryFile: entry.summaryFile, proofFile: entry.proofFile, manifestFile: entry.manifestFile, publicKeyFile: entry.publicKeyFile, artifactRoot: entry.artifactRoot, expectIssuer: entry.expectIssuer, expectPipelineId: value.runId, expectJobId: value.jobId, expectPolicyHash: entry.expectPolicyHash, expectRulesHash: entry.expectRulesHash, expectBaselineHash: entry.expectBaselineHash, expectWaiversHash: entry.expectWaiversHash };
81
+ }).sort((a, b) => a.project.localeCompare(b.project));
82
+ const declaration = JSON.parse(read(c.compatibilityFile, 4 * 1024 * 1024).bytes);
83
+ // The release owner supplies the API/schema/order/recovery decisions. Only
84
+ // raw-file digests are populated here; no compatibility decision is guessed.
85
+ const reference = (entry) => {
86
+ keys(entry, ["file", "sha256"], "兼容性引用必须固定原件文件,不能含推测或执行入口");
87
+ const actual = read(entry.file, 4 * 1024 * 1024);
88
+ assert(entry.sha256 === undefined || entry.sha256 === actual.sha256, `兼容性原件不是声明中的摘要: ${entry.file}`);
89
+ entry.sha256 = actual.sha256;
90
+ };
91
+ reference(declaration.api?.evidence); reference(declaration.api?.reviewEvidence);
92
+ reference(declaration.database?.reviewEvidence); reference(declaration.rollback?.reviewEvidence);
93
+ if (declaration.database?.migrations !== undefined) {
94
+ assert(Array.isArray(declaration.database.migrations), "数据库迁移必须是明确原件列表");
95
+ declaration.database.migrations.forEach(reference);
96
+ }
97
+ if (declaration.rollback?.mode === "requires-approved-recovery") reference(declaration.rollback.recoveryEvidence);
98
+ const input = { schemaVersion: 1, kind: "release-set-input", releaseSetId: c.releaseSetId, environment: c.environment, components, compatibility: declaration, inputProvenance: { schemaVersion: 1, kind: "release-input-originals", configHash: configOriginal.sha256, originals: [...originals.values()].sort((a, b) => a.file.localeCompare(b.file)) } };
99
+ const { contract } = validateReleaseSetInput({ input, evidenceRoot, now });
100
+ // Re-read every original before returning. A changing central archive cannot
101
+ // produce a plan assembled from different instants of its inputs.
102
+ for (const expected of originals.values()) assert(original(local(evidenceRoot, expected.file), Math.max(expected.bytes, 1)).sha256 === expected.sha256, `发布输入原件在检查期间发生变化: ${expected.file}`);
103
+ assert(original(configOriginal.file, 4 * 1024 * 1024).sha256 === configOriginal.sha256, "发布输入配置在检查期间发生变化");
104
+ return { status: "preview", written: false, signed: false, approved: false, configHash: configOriginal.sha256, inputHash: releaseDigest(json(input)), inputFile, evidenceRoot, input, summary: { releaseSetId: c.releaseSetId, environment: c.environment, components: components.map(({ project, role, sha, authorId, expectPipelineId, expectJobId }) => ({ project, role, sha, authorId, pipelineId: expectPipelineId, jobId: expectJobId })), deploymentOrder: contract.deploymentOrder, schemaVersion: contract.schemaVersion, apiContractHash: contract.apiContractHash, originalCount: originals.size } };
105
+ }
106
+
107
+ export function applyReleaseInputPlan({ configFile, expectConfigHash, expectInputHash, now = new Date() }) {
108
+ assert(HASH.test(expectConfigHash || "") && HASH.test(expectInputHash || ""), "生成发布输入必须明确确认预览的 configHash 和 inputHash");
109
+ const plan = buildReleaseInputPlan({ configFile, now });
110
+ assert(plan.configHash === expectConfigHash, "发布输入配置已变化;旧 configHash 确认失效");
111
+ assert(plan.inputHash === expectInputHash, "发布输入原件/组件已变化;旧 inputHash 确认失效");
112
+ writeReleaseJson(plan.inputFile, plan.input);
113
+ return { ...plan, status: "created", written: true };
114
+ }
@@ -95,11 +95,8 @@ export function verifyReleaseCompatibility(compatibility, components) {
95
95
 
96
96
  // Each component is checked against its own real release report and signed
97
97
  // proof. The combined record fixes the exact front/back artifact/config pair.
98
- export function createReleaseSet({ inputFile, evidenceRoot = path.dirname(inputFile), expectInputHash, privateKeyFile, issuer, outFile, now = new Date() }) {
99
- const bytes = readFileSync(controlledPath(inputFile));
100
- assert(HASH.test(expectInputHash || "") && releaseDigest(bytes) === expectInputHash, "组合发布输入不是批准的固定摘要");
101
- const input = JSON.parse(bytes);
102
- assert(input.schemaVersion === 1 && input.kind === "release-set-input" && ID.test(input.releaseSetId || "") && ENVIRONMENTS.includes(input.environment) && ID.test(issuer || ""), "组合发布输入身份/环境无效");
98
+ export function validateReleaseSetInput({ input, evidenceRoot, now = new Date() }) {
99
+ assert(input?.schemaVersion === 1 && input.kind === "release-set-input" && ID.test(input.releaseSetId || "") && ENVIRONMENTS.includes(input.environment), "组合发布输入身份/环境无效");
103
100
  assert(Array.isArray(input.components) && input.components.length > 0 && input.components.length <= 50 && new Set(input.components.map((c) => c.project)).size === input.components.length, "组合发布组件为空、重复或过多");
104
101
  controlledPath(evidenceRoot);
105
102
  const components = input.components.map((entry) => {
@@ -121,6 +118,31 @@ export function createReleaseSet({ inputFile, evidenceRoot = path.dirname(inputF
121
118
  componentShape(component); return component;
122
119
  }).sort((a, b) => a.project.localeCompare(b.project));
123
120
  const compatibility = collectCompatibility(input.compatibility, components, evidenceRoot), contract = verifyReleaseCompatibility(compatibility, components);
121
+ if (input.inputProvenance !== undefined) {
122
+ const provenance = input.inputProvenance;
123
+ assert(provenance && provenance.schemaVersion === 1 && provenance.kind === "release-input-originals" && HASH.test(provenance.configHash || "") && Array.isArray(provenance.originals) && provenance.originals.length && new Set(provenance.originals.map((entry) => entry.file)).size === provenance.originals.length, "自动生成发布输入的原件绑定结构无效/重复");
124
+ const originalFiles = new Set(provenance.originals.map((entry) => entry.file));
125
+ const requiredFiles = [...input.components.flatMap((entry) => [entry.summaryFile, entry.proofFile, entry.publicKeyFile, entry.manifestFile]), ...Object.keys(compatibility.evidence)];
126
+ for (const component of components) {
127
+ const entry = input.components.find((item) => item.project === component.project);
128
+ requiredFiles.push(...[...component.manifest.artifacts, ...component.manifest.configFiles].map((item) => `${entry.artifactRoot}/${item.path}`));
129
+ }
130
+ assert(requiredFiles.every((file) => originalFiles.has(file)), "自动生成发布输入缺少必需原件的字节绑定");
131
+ for (const entry of provenance.originals) {
132
+ assert(entry && typeof entry.file === "string" && !path.isAbsolute(entry.file) && !entry.file.includes("\\") && entry.file.split("/").every((part) => part && part !== "." && part !== "..") && HASH.test(entry.sha256 || "") && Number.isSafeInteger(entry.bytes) && entry.bytes >= 0 && entry.bytes <= 256 * 1024 * 1024, "自动生成发布输入的原件路径/摘要/字节数无效");
133
+ const file = controlledPath(safePath(evidenceRoot, entry.file)), stat = lstatSync(file);
134
+ assert(stat.isFile() && stat.size === entry.bytes && releaseDigest(readFileSync(file)) === entry.sha256, `自动生成发布输入的原件字节发生变化: ${entry.file}`);
135
+ }
136
+ }
137
+ return { input, components, compatibility, contract };
138
+ }
139
+
140
+ export function createReleaseSet({ inputFile, evidenceRoot = path.dirname(inputFile), expectInputHash, privateKeyFile, issuer, outFile, now = new Date() }) {
141
+ const bytes = readFileSync(controlledPath(inputFile));
142
+ assert(HASH.test(expectInputHash || "") && releaseDigest(bytes) === expectInputHash, "组合发布输入不是批准的固定摘要");
143
+ const input = JSON.parse(bytes);
144
+ assert(input.schemaVersion === 1 && input.kind === "release-set-input" && ID.test(input.releaseSetId || "") && ENVIRONMENTS.includes(input.environment) && ID.test(issuer || ""), "组合发布输入身份/环境无效");
145
+ const { components, compatibility, contract } = validateReleaseSetInput({ input, evidenceRoot, now });
124
146
  const record = signRecord({ schemaVersion: 1, kind: "release-set", releaseSetId: input.releaseSetId, environment: input.environment, issuer, inputHash: expectInputHash, createdAt: now.toISOString(), components, compatibility, compatibilityHash: contract.compatibilityHash }, privateKeyFile);
125
147
  writeReleaseJson(outFile, record);
126
148
  return { status: "created", signed: true, releaseSetFile: path.resolve(outFile), releaseSetHash: releaseDigest(stableStringify(record)), components: components.map(({ project, sha }) => ({ project, sha })) };