diff --git a/.github/workflows/alpha-ci.yml b/.github/workflows/alpha-ci.yml index 1c5d97cea405..a5d638647b3d 100644 --- a/.github/workflows/alpha-ci.yml +++ b/.github/workflows/alpha-ci.yml @@ -285,8 +285,14 @@ jobs: # # 这一步同时覆盖了 alpha 自有的 opencode web search 闸 —— 那份行为闸住在 # packages/opencode/test,而本 job 的整包地板都不覆盖 opencode(全量 opencode 测试不属 alpha)。 + # + # `#777`:这一步**不得**因为前一步红而被跳过。GitHub 的 step 默认条件是 `success()`, + # 于是 2026-08-02 起 `bun test (ui-mac)` 连红两天,把 77 个登记闸门一起带走 —— 其中 + # llm / core / opencode 那几个**只在这一步执行**,别处再没有第二次机会。一步失败 + # 连带 77 道门集体消失,是「主线红」这一件坏事自动升级成两件。 + # 用 `!cancelled()` 而不是 `always()`:cancel-in-progress 取消掉的 run 不必再跑一遍。 - name: assert gate files (逐个点名,整包地板抓不到单文件消失) - if: needs.detect.outputs.code == 'true' + if: ${{ !cancelled() && needs.detect.outputs.code == 'true' }} run: bash scripts/assert-gate-files.sh seed-assets: diff --git a/CLAUDE.md b/CLAUDE.md index 1cb53c2cef3e..1a1321eded96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,8 +46,14 @@ ``` bun install bun run --cwd packages/ui-mac dev # electron 解析失败时加 ELECTRON_EXEC_PATH(见 ALPHA.md);flag 须在 run 后(REQ-027) -bash scripts/alpha-check.sh # push 前自检:北极星守卫 + typecheck + 单测(与 alpha-ci 1:1) +bash scripts/alpha-check.sh # push 前自检:跑 alpha-ci 的全部 12 个代码步,末尾自陈逐步覆盖 ``` ## CI(规范见 `docs/runbooks/ci.md`) -**本地先跑,CI 兜底**。push 前必过 `scripts/alpha-check.sh`。GitHub 上只有 `alpha-ci`(三关,required)+ `sync-upstream` 两个 workflow active;继承的 ~26 个上游 workflow 已禁用(要 Blacksmith runner,本 fork 没有 → 永久 queued,即"CI 卡"真因)。排查手册见 `docs/runbooks/ci.md` §5。 +**本地先跑,CI 兜底**。push 前必过 `scripts/alpha-check.sh` —— 它跑 `alpha-ci` 的**全部 12 个代码步**, +并在末尾打印逐步对照表(MIRRORED / SUPERSET / DEGRADED,后两者必须写理由)。这张表由 +`packages/ui-mac/src/main/local-gate-parity.test.ts` 反向核对:CI 加一步而本地没登记即红。 +`#777` 之前这里写的是「与 alpha-ci 1:1」,而实测只跑了 9 步、其中 3 步是裸 `bun test` 的降级档 +(跑 0 条照样 exit 0)—— 一句没人核对的 1:1,比没有这句话更坏。 +GitHub 上只有 `alpha-ci` + `sync-upstream` 两个 workflow active;继承的 ~26 个上游 workflow 已禁用(要 Blacksmith runner,本 fork 没有 → 永久 queued,即"CI 卡"真因)。排查手册见 `docs/runbooks/ci.md` §5。 +**门自身的环境清单**(每道门 × 每个环境 × 真实状态)见 `docs/architecture/quality-gate-environments.md`。 diff --git a/docs/README.md b/docs/README.md index 02129c706a31..3657acc7ae52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ accepted contracts. Follow the [Alpha Documentation Contract](https://github.com | System structure and upstream boundary | [`architecture/`](architecture/) | | Host extension package contract/host decision boundary | [`architecture/host-extension-package-contract-boundary.md`](architecture/host-extension-package-contract-boundary.md) | | Alpha Connection record lifetime and handler allowlist | [`architecture/alpha-connection-lifetime.md`](architecture/alpha-connection-lifetime.md) | +| Which quality gate really runs in which environment (and where the environment is declared) | [`architecture/quality-gate-environments.md`](architecture/quality-gate-environments.md) | | Platform and endpoint integration | [`contracts/`](contracts/) | | Session tool permission DTOs and decision receipts | [`contracts/session-permission.md`](contracts/session-permission.md) | | Build, distribution, CI, uninstall, and Settings recovery operations | [`runbooks/`](runbooks/) | diff --git a/docs/architecture/quality-gate-environments.md b/docs/architecture/quality-gate-environments.md new file mode 100644 index 000000000000..05e20ff805c6 --- /dev/null +++ b/docs/architecture/quality-gate-environments.md @@ -0,0 +1,262 @@ +# 质量门 × 运行环境 —— 每道门在每个环境里的真实状态 + +> 勘破文档(`#777`)。这里写的每一格都是**跑出来的**,不是推断的;跑不出来的格子写「未测量」, +> 不写猜测。measurement 日期 2026-08-03,基线 `alpha@10daf61b`。 +> +> 存在理由:一道**恒红**的门等于没有门 —— 大家会习惯性 `--no-verify` / `--admin`,真出问题时也没人看。 +> 一道**假绿**的门更糟 —— 它主动告诉你「没事」。本仓在半个月里撞上同一形态四次 +> (`#754` / `#769` / 本票原列的 `alpha-check` typecheck / 2026-08-02 起主线连红两天), +> 所以这里不再逐个修,而是把整类列出来并在咽喉点收口。 + +## 1. 这一类的共同前提 + +四个实例长得都不一样,共同前提只有一句: + +> **门在运行时依赖某个环境事实,而这个事实从没有被任何地方声明过,于是没人能检查它对不对。** + +| 实例 | 门 | 隐含的环境前提 | 前提不成立时发生什么 | +| --- | --- | --- | --- | +| `#754` | 本地 pre-push | 「`git -C` 决定跨仓 git 的仓库」 | `git push` 注入的 `GIT_DIR` 压过它 ⇒ **任何 push 恒红** ⇒ 人人 `--no-verify` | +| `#769` | CI 的 vendor lock 闸 | 「兄弟仓 `../alpha-web` 在」 | 裸 checkout 没有 ⇒ **CI 上恒红**,红的理由与它要验的漂移无关 | +| `#777`-A | `bun test` 的 31 条 host 用例 | 「机器快到 5 秒内能跑完一整套子 suite」 | 慢一点就超时 ⇒ 红,而红的理由与被验的行为无关 | +| `#777`-B | 生产安装闸的组件测试 | 「跑在本产品发布的平台上」 | ubuntu runner ⇒ 写盘前直接拒 ⇒ 门跑不起来 | + +**咽喉的形状因此是确定的:让门的环境需求变成一处显式声明,新增的门默认拿到它,缺失时显式降级并自陈。** +枚举对新成员默认放行,咽喉对新成员默认拒绝 —— 优先咽喉。 + +## 2. 枚举方法(两条互相独立的检索轴) + +单跑一个正则会给假的「没有」,所以每一类都用两条轴交叉: + +| 枚举对象 | 轴 1 | 轴 2 | 结果一致? | +| --- | --- | --- | --- | +| CI 的代码步 | `.github/workflows/alpha-ci.yml` 里带 `run:` 的具名步骤 | `scripts/alpha-check.sh` 的 `CI_STEPS` 对照表 | 是(12 = 12,由 `local-gate-parity.test.ts` 持续判) | +| 子进程 host 测试 | `grep -ran -l "process.execPath" src` | `grep -ran -l "Bun.spawnSync" src` | 一致到 31 个文件 | +| host 的超时声明 | `grep -c "timeout"`(逐文件) | `grep -oE "\}, *[0-9_]+\)"`(数字第三参形态) | **否 —— 轴 1 漏了 6 个文件**。数字形态 `}, 180_000)` 不含 "timeout" 这个词 | +| 超时声明的**粒度** | 逐文件 | 逐 `test(...)` 声明 | **否 —— 逐文件会数错**。一个文件里可以既有带声明的又有不带的 | +| 上游守卫白名单 | `alpha-ci.yml` 的 `':(exclude)…'` | `alpha-check.sh` 的 `UPSTREAM_EXCLUDES` | 是(24 = 24,逐条同序) | +| alpha 自有的 opencode 测试 | 文件名 `alpha-*.test.ts` | `git cat-file -e origin/dev:` 失败者 | 是(各 5 个,同一组) | + +轴 1 与轴 2 在第三、四行**给出了不同答案**,而只跑轴 1 会得出「31 条 host 全都没声明超时」这个错结论 —— +这正是「观测手段自己有盲区」。`grep` 一律带 `-a`(字面 NUL 会让它对整个文件静默返回空,`#760`)。 + +## 3. 门 × 环境:真实状态表 + +环境有四个:**alpha-ci(ubuntu runner)** / **本地 `alpha-check.sh`(开发机 macOS)** / +**本地 pre-push 钩子**(= 同一个脚本)/ **开发者手跑 `bun test`**。 + +### 3.1 CI 的 12 个代码步 + +| # | job | 步骤 | alpha-ci | alpha-check(修前) | alpha-check(修后) | +| --- | --- | --- | --- | --- | --- | +| 1 | upstream-guard | No literal NUL bytes | 真结果 | 有 | 有 | +| 2 | upstream-guard | Fail on any modification to upstream package files | 真结果 | 有(且是超集:含未提交改动) | 同 | +| 3 | typecheck | contracts-consumer | 真结果 exit 0 | 有 | 有 | +| 4 | typecheck | ext | 真结果 exit 0 | 有 | 有 | +| 5 | typecheck | ui-mac | 真结果 exit 0 | 有 | 有 | +| 6 | test | verify immutable Alpha contract vendor lock | **降级档**,自陈 `PROVENANCE NOT VERIFIED this run`(`#769`) | 有(开发机有兄弟仓 ⇒ 已验档,是超集) | 同 | +| 7 | test | bun test (contracts consumer fixtures) 下界 15 | 真结果 | **降级**:裸 `bun test`,无下界 | 走 `bun-test-floor.sh 15` | +| 8 | test | bun test (ext) 下界 100 | 真结果 | **降级**:同上 | 走 `bun-test-floor.sh 100` | +| 9 | test | bun test (ui-mac) 下界 3000 | **红**(见 §3.2) | **降级**:同上 | 走 `bun-test-floor.sh 3000` | +| 10 | test | assert gate files(77 个) | **连续两天 skipped**(见 §3.3) | **完全没有** | 有 | +| 11 | seed-assets | Assert seed/vendored resources present | 真结果 | **完全没有** | 有 | +| 12 | docs-gate | Relative-link validity in changed Markdown | 真结果 | **完全没有** | 有 | + +修前 = 12 步里跑了 9 步,其中 3 步是降级档 ⇒ **忠实镜像的只有 6 步**,而三处文档都写着「1:1」。 + +第 7/8/9 行的「降级」不是少验一点,是**闸门消失时全绿**。实测三种「跑了 0 条」的形态 +(bun 1.3.14,`#777` 当场逐个跑的,不是转述): + +| 形态 | 裸 `bun test` 的 exit | `bun-test-floor.sh` | +| --- | --- | --- | +| 文件在,但用例被清空 / 被条件注册成零条 | **0 —— 假绿** | 1(`只跑了 0 条断言,低于下界 N`) | +| 指定的测试文件不存在 | 1 | 1 | +| 整个目录零匹配 | 1 | 1 | + +> 顺带更正一条仓内既有说法:`alpha-ci.yml` / `bun-test-floor.sh` 的注释把「指定的测试文件 +> 根本不存在」也列进了「exit 0」那一档 —— 在 bun 1.3.14 上它 exit 1。**真正的假绿只有第一行**, +> 而第一行恰好正是「有人把闸门文件清空 / 把用例条件掉」的形状,所以下界该立还是要立。 +> (这属于 `#777` 范围外的注释更正,本 PR 不动那两处文字,记在这里。) + +### 3.2 `bun test (ui-mac)` 在 CI 上为什么红 —— 四条,全是环境不是缺陷 + +`alpha` 主线自 2026-08-02 起,凡带代码的 run 无一例外全红(实测 8 个 run id: +`30779269408` / `30754009329` / `30750696800` / `30745161428` / `30742164690` / `30739936324` / +`30739675979` / `30731715823`;只有纯文档 commit 因整体跳过而绿)。 +同一棵树在开发机上:**3756 pass / 0 fail / Ran 3756 tests across 255 files**。 + +| 失败用例 | CI 上的真实报错 | 真因 | 属于哪个子形态 | +| --- | --- | --- | --- | +| `生产 ext-import-skill-folder 分流到本地插件预览…` | `TypeError: this.electron.ipcMain?.on is not a function` @ `electron-log/src/main/index.js:16` | 测试的 electron 桩缺 `ipcMain.on`,而 `electron-log` 在**模块顶层**就调它。开发机上 `electron` 这个 specifier 没落到桩上,CI 上落了 ⇒ **只在 CI 发作** | 依赖解析 | +| `真实 ext-install-catalog 返回值只含公开状态且不回显 canary` | `expected authorization pause, got {"ok":false,"reason":"platform linux not supported by this entry — refusing before any disk write"}` | 生产 `synthesizeManifest` 把 `compatibility.platforms` 写死 `["darwin","win32"]`(ADR-026);runner 是 ubuntu | 平台 | +| `默认关的真实 catalog MCP:提前返回分支…` | 同上 | 同上 | 平台 | +| `package safe view and admission traverse the production ExtensionHub…` | `[5035.75ms] ^ this test timed out after 5000ms` | host 没声明超时,拿 bun 默认 5s 去等一整套子 suite | 时长 | +| `REQ-128 Phase 3 第 1→9 跳…`(内含两条子用例) | `[5933.51ms]` / `[6447.61ms]` `^ timed out after 5000ms` | 同上,发生在**子进程内部** | 时长 | + +`?.` 只挡 `ipcMain` 为空,挡不住 `ipcMain` 在而 `.on` 缺 —— 后者照样是 TypeError。 +桩的补法是照 `node_modules` 里**装着的那个** `electron-log@5.4.4` 的 +`src/main/ElectronExternalApi.js` 逐条枚举出来的(`app.{isReady,isPackaged,name,getName,getVersion,getPath,on,once,off}`、 +`ipcMain.{on,handle}`、`dialog.showErrorBox`、`shell.openExternal`、`session.defaultSession`、 +`webContents.getAllWebContents`、`BrowserWindow.getAllWindows`),不是凭记忆补的。 + +### 3.3 连带伤害:一步红,77 道门一起消失 + +失败发生在 `test` job 的第 9 步,而第 10 步 `assert gate files` 的条件是 GitHub 的默认 +`success()` ⇒ **skipped**。登记簿里 77 个闸门中,`packages/llm` 1 个、`packages/core` 1 个、 +`packages/opencode` 2 个**只在这一步执行**,别处再没有第二次机会。 +即:「主线红」这一件坏事,自动升级成「主线红 + 77 道门连续两天一次都没跑」两件。 +修法:`if: ${{ !cancelled() && … }}`(不用 `always()`,`cancel-in-progress` 取消的 run 不必再跑)。 + +### 3.4 子进程 host 测试的超时声明(基线实测:31 个文件 / 31 条) + +粒度是**每条 `test(...)` 声明**,不是每个文件 —— 一个文件里可以既有带声明的又有不带的 +(`package-admission.wiring.test.ts` 一个文件三条)。按文件数会数错。 + +| 声明 | 条数 | 结论 | +| --- | --- | --- | +| 显式 `120_000` | 6 | 有人在自己那格踩过坑,补了自己那一个 | +| 显式 `180_000` | 5 | 同上 | +| 显式 `300_000` | 1 | 同上 | +| **bun 默认 5000ms** | **19** | 从没声明过 —— CI 上已炸掉 2 条,其余 17 条是同一批潜伏成员 | + +`local-package-renderer.test.ts` 的抬头甚至已经写着「bun 默认 5s 会把它变成一条**间歇性**红 —— +而间歇红比没有闸更贵」。**同一条规律只被应用了一次**,与 `#647` 当年「只给 ui-mac 加点名下界、 +漏了 ext」是同一个错。这就是为什么本票要求整类收口。 + +### 3.5 分支保护 —— 这一类里最贵的一格 + +| 读者 | 要求的 context | 写者 | 实际产出的 job 名 | 对得上? | +| --- | --- | --- | --- | --- | +| `alpha` 分支保护 | `north-star guard (zero upstream edits)` | alpha-ci | 同名 | 是 | +| | `typecheck (alpha packages)` | | 同名 | 是 | +| | **`unit tests (ui-mac)`** | | **`unit tests (alpha packages)`** | **否** | +| | `docs gate` | | 同名 | 是 | + +job 名在 2026-07-22(`ebd29cda`)改掉,分支保护没跟。实测 PR `#802` / `#791` 的 +`statusCheckRollup` 里**根本没有** `unit tests (ui-mac)` 这一项 —— 不是 docs-only 才缺, +是**每个 PR 都缺**。于是每个 PR 在那一格永久 pending ⇒ 每次合并都得 `--admin` ⇒ +主线真红时也没有任何东西挡住任何人。 + +**「大家习惯性 --admin」不是纪律松懈,是分支保护自己造出来的。** +修法在 GitHub 设置里(不在本仓文件里),归 `#717`;`#717` 票面把成因写成「docs-only path 没发布 +该 context」,与实测不符 —— 改名是全量的。 + +### 3.6 无人执行的门 + +| 门 | 谁跑它 | 状态 | +| --- | --- | --- | +| `packages/opencode/test/tool/alpha-websearch-failure.test.ts` | 只有 `assert-gate-files.sh` | 登记在册;`#777` 之前该步在 CI 上连续两天 skipped、本地根本没有 | +| `packages/opencode/test/permission/alpha-ask-deadline.test.ts` | 同上 | 同上 | +| `packages/opencode/test/tool/alpha-mcp-websearch-gate.test.ts` | **无** | `#649` 未决(本地 23.45s,且打印 `Bun.serve` 10 秒超时告警) | +| `packages/opencode/test/mcp/alpha-cloud-mcp-multisource.test.ts` | **无** | `#649` 未决 | +| `packages/opencode/test/mcp/alpha-cloud-mcp-revival.test.ts` | **无** | `#649` 未决 | + +`#649` 是本类的**极端成员**:门假设的环境是「某个 CI」,而那个环境**不存在**。 +本票不处置它(逐份取舍要付 CI 时间的账,归 `#649`),但把它归类记在这里。 + +## 4. 咽喉:两处声明,覆盖仓内真实存在的两种运行形状 + +仓内的 `bun test` 只有两种形状,咽喉必须两种都盖住: + +### 4.1 超时:两处声明,覆盖两种运行形状 + +| 形状 | 谁是这个形状 | 声明在哪 | 机制 | +| --- | --- | --- | --- | +| **A 单文件** | 31 条 host 用例用 `Bun.spawnSync([bun, "test", <一个绝对路径>])` 起的子进程 —— host 自己拼 argv,传不进 CLI flag | `packages/ui-mac/scripts/test-preload.ts` | `setDefaultTimeout(120_000)` | +| **B 多文件** | CI 的三条 test 步、`assert-gate-files.sh` 的 77 次点名、`alpha-check.sh` 的 `[4/7]` —— **所有闸门运行的唯一入口** | `scripts/bun-test-floor.sh` | `bun test --timeout 120000` | + +preload 此前已经承担过一次同类职责(把 UI locale 钉成 `zh`,因为 happy-dom 的 navigator 让 +`detectLocale()` 落到 `en`)—— **同一个位置,同一个问题形态**。 + +### 4.2 平台:检测与自陈是全局的,**钉桩是 opt-in 的** + +| 角色 | 落点 | 行为 | +| --- | --- | --- | +| 检测 + 自陈 | `scripts/test-preload.ts` | host 不是发布平台时打印 `PLATFORM DEGRADED this run: …must opt in via pinShippedPlatform()` | +| 钉桩(声明「这道门需要发布平台」) | `test-component/pin-shipped-platform.ts` 的 `pinShippedPlatform()` | 把 `process.platform` 钉到 `darwin` 并打印 `PLATFORM SIMULATED this run; genuinely -specific behaviour is NOT covered.` | +| 目前的调用方 | `test-component/ext-install-catalog-result.cases.ts` | 它跑生产 `ext-install-catalog` 全链,里面有 ADR-026 的平台闸 | + +> **为什么不是全局钉 —— 这是在真环境里量出来的,不是设计品味。** +> 本票第一版把钉桩放进 preload(全局)。alpha-ci 上实测:14 个 renderer 测试文件整片挂在 +> `error: Cannot find module @rollup/rollup-darwin-x64` —— vite/rollup 按 `process.platform` +> 选原生可选依赖,而 linux runner 上装的是 linux 那一份。`3752 pass` 掉到 `3625 pass`。 +> **为了修 2 条而弄坏 14 个文件,是把一处假红换成一片真红。** +> 全局改环境的代价,必须在**真环境**里量过才算知道 —— 开发机上这一版全绿。 + +### 候选机制全部实测(bun 1.3.14)—— 其中一条把本票自己骗过一次 + +| 候选 | 实测结果 | +| --- | --- | +| `bunfig.toml` 的 `[test] timeout = 9000` | **不被读取** —— 6 秒用例照样 `timed out after 5000ms` | +| `BUN_TEST_TIMEOUT` / `BUN_TIMEOUT` / `BUN_TEST_TIMEOUT_MS` | 全部无效(`strings $(which bun)` 里也只有 `BUN_CONFIG_HTTP_IDLE_TIMEOUT`) | +| preload 里 `beforeAll(() => setDefaultTimeout(…))` | 无效 | +| preload 里 `setDefaultTimeout()` | **只对一次运行的第一个文件生效** —— 单文件跑得通,`bun test src`(257 个文件)从第二个文件起退回 5000ms | +| `bun test --timeout N` | **跨全部文件生效**;单条用例的显式超时仍恒胜(两个方向都实测过) | + +> **本票自己踩了第四行。** 第一版把 preload 当成唯一咽喉,证据是「单文件探针 6 秒用例通过」—— +> 而闸门跑的是 257 个文件。这个假绿是被**新加的那道门自己**在全量里抓出来的 +> (`(fail) 环境咽喉对子进程也生效 [5005.06ms]`),不是靠人复查。 +> 教训与本文 §2 同源:**先证明这个手段能测出已知的坏,再用它判未知的好** —— +> 而「已知的坏」必须用**真实形状**去测,单文件探针测不出多文件的坏。 +> 现在的行为闸让慢用例**排在第二个文件**,占位文件 `gate-environment-first.cases.ts` 就是为此存在: +> 让慢的当第一个文件,验的会是形状 A(本来就成立),给出假绿。 + +### 取值与代价 + +120s 与已显式声明的那批同档(本包最慢的**正当** host 在 CI 上实测 37.7s)。 +判据是「不让机器速度决定结论」,不是「越小越严」——**超时不是断言**:一条卡死的用例仍会在 +120s 内判红,而 5s 的代价是让真闸在慢机器上恒假红。 +**代价要说清**:一条本来 0.1 秒、退化成 60 秒的用例不会再被超时抓到 —— +这一格换来的是「慢机器上不假红」,失去的是「性能退化的顺带发现」。需要把时长当断言的用例 +(如 `artifact-quota.test.ts` 的 `}, 1000)`)照旧显式写。 + +### 平台钉桩是**模拟**,不是覆盖 + +在 linux 内核上跑 darwin 分支,凡真正依赖 host 内核行为的东西这一轮没有验到。 +所以那行 `PLATFORM SIMULATED this run` 必须打出来 —— 一道门可以降级,**不许静默降级**。 +只钉 `darwin` 不钉 `win32`:`node:path` 在加载时按 `process.platform` 选分支, +darwin 与 linux 同为 posix,钉 `win32` 会把路径语义改坏。 + +### 自陈必须真的到达运行输出 + +`PLATFORM SIMULATED` / `[gate-environment]` 是**子进程**打的,而 host 把子进程 stdout 吃进变量、 +只在失败时抛出来 —— 于是「这次降级了什么」在**绿的那一次反而看不见**,而那正是需要它的时候。 +实测:第一版在 alpha-ci 全绿的 run 里 `grep` 不到任何一行自陈。 +现在 `gate-environment.test.ts` 在成功路径上把子进程的自陈行转述出来; +运行级的那句(`PLATFORM DEGRADED this run: … must opt in via pinShippedPlatform()`) +由 preload 直接打在每个测试进程的 stdout 上,不经转述。 + +### 还有一条只有真环境才说得出来的 + +行为闸的形状 B 要经 `bash scripts/bun-test-floor.sh`,而那个脚本里写的是裸 `bun`。 +第一版在 alpha-ci 上直接挂在 `bun-test-floor.sh: line 55: bun: command not found` —— +从 bun 进程 spawn 出去的 bash 拿到的 `PATH` 里没有 runner 装的那个 bun。 +开发机上 `bun` 在 PATH 里,所以本地怎么跑都看不到。 +修法是把**正在跑本测试的那个 bun**所在目录前置进子进程 PATH:既修好,也顺带保证父子跑同一个二进制。 + +## 5. 新增一道门时,忘记处理环境会被什么挡住 + +| 忘了什么 | 谁判红 | +| --- | --- | +| 新 host 没声明超时 | 不需要声明 —— 咽喉默认给到。**咽喉本身**被删掉时,`src/main/gate-environment.test.ts` 当场红(它跑一条 6 秒、不写超时的用例) | +| 新门走到生产平台闸、忘了 opt-in | preload 在非发布平台上打出 `PLATFORM DEGRADED this run: … must opt in via pinShippedPlatform()`;门本身会红在 `platform … not supported by this entry`(**具名**,不是超时那种莫名其妙的红) | +| `pinShippedPlatform()` 被改坏 | `src/main/gate-environment.test.ts` —— 子用例按 `os.platform()` **自陈这一半本次跑没跑到**(开发机跑不到、alpha-ci 跑得到),不会让本地的绿被误读 | +| CI 加了一步而本地没跟 | `src/main/local-gate-parity.test.ts` —— 双向比对,CI 改名也红 | +| 本地某一步被悄悄降级 | 同上:档位只能是 `MIRRORED` / `SUPERSET:<理由>` / `DEGRADED:<理由>`,**没有「静默不跑」这一档** | +| ADR 新增收编而只改了 CI 一侧 | 同上(`#637` 退出条件 3) | +| `assert gate files` 又被条件掉 | 同上(断言该步带 `!cancelled()`) | +| 新增闸门文件没登记 | `src/main/gate-file-registry.test.ts`(既有) | + +两条解析自检钉在 `local-gate-parity.test.ts` 里(`ciSteps ≥ 12`、`ciExcludes ≥ 20`): +一份退化成解析不出东西的解析器会让每条断言空对空地全绿 —— 先证明手段能测出已知的坏, +再用它判未知的好。 + +## 6. 已知不修(留痕) + +- **`bun test src` 手跑仍是 5s 默认**?不是 —— preload 对手跑也生效(同一份 bunfig)。 + 但**手跑不判下界**,`Ran 0 tests` 仍会 exit 0。判据只有一条:门要走 + `scripts/bun-test-floor.sh`,手跑只是看看。 +- **CI 仍跑在 ubuntu**。换 macOS runner 能让平台那一格从「模拟」变成「真验」,代价是 10 倍 + Actions 分钟数。本票不做,记在这里;真要做是一张独立票。 +- **`#649` 的三份 opencode 测试仍无人执行**。逐份取舍要付 CI 时间的账,归 `#649`。 +- **分支保护的幽灵 context** 归 `#717`(改的是 GitHub 设置,不是本仓文件)。 diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index cf69cc1f1d86..d56e8a150b65 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -9,7 +9,17 @@ bash scripts/alpha-check.sh ``` -绿了再 push。它跑的就是 `alpha-ci` 的三关(北极星守卫 / typecheck / 单测),本地几秒出结果,和 CI 1:1 一致。 +绿了再 push。它跑的是 `alpha-ci` 的**全部 12 个代码步**,并在末尾打印一张逐步对照表 +(`MIRRORED` / `SUPERSET:<理由>` / `DEGRADED:<理由>`)。 + +> `#777` 起,「与 alpha-ci 1:1」不再是散文。2026-08-03 实读:此前这句话写在三处 +> (本文件、`CLAUDE.md`、`scripts/alpha-check.sh` 抬头),而脚本实际只跑了 12 步里的 9 步 —— +> 缺 `assert-gate-files.sh`(77 个登记闸门里 llm / core / opencode 那几个**只在这一步执行**)、 +> 缺 `assert-seed-assets.sh`、缺 `check-doc-links.py`;而且三条测试用的是裸 `bun test` +> (跑 0 条照样 exit 0 —— CI 早在 `#647` 修掉的假绿形态,本地原样留着)。 +> **「本地绿 ⇒ 可以合」这条铁律的全部依据就是这句 1:1**,所以它现在由 +> `packages/ui-mac/src/main/local-gate-parity.test.ts` 双向核对:CI 加一步而对照表没登记即红, +> CI 改一个步骤名而对照表没跟也即红。 ## 1. 为什么 local-first(优先本地跑) @@ -18,15 +28,21 @@ bash scripts/alpha-check.sh - **CI 的真正职责**是①**强制门禁**(branch protection:不绿不让 merge,挡手滑)②**中立环境兜底**(抓"在我机器上是好的"那类环境差异)——不是给你当调试台用的。 - 所以顺序永远是:**本地 `alpha-check` 绿 → push → CI 复核 → merge**。 -## 2. 三关是什么(与 `alpha-ci` 1:1) +## 2. 七关是什么(逐步对照见 `alpha-check.sh` 的 `CI_STEPS`) -| 关 | 本地命令 | CI job | 失败含义 | +| 关 | 本地步骤 | CI job | 失败含义 | |---|---|---|---| -| **北极星守卫**(零改上游) | `scripts/alpha-check.sh` 内含;等价 `git diff --diff-filter=DMR --name-only origin/dev...HEAD -- <上游7包>` 必须空 | `north-star guard (zero upstream edits)` | 改了上游文件 → 下次 fork-sync 冲突,破北极星 | -| **typecheck**(三个 alpha 包) | `bun run --cwd packages/alpha-contracts-consumer typecheck` + `bun run --cwd packages/ext typecheck` + `bun run --cwd packages/ui-mac typecheck` | `typecheck (alpha packages)` | 类型不过 | -| **契约锁 + 单元测试** | `packages/alpha-contracts-consumer` 的 `check:vendor`/`bun test`,再从 `packages/ext`、`packages/ui-mac` 各跑 `bun test` | `unit tests (alpha packages)` | vendored hash、producer/consumer fixture 或运行时守卫回归 | - -- **上游 7 包** = `packages/{opencode,core,server,app,ui,tui,sdk}`(见 alpha-ci.yml `env.UPSTREAM_PATHS`)。 +| **北极星守卫**(零改上游) | `[1/7]`;等价 `git diff --diff-filter=DMR --name-only origin/dev...HEAD -- <上游 8 包>` 必须空 | `north-star guard (zero upstream edits)` | 改了上游文件 → 下次 fork-sync 冲突,破北极星 | +| **NUL 字节闸** | `[2/7]` `scripts/assert-no-nul-bytes.py` | 同上 job | 字面 NUL 让 `grep` 对整个文件静默失明(`#760`) | +| **typecheck**(三个 alpha 包) | `[3/7]` contracts-consumer + ext + ui-mac | `typecheck (alpha packages)` | 类型不过 | +| **契约锁 + 单元测试** | `[4/7]` `check:vendor` + `bun-test-floor.sh` × 3(15 / 100 / 3000) | `unit tests (alpha packages)` | vendored hash、producer/consumer fixture 或运行时守卫回归 | +| **闸门文件点名** | `[5/7]` `scripts/assert-gate-files.sh`(77 个) | 同上 job | 某个闸门文件被删/被清空 —— 整包地板抓不到 | +| **seed assets** | `[6/7]` `scripts/assert-seed-assets.sh` | `seed assets present` | 打包资源被静默删除(B7/B15) | +| **docs gate** | `[7/7]` `scripts/check-doc-links.py <改动的 md>` | `docs gate` | Markdown 相对链接断了 | + +- **上游包** = `packages/{opencode,core,server,tui,sdk,protocol,schema,client}`(**8 个**,见 alpha-ci.yml + `env.UPSTREAM_PATHS`;`app`/`ui` 已按 ADR-020 移出守卫,`protocol`/`schema`/`client` 按 ADR-033 补入)。 + 这份清单与 24 条 ADR-033 收编白名单,两处必须逐条相同 —— 由 `local-gate-parity.test.ts` 判(`#637`)。 - **bun 版本钉 `1.3.14`**(与 CI 一致,根 `package.json` 的 `packageManager`)。本机版本不同先对齐。 - 根 `bun test` 被故意禁用(`do not run tests from root`)——测试按包跑,别在根跑。 - Alpha Platform wire pin 不使用 `bun.lock`。`check:vendor` 要求 @@ -64,7 +80,15 @@ bash scripts/alpha-check.sh | **`alpha-ci`** | 本仓 CI(上面三关) | `push` / `pull_request` → `alpha`;也可 `workflow_dispatch` | | **`sync-upstream`** | 每日上游 `dev → merge alpha` 同步 | 定时 + 手动 | -- **required check 只有 `alpha-ci` 的三个 job**;其它 workflow 一律**不影响 merge**(PR 上若看到别的 check = 历史残留,忽略)。 +- **分支保护 required contexts 与真实 job 名对不上,`unit tests (ui-mac)` 是个幽灵**(2026-08-03 实读,`#717`)。 + `alpha` 保护要求四个 context:`north-star guard (zero upstream edits)` / `typecheck (alpha packages)` / + **`unit tests (ui-mac)`** / `docs gate`。而那个 job 早在 2026-07-22(`ebd29cda`)就改名成 + `unit tests (alpha packages)` —— **没有任何 PR 会产出 `unit tests (ui-mac)` 这个 context**, + code PR 也不会。后果:每个 PR 在这一格上永久 pending ⇒ 每次合并都得 `--admin` ⇒ + 主线真红的时候也没有任何东西挡住。**「大家习惯性 --admin」不是纪律松懈,是分支保护自己造出来的。** + `#717` 的票面把成因写成「docs-only path 没发布该 context」,与实测不符:改名是全量的。 + 修法(改的是 GitHub 设置,不在本仓文件里)由 `#717` 承担。 +- 其它 workflow 一律**不影响 merge**(PR 上若看到别的 check = 历史残留,忽略)。 - **不要盲目 `gh workflow enable`** 上游那些——除非你真给 fork 接了对应 runner。要恢复某个:`gh workflow enable .yml`(可逆,文件没删)。 ## 4. 提交纪律 @@ -81,11 +105,14 @@ bash scripts/alpha-check.sh - 一直 `queued` = 等 runner(无匹配 runner / Actions 额度耗尽 / 并发上限); - `in_progress` 慢 = job 本身慢(装依赖 / 测试)——那才是 REQ-009 缓存优化的场景。 3. **命令**:`gh run cancel ` 取消 · `gh run watch ` 盯 · `gh run rerun ` 重跑 · `gh run list --workflow=alpha-ci.yml` 只看本仓 CI。 -4. **别被堵**:本地三关已绿 = 代码没问题;required 只有 alpha-ci 三个。真急可 `gh pr merge --admin` 绕过(慎用,别养成习惯)。 +4. **别被堵**:本地七步已绿 = 代码没问题。注意 §3 那条 —— 目前 required 里有一个**永远不会上报**的 + context(`unit tests (ui-mac)`,`#717`),所以「PR 卡着不动」多半不是 CI 慢,是那一格永远 pending。 + 真急可 `gh pr merge --admin` 绕过(它现在是**结构性必需**,不是纪律松懈;`#717` 修好之前别把 + 「又 --admin 了一次」当成有人偷懒)。 ## 6. pre-push 钩子 —— local-first 强制(2026-07-05 REQ-015 起默认开启) -`.githooks/pre-push` = 跑 `scripts/alpha-check.sh`(与 alpha-ci 1:1)。**默认开启**:`alpha-check.sh` 每次运行都会幂等重挂 `git config core.hooksPath .githooks`。 +`.githooks/pre-push` = 跑 `scripts/alpha-check.sh`(覆盖 alpha-ci 全部 12 个代码步,末尾自陈对照表)。**默认开启**:`alpha-check.sh` 每次运行都会幂等重挂 `git config core.hooksPath .githooks`。 为什么不能用上游 husky 门(此前「配置过又失效」的根因,REQ-015): - `.husky/pre-push` 跑**全量** `bun turbo typecheck`,在 ADR-020 冻结偏斜下 `session-ui` 恒红(上游叶子包,alpha 不 ship,权威门 alpha-ci 不含)→ 逼出 `--no-verify` 习惯; diff --git a/packages/ui-mac/scripts/test-preload.ts b/packages/ui-mac/scripts/test-preload.ts index 1a709495d65c..de853ab0305a 100644 --- a/packages/ui-mac/scripts/test-preload.ts +++ b/packages/ui-mac/scripts/test-preload.ts @@ -1,6 +1,65 @@ -// bun test preload: pin the UI locale for the whole test process before any test file -// evaluates. Consumed by src/renderer/i18n/index.ts detectLocale(), which prefers an -// explicit ALPHA_UI_LOCALE over navigator sniffing. `||=` respects an override the caller -// already exported (e.g. to run the suite in another locale). Child processes spawned by -// tests (alpha-composer-model.component.test.ts) inherit this via process.env. +// bun test preload —— **本包全部测试进程的环境声明处**(唯一一处)。 +// +// 这个文件是 `#777` 的咽喉:一道门跑在什么环境里,由这里一次性声明,而不是由每个测试文件 +// 各自记得。`packages/ui-mac/bunfig.toml` 的 `preload` 把它注入到每一个 `bun test` 进程 —— +// 包括 host 测试用 `Bun.spawnSync([process.execPath, "test", …], { cwd: packages/ui-mac })` +// 起的**子进程**(子进程 cwd 就是本包,于是读到同一份 bunfig)。新增一道门什么都不用记, +// 环境是默认给到的;想覆盖仍然可以在单条用例上显式写(显式值恒胜,已实测)。 +// +// 每一条都必须是**声明 + 自陈**:静默地改环境等于把恒红换成假绿。 + +import { setDefaultTimeout } from "bun:test" + +// ── ① UI 语言 ──────────────────────────────────────────────────────────────── +// Consumed by src/renderer/i18n/index.ts detectLocale(), which prefers an explicit +// ALPHA_UI_LOCALE over navigator sniffing. `||=` respects an override the caller already +// exported (e.g. to run the suite in another locale). Child processes spawned by tests +// (alpha-composer-model.component.test.ts) inherit this via process.env. process.env.ALPHA_UI_LOCALE ||= "zh" + +// ── ② 每条用例的默认超时(**只管单文件运行**,权威在 scripts/bun-test-floor.sh)──────── +// bun 默认 5000ms,对「在子进程里跑一整套 `.cases.ts`」的 host 用例来说,那不是超时, +// 是**机器速度在替断言下判决**(2026-08-02 主线连红两天,三条即此)。 +// +// ⚠️ 这一行的作用范围**远小于它看起来的样子** —— 实测(bun 1.3.14): +// `setDefaultTimeout()` 从 preload 调用,**只对一次运行的第一个测试文件生效**; +// `bun test src`(257 个文件)从第二个文件起就退回 5000ms。 +// 最初的探针只跑了单文件,于是给出一个**假的通过** —— 观测手段自己有盲区。 +// `beforeAll(() => setDefaultTimeout(...))` 同样无效。跨全部文件唯一有效的是 +// `bun test --timeout N`,而那个 flag 现在由 `scripts/bun-test-floor.sh` 一处给出。 +// +// 那为什么这里还留着:**host 起的子进程都是单文件运行**(`bun test <一个绝对路径>`), +// 它们的 argv 是 host 自己拼的、传不进 flag —— 那一半正好落在本行的有效范围内。 +// 两处合起来才覆盖两种形状,两处都有行为闸看着(`src/main/gate-environment.test.ts`)。 +// +// 取值 120s:与仓内已显式声明超时的那批同档(最慢的正当 host 在 CI 上实测 37.7s)。 +// 需要把时长本身当断言的用例(如 artifact-quota 的 `}, 1000)`)照旧显式写,显式值恒胜。 +setDefaultTimeout(120_000) + +// ── ③ 平台:**只检测 + 自陈,不改** ──────────────────────────────────────────── +// 本产品 ship 的桌面平台是 darwin + win32(ADR-026;`ext-install-planner.ts` 的 +// `synthesizeManifest` 把 `compatibility.platforms` 写死成 `["darwin","win32"]`)。 +// alpha-ci 的 runner 是 ubuntu ⇒ 凡是走到生产平台闸的用例,在写盘前就被拒: +// `platform linux not supported by this entry — refusing before any disk write` +// 它们量到的是「runner 不是我们发布的平台」,不是它们存在的理由。 +// +// ⚠️ 本票**试过**在这里全局钉 `process.platform = "darwin"`,在 alpha-ci 上实测**炸得更狠**: +// 14 个 renderer 测试文件整片挂在 +// `error: Cannot find module @rollup/rollup-darwin-x64` +// —— vite/rollup 按 `process.platform` 选原生可选依赖,而 linux runner 上装的是 linux 那份。 +// 3752 pass 掉到 3625。**为了修 2 条而弄坏 14 个文件,那是把一处假红换成一片真红。** +// 教训写在这里而不是只写在票里:全局改环境的代价,必须在真环境里量过才算知道。 +// +// 所以平台是**按需 opt-in**:需要走生产平台闸的 cases 文件自己调 +// `test-component/pin-shipped-platform.ts` 的 `pinShippedPlatform()`(它同样会自陈)。 +// 这里只负责让「本次运行的 host 不是发布平台」这件事**说出口** —— 一道门可以降级, +// 但不许静默降级;而看到这行的人才知道要去 opt-in。 +export const SHIPPED_PLATFORMS = ["darwin", "win32"] as const +if (!(SHIPPED_PLATFORMS as readonly string[]).includes(process.platform)) { + console.log( + `[alpha-test-env] host platform "${process.platform}" is not one this product ships on ` + + `(${SHIPPED_PLATFORMS.join("/")}). PLATFORM DEGRADED this run: any gate that reaches a production ` + + `platform check refuses before doing anything, and measures the runner instead of the behaviour. ` + + `Such gates must opt in via pinShippedPlatform() (test-component/pin-shipped-platform.ts).`, + ) +} diff --git a/packages/ui-mac/src/main/gate-environment.test.ts b/packages/ui-mac/src/main/gate-environment.test.ts new file mode 100644 index 000000000000..67cbe076b96a --- /dev/null +++ b/packages/ui-mac/src/main/gate-environment.test.ts @@ -0,0 +1,81 @@ +// `#777` —— 环境咽喉的宿主。判据本体在 `test-component/gate-environment*.cases.ts`。 +// +// 这道门保证什么:**一道门跑在什么环境里,由两处声明给定,而这两处合起来覆盖仓内真实存在的 +// 两种运行形状**;任何一处被删掉,本文件当场红(不是「下一个人踩到时才红」)。 +// +// 形状 A:单文件运行 —— 31 条 host 用例用 `Bun.spawnSync([bun, "test", <一个绝对路径>])` +// 起的子进程全是这个形状。声明在 `packages/ui-mac/scripts/test-preload.ts` +// (`setDefaultTimeout`),因为 host 自己拼 argv,传不进 CLI flag。 +// 形状 B:多文件运行 —— CI 的三条 test 步、`assert-gate-files.sh` 的 77 次点名、 +// `alpha-check.sh` 的 [4/7] 全是这个形状。声明在 `scripts/bun-test-floor.sh` +// (`bun test --timeout`),因为**这才是所有闸门运行的唯一入口**。 +// +// 为什么需要它:本包有 31 条 host 用例在子进程里跑整套 `.cases.ts`(粒度是每条 `test(...)` +// 声明,不是每个文件),其中 **19 条**从未声明过超时(实测 2026-08-03)。bun 默认 5000ms ⇒ +// 谁的机器慢,谁那里就红,而红的理由与被验的行为无关。2026-08-02 起 alpha 主线 +// `unit tests (alpha packages)` 连红两天,其中三条就是这个;同期还有两条是 runner 平台 +// 不是本产品发布平台,生产安装闸在写盘前直接拒。 +// 「间歇性 flaky」是这类红最贵的误诊 —— 它把一道真闸变成噪声。 +// +// 已实测的事实(bun 1.3.14,逐条跑出来的,不是推断): +// · `bunfig.toml` 的 `[test] timeout` **不被读取** —— 写了照样 5000ms 被杀; +// · `BUN_TEST_TIMEOUT` / `BUN_TIMEOUT` / `BUN_TEST_TIMEOUT_MS` 全部无效; +// · preload 里 `setDefaultTimeout()` **只对一次运行的第一个文件生效** +// ——「单文件探针通过」会给出一个假的结论,这一点本票自己踩过一次; +// · preload 里 `beforeAll(() => setDefaultTimeout(...))` 同样无效; +// · `bun test --timeout N` 跨全部文件生效,且单条用例的显式超时恒胜。 + +import { expect, test } from "bun:test" +import path from "node:path" + +const UI_MAC = path.resolve(import.meta.dir, "../..") +const REPO_ROOT = path.resolve(UI_MAC, "../..") +const SLOW = path.resolve(UI_MAC, "test-component/gate-environment.cases.ts") +const FIRST = path.resolve(UI_MAC, "test-component/gate-environment-first.cases.ts") + +// `#777` 实测:形状 B 要经 `bash scripts/bun-test-floor.sh`,而那个脚本里写的是裸 `bun`。 +// 在 alpha-ci 上第一版直接挂在 `bun-test-floor.sh: line 55: bun: command not found` —— +// 从 bun 进程 spawn 出去的 bash 拿到的 PATH 里没有 runner 装的那个 bun。 +// 把**正在跑本测试的那个 bun**所在目录前置进 PATH:既修好,也保证父子跑的是同一个二进制。 +const BUN_DIR = path.dirname(process.execPath) +const ENV = { ...process.env, PATH: `${BUN_DIR}${path.delimiter}${process.env.PATH ?? ""}` } + +function run(cmd: string[], cwd: string) { + const r = Bun.spawnSync({ cmd, cwd, env: ENV }) + return { output: `${r.stdout.toString()}${r.stderr.toString()}`, code: r.exitCode } +} + +// 本宿主自己**显式**声明超时:它要等两个子进程各跑一条 6 秒用例。不声明的话,它自己就会 +// 变成本文件正在讨论的那种假红(在 `bun test src` 里默认退回 5000ms)。显式值恒胜。 +test( + "环境咽喉覆盖两种运行形状(单文件子进程 + 多文件闸门入口)", + { timeout: 180_000 }, + () => { + // ── 形状 A:host 起子进程的那条路,单文件,靠 preload ────────────────────── + const solo = run([process.execPath, "test", SLOW], UI_MAC) + if (solo.code !== 0) throw new Error(`[形状 A 单文件子进程] ${solo.output}`) + // 条数写死:少一条就是有人把两半里的一半摘了,而 `0 fail` 照样成立。 + expect(solo.output).toMatch(/Ran 2 tests across 1 file/) + expect(solo.output).toMatch(/\b2 pass\b/) + expect(solo.output).toMatch(/\b0 fail\b/) + // 子进程必须**自陈**平台那一半这次到底跑没跑到 —— 开发机(darwin)跑不到,alpha-ci 跑得到。 + expect(solo.output).toMatch(/\[gate-environment\] host=/) + // 并且要**转述到本次运行的输出里**:子进程的 stdout 被 host 吃进变量,只有失败时才抛出来 —— + // 于是「降级了什么」这句话在绿的那一次反而看不见,而那正是需要它的时候(`#777` 实测: + // 第一版在 alpha-ci 全绿的 run 里 grep 不到任何一行自陈)。 + for (const line of solo.output.split("\n")) { + if (/\[gate-environment\] host=|PLATFORM SIMULATED/.test(line)) console.log(` ↳ ${line.trim()}`) + } + + // ── 形状 B:闸门真正的入口,多文件,靠 bun-test-floor.sh 的 --timeout ──────── + // 慢用例**必须排第二**:preload 的 setDefaultTimeout 只覆盖第一个文件,让慢的当第一个 + // 会验成一件本来就成立的事(= 形状 A),给出假绿。占位文件就是为此存在的。 + const multi = run( + ["bash", "scripts/bun-test-floor.sh", "3", "packages/ui-mac", FIRST, SLOW], + REPO_ROOT, + ) + if (multi.code !== 0) throw new Error(`[形状 B 多文件闸门入口] ${multi.output}`) + expect(multi.output).toMatch(/Ran 3 tests across 2 files/) + expect(multi.output).toMatch(/3 条断言真的执行了/) + }, +) diff --git a/packages/ui-mac/src/main/local-gate-parity.test.ts b/packages/ui-mac/src/main/local-gate-parity.test.ts new file mode 100644 index 000000000000..b9e990b7909e --- /dev/null +++ b/packages/ui-mac/src/main/local-gate-parity.test.ts @@ -0,0 +1,147 @@ +// `#777` —— 「本地门与 alpha-ci 1:1」的**可检查断言**。 +// +// 为什么存在:`CLAUDE.md` 的铁律是「合并门在本地验证,本地绿 ⇒ 直接合」。这条铁律的全部 +// 依据,是 `scripts/alpha-check.sh` 抬头那句「This mirrors alpha-ci's jobs 1:1」。 +// 2026-08-03 实读:那句话是**散文**,而实际本地只跑了 CI 十二个代码步里的九个,其中三个 +// 还是降级档(裸 `bun test`,跑 0 条照样 exit 0)。缺的三步里有 `assert-gate-files.sh` —— +// 77 个登记闸门中 llm / core / opencode 那几个**只在那一步执行**。 +// +// 一句没人核对的「1:1」比没有这句话更坏:它让「本地绿」被当成「CI 会绿」的证据。 +// 所以这里把它变成断言: +// ① alpha-ci 的每一个代码步,都必须登记在 alpha-check.sh 的 CI_STEPS 对照表里(反之亦然); +// ② 登记的档位只能是 MIRRORED / SUPERSET:<理由> / DEGRADED:<理由> —— 没有「静默不跑」这一档; +// ③ 两处的 UPSTREAM_PATHS 与 ADR-033 收编白名单必须逐条相同(`#637` 退出条件 3: +// ADR-033 落地时只同步了 paths、白名单漏了,于是本地 north-star 在干净 alpha 上恒假红, +// 人人 `--no-verify`。修好了但没有防漂断言 = 下一次收编重演); +// ④ `assert gate files` 这一步不得因为前一步红而被跳过(GitHub step 默认条件是 success())。 +// +// 删掉本文件会失去什么:上面四条全部退回「靠人记得」。CI 改一个 job/步骤名、加一步、 +// 或者有人把 alpha-check 的某一步删掉,都不再有任何东西变红。 + +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { describe, expect, test } from "bun:test" + +const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", "..") +const WORKFLOW = readFileSync(resolve(REPO_ROOT, ".github/workflows/alpha-ci.yml"), "utf8") +const SCRIPT = readFileSync(resolve(REPO_ROOT, "scripts/alpha-check.sh"), "utf8") + +/** + * alpha-ci.yml 里「有名字、且真的执行一条命令」的步骤。 + * 刻意不引 YAML 库:本仓无 yaml 依赖,而这份文件的缩进是自己写的、稳定的。 + */ +type CiStep = { job: string; name: string; condition: string } + +function parseWorkflowSteps(yaml: string): CiStep[] { + const out: CiStep[] = [] + let job = "" + let cur: { name?: string; run?: boolean; condition?: string } | null = null + const flush = () => { + if (cur?.name && cur.run) out.push({ job, name: cur.name, condition: cur.condition ?? "" }) + cur = null + } + for (const line of yaml.split("\n")) { + const jobHeader = /^ {2}([a-z][a-z0-9-]*):\s*$/.exec(line) + if (jobHeader) { + flush() + job = jobHeader[1] + continue + } + const stepStart = /^ {6}- (.*)$/.exec(line) + if (stepStart) { + flush() + cur = {} + line2prop(stepStart[1], cur) + continue + } + const prop = /^ {8}(\S.*)$/.exec(line) + if (prop && cur) line2prop(prop[1], cur) + } + flush() + return out +} + +function line2prop(text: string, cur: { name?: string; run?: boolean; condition?: string }) { + const m = /^([a-z]+):\s*(.*)$/.exec(text) + if (!m) return + if (m[1] === "name") cur.name = m[2].trim() + else if (m[1] === "run") cur.run = true + else if (m[1] === "if") cur.condition = m[2].trim() +} + +/** + * 不是门的步骤:它们把环境准备好,不判任何东西。**显式登记**,新加一个就必须在这里表态 —— + * 否则默认被当成门,少一条对照即红(咽喉对新成员默认拒绝)。 + */ +const NON_GATE_STEPS = new Set(["detect|Classify diff (code vs docs-only)", "upstream-guard|Ensure origin/dev is available"]) + +function parseLedger(script: string): Array<{ job: string; name: string; status: string }> { + const block = /^CI_STEPS=\(\n([\s\S]*?)^\)$/m.exec(script) + if (!block) throw new Error("scripts/alpha-check.sh 里找不到 CI_STEPS=( … ) 对照表") + return block[1] + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.startsWith('"')) + .map((l) => { + const [job, name, status] = l.replace(/^"|"$/g, "").split("|") + return { job, name, status } + }) +} + +function bashArrayItems(script: string, varName: string): string[] { + const block = new RegExp(`^${varName}=\\(\\n([\\s\\S]*?)^\\)$`, "m").exec(script) + if (!block) throw new Error(`scripts/alpha-check.sh 里找不到 ${varName}=( … )`) + return [...block[1].matchAll(/'([^']+)'/g)].map((m) => m[1]) +} + +describe("#777 本地门与 alpha-ci 的对照表", () => { + test("alpha-ci 的每个代码步都登记在 alpha-check.sh 的 CI_STEPS 里", () => { + const ciSteps = parseWorkflowSteps(WORKFLOW).filter((s) => !NON_GATE_STEPS.has(`${s.job}|${s.name}`)) + const ledger = parseLedger(SCRIPT) + // 先证明解析手段本身没瞎:一份跑不出步骤的解析器会让下面每条断言都空对空地绿。 + expect(ciSteps.length).toBeGreaterThanOrEqual(12) + const missing = ciSteps.filter((s) => !ledger.some((l) => l.job === s.job && l.name === s.name)) + expect( + missing.map((s) => `${s.job}|${s.name}`), + "alpha-ci 有这些代码步,而 scripts/alpha-check.sh 的 CI_STEPS 没有登记 —— 「1:1」这句话当场变成假话", + ).toEqual([]) + }) + + test("CI_STEPS 里的每一行都对得上 alpha-ci 的一个真实步骤(改名即红)", () => { + const ciSteps = parseWorkflowSteps(WORKFLOW) + const ledger = parseLedger(SCRIPT) + expect(ledger.length).toBeGreaterThanOrEqual(12) + const stale = ledger.filter((l) => !ciSteps.some((s) => s.job === l.job && s.name === l.name)) + expect(stale.map((l) => `${l.job}|${l.name}`), "CI_STEPS 登记了 alpha-ci 里不存在的步骤(改名或删除后没同步)").toEqual([]) + }) + + test("每一行的档位只能是 MIRRORED / SUPERSET:<理由> / DEGRADED:<理由>", () => { + for (const { job, name, status } of parseLedger(SCRIPT)) { + const where = `${job}|${name}` + if (status === "MIRRORED") continue + expect(status.startsWith("SUPERSET:") || status.startsWith("DEGRADED:"), `${where} 的档位非法:${status}`).toBe(true) + // 降级/超集必须说清是什么。「静默不跑」不是一个合法档位 —— 那是把恒红换成假绿。 + expect(status.split(":").slice(1).join(":").trim().length, `${where} 的档位缺理由`).toBeGreaterThan(0) + } + }) + + test("#637 退出条件 3:UPSTREAM_PATHS 与 ADR-033 收编白名单两处逐条相同", () => { + const ciPaths = /UPSTREAM_PATHS:\s*"([^"]+)"/.exec(WORKFLOW)?.[1].split(/\s+/) + const shPaths = /^UPSTREAM_PATHS="([^"]+)"/m.exec(SCRIPT)?.[1].split(/\s+/) + expect(ciPaths, "alpha-ci.yml 里没解析到 env.UPSTREAM_PATHS").toBeDefined() + expect(shPaths).toEqual(ciPaths!) + + const ciExcludes = [...WORKFLOW.matchAll(/':\(exclude\)([^']+)'/g)].map((m) => m[1]) + const shExcludes = bashArrayItems(SCRIPT, "UPSTREAM_EXCLUDES").map((s) => s.replace(":(exclude)", "")) + // 解析手段自检:两边都必须真解析出内容,否则 `[] === []` 会给一条假绿。 + expect(ciExcludes.length).toBeGreaterThanOrEqual(20) + expect(shExcludes, "本地 north-star 的收编白名单与 CI 漂移了 —— 干净 alpha 上会恒假红(#637)").toEqual(ciExcludes) + }) + + test("`assert gate files` 不因前一步失败而被跳过", () => { + const step = parseWorkflowSteps(WORKFLOW).find((s) => s.name.startsWith("assert gate files")) + expect(step, "alpha-ci.yml 里找不到 `assert gate files` 步骤").toBeDefined() + // GitHub 的 step 默认条件是 success():前一步红 ⇒ 这一步 skipped ⇒ 77 个登记闸门集体消失。 + expect(step!.condition, "assert gate files 缺 !cancelled() —— 前一步一红,77 个闸门就一起没了").toContain("!cancelled()") + }) +}) diff --git a/packages/ui-mac/test-component/claude-plugin-intake.ipc.cases.ts b/packages/ui-mac/test-component/claude-plugin-intake.ipc.cases.ts index e0ada644bf71..c2aaa3fe09f7 100644 --- a/packages/ui-mac/test-component/claude-plugin-intake.ipc.cases.ts +++ b/packages/ui-mac/test-component/claude-plugin-intake.ipc.cases.ts @@ -22,11 +22,46 @@ import path from "node:path" import { materializeCorpus, treeFingerprint } from "./claude-plugin-corpus.fixture" // electron 必须在**任何**会牵出主进程模块的 import 之前 mock 掉(否则真 electron 被拉起来)。 +// +// `#777`:这个桩必须覆盖 **electron-log 真正会碰的那一面**,不只是我们自己调的那一面。 +// 本用例走生产 composition root,`initLogging()` ⇒ `import "electron-log/main.js"`,而 +// electron-log 在**模块顶层**就 `externalApi.onIpc('__ELECTRON_LOG__', …)`,即 +// `this.electron.ipcMain?.on(channel, listener)`。`?.` 只挡 `ipcMain` 为空,挡不住 +// `ipcMain` 在而 `.on` 缺 —— 缺了就是 TypeError,整个文件 0 pass 1 fail。 +// 这条在开发机上不发作、在 alpha-ci 上发作(两边 `electron` 这个 specifier 落到 mock 还是落到 +// 真包不一样),于是它是一条**只在 CI 红**的假红。下面这份键是照 node_modules 里装着的 +// electron-log@5.4.4 `src/main/ElectronExternalApi.js` 逐条枚举出来的(不是凭记忆): +// app.{isReady,isPackaged,name,getName,getVersion,getPath,on,once,off} +// ipcMain.{on,handle} · dialog.showErrorBox · shell.openExternal +// session.defaultSession · webContents.getAllWebContents · BrowserWindow.getAllWindows const ipcHandlers = new Map unknown>() const electronStub: Record = { - app: { isPackaged: true, getVersion: () => "0.0.0", getAppPath: () => "/tmp", getPath: () => "/tmp", on: () => {} }, - ipcMain: { handle: (ch: string, fn: (...a: unknown[]) => unknown) => ipcHandlers.set(ch, fn), removeHandler: () => {} }, - dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }), showMessageBox: async () => ({ response: 0 }) }, + app: { + isPackaged: true, + isReady: () => true, + name: "alpha", + getName: () => "alpha", + getVersion: () => "0.0.0", + getAppPath: () => "/tmp", + getPath: () => "/tmp", + on: () => {}, + once: () => {}, + off: () => {}, + }, + ipcMain: { + handle: (ch: string, fn: (...a: unknown[]) => unknown) => ipcHandlers.set(ch, fn), + removeHandler: () => {}, + // electron-log 顶层就要它;缺了整个文件挂在一条与本闸无关的 TypeError 上(`#777`)。 + on: () => {}, + once: () => {}, + off: () => {}, + removeListener: () => {}, + }, + dialog: { + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + showMessageBox: async () => ({ response: 0 }), + showErrorBox: () => {}, + }, BrowserWindow: Object.assign(class {}, { getAllWindows: () => [], getFocusedWindow: () => null, fromWebContents: () => null }), crashReporter: { start: () => {}, addExtraParameter: () => {} }, shell: { openExternal: () => {}, openPath: () => {} }, diff --git a/packages/ui-mac/test-component/ext-install-catalog-result.cases.ts b/packages/ui-mac/test-component/ext-install-catalog-result.cases.ts index d8f8f11a8f08..5ff4c9cb5ba2 100644 --- a/packages/ui-mac/test-component/ext-install-catalog-result.cases.ts +++ b/packages/ui-mac/test-component/ext-install-catalog-result.cases.ts @@ -4,6 +4,12 @@ import { tmpdir } from "node:os" import { join } from "node:path" import bundledCatalog from "../src/renderer/extensions/alpha-catalog.json" +// `#777`:本文件跑的是生产 `ext-install-catalog` 全链,里面有 ADR-026 的平台闸 +// (`platforms: ["darwin","win32"]`)。在 ubuntu runner 上不声明这一条,两条用例量到的是 +// 「runner 不是发布平台」而不是「返回值不回显 canary」。必须在任何生产模块 import 之前调。 +import { pinShippedPlatform } from "./pin-shipped-platform" + +pinShippedPlatform() type IpcHandler = (event: { sender: { id: number } }, ...args: unknown[]) => unknown diff --git a/packages/ui-mac/test-component/gate-environment-first.cases.ts b/packages/ui-mac/test-component/gate-environment-first.cases.ts new file mode 100644 index 000000000000..e0606445b428 --- /dev/null +++ b/packages/ui-mac/test-component/gate-environment-first.cases.ts @@ -0,0 +1,11 @@ +// `#777` —— 只为**当第一个文件**而存在(判据本体在 `gate-environment.cases.ts`)。 +// +// 理由:preload 里的 `setDefaultTimeout()` 实测**只对一次运行的第一个测试文件生效**。 +// 于是「多文件运行也拿得到抬高的超时」这一半,必须让慢用例**排在第二个文件**才验得到 —— +// 让慢的那个当第一个文件,会验成一件本来就成立的事(单文件那一半),给出假绿。 + +import { expect, test } from "bun:test" + +test("占位:让慢用例落到第二个文件上", () => { + expect(1).toBe(1) +}) diff --git a/packages/ui-mac/test-component/gate-environment.cases.ts b/packages/ui-mac/test-component/gate-environment.cases.ts new file mode 100644 index 000000000000..dd1220e859ec --- /dev/null +++ b/packages/ui-mac/test-component/gate-environment.cases.ts @@ -0,0 +1,41 @@ +// `#777` —— 环境咽喉的**行为**判据(本体)。宿主在 `src/main/gate-environment.test.ts`。 +// +// 这份文件被宿主用**和其余 31 条 host 用例一模一样的方式**拉起来 +// (`Bun.spawnSync([process.execPath, "test", ], { cwd: packages/ui-mac })`), +// 所以它验的不只是「声明写对了」,而是「声明对**子进程**也生效」—— +// 那正是 2026-08-02 alpha 主线连红两天里,两条子用例被 5000ms 杀掉的位置。 +// +// 刻意**不写显式超时**:显式值恒胜(已实测),写了就把要验的东西绕过去了。 + +import { expect, test } from "bun:test" +import os from "node:os" + +import { pinShippedPlatform, SHIPPED_PLATFORMS } from "./pin-shipped-platform" + +// ── ① 默认超时 ──────────────────────────────────────────────────────────────── +// bun 默认 5000ms。抬高它的那两处声明(preload 管单文件、bun-test-floor.sh 管多文件) +// 只要少一处,这条 6 秒的用例就 `timed out after 5000ms`,宿主随之变红。 +test("默认超时被咽喉抬过 5s —— 这条用例没有自己的超时声明", async () => { + const started = Date.now() + await new Promise((resolve) => setTimeout(resolve, 6_000)) + expect(Date.now() - started).toBeGreaterThanOrEqual(6_000) +}) + +// ── ② 平台 opt-in ───────────────────────────────────────────────────────────── +// `os.platform()` 是 host 的真实平台(钉桩只动 `process.platform`,不动它)—— +// 于是这条用例在两种 host 上验的是两件不同的事,而且它**自己说出来**验的是哪一件: +// · host 就是发布平台(开发机 darwin):`pinShippedPlatform()` 应当是 no-op —— 弱断言; +// · host 不是发布平台(alpha-ci 的 ubuntu):它必须真的把平台钉住 —— 这才是本条存在的理由。 +// 不打印这一行,本地看到「绿」会以为平台那一半验过了。测不到就说测不到。 +test("pinShippedPlatform 在非发布平台上真的钉住,在发布平台上是 no-op", () => { + const host = os.platform() + const hostIsShipped = (SHIPPED_PLATFORMS as readonly string[]).includes(host) + const pinned = pinShippedPlatform() + console.log( + hostIsShipped + ? `[gate-environment] host=${host}(本身就是发布平台)⇒ 平台钉桩这一半**本次没有被执行**,只验了它是 no-op` + : `[gate-environment] host=${host}(非发布平台)⇒ 平台钉桩这一半**真的被执行了**;process.platform=${process.platform}`, + ) + expect(pinned).toBe(!hostIsShipped) + expect(SHIPPED_PLATFORMS).toContain(process.platform) +}) diff --git a/packages/ui-mac/test-component/pin-shipped-platform.ts b/packages/ui-mac/test-component/pin-shipped-platform.ts new file mode 100644 index 000000000000..4350ccf20a0e --- /dev/null +++ b/packages/ui-mac/test-component/pin-shipped-platform.ts @@ -0,0 +1,40 @@ +// `#777` —— 「这道门需要跑在本产品发布的平台上」的**显式声明**。 +// +// 谁需要它:任何会走到生产平台闸的用例。生产侧 `ext-install-planner.ts` 有四处 +// `if (!manifest.compatibility.platforms.includes(deps.platform())) return { ok:false, … }` +// 而 `synthesizeManifest` 把 `platforms` 写死成 `["darwin","win32"]`(ADR-026 桌面双平台)。 +// alpha-ci 跑 ubuntu ⇒ 这些用例在 CI 上量到的是「runner 不是发布平台」,不是它们要验的行为 +// (实测报错原文:`platform linux not supported by this entry — refusing before any disk write`)。 +// +// 为什么是 opt-in 而不是在 preload 里全局钉:**实测过全局钉,代价更大**。alpha-ci 上 +// 全局 `process.platform = "darwin"` 让 14 个 renderer 文件整片挂在 +// `Cannot find module @rollup/rollup-darwin-x64`(vite/rollup 按 process.platform 选原生 +// 可选依赖,linux runner 上装的是 linux 那份),3752 pass 掉到 3625。 +// 为了修 2 条而弄坏 14 个文件 = 把一处假红换成一片真红。 +// +// **这是模拟,不是覆盖**:在 linux 内核上跑 darwin 分支,凡真正依赖 host 内核行为的东西 +// 这一轮没有验到。所以它会把这句话打出来 —— 一道门可以降级,不许静默降级。 +// +// 只钉 darwin、不钉 win32:`node:path` 在加载时按 `process.platform` 选分支, +// darwin 与 linux 同为 posix;钉 win32 会把路径语义整个改坏。 + +const SHIPPED_PLATFORMS = ["darwin", "win32"] as const + +/** + * host 不是发布平台时把 `process.platform` 钉到 `darwin`,并自陈本次是模拟。 + * host 本来就是发布平台时什么都不做(返回 false),调用方据此可以说清 + * 「平台这一半本次到底跑没跑到」。 + */ +export function pinShippedPlatform(): boolean { + if ((SHIPPED_PLATFORMS as readonly string[]).includes(process.platform)) return false + const host = process.platform + Object.defineProperty(process, "platform", { value: "darwin", configurable: true, enumerable: true }) + console.log( + `[alpha-test-env] host platform "${host}" is not one this product ships on (${SHIPPED_PLATFORMS.join("/")}); ` + + `process.platform pinned to "darwin" for THIS FILE so the production install gate exercises a supported platform. ` + + `PLATFORM SIMULATED this run; genuinely ${host}-specific behaviour is NOT covered.`, + ) + return true +} + +export { SHIPPED_PLATFORMS } diff --git a/scripts/alpha-check.sh b/scripts/alpha-check.sh index d6615970c67f..8fb873eb7cd6 100755 --- a/scripts/alpha-check.sh +++ b/scripts/alpha-check.sh @@ -2,14 +2,42 @@ # alpha-check — run the exact gates alpha-ci enforces, LOCALLY, before you push. # # Standard: local-first (see docs/runbooks/ci.md). CI is the enforcing backstop, not the place -# you first discover a failure. This mirrors alpha-ci's jobs 1:1 and runs in seconds. +# you first discover a failure. # # bash scripts/alpha-check.sh # # Exit 0 = safe to push (CI will mirror this). Non-zero = fix before pushing. +# +# ── 「与 alpha-ci 1:1」是一条**可检查的断言**,不是一句自我介绍(`#777`)──────────── +# 这句话此前写在三处(本文件抬头、CLAUDE.md、docs/runbooks/ci.md),而实际只跑了 +# alpha-ci 十二个代码步里的九个,其中三个还是降级档(裸 `bun test`:跑 0 条照样 exit 0, +# 正是 `#647` 在 CI 上修掉的那个假绿形态)。「本地绿 ⇒ 可以合」这条铁律的全部依据就是 +# 这句 1:1 —— 它是散文的时候,那条铁律没有地基。 +# +# 现在:下面的 CI_STEPS 是本脚本对 alpha-ci 的**逐步对照表**,脚本结束时会把它打出来; +# packages/ui-mac/src/main/local-gate-parity.test.ts 反过来从 .github/workflows/alpha-ci.yml +# 里枚举出全部代码步,与这张表比对 —— CI 新增一步而这里没登记,即红。 set -uo pipefail cd "$(git rev-parse --show-toplevel)" +# 每行:||<本地档位> +# 档位只有三种:MIRRORED(跑同一条命令)、SUPERSET(本地还多验了东西)、 +# DEGRADED:<理由>(跑不了/跑的是降级档 —— 必须写清降级了什么)。没有第四种。 +CI_STEPS=( + "upstream-guard|No literal NUL bytes in version-controlled files|MIRRORED" + "upstream-guard|Fail on any modification to upstream package files|SUPERSET:committed delta ∪ 未提交工作树改动" + "typecheck|typecheck @alpha-code/contracts-consumer|MIRRORED" + "typecheck|typecheck @alpha-code/ext|MIRRORED" + "typecheck|typecheck ui-mac|MIRRORED" + "test|verify immutable Alpha contract vendor lock|SUPERSET:开发机有兄弟仓 ⇒ 跑的是 provenance 已验档,CI 是降级档(#769)" + "test|bun test (contracts consumer fixtures)|MIRRORED" + "test|bun test (ext)|MIRRORED" + "test|bun test (ui-mac)|MIRRORED" + "test|assert gate files (逐个点名,整包地板抓不到单文件消失)|MIRRORED" + "seed-assets|Assert seed/vendored resources present (B7)|MIRRORED" + "docs-gate|Relative-link validity in changed Markdown|MIRRORED" +) + # REQ-015 self-heal(2026-07-05):husky 的 prepare 在每次 `bun install` 后把 core.hooksPath # 重置回 .husky/_(其全量 turbo typecheck 在 ADR-020 冻结偏斜下因 session-ui 恒红)。 # 此处幂等重挂 alpha 门,使 .githooks/pre-push(= 本脚本)成为默认 push 门。逃生:ALPHA_HOOKS_DISABLE=1。 @@ -68,7 +96,7 @@ UPSTREAM_EXCLUDES=( ) fail=0 -echo "▶ [1/4] north-star guard (zero upstream edits)" +echo "▶ [1/7] north-star guard (zero upstream edits)" git fetch --no-tags origin dev --quiet 2>/dev/null || echo " (warn: could not fetch origin/dev — comparing against last-known origin/dev)" # committed delta (mirrors CI) ∪ working-tree edits (earlier local feedback) committed="$(git diff --diff-filter=DMR --name-only origin/dev...HEAD -- $UPSTREAM_PATHS "${UPSTREAM_EXCLUDES[@]}" 2>/dev/null || true)" @@ -83,7 +111,7 @@ else echo " ✓ zero upstream package edits" fi -echo "▶ [2/4] no literal NUL bytes in version-controlled files" +echo "▶ [2/7] no literal NUL bytes in version-controlled files" # #760:字面 NUL 不会让运行时出错,它坏的是**验证手段** —— BSD grep / rg / file(1) 看到 NUL 就把 # 整个文件判成二进制并静默返回空,于是「我 grep 过了,没有」变成假话。本仓 CLAUDE.md 要求 # 「大文件 Edit 后 grep + git show 双验」,而在这些文件上 grep 会安静地说「没有」。 @@ -95,7 +123,7 @@ else echo " ✗ literal NUL bytes found"; fail=1 fi -echo "▶ [3/4] typecheck (alpha packages: contracts-consumer + ext + ui-mac)" +echo "▶ [3/7] typecheck (alpha packages: contracts-consumer + ext + ui-mac)" # REQ-027:flag 必须在 `run` 之后 —— `bun --cwd X run Y` 在 bun 1.3.x 打印 usage 后静默退出 0(不执行脚本)。 if bun run --cwd packages/alpha-contracts-consumer typecheck \ && bun run --cwd packages/ext typecheck \ @@ -105,21 +133,70 @@ else echo " ✗ typecheck failed"; fail=1 fi -echo "▶ [4/4] contract lock + unit tests (contracts-consumer + ext + ui-mac)" +echo "▶ [4/7] contract lock + unit tests (contracts-consumer + ext + ui-mac)" # REQ-062:ext 测试入门 —— 其中 prompt-rebrand drift 锁逐条断言转写子串仍在上游底座原文, # 上游 sync 改写底座即红(ADR-015 合并验证的机械化)。 +# +# `#777`:三条测试从裸 `bun test` 换成 scripts/bun-test-floor.sh —— 与 CI 同一条命令、 +# 同一个下界。裸 `bun test` 对「文件被清空 / 用例被条件注册成零条 / 指定文件不存在」都会 +# 打印 `Ran 0 tests` 并**退出 0**(`#647` 已实测)。CI 早在 #647 就修掉了这个假绿, +# 而本地这道门原样留着 —— 于是「本地绿」在闸门被清空时也成立。 if bun run --cwd packages/alpha-contracts-consumer check:vendor \ - && (cd packages/alpha-contracts-consumer && bun test) \ - && (cd packages/ext && bun test) \ - && bun run --cwd packages/ui-mac test; then + && bash scripts/bun-test-floor.sh 15 packages/alpha-contracts-consumer \ + && bash scripts/bun-test-floor.sh 100 packages/ext \ + && bash scripts/bun-test-floor.sh 3000 packages/ui-mac src; then echo " ✓ tests" else echo " ✗ tests failed"; fail=1 fi +# `#777`:下面三步此前**本地完全没有**,而 CI 有。缺 [5/7] 尤其贵 —— 77 个登记闸门里 +# llm / core / opencode 那几个只在这一步执行,别的步骤一条都不覆盖它们。 +echo "▶ [5/7] assert gate files (逐个点名;整包地板抓不到单个闸门文件消失)" +if bash scripts/assert-gate-files.sh; then + echo " ✓ gate files" +else + echo " ✗ gate files failed"; fail=1 +fi + +echo "▶ [6/7] seed assets present (B7)" +if bash scripts/assert-seed-assets.sh; then + echo " ✓ seed assets" +else + echo " ✗ seed assets missing"; fail=1 +fi + +echo "▶ [7/7] docs gate (relative-link validity in changed Markdown)" +# CI 只查**这次改动过的** Markdown(detect job 收集)。本地口径同构:相对 origin/alpha 的 +# 提交 delta ∪ 未提交工作树改动,再滤成 *.md。一个都没有 ⇒ 与 CI 一样是 no-op。 +md_committed="$(git diff --name-only --diff-filter=d origin/alpha...HEAD -- '*.md' 2>/dev/null || true)" +md_worktree="$(git diff --name-only --diff-filter=d HEAD -- '*.md' 2>/dev/null || true)" +md_untracked="$(git ls-files --others --exclude-standard -- '*.md' 2>/dev/null || true)" +md_list="$(printf '%s\n%s\n%s\n' "$md_committed" "$md_worktree" "$md_untracked" | sed '/^$/d' | sort -u)" +if [ -z "$md_list" ]; then + echo " ✓ no Markdown changed — docs gate is a no-op (与 CI 同)" +else + # shellcheck disable=SC2086 + if python3 scripts/check-doc-links.py $md_list; then + echo " ✓ docs links ($(echo "$md_list" | wc -l | tr -d ' ') 个 Markdown)" + else + echo " ✗ broken relative links"; fail=1 + fi +fi + +# ── 覆盖自陈(`#777`)────────────────────────────────────────────────────────── +# 「和 CI 1:1」以前是散文。现在这张表由脚本自己打出来,并由 +# packages/ui-mac/src/main/local-gate-parity.test.ts 反向核对(CI 加了步而这里没登记即红)。 +echo +echo "── 本脚本对 alpha-ci 的覆盖(${#CI_STEPS[@]}/${#CI_STEPS[@]} 个代码步)──────────────────────" +for row in "${CI_STEPS[@]}"; do + printf ' %-14s %-58s %s\n' "${row%%|*}" "$(echo "$row" | cut -d'|' -f2)" "${row##*|}" +done +echo " 注:MIRRORED = 同一条命令;SUPERSET = 本地还多验了;DEGRADED = 降级,理由在同一行。" + echo if [ "$fail" -eq 0 ]; then - echo "✅ all local gates green — safe to push (alpha-ci will mirror this in ~40s)." + echo "✅ all local gates green — safe to push (alpha-ci will mirror this)." else echo "❌ local gates failed — fix before pushing (alpha-ci would fail the same way)." fi diff --git a/scripts/bun-test-floor.sh b/scripts/bun-test-floor.sh index c3fbf7fae7c7..77b14842d8cd 100755 --- a/scripts/bun-test-floor.sh +++ b/scripts/bun-test-floor.sh @@ -27,11 +27,32 @@ floor="$1" workdir="$2" shift 2 +# ── 每条用例的默认超时(`#777` 的环境咽喉)────────────────────────────────────── +# bun 默认 5000ms。本仓有 31 条 host 用例在子进程里跑**一整套** `.cases.ts`,5 秒对它们 +# 不是超时,是**机器速度在替断言下判决**:2026-08-02 起 alpha 主线 `unit tests (alpha packages)` +# 连红两天,其中三条就是这个(host 5035.75ms / 子用例 5933.51ms、6447.61ms), +# 而同一棵树在开发机上 0 fail。这种红最贵 —— 它会被读成「间歇性 flaky」,一道真闸被当噪声。 +# +# 为什么闸门在这里、而不是每个测试文件各写一遍:所有**闸门**运行(CI 的三条 test 步、 +# assert-gate-files.sh 的 77 次点名、alpha-check.sh 的 [4/7])都经过本脚本 —— 一处声明, +# 新增的门默认拿到。单条用例仍可显式覆盖(显式值恒胜,已实测)。 +# +# 为什么是 CLI flag 而不是别的(bun 1.3.14,全部实测,不是推断): +# · bunfig.toml 的 `[test] timeout` **不被读取** —— 写了照样 5000ms 被杀; +# · BUN_TEST_TIMEOUT / BUN_TIMEOUT / BUN_TEST_TIMEOUT_MS 全部无效; +# · preload 里 `setDefaultTimeout()` **只对一次运行的第一个文件生效** —— 单文件跑得通, +# `bun test src`(257 个文件)从第二个文件起就退回 5000ms。**单文件探针会给你一个假的通过**; +# · preload 里 `beforeAll(() => setDefaultTimeout(...))` 同样无效; +# · `bun test --timeout N` 跨全部文件生效 —— 只有这一条成立。 +# 取值 120s:与仓内已显式声明超时的那批同档(最慢的正当 host 在 CI 上实测 37.7s)。 +# 超时不是断言:卡死的用例仍会在 120s 内判红,而 5s 的代价是让真闸在慢机器上恒假红。 +ALPHA_TEST_TIMEOUT_MS="${ALPHA_TEST_TIMEOUT_MS:-120000}" + log="$(mktemp)" trap 'rm -f "$log"' EXIT set +e -(cd "$workdir" && bun test "$@") 2>&1 | tee "$log" +(cd "$workdir" && bun test --timeout "$ALPHA_TEST_TIMEOUT_MS" "$@") 2>&1 | tee "$log" status=${PIPESTATUS[0]} set -e diff --git a/scripts/gate-files.tsv b/scripts/gate-files.tsv index d17521a005ec..95425a505366 100644 --- a/scripts/gate-files.tsv +++ b/scripts/gate-files.tsv @@ -105,6 +105,10 @@ 7 packages/ui-mac src/shared/route-deep-link-consumer.test.ts - 受托:真跑 createDeepLinkConsumer,断言导航目标 == manifest 独立推导的 href(route authority 主判据) 14 packages/ui-mac src/shared/route-upstream-shape.test.ts - 受托:上游 layout 目录的 deep-link 路径 SHAPE 契约 +# ── 闸门自身的环境与对照(#777)────────────────────────────────────────────── +5 packages/ui-mac src/main/local-gate-parity.test.ts - #777:「本地门与 alpha-ci 1:1」的可检查断言 —— 此前那句话只写在三处散文里,而实际本地只跑了 CI 十二个代码步里的九个(其中三个是裸 `bun test` 的降级档,跑 0 条照样 exit 0)。本文件把 alpha-ci 的代码步逐条枚举出来,与 alpha-check.sh 的 CI_STEPS 对照表双向比对(CI 加一步而本地没登记即红、CI 改名而对照表没跟即红),档位只允许 MIRRORED/SUPERSET:<理由>/DEGRADED:<理由>(没有「静默不跑」这一档);另含 #637 退出条件 3 的防漂断言:两处 UPSTREAM_PATHS 与 24 条 ADR-033 收编白名单逐条相同(ADR-033 落地时只同步 paths、白名单漏了,干净 alpha 上恒假红、人人 --no-verify),以及 `assert gate files` 必须带 !cancelled()(GitHub step 默认 success() ⇒ 前一步一红,77 个登记闸门集体消失,2026-08-02 起实际发生了两天)。两条解析自检(ciSteps ≥ 12、ciExcludes ≥ 20)防止解析器退化后空对空全绿。下界=实际条数 5,不留余量 +1 packages/ui-mac src/main/gate-environment.test.ts - #777:环境咽喉的行为闸 —— 一道门跑在什么环境里由两处声明给定,合起来覆盖仓内真实存在的两种运行形状,任一处被删即红。形状 A 单文件(31 条 host 用例起的子进程,argv 自己拼、传不进 CLI flag)靠 packages/ui-mac/scripts/test-preload.ts 的 setDefaultTimeout;形状 B 多文件(CI 三条 test 步 + assert-gate-files 的 77 次点名 + alpha-check [4/7],所有闸门运行的唯一入口)靠 scripts/bun-test-floor.sh 的 `bun test --timeout`。宿主起两个子进程各断言一半:①一条不写显式超时的 6 秒用例 —— 声明一旦被删就在 5000ms 被杀(本包 31 条 host 用例里 19 条从未声明超时,bun 默认 5s 让机器速度替断言下判决,2026-08-02 主线三条红即此);②同一条慢用例**排在第二个文件**跑一遍 —— 实测 preload 的 setDefaultTimeout 只覆盖第一个文件,让慢的当第一个会验成一件本来就成立的事(占位文件 gate-environment-first.cases.ts 为此存在);③平台 opt-in —— 本产品只 ship darwin/win32(ADR-026),alpha-ci 跑 ubuntu,生产安装闸在写盘前就拒整个 runner;test-component/pin-shipped-platform.ts 的 pinShippedPlatform() 必须在非发布平台上真钉住、在发布平台上是 no-op(实测:把钉桩放进 preload 全局会让 14 个 renderer 文件挂在 Cannot find module @rollup/rollup-darwin-x64,3752 pass 掉到 3625,故只能 opt-in),子用例按 os.platform() 自陈这一半本次到底跑没跑到(开发机跑不到,CI 跑得到),不打印就会让本地的绿被误读成平台也验过了。下界=实际条数 1,不留余量 + # ── core(alpha 自有;本 job 的整包地板不覆盖 packages/core)────────────────── 9 packages/core test/alpha-websearch-sovereignty.test.ts - 受托:V2 core 那份 websearch 副本的主权闸运行时证据(websearch-copies 的运行时半场)