diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4ca9d6e..4e51e73 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "erp-workflow", - "description": "ERP 项目全流程框架:阶段 A 计划(plan-start 入口 + A0~A5 共 6 个 skill + 增量需求入口 add-req + 微改快速通道 quick-field/quick-ui + B 阶段瘦入口 coding-start = 11 个 skill;plan-start 终结闸 4 项前移硬校验;add-req 经 req-ledger 哈希台账识别后续新增/变更需求只跑增量;quick-field 对「加列 + 界面呈现」、quick-ui 对「已有页面纯前端小交互」保 SSoT 跳流程、不作废 tag、不重跑 coding) + 阶段 B 编码(单个静默 Workflow 脚本 coding.mjs,子代理自动跑后端+前端功能循环、测试闸门、本地里程碑 tag)。", + "description": "ERP 项目全流程框架:阶段 A 计划(plan-start 入口 + A0~A5 共 6 个 skill + 增量需求入口 add-req + 微改快速通道 quick-field/quick-ui + B 阶段瘦入口 coding-start + 全量回归入口 full-test = 12 个 skill;plan-start 终结闸 4 项前移硬校验;add-req 经 req-ledger 哈希台账识别后续新增/变更需求只跑增量;quick-field 对「加列 + 界面呈现」、quick-ui 对「已有页面纯前端小交互」保 SSoT 跳流程、不作废 tag、不重跑 coding) + 阶段 B 编码(单个静默 Workflow 脚本 coding.mjs,子代理自动跑后端+前端功能循环、测试闸门、本地里程碑 tag;测试闸缺省 affected 增量模式——e2e 只跑本轮改动关联用例,跨功能全量回归由 full-test 按需触发)。", "version": "0.3.0", "skills": [ "./skills/plan/plan-start", @@ -13,6 +13,7 @@ "./skills/plan/add-req", "./skills/plan/quick-field", "./skills/plan/quick-ui", - "./skills/coding/coding-start" + "./skills/coding/coding-start", + "./skills/coding/full-test" ] } diff --git a/README.md b/README.md index 5da0039..49b1a1a 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 按全部 FE spec 聚合的作用域并集枚举路由,交互/文字/样式三层断言;交互失效 /sentinel 错/样式违规(非 token 色、横向溢出、控件重叠等)转可 fix must-fix →fix→单测复验→重跑门(≤3 轮),软文字按来源仲裁,green 才放行) - → testGate(frontend,全量回归 vitest+playwright,兜底行为 fix 引入的回归) + → testGate(frontend,vitest 全量 + playwright 按 testScope 定宽;缺省 affected 只跑本轮 + 改动关联的 e2e,跨功能全量回归由 /erp-workflow:full-test 按需触发) → runMilestone(milestone/frontend-phase) · 前端 jsdom-only 重叠(可选,parallel.frontendOverlap,缺省关):前端**不依赖后端**的部分 @@ -143,18 +144,20 @@ erp-workflow-plugin/ │ ├── add-req/ # 增量需求入口(Plan 完结后追加/修改需求,经 req-ledger 哈希台账只跑增量) │ ├── quick-field/ # 字段微改快速通道(加列 + 界面呈现:保 SSoT 跳流程,不作废 tag、不重跑 coding) │ └── quick-ui/ # 前端小功能快速通道(已有页面局部小交互,只消费既有端点:直改 frontend/,不作废 tag、不重跑 coding) - └── coding/ # 阶段 B:1 个 skill(瘦入口) - └── coding-start/ # 启动 workflows/coding.mjs + └── coding/ # 阶段 B:2 个 skill + ├── coding-start/ # 启动 workflows/coding.mjs + └── full-test/ # 全量回归闸(按需触发,跑 scripts/test.mjs 全链 + 全量 e2e) ``` -## Skill 清单(11 个) +## Skill 清单(12 个) -### 入口(5 个) +### 入口(6 个) | Skill | 作用 | 谁调用 | |---|---|---| | `plan-start` | **A 阶段入口 + Plan 终结硬闸**。读 docs/08 § 一 找第一个未勾 A 子项 → 派发对应 A skill;A 全部完成时校验 4 项前移闸门(REQ 真实数据、`config-vars.yaml` 全部配置(含 DB 凭据 / 密钥)全锁、docs/04 § 零 命令齐、docs/05+02 已评审),全过才提示运行 `/erp-workflow:coding-start`,否则指出缺口不放行 | **用户手动** `/erp-workflow:plan-start` | | `coding-start` | **B 阶段瘦入口**(`allowed-tools: Read Glob Workflow Bash(git ...)`)。校验 Plan 完成态(docs/08 § 一 全勾)+ 取得 projectRoot(git 就绪由 coding.mjs runBranchSetup 在运行时把守)→ 读 docs/08 § 二/§ 三 + `git tag -l 'milestone/*'` 概述阶段进度(Workflow Router 再用 `req-done/*` 判定功能级 resume)→ 若有 `docs/superpowers/RESUME.md` 则读末尾条目复盘上次 halt 原因/待跑模块 → 调用 `Workflow({scriptPath:"${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", args:{projectRoot}})` 启动整个编码阶段 → 告知"已在后台启动" | **用户手动** `/erp-workflow:coding-start` | +| `full-test` | **全量回归闸**(按需触发)。跑项目 `scripts/test.mjs` 全链(setup-test-db → build → lint → 后端单测+集成 + 前端 vitest → **全量 e2e**(显式清空 `ERP_E2E_SCOPE`)→ 绿则晋升迁移 → 删副本),出 `docs/superpowers/module-reports/full-regression-<日期>.md` 证据报告并提交。存在的理由:`coding-start` 缺省 affected 增量模式只跑本轮改动关联的 e2e(省时),**跨功能连带回归的网就是本命令**——一批需求做完 / 合并前 / 改过共享组件·router·全局状态·API 契约·DB 迁移时手动跑。只跑测试、不改源码、红了不修。 | **用户手动** `/erp-workflow:full-test` | | `add-req` | **增量需求入口**(Plan 完结后追加 / 修改需求时用)。`node lib/req-ledger.mjs scan` 用内容哈希台账(`.req-ledger.json`)识别 docs/01 REQ 卡片 + docs/08 §三 FE 行的**新增 / 变更**;只对增量做 **A3-delta**(写新 `V_n` migration + 同步 docs/03,**绝不改 V1**)+ **A5-delta**(补 docs/05 端点 / docs/02 顺序 / docs/08 模块行 / FE 行),并 `git tag -d` 作废**变更**单元已有的 `req-done/*`(及所属 `milestone/*`,复位 docs/08 里程碑字段),使 coding.mjs Router 只重跑增量。首跑无台账则先建基线。 | **用户手动** `/erp-workflow:add-req` | | `quick-field` | **字段微改快速通道**(已完成需求追加纯展示/录入字段时替代 add-req 全流程)。保 schema SSoT 不缩水(`V_n` ALTER + docs/03 同步 + validate-ddl fail-closed),后端/前端代码由主会话直改(grep 清单 + 轻量编译/lint 自检),台账 `req-ledger commit` 重基线但**绝不作废任何 req-done/milestone tag**——Router 视一切已完成,不重跑 coding.mjs(不冷起栈、不跑测试门/行为门)。代价是字段未过行为验收:登记到 docs/08 §四 待验清单,攒批人工验收。仅限追加式 ADD COLUMN(可空/带 DEFAULT)+ 既有链路多带一个字段;超界即停下回落 `/add-req`。 | **用户手动** `/erp-workflow:quick-field` | | `quick-ui` | **前端小功能快速通道**(quick-field 的姊妹通道,已完成页面追加局部小交互时替代 add-req)。代码由主会话直改 `frontend/`(lint/类型检查 + grep 自检,不起栈、不跑行为门/e2e),可见布局变化同步 `prototype/`、按需更新 docs/08 §三 FE 行文字,台账 `req-ledger commit` 重基线但**绝不作废任何 tag**——前端阶段不重跑。待验条目登记 docs/08 §四。硬边界:纯前端、只消费 docs/05 既有端点、不新增页面/路由/FE 行;超界即停下回落 `/add-req`。 | **用户手动** `/erp-workflow:quick-ui` | diff --git a/skills/coding/coding-start/SKILL.md b/skills/coding/coding-start/SKILL.md index 227143e..32eb61d 100644 --- a/skills/coding/coding-start/SKILL.md +++ b/skills/coding/coding-start/SKILL.md @@ -26,7 +26,8 @@ allowed-tools: Read Glob Workflow Bash(git rev-parse *) Bash(git tag *) approve 即打 req-done 前端行为门 阶段级 behavior(整个前端只跑一次:起全栈验「按钮真生效/文字对/样式合规」, 硬问题可 fix→单测复验→重跑门(≤3 轮),green 才进测试闸) - 前端测试闸 test-gate(全量回归,兜底行为 fix 引入的回归) + 前端测试闸 test-gate(e2e 只跑本轮改动关联用例 = affected 增量模式; + build/lint/单测 仍全量。跨功能全量回归请按需跑 /erp-workflow:full-test) 跨模块记录 → 模块报告 → 里程碑(merge --no-ff + milestone/ tag) 任一模块 halt → fail-fast 停在该模块,修复后重跑本入口即可续跑 @@ -103,6 +104,15 @@ Workflow({ }) ``` +> `testScope` **刻意不传**——缺省即 `"affected"`(增量模式):test-gate 的 **e2e 段**只跑本轮改动关联的用例 +> (前端取本轮待跑 FE 各自 plan 里 `测试先行类型 = e2e` 的 `test_file` 并集;后端模块本轮无待跑 FE 时退到 +> `frontend/e2e/__smoke__/` 冒烟集),载体是 env `ERP_E2E_SCOPE`,由 fe-skeleton 生成的 `playwright.config` +> 消费。**收窄只作用于 e2e**:build / lint / 后端单测+集成 / 前端 vitest 一律仍全量。首跑(全新项目)时 +> "本轮待跑 FE" 天然等于全部 FE,与全量重合——收窄只在 `/add-req` 后的增量重跑里真的生效。 +> **代价明说**:跨功能连带回归(改共享组件 / router / 全局状态 / API 契约 / DB 迁移打破了没动过的功能) +> 在增量轮里不再有网,由用户按需跑 `/erp-workflow:full-test` 兜底。**逃生口**:传 `testScope: "full"` +> 即回到每轮全量回归的现行行为。防假绿:范围推导错导致 e2e 匹配 0 条时,编排层硬判 red(`guardEmptyE2e`)。 + > `pluginRoot` 供 coding.mjs 调用插件 `lib/req-ledger.mjs` 在首跑时自动建立需求台账基线(P1#3,使日后 `/add-req` 能识别增量、免空跑);缺省则该增强静默跳过。 > > `parallel` 是模块级并行总开关:`modules: true` 时 coding.mjs 先跑调度器(LLM 只输出模块间依赖边,JS 做确定性拓扑波次),同波次 ≥2 个后端模块各自在 lane worktree + lane 测试库 + 专属 migration 版本段内并行推进;任何调度/隔离环节失败一律 fail-open 降级串行,绝不因并行机制 halt。**逃生口**:改传 `parallel: { modules: false }`(或整个不传 `parallel`)即完全回退现行串行路径。 diff --git a/skills/coding/full-test/SKILL.md b/skills/coding/full-test/SKILL.md new file mode 100644 index 0000000..ab3a239 --- /dev/null +++ b/skills/coding/full-test/SKILL.md @@ -0,0 +1,143 @@ +--- +name: full-test +description: 全量回归入口——按需跑一次完整测试闸(build + lint + 后端单测/集成 + 前端 vitest + **全量 e2e**),出证据报告并提交。coding-start 的增量模式只跑本轮改动关联的 e2e(省时),跨功能连带回归靠本命令兜底:改完一批需求、合并前、发布前、或怀疑历史功能被打破时手动运行。只跑测试、不改任何源码;红了只报告不修复。 +user-invocable: true +allowed-tools: Read Write Edit Glob Grep Bash(node *) Bash(git *) Bash(ls *) Bash(cat *) Bash(sleep *) Bash(pnpm *) Bash(npm *) Bash(npx *) +--- + +**所有输出必须使用中文。** + +# full-test — 全量回归闸(按需触发) + +`/erp-workflow:coding-start` 缺省跑 **affected 增量模式**:test-gate 的 e2e 段只覆盖本轮改动关联的用例, +其余 e2e 不跑(`testScope: "affected"`,见 `workflows/coding.mjs` 顶部 `TEST_SCOPE`)。 +这是为省时刻意留的缺口——**跨功能连带回归由本命令兜底**。 + +**什么时候该跑:** 一批需求做完准备合并 / 发布前 / 改过共享组件·router·全局状态·API 契约·DB 迁移 / +怀疑某个没动过的功能被打破了 / 距上次全量回归已过去若干轮增量。 + +**本命令不做的事:** 不改任何源码、不修红灯、不打 tag、不合并分支、不动 `docs/08`。红了只出报告。 + +## 步骤 0:打印横幅 + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + [full-test] 全量回归闸 + + 跑 scripts/test.mjs 完整链: + setup-test-db(复制源库→一次性副本) + → build(前后端)→ lint(前后端) + → 单测 + 集成(后端 + 前端 vitest) + → e2e(**全量** playwright,不设 ERP_E2E_SCOPE) + → 绿则晋升本轮新迁移到源库 → 无论红绿删副本 + + 只跑测试、不改源码。预计耗时较长(含冷起全栈),跑完出证据报告。 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## 步骤 1:前置校验(任一不满足即停下,不启动测试) + +1. **取 projectRoot**:`Bash(git rev-parse --show-toplevel)`,stdout 记为 `projectRoot`(绝对路径)。失败 → 缺口「当前目录不在 git 仓库内」。 +2. **闸门脚本存在**:`Glob` `scripts/test.mjs`。不存在 → 缺口「项目缺 scripts/test.mjs,先跑 /erp-workflow:plan-start 生成骨架」。 +3. **工作树干净**:`Bash(git status --porcelain)` 无输出。有输出 → 缺口「工作树不干净:<摘要>」。 + > 为什么是硬门:`test.mjs` 绿后会把副本上验证过的新迁移 **晋升到源库**(`promote-to-source.mjs`)。 + > 带着未提交改动跑,等于让一份没进版本库的状态去改源库 schema,事后无法复现是哪次改动导致的。 +4. **不在跑其它闸门**:`Glob` `.tmp/behavior-gate/` / `.tmp/seed-gen/` 下是否有本次之外的活动痕迹,并确认没有正在运行的 `coding.mjs`。有 → 提示用户「检测到可能正在运行的编码流程,全量回归会冷起同端口的栈、互相击杀,请等它结束再跑」并停下。 + +任一缺口 → 输出拦截横幅(逐条列缺口 + 处理位置),停下,末尾附 `[ERP-HALT]`。 + +## 步骤 2:确认 e2e 范围开关不会误伤 + +`Grep` `frontend/playwright.config.*` 是否含 `ERP_E2E_SCOPE`: + +- **含** → 本命令**必须显式清空**该变量再跑(见步骤 3),保证跑的是全量而不是继承了外壳里的残留值。 +- **不含** → 说明该项目的 config 还没接范围开关(存量项目),此时 e2e 本来就恒为全量,直接跑即可;在报告里注记一句即可,不必修 config(补齐由 coding.mjs 的 fe-skeleton stage 负责)。 + +## 步骤 3:跑闸门(后台子进程 + 轮询,绝不在主推理流里同步跑) + +`scripts/test.mjs` 动辄几十分钟,**必须**派**隔离后台子进程**跑,主会话只轮询、只读回结构化摘要: + +1. 建日志目录 `.tmp/full-test/`(`Bash(node -e "...")` 用 `fs.mkdirSync(..., {recursive:true})`,跨平台)。 +2. 用 `Bash` 的 `run_in_background` 起 detached 子进程,**显式清空** `ERP_E2E_SCOPE`,输出重定向到 `.tmp/full-test/run.log`,退出码写 `.tmp/full-test/exit`: + + ``` + ERP_E2E_SCOPE= node scripts/test.mjs > .tmp/full-test/run.log 2>&1; echo $? > .tmp/full-test/exit + ``` + + > Windows/PowerShell 环境下改用 `node -e` 起一个 `spawn` 包装(`env: {...process.env, ERP_E2E_SCOPE: ''}`), + > 效果等价——关键是**子进程 env 里 `ERP_E2E_SCOPE` 为空**,且日志落盘、退出码落盘。 +3. **在同一回合内轮询到终态**:反复 `Bash` 调用(每轮 `sleep 60`~`120` 后读 `.tmp/full-test/exit` 是否存在 + 读 `run.log` 尾部若干行),多少轮都行。 + **绝不**用 Monitor / ScheduleWakeup / 「装个监听器然后结束回合等通知」——结束回合就等于宣告完成。 +4. 每轮向用户播报一行当前进度(从 `run.log` 里 `[test.mjs] N/5 <阶段>` 行取),让长跑可见。 +5. 拿到 exit 码后,从 `run.log` **只**提取结构化摘要(**不要**把全文灌进上下文): + - 各阶段(build / lint / 单测 / e2e)的通过与否; + - 失败用例名 + 断言摘要(≤30 行); + - e2e 实跑条数(playwright 的 `N passed` 行)——**0 条即异常**,说明范围开关被误设,按红处理。 + +## 步骤 4:证据报告 + 提交 + +写 `docs/superpowers/module-reports/full-regression-.md`(同日多次跑加 `-r` 后缀,**绝不覆盖**前一次): + +```markdown +# 全量回归报告 [-r] + +- 触发:/erp-workflow:full-test(人工按需) +- 分支 / HEAD: @ <短哈希> +- 命令:ERP_E2E_SCOPE=(空,全量) node scripts/test.mjs +- 结论:GREEN / RED +- e2e 实跑条数:(0 条 = 范围开关误设,按 RED 处理) +- 各阶段:build / lint / 后端单测集成 / 前端 vitest / e2e +- 迁移晋升:<已晋升 V... / 无新迁移 / 未晋升(红)> + +## 失败摘要(RED 时必填) +<失败用例 + 断言摘要,≤30 行> + +## 距上次全量回归的增量 +..HEAD 摘要;首次跑写「首次」> +``` + +`Bash(git add <报告> && git commit -m "docs(full-test): 全量回归报告 <日期> ")`。 +commit 失败 → 报告已落盘即可,把 stderr 摘要告诉用户,不 halt。 + +## 步骤 5:输出结论横幅 + +绿: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + [full-test] ✅ 全量回归 GREEN + + e2e 实跑: 条(全量) + 报告:docs/superpowers/module-reports/full-regression-<日期>.md + 迁移晋升:<已晋升 V... / 无新迁移> + + [ERP-HALT] 全量回归通过。 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +红: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + [full-test] ⛔ 全量回归 RED + + 失败阶段: + 失败用例:<逐条列出,不截断关键断言> + 报告:docs/superpowers/module-reports/full-regression-<日期>.md + + 这些失败**不在**最近几轮增量的 e2e 覆盖范围内——属于跨功能连带回归。 + 下一步:把失败功能作为变更单元跑 /erp-workflow:add-req 重新走编码, + 或直接修复后再跑一次 /erp-workflow:full-test。 + [ERP-HALT] 已停下,本命令不修复。 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +本 skill 到此结束,**不调用任何下游 skill**、**不自动修复**。 + +## 参考 + +- `scripts/test.mjs`(项目内闸门脚本:setup→build→lint→test→e2e→promote→drop 全时序) +- `frontend/playwright.config.*`(`ERP_E2E_SCOPE` 范围开关的消费端,由 coding.mjs 的 fe-skeleton stage 生成/补齐) +- `workflows/coding.mjs` 顶部 `TEST_SCOPE`(增量模式定义与收窄边界) +- `coding-start`(编码入口,缺省 affected 增量模式;传 `testScope: "full"` 可让它自己跑全量) +- `add-req`(增量需求入口,本命令报出的连带回归从这里回流) diff --git a/skills/plan/add-req/SKILL.md b/skills/plan/add-req/SKILL.md index de53291..232c0c0 100644 --- a/skills/plan/add-req/SKILL.md +++ b/skills/plan/add-req/SKILL.md @@ -166,6 +166,8 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan 台账已更新并随全部增量产物 git 提交(工作树干净)。 运行 /erp-workflow:coding-start 增量编码(Router 只跑缺 tag 的模块/功能)。 + 注:增量编码的 test-gate 只跑本轮改动关联的 e2e(省时);跨功能连带回归 + 请在一批需求做完 / 合并前跑 /erp-workflow:full-test 兜一次全量。 [ERP-HALT] 增量处理完成,已停下(不自动进编码)。 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` diff --git a/workflows/coding.mjs b/workflows/coding.mjs index fce8e75..ca56d4a 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -13,7 +13,8 @@ export const meta = { ], // 注:'Behavior' = 阶段级行为验收门(v3)——整个前端阶段在 featureLoop 全部 FE 完成后只跑**一次** // 行为验收(含 fix 循环),不再在每个 FE 的 review 循环内做 approve 子门(v2 per-FE 形态已撤销)。 - // 时序:featureLoop(frontend) → Behavior(行为门+fix)→ Gate(testGate 全量回归,兜底行为 fix 引入的回归)。 + // 时序:featureLoop(frontend) → Behavior(行为门+fix)→ Gate(testGate,兜底行为 fix 引入的回归; + // e2e 宽度见 TEST_SCOPE:缺省 affected 只跑本轮改动关联用例,跨功能全量回归归 /erp-workflow:full-test)。 // 注:'Preview' = 静态原型渲染门——前端段开头一次性渲染 prototype/**/*.html 本身(Playwright headless // 截图归档 + 原型自带交互点击冒烟)。与 Behavior 正交:Preview 测**静态原型**、Behavior 测**实现**。 // 硬门:原型渲染失败 / 脚本未捕获异常(jsErrors) → halt(原型属上游 plan 权威,coding 不改原型源码, @@ -83,9 +84,14 @@ const ADJUDICATE_SCHEMA = { type:'object', additionalProperties:false, guidance:{type:'string'}, rationale:{type:'string'} } } +// e2eScope / e2ePassed:本次 e2e 段实际跑了什么范围、匹配到多少条用例。 +// 存在的理由是**防假绿**:增量模式把 e2e 收窄成 env 过滤后,范围推导错误的表现是"0 条用例、exit 0", +// 与真绿不可区分。故让子代理据实回报条数,JS 侧硬判 0 条即 red(见 testGate),不给沉默通过留口子。 const GATE_SCHEMA = { type:'object', additionalProperties:false, required:['status'], properties:{ status:{type:'string',enum:['green','red']}, - failures:{type:'array',items:{type:'string'}} } } + failures:{type:'array',items:{type:'string'}}, + e2eScope:{type:'string'}, + e2ePassed:{type:'integer'} } } // BEHAVIOR_GATE_SCHEMA:前端行为门(阶段级,frontend-phase 末尾一次)返回。 // 不杂交 GATE×STAGE_RESULT——复用既有词汇但独立成型:交互层 / 文字层 / 样式层 / 覆盖率 / 环境错误分别结构化, @@ -287,6 +293,18 @@ if (ROOT === '.' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) { // 缺省(旧 coding-start 未传)则相关增强静默跳过——由 /add-req 首跑兜底建基线。 const PLUGIN = ARGS?.pluginRoot || '' +// 测试范围(testScope):test-gate 的 **e2e 段**跑多宽。缺省 'affected'。 +// - 'affected'(缺省):只跑本轮改动关联的 e2e——前端阶段取本轮 feItems 各自 plan 里 +// `测试先行类型 = e2e` 的 test_file 并集;后端模块(本轮无前端改动)退到冒烟集。 +// - 'full':现行全量回归。 +// **收窄只作用于 e2e 段**:build / lint / 后端单测+集成 / 前端 vitest 一律仍是全量——它们是分钟级的, +// 却是编译期与逻辑回归的第一道网,砍掉省不下多少时间反而漏掉最常见的连带破坏。 +// 载体是 env `ERP_E2E_SCOPE`(fe-skeleton 生成的 playwright.config 消费,缺省不过滤)。**绝不**改 +// `scripts/test.mjs` 的调用形态——它的 setup→build→lint→test→e2e→promote→drop 时序是迁移晋升链路的 +// 契约(且模板改动不落已生成项目,见 preflight 硬门),加 env 是唯一既生效又不动安全边界的接口。 +// 全量回归改为按需触发:`/erp-workflow:full-test`。 +const TEST_SCOPE = ARGS?.testScope === 'full' ? 'full' : 'affected' + // 模块执行上下文:串行/主根模式 root=ROOT、lane=null;并行 lane 模式由 Phase C/D 填充。 // 所有"模块作用域"的 prompt builder 与 runner 一律经 C 取根,不再直接闭包 ROOT。 // ROOT 仍保留:仅供"主根专属"操作(milestone merge / docs/08 字段 / RESUME / ledger / preflight)使用。 @@ -743,10 +761,16 @@ function fixPrompt(id, phase, issues, c) { // ---- 测试闸(原 test-gate)---- // attempt:1 = 首次跑;2 = 上轮 red 后的 flake 重试。每次 attempt 写到独立证据文件,避免 retry // 把首次 red 证据覆盖掉(report § ⑤ 失去 flake 信号)。 +// e2e 范围见 TEST_SCOPE 注释:affected(缺省)只收窄 **e2e 段**,其余全量不动。 +// 首跑(全新项目)时"本轮 feItems"天然等于全部 FE,affected 与 full 的 e2e 范围重合—— +// 收窄只在增量重跑(/add-req 后只有少数 FE 待跑)时才真的生效,这正是它存在的场景。 function gatePrompt(module, phase, attempt = 1, c) { const fe = isFrontend(phase) const id = module?.id ?? '' const phaseId = fe ? 'frontend-phase' : id + const affected = TEST_SCOPE !== 'full' + const feItems = (module?.feItems) || [] + const feList = feItems.map(x => `\`${x}\``).join(', ') return [ `# test-gate — ${fe ? '前端阶段' : `模块 ${id}`} 硬测试闸(phase=${phase}, attempt=${attempt})`, '', @@ -758,8 +782,30 @@ function gatePrompt(module, phase, attempt = 1, c) { '', '## 命令', fe - ? `- 前端:命令从 \`${c.root}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.e2e_command\` 拼接(缺失则 \`pnpm test:ci && pnpm e2e:ci\`),跑 vitest + playwright(含全 FE 回归)。**收尾(无论红绿)**:测试跑完后执行 \`node ${c.root}/scripts/drop-test-db.mjs\` 删测试副本(前端阶段不走 test.mjs,副本清理落在本门;源库不动)。` - : `- 后端:跑 \`${c.root}/scripts/test.mjs\`(跨平台 Node 测试入口;含本模块新增 + 已合并模块回归;其内部自带 复制副本→测试→绿后晋升→删副本 全时序)。`, + ? `- 前端:命令从 \`${c.root}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.e2e_command\` 拼接(缺失则 \`pnpm test:ci && pnpm e2e:ci\`),跑 vitest + playwright。**收尾(无论红绿)**:测试跑完后执行 \`node ${c.root}/scripts/drop-test-db.mjs\` 删测试副本(前端阶段不走 test.mjs,副本清理落在本门;源库不动)。` + : `- 后端:跑 \`${c.root}/scripts/test.mjs\`(跨平台 Node 测试入口;含本模块新增 + 已合并模块回归;其内部自带 复制副本→测试→绿后晋升→删副本 全时序)。**命令形态一字不改**——不传参、不拆步骤、不跳过其中任何一步(那套时序是迁移晋升链路的契约)。`, + // e2e 范围收窄(增量省时):唯一接口是 env ERP_E2E_SCOPE,由 fe-skeleton 生成的 playwright.config 消费。 + // 之所以不改命令本身:后端走 test.mjs(内部固定调 {{e2e_cmd}},改不到),前端命令来自 docs/04 约定; + // env 则天然经 spawnSync 继承打穿到 playwright 进程,两条路径同一个开关,且旧项目补齐 config 即可生效。 + ...(affected ? [ + '', + '### e2e 范围(本次 = **affected 增量模式**,非全量回归)', + '- **只收窄 e2e 段**:build / lint / 后端单测+集成 / 前端 vitest 一律照常**全量**跑,一条都不许跳。', + fe && feItems.length + ? [ + `- 本轮待跑 FE:${feList}。逐个 Read 其 plan(\`${c.root}/docs/superpowers/plans/<日期>-.md\`,同一 FE 多份取最新日期),取出所有 \`测试先行类型 = e2e\` 任务的 \`test_file\`,去重后得到本次 e2e 范围。`, + `- 把它们相对 \`frontend/e2e/\` 的路径片段用逗号连成一串,作为 env \`ERP_E2E_SCOPE\` 前置到 e2e 命令上(例:\`ERP_E2E_SCOPE=order/create.spec.ts,order/list.spec.ts pnpm e2e:ci\`;Windows 下用 \`cross-env\` 或在派发的子进程 env 里设,**不要**写成 PowerShell 语法而在 sh 里跑)。`, + '- 一个 test_file 都取不到(plan 缺失 / 无 e2e 任务)→ 退到 `ERP_E2E_SCOPE=smoke`(骨架期生成的冒烟集),**不要**因此改成全量、也不要跳过 e2e 段。', + ].join('\n') + : `- 本模块本轮无待跑 FE(后端模块 / 本轮无前端改动),e2e 范围退到冒烟集:\`ERP_E2E_SCOPE=smoke\`(骨架期生成的 \`frontend/e2e/__smoke__/\`,验"栈起得来 + 首页可达 + 登录态有效")。**不要**跳过 e2e 段——它同时是起栈健康检查。`, + '- **禁止空跑**:e2e 段匹配到 **0 条**用例即视为**范围推导错误**,按 `red` 返回(`failures` 写明推导出的范围与来源),**绝不**当成绿。这是收窄唯一真正的风险,宁可报错重来。', + `- 证据里必须逐字写一行 \`e2eScope: <你实际用的 ERP_E2E_SCOPE 值>\`,并在返回里回填 \`e2eScope\` / \`e2ePassed\`(e2e 段通过的用例条数)。`, + '- **全量回归不在本门**:本次跳过的那些 e2e 由用户按需跑 `/erp-workflow:full-test` 兜底,你**不需要**、也**不许**为"回归覆盖不足"halt 或改回全量。', + ] : [ + '', + '### e2e 范围(本次 = **full 全量回归**)', + '- 不设 `ERP_E2E_SCOPE`(或显式置空),playwright 跑全部 spec。返回里 `e2eScope` 填 `full`。', + ]), '- 子会话只返回结构化 JSON:`{command, exit_code, passed, failed, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行含 FAIL 摘要)。', // 补 9:testGate 的 e2e(Playwright globalSetup 按固定端口冷起后端)与 Seed 同属起栈类 stage—— // 整段由编排层包进全局起栈互斥(stackLock,Phase D 定义);lane 测试库硬约束已由 featureStageContract 注入。 @@ -773,6 +819,7 @@ function gatePrompt(module, phase, attempt = 1, c) { '', '## 输出(必须符合下发的 GATE JSON schema)', '- `status`: `green`(`exit_code = 0` 且 `failed = 0`)| `red`;`failures`: 失败用例摘要(green 时可省略 / 空数组)。', + `- \`e2eScope\`: 本次 e2e 实际范围(${affected ? '你算出的 ERP_E2E_SCOPE 值,如 `order/create.spec.ts,...` 或 `smoke`' : '`full`'});\`e2ePassed\`: e2e 段通过条数(**据实填**,编排层据此判空跑)。`, '- 不要返回额外字段。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。', ].filter(Boolean).join('\n') } @@ -1173,7 +1220,7 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { // behaviorReverifyPrompt:阶段级行为 fix 后的功能复验。fix 改的是 frontend/ UI 源码,可能引入功能回归—— // 在下一 behaviorRound 重起全栈之前,先派子会话跑**全量前端单测**(vitest,不跑 e2e——e2e/行为维度由下一轮 -// 行为门重跑 + 阶段 testGate 全量回归兜底),红则当功能回归硬边界(调用方 allowContinue:false)。 +// 行为门重跑 + 阶段 testGate 兜底),红则当功能回归硬边界(调用方 allowContinue:false)。 function behaviorReverifyPrompt(behaviorRound, fixedCount, c) { const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-reverify-r${behaviorRound}.md` return [ @@ -1257,7 +1304,17 @@ function frontendSkeletonPrompt(feItems, c) { // 晋升链路断且无声。副本清理归各闸门的 finally(test.mjs / seed runner / 行为门 runner)在 promote 后做; // tdd 内循环单独跑 e2e 时副本留存到下次 globalSetup 重新复制,无害。 ' - **globalTeardown**(如 `frontend/e2e/global-teardown.*`):kill globalSetup 起的后端进程树(**不**删副本——副本清理由外层闸门在晋升后负责)。', - ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐;**已存在但缺 `webServer` 就绪门(或用了无就绪门的并行起法)→ 必须补齐/改正**,这是已知会造成整轮 e2e 假红的缺陷,不属于"已存在就不动"的范围。', + // e2e 范围开关(增量省时的落点):编排层唯一能打穿到 playwright 进程的接口就是 env—— + // 后端 test-gate 走 scripts/test.mjs,内部固定调 {{e2e_cmd}},命令行参数加不进去;而 env 经 + // spawnSync 天然继承。故把过滤逻辑放进**由本 stage 生成的 config**里,前后端两条路径同一个开关。 + // 缺省(env 未设)必须是全量——任何"默认收窄"都会让不知情的人拿到一份假绿。 + ' - **e2e 范围开关 `ERP_E2E_SCOPE`(必须实现,增量模式靠它省时)**:在同一份配置里读 `process.env.ERP_E2E_SCOPE`——', + ' - 未设 / 空串 → **不过滤,跑全部 spec**(缺省即全量,绝不能反过来)。', + ' - 值为 `smoke` → 只匹配 `e2e/__smoke__/` 下的 spec。', + ' - 其它值 → 按逗号切分成路径片段列表,只匹配**路径含其中任一片段**的 spec(如 `order/create.spec.ts,order/list.spec.ts`)。', + ' - 实现用 `testMatch`(接受正则数组)或等价过滤;片段先做正则转义再拼,**绝不**把用户值当正则直接塞进去。', + ' - **冒烟集 `frontend/e2e/__smoke__/app-smoke.spec.*`(必须生成)**:3 条以内的最小用例——① 带 `storageState` 打开 baseURL 首页,断言应用根节点已挂载(非空白、无未捕获错误);② 访问 router 里**已指向真组件**的任一路由,断言其可达且未渲染 `data-fe-stub`;③ 断言登录态有效(如页面出现仅登录后可见的导航元素)。它是增量模式下后端 test-gate 的 e2e 段唯一会跑的东西,职责是「栈起得来 + 应用没整体炸」,**不是**业务断言,故必须与具体业务功能解耦、不随 FE 增减而失效。', + ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐;**已存在但缺 `webServer` 就绪门(或用了无就绪门的并行起法)、缺 `ERP_E2E_SCOPE` 支持、缺冒烟集 → 必须补齐/改正**,这些都是已知会造成整轮 e2e 假红或增量模式失效的缺陷,不属于"已存在就不动"的范围。', '6. **单测基线(测试隔离接线点)**:vitest 配置(按 docs/04 § 零 `frontend.unit_test_runner` 约定,如 `frontend/vitest.config.*` 或 package.json 内配置段)`include` **限定** `tests/**/*.test.*`——单测一律落 `frontend/tests/**`(镜像 `src/` 结构,smoke 类归 `tests/__smoke__/` 且文件名同样以 `.test.*` 结尾,见 docs/04 § 2.1),`frontend/src/` 内的测试残留不被执行(约定漂移立即可见)。不存在才创建,已存在则只补齐 include 限定。**legacy 守卫**:若 `frontend/src/` 内已存在测试文件(旧约定 colocation 残留),**绝不**收窄 include(否则旧单测静默停跑、回归覆盖丢失)——保持现有 include 不动,登记 `decisions[]` 提示人工迁移(src/ 内测试迁至 tests/ 镜像路径后方可收窄)。', '- **lazy 硬护栏**:router 表里**任何** FE 路由都不得用顶部静态 `import`;必须 `() => import(...)`。自检:Grep 路由文件,确认每个 FE 路由的 `component` 都是动态 import 形态。', '- **路径硬护栏**:所有产出文件必须以 `frontend/` 开头;命中 `backend/` / `sql/` / `scripts/` → 越界硬停。', @@ -1289,11 +1346,14 @@ function frontendSkeletonStatePromptM(feItems, c) { '', `用 Grep / Read 检查 \`${c.root}/frontend/\`:是否已存在 router 配置文件,且其中**本轮待实现的 FE 路由**(对应 FE:${list})都已声明、全部为 lazy import(\`() => import(...)\`),占位组件 \`FeStub\`(\`frontend/src/views/_stub/FeStub.*\`)存在,**且 e2e 基线脚手架存在**——Playwright 配置文件(\`frontend/playwright.config.*\`)+ globalSetup 文件(如 \`frontend/e2e/global-setup.*\`)+ **前端 dev server 就绪门**(配置内有 \`webServer\` 段且带 \`url\`,或 globalSetup 内有显式轮询前端端口至可连的逻辑),**且单测基线存在**——vitest 配置 \`include\` 限定 \`tests/**/*.test.*\`。`, '- **就绪门判据从严**:只要找不到 `webServer.url`、也找不到 globalSetup 内对前端端口的显式轮询(哪怕 e2e 目前跑得通)→ 判 `exists:false`。这是已知会让整轮 e2e 假红 40+ 分钟的竞态缺陷,值得多跑一次骨架 stage 去补。', + // 存量项目的 config 是在这两项存在之前生成的,必须靠本判据把它们拽回来补齐—— + // 否则 test-gate 设了 ERP_E2E_SCOPE 也没人消费,静默退化成全量(不假绿,但增量省时完全失效)。 + `- **e2e 范围开关判据**:Grep \`${c.root}/frontend/playwright.config.*\` 是否含 \`ERP_E2E_SCOPE\`,且 \`${c.root}/frontend/e2e/__smoke__/\` 下存在至少一个 spec 文件。任一缺失 → 判 \`exists:false\`(存量项目普遍缺这两项,需要重跑骨架 stage 补齐)。`, '- 判据**只针对上面列出的 FE**:router 里其它 FE 路由(往轮已实现,指向真组件)一律视为满足,**不要**因为它们没指向 `FeStub` 就判缺失。', '- 上面列出的 FE,其路由若已指向**真组件**(resume:上次已实现到一半)同样视为满足——本检测只问"路由在不在、是不是 lazy",不问指向谁。', `- **legacy 豁免**:若 \`${c.root}/frontend/src/\` 内已存在测试文件(\`*.test.*\` / \`*.spec.*\`,旧约定 colocation 残留),vitest include 项**不作为缺失判据**(视为满足)——该状态须人工迁移决策,绝不由骨架重跑静默收窄 include 停跑旧测试。`, '- 全部满足(骨架已建齐,含 e2e 基线脚手架 + 单测基线)→ `{ "exists": true }`', - '- 任一缺失(无 router / 缺上面某个待实现 FE 的路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup / **缺前端 dev server 就绪门** / vitest include 未限定 tests/**/*.test.*(且非 legacy 豁免))→ `{ "exists": false }`', + '- 任一缺失(无 router / 缺上面某个待实现 FE 的路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup / **缺前端 dev server 就绪门** / **缺 `ERP_E2E_SCOPE` 支持或冒烟集** / vitest include 未限定 tests/**/*.test.*(且非 legacy 豁免))→ `{ "exists": false }`', '## 输出(EXISTS_SCHEMA)', ].join('\n') } @@ -2386,6 +2446,9 @@ function reportPrompt(module, c, facts = {}) { `- 上游 test-gate 已 green(编排层判定:共 ${gAttempts} 个 attempt,末次 \`${gateEvidence}\` = green)。红态根本走不到本步——testGate 会先 halt,故**不需要**你重新校验绿不绿。`, `- 只需 Read 末次证据 \`${c.root}/docs/superpowers/module-reports/${gateEvidence}\` 取 § ⑤ 要用的摘要${gateFlake ? `;另 Read 首次 \`${phaseId}-test-gate-r1.md\`(attempt>1 = flake,§ ⑤ 需要首次失败用例)` : '(单 attempt,无 flake,无需读其它 attempt 文件)'}。该文件缺失 → halt(证据链断裂)。`, gateFlake ? `- **flake 标注(编排层已判定)**:attempt 数 = ${gAttempts} 且末次 green → § ⑤ 顶部必须写 \`flake-detected: r1 red, r${gAttempts} green\`,并附首次失败用例与末次绿色记录链接。` : '', + // 报告必须说清"这次到底测了多宽"——增量模式下 e2e 只覆盖本轮改动,报告若仍写"全量回归通过" + // 就是伪造覆盖面。全量回归改由用户按需跑 /erp-workflow:full-test,报告只如实记录本次范围。 + `- **本次 e2e 范围(编排层给定,如实写进 § ⑤,不要拔高成"全量回归")**:\`${gate.e2eScope || (TEST_SCOPE === 'full' ? 'full' : '(子代理未回填,按证据文件里的 e2eScope 行为准)')}\`${TEST_SCOPE === 'full' ? '(全量)' : `(增量模式:只跑本轮改动关联的 e2e${Number.isInteger(gate.e2ePassed) ? `,通过 ${gate.e2ePassed} 条` : ''};未覆盖部分由 \`/erp-workflow:full-test\` 按需兜底)`}。`, fe ? `- 阶段级行为门已 green(编排层判定:behaviorRound=${bhRounds},末次证据 \`${bhEvidence}\`,routesPlanned/Reached=${bh.routesPlanned ?? '?'}/${bh.routesReached ?? '?'},controlsEnumerated=${bh.controlsEnumerated ?? '?'},authState=${bh.authState || '?'})。同样**不要**再 Glob 全部 \`frontend-phase-behavior-r*-a*.md\` 自证——只 Read 末次那一份取 § ⑤/§ ⑧ 需要的逐 FE 小节${bhRounds > 1 ? `;另 Read \`frontend-phase-behavior-reverify-r*.md\`(有 ${bhRounds - 1} 轮行为 fix,§ ⑤ 要记录 fix 轮数与复验结果)` : '(一轮即绿,无 fix、无复验文件)'}。` : '', @@ -3132,15 +3195,26 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath, c, { flipDoc // flake 重试:每个 attempt 写独立证据文件 `-test-gate-r.md`,不覆盖前一次 red 证据(report § ⑤ 用得到)。 // red 是硬正确性边界——**绝不** continue 跳过;只让仲裁在"再跑一次辨 flake"与"确属真失败 → halt"间裁决。 +// 空跑守卫(affected 模式专用):e2e 收窄的唯一真风险是"范围推导错 → 0 条用例 → exit 0", +// 表现与真绿完全一致。子代理侧已被要求禁止空跑,但那是自律;这里再做一次**编排层硬判**—— +// green 且 e2ePassed 明确为 0 一律翻成 red,走既有 retry/仲裁链(重跑一次通常会带出正确范围)。 +// e2ePassed 缺席(旧行为 / 未回填)不翻——只惩罚"明确说了 0 条",不猜。 +function guardEmptyE2e(g) { + if (TEST_SCOPE === 'full') return g + if (g.status !== 'green' || g.e2ePassed !== 0) return g + return { ...g, status:'red', + failures: [...(g.failures || []), `e2e 收窄范围匹配到 0 条用例(e2eScope="${g.e2eScope || '?'}")——空跑不算绿,范围推导有误`] } +} + async function testGate(module, phase, c) { let attempt = 1 const site = `gate:${phase}:${module.id}` - etaLog(site, 1, '全量回归 + 冷起栈') - let g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) + etaLog(site, 1, TEST_SCOPE === 'full' ? '全量回归 + 冷起栈' : '增量回归(e2e 收窄)+ 冷起栈') + let g = guardEmptyE2e(await agentR(gatePrompt(module, phase, attempt, c), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})) if (g.status === 'red') { // 自动重试 1 次(防 flaky) attempt = 2 etaLog(site, 2, 'red → 防 flaky 自动重跑') - g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) + g = guardEmptyE2e(await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})) } // 仍 red:经仲裁辨识 flake。allowContinue:false → 红色不可跳过,仲裁只在 retry / halt 间选。 for (let adj = 1; g.status === 'red' && adj <= ADJUDICATE_MAX; adj++) { @@ -3150,7 +3224,7 @@ async function testGate(module, phase, c) { throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${verdict.rationale || (g.failures||[]).join('; ')}`) attempt += 1 // retry:再跑一个独立 attempt 证据文件 etaLog(site, attempt, '仲裁判 retry(辨 flake)') - g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA}) + g = guardEmptyE2e(await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA})) } if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${ADJUDICATE_MAX} 轮仲裁后仍 red:${(g.failures||[]).join('; ')}`) // attempts 回传给 module-report:报告不必再 Glob + 通读全部 attempt 证据去自证"最后一份绿", @@ -3747,13 +3821,13 @@ async function runModule(module, c) { await featureLoop(module.feItems, 'frontend', c, module.feStage || 'all') // 阶段级行为门(v3):整个前端阶段只跑一次行为验收——起全栈 + 演示/sentinel 种子,按全部 FE spec 聚合 // 作用域并集验「按钮真生效/文字对」;硬问题转 must-fix→fix→单测复验→重跑门(≤BEHAVIOR_STAGE_MAX 轮)。 - // 放在 testGate 之前:行为 fix 改动 frontend/ 源码,绿后由 testGate 全量回归兜底,不让回归证据过期。 + // 放在 testGate 之前:行为 fix 改动 frontend/ 源码,绿后由 testGate 兜底(单测全量 + 本轮 e2e),不让回归证据过期。 if (c.lane == null) phase('Behavior') // 行为门虽也冷起全栈,但只在 frontend-phase 终波(宽 1、主根)跑——无兄弟 lane 可互杀, // 不进起栈互斥(补 9 范围仅 Seed / testGate)。 reportFacts.behavior = await runBehaviorGate(module.feItems, c) if (c.lane == null) phase('Gate') - // 阶段级 testGate(全量回归 vitest+playwright),与行为门职责正交;起栈互斥(补 9)同后端段整段包锁。 + // 阶段级 testGate(vitest 全量 + playwright 按 TEST_SCOPE 定宽),与行为门职责正交;起栈互斥(补 9)同后端段整段包锁。 reportFacts.gate = await withStackLock(() => testGate(module, 'frontend', c)) } if (c.lane == null) phase('Milestone')