Commit 4dea0e57287187155d0912143a34e5b96419b3a5

Authored by yanghl
1 parent 543ea15a

perf(coding): test-gate 缺省只跑本轮改动关联的 e2e,全量回归拆成 /full-test 按需触发

起因:增量改一个功能仍会跑全量 e2e——用户改的功能与被跑的用例(如打印按钮那条)
毫无关系。实测 test-gate 占全流程墙钟 13.3%,且后端每个模块的 scripts/test.mjs
内部还会各跑一遍全量 playwright。

coding.mjs 新增 args.testScope(缺省 'affected',逃生口 'full'):

- **只收窄 e2e 段**。build / lint / 后端单测+集成 / 前端 vitest 一律仍全量——它们是
  分钟级的,却是编译期与逻辑回归的第一道网,砍掉省不下多少却漏掉最常见的连带破坏。
- 前端阶段 test-gate:范围 = 本轮待跑 FE 各自 plan 里 `测试先行类型 = e2e` 的
  test_file 并集(verify 段早有同款推导,line 659);取不到则退冒烟集。
- 后端模块 test-gate(本轮无 FE 改动):退到 frontend/e2e/__smoke__/ 冒烟集。
  不跳过 e2e 段——它同时是起栈健康检查。
- 首跑(全新项目)时「本轮待跑 FE」天然等于全部 FE,affected 与 full 重合;收窄只在
  /add-req 后的增量重跑里真的生效,这正是它存在的场景。

载体是 env ERP_E2E_SCOPE,**scripts/test.mjs 一个字未动**——它的 setup→build→lint→
test→e2e→promote→drop 是迁移晋升链路的契约,且模板改动不落已生成项目(见 preflight
硬门);env 经 spawnSync 天然继承到 playwright 进程,前后端两条路径同一个开关。
消费端由 fe-skeleton 生成的 playwright.config 实现(未设=全量,绝不默认收窄),
同时生成与业务解耦的冒烟集;state 判据同步加严,存量项目会重跑一次骨架 stage 补齐。

防假绿(收窄的唯一真风险):范围推导错的表现是「0 条用例 + exit 0」,与真绿不可区分。
GATE_SCHEMA 加 e2eScope/e2ePassed,编排层 guardEmptyE2e() 硬判——green 且明确 0 条一律
翻 red 走既有 retry/仲裁链。module-report 被要求如实记录本次范围,禁止拔高成「全量回归」。

新增 /erp-workflow:full-test(skills/coding/full-test):跑 scripts/test.mjs 全链 +
显式清空 ERP_E2E_SCOPE 的全量 e2e,出 full-regression-<日期>.md 证据并提交。
前置硬门:工作树干净(绿后会晋升迁移到源库)、不与正在跑的 coding 流程撞端口。
后台子进程 + 回合内轮询;只跑测试、不改源码、红了只报告不修复。

代价明说:增量轮里跨功能连带回归(改共享组件 / router / 全局状态 / API 契约 / DB 迁移
打破没动过的功能)不再有网,由 /full-test 按需兜底。逃生口 testScope: "full"。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
.claude-plugin/plugin.json
1 1 {
2 2 "name": "erp-workflow",
3   - "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)。",
  3 + "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 按需触发)。",
4 4 "version": "0.3.0",
5 5 "skills": [
6 6 "./skills/plan/plan-start",
... ... @@ -13,6 +13,7 @@
13 13 "./skills/plan/add-req",
14 14 "./skills/plan/quick-field",
15 15 "./skills/plan/quick-ui",
16   - "./skills/coding/coding-start"
  16 + "./skills/coding/coding-start",
  17 + "./skills/coding/full-test"
17 18 ]
18 19 }
... ...
README.md
... ... @@ -70,7 +70,8 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
70 70 按全部 FE spec 聚合的作用域并集枚举路由,交互/文字/样式三层断言;交互失效
71 71 /sentinel 错/样式违规(非 token 色、横向溢出、控件重叠等)转可 fix must-fix
72 72 →fix→单测复验→重跑门(≤3 轮),软文字按来源仲裁,green 才放行)
73   - → testGate(frontend,全量回归 vitest+playwright,兜底行为 fix 引入的回归)
  73 + → testGate(frontend,vitest 全量 + playwright 按 testScope 定宽;缺省 affected 只跑本轮
  74 + 改动关联的 e2e,跨功能全量回归由 /erp-workflow:full-test 按需触发)
74 75 → runMilestone(milestone/frontend-phase)
75 76  
76 77 · 前端 jsdom-only 重叠(可选,parallel.frontendOverlap,缺省关):前端**不依赖后端**的部分
... ... @@ -143,18 +144,20 @@ erp-workflow-plugin/
143 144 │ ├── add-req/ # 增量需求入口(Plan 完结后追加/修改需求,经 req-ledger 哈希台账只跑增量)
144 145 │ ├── quick-field/ # 字段微改快速通道(加列 + 界面呈现:保 SSoT 跳流程,不作废 tag、不重跑 coding)
145 146 │ └── quick-ui/ # 前端小功能快速通道(已有页面局部小交互,只消费既有端点:直改 frontend/,不作废 tag、不重跑 coding)
146   - └── coding/ # 阶段 B:1 个 skill(瘦入口)
147   - └── coding-start/ # 启动 workflows/coding.mjs
  147 + └── coding/ # 阶段 B:2 个 skill
  148 + ├── coding-start/ # 启动 workflows/coding.mjs
  149 + └── full-test/ # 全量回归闸(按需触发,跑 scripts/test.mjs 全链 + 全量 e2e)
148 150 ```
149 151  
150   -## Skill 清单(11 个)
  152 +## Skill 清单(12 个)
151 153  
152   -### 入口(5 个)
  154 +### 入口(6 个)
153 155  
154 156 | Skill | 作用 | 谁调用 |
155 157 |---|---|---|
156 158 | `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` |
157 159 | `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` |
  160 +| `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` |
158 161 | `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` |
159 162 | `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` |
160 163 | `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` |
... ...
skills/coding/coding-start/SKILL.md
... ... @@ -26,7 +26,8 @@ allowed-tools: Read Glob Workflow Bash(git rev-parse *) Bash(git tag *)
26 26 approve 即打 req-done
27 27 前端行为门 阶段级 behavior(整个前端只跑一次:起全栈验「按钮真生效/文字对/样式合规」,
28 28 硬问题可 fix→单测复验→重跑门(≤3 轮),green 才进测试闸)
29   - 前端测试闸 test-gate(全量回归,兜底行为 fix 引入的回归)
  29 + 前端测试闸 test-gate(e2e 只跑本轮改动关联用例 = affected 增量模式;
  30 + build/lint/单测 仍全量。跨功能全量回归请按需跑 /erp-workflow:full-test)
30 31 跨模块记录 → 模块报告 → 里程碑(merge --no-ff + milestone/<id> tag)
31 32 任一模块 halt → fail-fast 停在该模块,修复后重跑本入口即可续跑
32 33  
... ... @@ -103,6 +104,15 @@ Workflow({
103 104 })
104 105 ```
105 106  
  107 +> `testScope` **刻意不传**——缺省即 `"affected"`(增量模式):test-gate 的 **e2e 段**只跑本轮改动关联的用例
  108 +> (前端取本轮待跑 FE 各自 plan 里 `测试先行类型 = e2e` 的 `test_file` 并集;后端模块本轮无待跑 FE 时退到
  109 +> `frontend/e2e/__smoke__/` 冒烟集),载体是 env `ERP_E2E_SCOPE`,由 fe-skeleton 生成的 `playwright.config`
  110 +> 消费。**收窄只作用于 e2e**:build / lint / 后端单测+集成 / 前端 vitest 一律仍全量。首跑(全新项目)时
  111 +> "本轮待跑 FE" 天然等于全部 FE,与全量重合——收窄只在 `/add-req` 后的增量重跑里真的生效。
  112 +> **代价明说**:跨功能连带回归(改共享组件 / router / 全局状态 / API 契约 / DB 迁移打破了没动过的功能)
  113 +> 在增量轮里不再有网,由用户按需跑 `/erp-workflow:full-test` 兜底。**逃生口**:传 `testScope: "full"`
  114 +> 即回到每轮全量回归的现行行为。防假绿:范围推导错导致 e2e 匹配 0 条时,编排层硬判 red(`guardEmptyE2e`)。
  115 +
106 116 > `pluginRoot` 供 coding.mjs 调用插件 `lib/req-ledger.mjs` 在首跑时自动建立需求台账基线(P1#3,使日后 `/add-req` 能识别增量、免空跑);缺省则该增强静默跳过。
107 117 >
108 118 > `parallel` 是模块级并行总开关:`modules: true` 时 coding.mjs 先跑调度器(LLM 只输出模块间依赖边,JS 做确定性拓扑波次),同波次 ≥2 个后端模块各自在 lane worktree + lane 测试库 + 专属 migration 版本段内并行推进;任何调度/隔离环节失败一律 fail-open 降级串行,绝不因并行机制 halt。**逃生口**:改传 `parallel: { modules: false }`(或整个不传 `parallel`)即完全回退现行串行路径。
... ...
skills/coding/full-test/SKILL.md 0 → 100644
  1 +---
  2 +name: full-test
  3 +description: 全量回归入口——按需跑一次完整测试闸(build + lint + 后端单测/集成 + 前端 vitest + **全量 e2e**),出证据报告并提交。coding-start 的增量模式只跑本轮改动关联的 e2e(省时),跨功能连带回归靠本命令兜底:改完一批需求、合并前、发布前、或怀疑历史功能被打破时手动运行。只跑测试、不改任何源码;红了只报告不修复。
  4 +user-invocable: true
  5 +allowed-tools: Read Write Edit Glob Grep Bash(node *) Bash(git *) Bash(ls *) Bash(cat *) Bash(sleep *) Bash(pnpm *) Bash(npm *) Bash(npx *)
  6 +---
  7 +
  8 +**所有输出必须使用中文。**
  9 +
  10 +# full-test — 全量回归闸(按需触发)
  11 +
  12 +`/erp-workflow:coding-start` 缺省跑 **affected 增量模式**:test-gate 的 e2e 段只覆盖本轮改动关联的用例,
  13 +其余 e2e 不跑(`testScope: "affected"`,见 `workflows/coding.mjs` 顶部 `TEST_SCOPE`)。
  14 +这是为省时刻意留的缺口——**跨功能连带回归由本命令兜底**。
  15 +
  16 +**什么时候该跑:** 一批需求做完准备合并 / 发布前 / 改过共享组件·router·全局状态·API 契约·DB 迁移 /
  17 +怀疑某个没动过的功能被打破了 / 距上次全量回归已过去若干轮增量。
  18 +
  19 +**本命令不做的事:** 不改任何源码、不修红灯、不打 tag、不合并分支、不动 `docs/08`。红了只出报告。
  20 +
  21 +## 步骤 0:打印横幅
  22 +
  23 +```
  24 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  25 + [full-test] 全量回归闸
  26 +
  27 + 跑 scripts/test.mjs 完整链:
  28 + setup-test-db(复制源库→一次性副本)
  29 + → build(前后端)→ lint(前后端)
  30 + → 单测 + 集成(后端 + 前端 vitest)
  31 + → e2e(**全量** playwright,不设 ERP_E2E_SCOPE)
  32 + → 绿则晋升本轮新迁移到源库 → 无论红绿删副本
  33 +
  34 + 只跑测试、不改源码。预计耗时较长(含冷起全栈),跑完出证据报告。
  35 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  36 +```
  37 +
  38 +## 步骤 1:前置校验(任一不满足即停下,不启动测试)
  39 +
  40 +1. **取 projectRoot**:`Bash(git rev-parse --show-toplevel)`,stdout 记为 `projectRoot`(绝对路径)。失败 → 缺口「当前目录不在 git 仓库内」。
  41 +2. **闸门脚本存在**:`Glob` `scripts/test.mjs`。不存在 → 缺口「项目缺 scripts/test.mjs,先跑 /erp-workflow:plan-start 生成骨架」。
  42 +3. **工作树干净**:`Bash(git status --porcelain)` 无输出。有输出 → 缺口「工作树不干净:<摘要>」。
  43 + > 为什么是硬门:`test.mjs` 绿后会把副本上验证过的新迁移 **晋升到源库**(`promote-to-source.mjs`)。
  44 + > 带着未提交改动跑,等于让一份没进版本库的状态去改源库 schema,事后无法复现是哪次改动导致的。
  45 +4. **不在跑其它闸门**:`Glob` `.tmp/behavior-gate/` / `.tmp/seed-gen/` 下是否有本次之外的活动痕迹,并确认没有正在运行的 `coding.mjs`。有 → 提示用户「检测到可能正在运行的编码流程,全量回归会冷起同端口的栈、互相击杀,请等它结束再跑」并停下。
  46 +
  47 +任一缺口 → 输出拦截横幅(逐条列缺口 + 处理位置),停下,末尾附 `[ERP-HALT]`。
  48 +
  49 +## 步骤 2:确认 e2e 范围开关不会误伤
  50 +
  51 +`Grep` `frontend/playwright.config.*` 是否含 `ERP_E2E_SCOPE`:
  52 +
  53 +- **含** → 本命令**必须显式清空**该变量再跑(见步骤 3),保证跑的是全量而不是继承了外壳里的残留值。
  54 +- **不含** → 说明该项目的 config 还没接范围开关(存量项目),此时 e2e 本来就恒为全量,直接跑即可;在报告里注记一句即可,不必修 config(补齐由 coding.mjs 的 fe-skeleton stage 负责)。
  55 +
  56 +## 步骤 3:跑闸门(后台子进程 + 轮询,绝不在主推理流里同步跑)
  57 +
  58 +`scripts/test.mjs` 动辄几十分钟,**必须**派**隔离后台子进程**跑,主会话只轮询、只读回结构化摘要:
  59 +
  60 +1. 建日志目录 `.tmp/full-test/`(`Bash(node -e "...")` 用 `fs.mkdirSync(..., {recursive:true})`,跨平台)。
  61 +2. 用 `Bash` 的 `run_in_background` 起 detached 子进程,**显式清空** `ERP_E2E_SCOPE`,输出重定向到 `.tmp/full-test/run.log`,退出码写 `.tmp/full-test/exit`:
  62 +
  63 + ```
  64 + ERP_E2E_SCOPE= node scripts/test.mjs > .tmp/full-test/run.log 2>&1; echo $? > .tmp/full-test/exit
  65 + ```
  66 +
  67 + > Windows/PowerShell 环境下改用 `node -e` 起一个 `spawn` 包装(`env: {...process.env, ERP_E2E_SCOPE: ''}`),
  68 + > 效果等价——关键是**子进程 env 里 `ERP_E2E_SCOPE` 为空**,且日志落盘、退出码落盘。
  69 +3. **在同一回合内轮询到终态**:反复 `Bash` 调用(每轮 `sleep 60`~`120` 后读 `.tmp/full-test/exit` 是否存在 + 读 `run.log` 尾部若干行),多少轮都行。
  70 + **绝不**用 Monitor / ScheduleWakeup / 「装个监听器然后结束回合等通知」——结束回合就等于宣告完成。
  71 +4. 每轮向用户播报一行当前进度(从 `run.log` 里 `[test.mjs] N/5 <阶段>` 行取),让长跑可见。
  72 +5. 拿到 exit 码后,从 `run.log` **只**提取结构化摘要(**不要**把全文灌进上下文):
  73 + - 各阶段(build / lint / 单测 / e2e)的通过与否;
  74 + - 失败用例名 + 断言摘要(≤30 行);
  75 + - e2e 实跑条数(playwright 的 `N passed` 行)——**0 条即异常**,说明范围开关被误设,按红处理。
  76 +
  77 +## 步骤 4:证据报告 + 提交
  78 +
  79 +写 `docs/superpowers/module-reports/full-regression-<YYYY-MM-DD>.md`(同日多次跑加 `-r<N>` 后缀,**绝不覆盖**前一次):
  80 +
  81 +```markdown
  82 +# 全量回归报告 <YYYY-MM-DD>[-r<N>]
  83 +
  84 +- 触发:/erp-workflow:full-test(人工按需)
  85 +- 分支 / HEAD:<branch> @ <短哈希>
  86 +- 命令:ERP_E2E_SCOPE=(空,全量) node scripts/test.mjs
  87 +- 结论:GREEN / RED
  88 +- e2e 实跑条数:<N>(0 条 = 范围开关误设,按 RED 处理)
  89 +- 各阶段:build <ok/fail> / lint <ok/fail> / 后端单测集成 <ok/fail> / 前端 vitest <ok/fail> / e2e <ok/fail>
  90 +- 迁移晋升:<已晋升 V<n>... / 无新迁移 / 未晋升(红)>
  91 +
  92 +## 失败摘要(RED 时必填)
  93 +<失败用例 + 断言摘要,≤30 行>
  94 +
  95 +## 距上次全量回归的增量
  96 +<git log --oneline <上次 full-regression 报告的提交>..HEAD 摘要;首次跑写「首次」>
  97 +```
  98 +
  99 +`Bash(git add <报告> && git commit -m "docs(full-test): 全量回归报告 <日期> <GREEN|RED>")`。
  100 +commit 失败 → 报告已落盘即可,把 stderr 摘要告诉用户,不 halt。
  101 +
  102 +## 步骤 5:输出结论横幅
  103 +
  104 +绿:
  105 +
  106 +```
  107 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  108 + [full-test] ✅ 全量回归 GREEN
  109 +
  110 + e2e 实跑:<N> 条(全量)
  111 + 报告:docs/superpowers/module-reports/full-regression-<日期>.md
  112 + 迁移晋升:<已晋升 V<n>... / 无新迁移>
  113 +
  114 + [ERP-HALT] 全量回归通过。
  115 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  116 +```
  117 +
  118 +红:
  119 +
  120 +```
  121 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  122 + [full-test] ⛔ 全量回归 RED
  123 +
  124 + 失败阶段:<build / lint / 单测 / e2e>
  125 + 失败用例:<逐条列出,不截断关键断言>
  126 + 报告:docs/superpowers/module-reports/full-regression-<日期>.md
  127 +
  128 + 这些失败**不在**最近几轮增量的 e2e 覆盖范围内——属于跨功能连带回归。
  129 + 下一步:把失败功能作为变更单元跑 /erp-workflow:add-req 重新走编码,
  130 + 或直接修复后再跑一次 /erp-workflow:full-test。
  131 + [ERP-HALT] 已停下,本命令不修复。
  132 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  133 +```
  134 +
  135 +本 skill 到此结束,**不调用任何下游 skill**、**不自动修复**。
  136 +
  137 +## 参考
  138 +
  139 +- `scripts/test.mjs`(项目内闸门脚本:setup→build→lint→test→e2e→promote→drop 全时序)
  140 +- `frontend/playwright.config.*`(`ERP_E2E_SCOPE` 范围开关的消费端,由 coding.mjs 的 fe-skeleton stage 生成/补齐)
  141 +- `workflows/coding.mjs` 顶部 `TEST_SCOPE`(增量模式定义与收窄边界)
  142 +- `coding-start`(编码入口,缺省 affected 增量模式;传 `testScope: "full"` 可让它自己跑全量)
  143 +- `add-req`(增量需求入口,本命令报出的连带回归从这里回流)
... ...
skills/plan/add-req/SKILL.md
... ... @@ -166,6 +166,8 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan &lt;root&gt;
166 166  
167 167 台账已更新并随全部增量产物 git 提交(工作树干净)。
168 168 运行 /erp-workflow:coding-start 增量编码(Router 只跑缺 tag 的模块/功能)。
  169 + 注:增量编码的 test-gate 只跑本轮改动关联的 e2e(省时);跨功能连带回归
  170 + 请在一批需求做完 / 合并前跑 /erp-workflow:full-test 兜一次全量。
169 171 [ERP-HALT] 增量处理完成,已停下(不自动进编码)。
170 172 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
171 173 ```
... ...
workflows/coding.mjs
... ... @@ -13,7 +13,8 @@ export const meta = {
13 13 ],
14 14 // 注:'Behavior' = 阶段级行为验收门(v3)——整个前端阶段在 featureLoop 全部 FE 完成后只跑**一次**
15 15 // 行为验收(含 fix 循环),不再在每个 FE 的 review 循环内做 approve 子门(v2 per-FE 形态已撤销)。
16   - // 时序:featureLoop(frontend) → Behavior(行为门+fix)→ Gate(testGate 全量回归,兜底行为 fix 引入的回归)。
  16 + // 时序:featureLoop(frontend) → Behavior(行为门+fix)→ Gate(testGate,兜底行为 fix 引入的回归;
  17 + // e2e 宽度见 TEST_SCOPE:缺省 affected 只跑本轮改动关联用例,跨功能全量回归归 /erp-workflow:full-test)。
17 18 // 注:'Preview' = 静态原型渲染门——前端段开头一次性渲染 prototype/**/*.html 本身(Playwright headless
18 19 // 截图归档 + 原型自带交互点击冒烟)。与 Behavior 正交:Preview 测**静态原型**、Behavior 测**实现**。
19 20 // 硬门:原型渲染失败 / 脚本未捕获异常(jsErrors) → halt(原型属上游 plan 权威,coding 不改原型源码,
... ... @@ -83,9 +84,14 @@ const ADJUDICATE_SCHEMA = { type:&#39;object&#39;, additionalProperties:false,
83 84 guidance:{type:'string'},
84 85 rationale:{type:'string'} } }
85 86  
  87 +// e2eScope / e2ePassed:本次 e2e 段实际跑了什么范围、匹配到多少条用例。
  88 +// 存在的理由是**防假绿**:增量模式把 e2e 收窄成 env 过滤后,范围推导错误的表现是"0 条用例、exit 0",
  89 +// 与真绿不可区分。故让子代理据实回报条数,JS 侧硬判 0 条即 red(见 testGate),不给沉默通过留口子。
86 90 const GATE_SCHEMA = { type:'object', additionalProperties:false,
87 91 required:['status'], properties:{ status:{type:'string',enum:['green','red']},
88   - failures:{type:'array',items:{type:'string'}} } }
  92 + failures:{type:'array',items:{type:'string'}},
  93 + e2eScope:{type:'string'},
  94 + e2ePassed:{type:'integer'} } }
89 95  
90 96 // BEHAVIOR_GATE_SCHEMA:前端行为门(阶段级,frontend-phase 末尾一次)返回。
91 97 // 不杂交 GATE×STAGE_RESULT——复用既有词汇但独立成型:交互层 / 文字层 / 样式层 / 覆盖率 / 环境错误分别结构化,
... ... @@ -287,6 +293,18 @@ if (ROOT === &#39;.&#39; || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) {
287 293 // 缺省(旧 coding-start 未传)则相关增强静默跳过——由 /add-req 首跑兜底建基线。
288 294 const PLUGIN = ARGS?.pluginRoot || ''
289 295  
  296 +// 测试范围(testScope):test-gate 的 **e2e 段**跑多宽。缺省 'affected'。
  297 +// - 'affected'(缺省):只跑本轮改动关联的 e2e——前端阶段取本轮 feItems 各自 plan 里
  298 +// `测试先行类型 = e2e` 的 test_file 并集;后端模块(本轮无前端改动)退到冒烟集。
  299 +// - 'full':现行全量回归。
  300 +// **收窄只作用于 e2e 段**:build / lint / 后端单测+集成 / 前端 vitest 一律仍是全量——它们是分钟级的,
  301 +// 却是编译期与逻辑回归的第一道网,砍掉省不下多少时间反而漏掉最常见的连带破坏。
  302 +// 载体是 env `ERP_E2E_SCOPE`(fe-skeleton 生成的 playwright.config 消费,缺省不过滤)。**绝不**改
  303 +// `scripts/test.mjs` 的调用形态——它的 setup→build→lint→test→e2e→promote→drop 时序是迁移晋升链路的
  304 +// 契约(且模板改动不落已生成项目,见 preflight 硬门),加 env 是唯一既生效又不动安全边界的接口。
  305 +// 全量回归改为按需触发:`/erp-workflow:full-test`。
  306 +const TEST_SCOPE = ARGS?.testScope === 'full' ? 'full' : 'affected'
  307 +
290 308 // 模块执行上下文:串行/主根模式 root=ROOT、lane=null;并行 lane 模式由 Phase C/D 填充。
291 309 // 所有"模块作用域"的 prompt builder 与 runner 一律经 C 取根,不再直接闭包 ROOT。
292 310 // ROOT 仍保留:仅供"主根专属"操作(milestone merge / docs/08 字段 / RESUME / ledger / preflight)使用。
... ... @@ -743,10 +761,16 @@ function fixPrompt(id, phase, issues, c) {
743 761 // ---- 测试闸(原 test-gate)----
744 762 // attempt:1 = 首次跑;2 = 上轮 red 后的 flake 重试。每次 attempt 写到独立证据文件,避免 retry
745 763 // 把首次 red 证据覆盖掉(report § ⑤ 失去 flake 信号)。
  764 +// e2e 范围见 TEST_SCOPE 注释:affected(缺省)只收窄 **e2e 段**,其余全量不动。
  765 +// 首跑(全新项目)时"本轮 feItems"天然等于全部 FE,affected 与 full 的 e2e 范围重合——
  766 +// 收窄只在增量重跑(/add-req 后只有少数 FE 待跑)时才真的生效,这正是它存在的场景。
746 767 function gatePrompt(module, phase, attempt = 1, c) {
747 768 const fe = isFrontend(phase)
748 769 const id = module?.id ?? '<module>'
749 770 const phaseId = fe ? 'frontend-phase' : id
  771 + const affected = TEST_SCOPE !== 'full'
  772 + const feItems = (module?.feItems) || []
  773 + const feList = feItems.map(x => `\`${x}\``).join(', ')
750 774 return [
751 775 `# test-gate — ${fe ? '前端阶段' : `模块 ${id}`} 硬测试闸(phase=${phase}, attempt=${attempt})`,
752 776 '',
... ... @@ -758,8 +782,30 @@ function gatePrompt(module, phase, attempt = 1, c) {
758 782 '',
759 783 '## 命令',
760 784 fe
761   - ? `- 前端:命令从 \`${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,副本清理落在本门;源库不动)。`
762   - : `- 后端:跑 \`${c.root}/scripts/test.mjs\`(跨平台 Node 测试入口;含本模块新增 + 已合并模块回归;其内部自带 复制副本→测试→绿后晋升→删副本 全时序)。`,
  785 + ? `- 前端:命令从 \`${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,副本清理落在本门;源库不动)。`
  786 + : `- 后端:跑 \`${c.root}/scripts/test.mjs\`(跨平台 Node 测试入口;含本模块新增 + 已合并模块回归;其内部自带 复制副本→测试→绿后晋升→删副本 全时序)。**命令形态一字不改**——不传参、不拆步骤、不跳过其中任何一步(那套时序是迁移晋升链路的契约)。`,
  787 + // e2e 范围收窄(增量省时):唯一接口是 env ERP_E2E_SCOPE,由 fe-skeleton 生成的 playwright.config 消费。
  788 + // 之所以不改命令本身:后端走 test.mjs(内部固定调 {{e2e_cmd}},改不到),前端命令来自 docs/04 约定;
  789 + // env 则天然经 spawnSync 继承打穿到 playwright 进程,两条路径同一个开关,且旧项目补齐 config 即可生效。
  790 + ...(affected ? [
  791 + '',
  792 + '### e2e 范围(本次 = **affected 增量模式**,非全量回归)',
  793 + '- **只收窄 e2e 段**:build / lint / 后端单测+集成 / 前端 vitest 一律照常**全量**跑,一条都不许跳。',
  794 + fe && feItems.length
  795 + ? [
  796 + `- 本轮待跑 FE:${feList}。逐个 Read 其 plan(\`${c.root}/docs/superpowers/plans/<日期>-<FE-NN>.md\`,同一 FE 多份取最新日期),取出所有 \`测试先行类型 = e2e\` 任务的 \`test_file\`,去重后得到本次 e2e 范围。`,
  797 + `- 把它们相对 \`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 里跑)。`,
  798 + '- 一个 test_file 都取不到(plan 缺失 / 无 e2e 任务)→ 退到 `ERP_E2E_SCOPE=smoke`(骨架期生成的冒烟集),**不要**因此改成全量、也不要跳过 e2e 段。',
  799 + ].join('\n')
  800 + : `- 本模块本轮无待跑 FE(后端模块 / 本轮无前端改动),e2e 范围退到冒烟集:\`ERP_E2E_SCOPE=smoke\`(骨架期生成的 \`frontend/e2e/__smoke__/\`,验"栈起得来 + 首页可达 + 登录态有效")。**不要**跳过 e2e 段——它同时是起栈健康检查。`,
  801 + '- **禁止空跑**:e2e 段匹配到 **0 条**用例即视为**范围推导错误**,按 `red` 返回(`failures` 写明推导出的范围与来源),**绝不**当成绿。这是收窄唯一真正的风险,宁可报错重来。',
  802 + `- 证据里必须逐字写一行 \`e2eScope: <你实际用的 ERP_E2E_SCOPE 值>\`,并在返回里回填 \`e2eScope\` / \`e2ePassed\`(e2e 段通过的用例条数)。`,
  803 + '- **全量回归不在本门**:本次跳过的那些 e2e 由用户按需跑 `/erp-workflow:full-test` 兜底,你**不需要**、也**不许**为"回归覆盖不足"halt 或改回全量。',
  804 + ] : [
  805 + '',
  806 + '### e2e 范围(本次 = **full 全量回归**)',
  807 + '- 不设 `ERP_E2E_SCOPE`(或显式置空),playwright 跑全部 spec。返回里 `e2eScope` 填 `full`。',
  808 + ]),
763 809 '- 子会话只返回结构化 JSON:`{command, exit_code, passed, failed, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行含 FAIL 摘要)。',
764 810 // 补 9:testGate 的 e2e(Playwright globalSetup 按固定端口冷起后端)与 Seed 同属起栈类 stage——
765 811 // 整段由编排层包进全局起栈互斥(stackLock,Phase D 定义);lane 测试库硬约束已由 featureStageContract 注入。
... ... @@ -773,6 +819,7 @@ function gatePrompt(module, phase, attempt = 1, c) {
773 819 '',
774 820 '## 输出(必须符合下发的 GATE JSON schema)',
775 821 '- `status`: `green`(`exit_code = 0` 且 `failed = 0`)| `red`;`failures`: 失败用例摘要(green 时可省略 / 空数组)。',
  822 + `- \`e2eScope\`: 本次 e2e 实际范围(${affected ? '你算出的 ERP_E2E_SCOPE 值,如 `order/create.spec.ts,...` 或 `smoke`' : '`full`'});\`e2ePassed\`: e2e 段通过条数(**据实填**,编排层据此判空跑)。`,
776 823 '- 不要返回额外字段。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。',
777 824 ].filter(Boolean).join('\n')
778 825 }
... ... @@ -1173,7 +1220,7 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1173 1220  
1174 1221 // behaviorReverifyPrompt:阶段级行为 fix 后的功能复验。fix 改的是 frontend/ UI 源码,可能引入功能回归——
1175 1222 // 在下一 behaviorRound 重起全栈之前,先派子会话跑**全量前端单测**(vitest,不跑 e2e——e2e/行为维度由下一轮
1176   -// 行为门重跑 + 阶段 testGate 全量回归兜底),红则当功能回归硬边界(调用方 allowContinue:false)。
  1223 +// 行为门重跑 + 阶段 testGate 兜底),红则当功能回归硬边界(调用方 allowContinue:false)。
1177 1224 function behaviorReverifyPrompt(behaviorRound, fixedCount, c) {
1178 1225 const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-reverify-r${behaviorRound}.md`
1179 1226 return [
... ... @@ -1257,7 +1304,17 @@ function frontendSkeletonPrompt(feItems, c) {
1257 1304 // 晋升链路断且无声。副本清理归各闸门的 finally(test.mjs / seed runner / 行为门 runner)在 promote 后做;
1258 1305 // tdd 内循环单独跑 e2e 时副本留存到下次 globalSetup 重新复制,无害。
1259 1306 ' - **globalTeardown**(如 `frontend/e2e/global-teardown.*`):kill globalSetup 起的后端进程树(**不**删副本——副本清理由外层闸门在晋升后负责)。',
1260   - ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐;**已存在但缺 `webServer` 就绪门(或用了无就绪门的并行起法)→ 必须补齐/改正**,这是已知会造成整轮 e2e 假红的缺陷,不属于"已存在就不动"的范围。',
  1307 + // e2e 范围开关(增量省时的落点):编排层唯一能打穿到 playwright 进程的接口就是 env——
  1308 + // 后端 test-gate 走 scripts/test.mjs,内部固定调 {{e2e_cmd}},命令行参数加不进去;而 env 经
  1309 + // spawnSync 天然继承。故把过滤逻辑放进**由本 stage 生成的 config**里,前后端两条路径同一个开关。
  1310 + // 缺省(env 未设)必须是全量——任何"默认收窄"都会让不知情的人拿到一份假绿。
  1311 + ' - **e2e 范围开关 `ERP_E2E_SCOPE`(必须实现,增量模式靠它省时)**:在同一份配置里读 `process.env.ERP_E2E_SCOPE`——',
  1312 + ' - 未设 / 空串 → **不过滤,跑全部 spec**(缺省即全量,绝不能反过来)。',
  1313 + ' - 值为 `smoke` → 只匹配 `e2e/__smoke__/` 下的 spec。',
  1314 + ' - 其它值 → 按逗号切分成路径片段列表,只匹配**路径含其中任一片段**的 spec(如 `order/create.spec.ts,order/list.spec.ts`)。',
  1315 + ' - 实现用 `testMatch`(接受正则数组)或等价过滤;片段先做正则转义再拼,**绝不**把用户值当正则直接塞进去。',
  1316 + ' - **冒烟集 `frontend/e2e/__smoke__/app-smoke.spec.*`(必须生成)**:3 条以内的最小用例——① 带 `storageState` 打开 baseURL 首页,断言应用根节点已挂载(非空白、无未捕获错误);② 访问 router 里**已指向真组件**的任一路由,断言其可达且未渲染 `data-fe-stub`;③ 断言登录态有效(如页面出现仅登录后可见的导航元素)。它是增量模式下后端 test-gate 的 e2e 段唯一会跑的东西,职责是「栈起得来 + 应用没整体炸」,**不是**业务断言,故必须与具体业务功能解耦、不随 FE 增减而失效。',
  1317 + ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐;**已存在但缺 `webServer` 就绪门(或用了无就绪门的并行起法)、缺 `ERP_E2E_SCOPE` 支持、缺冒烟集 → 必须补齐/改正**,这些都是已知会造成整轮 e2e 假红或增量模式失效的缺陷,不属于"已存在就不动"的范围。',
1261 1318 '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/ 镜像路径后方可收窄)。',
1262 1319 '- **lazy 硬护栏**:router 表里**任何** FE 路由都不得用顶部静态 `import`;必须 `() => import(...)`。自检:Grep 路由文件,确认每个 FE 路由的 `component` 都是动态 import 形态。',
1263 1320 '- **路径硬护栏**:所有产出文件必须以 `frontend/` 开头;命中 `backend/` / `sql/` / `scripts/` → 越界硬停。',
... ... @@ -1289,11 +1346,14 @@ function frontendSkeletonStatePromptM(feItems, c) {
1289 1346 '',
1290 1347 `用 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.*\`。`,
1291 1348 '- **就绪门判据从严**:只要找不到 `webServer.url`、也找不到 globalSetup 内对前端端口的显式轮询(哪怕 e2e 目前跑得通)→ 判 `exists:false`。这是已知会让整轮 e2e 假红 40+ 分钟的竞态缺陷,值得多跑一次骨架 stage 去补。',
  1349 + // 存量项目的 config 是在这两项存在之前生成的,必须靠本判据把它们拽回来补齐——
  1350 + // 否则 test-gate 设了 ERP_E2E_SCOPE 也没人消费,静默退化成全量(不假绿,但增量省时完全失效)。
  1351 + `- **e2e 范围开关判据**:Grep \`${c.root}/frontend/playwright.config.*\` 是否含 \`ERP_E2E_SCOPE\`,且 \`${c.root}/frontend/e2e/__smoke__/\` 下存在至少一个 spec 文件。任一缺失 → 判 \`exists:false\`(存量项目普遍缺这两项,需要重跑骨架 stage 补齐)。`,
1292 1352 '- 判据**只针对上面列出的 FE**:router 里其它 FE 路由(往轮已实现,指向真组件)一律视为满足,**不要**因为它们没指向 `FeStub` 就判缺失。',
1293 1353 '- 上面列出的 FE,其路由若已指向**真组件**(resume:上次已实现到一半)同样视为满足——本检测只问"路由在不在、是不是 lazy",不问指向谁。',
1294 1354 `- **legacy 豁免**:若 \`${c.root}/frontend/src/\` 内已存在测试文件(\`*.test.*\` / \`*.spec.*\`,旧约定 colocation 残留),vitest include 项**不作为缺失判据**(视为满足)——该状态须人工迁移决策,绝不由骨架重跑静默收窄 include 停跑旧测试。`,
1295 1355 '- 全部满足(骨架已建齐,含 e2e 基线脚手架 + 单测基线)→ `{ "exists": true }`',
1296   - '- 任一缺失(无 router / 缺上面某个待实现 FE 的路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup / **缺前端 dev server 就绪门** / vitest include 未限定 tests/**/*.test.*(且非 legacy 豁免))→ `{ "exists": false }`',
  1356 + '- 任一缺失(无 router / 缺上面某个待实现 FE 的路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup / **缺前端 dev server 就绪门** / **缺 `ERP_E2E_SCOPE` 支持或冒烟集** / vitest include 未限定 tests/**/*.test.*(且非 legacy 豁免))→ `{ "exists": false }`',
1297 1357 '## 输出(EXISTS_SCHEMA)',
1298 1358 ].join('\n')
1299 1359 }
... ... @@ -2386,6 +2446,9 @@ function reportPrompt(module, c, facts = {}) {
2386 2446 `- 上游 test-gate 已 green(编排层判定:共 ${gAttempts} 个 attempt,末次 \`${gateEvidence}\` = green)。红态根本走不到本步——testGate 会先 halt,故**不需要**你重新校验绿不绿。`,
2387 2447 `- 只需 Read 末次证据 \`${c.root}/docs/superpowers/module-reports/${gateEvidence}\` 取 § ⑤ 要用的摘要${gateFlake ? `;另 Read 首次 \`${phaseId}-test-gate-r1.md\`(attempt>1 = flake,§ ⑤ 需要首次失败用例)` : '(单 attempt,无 flake,无需读其它 attempt 文件)'}。该文件缺失 → halt(证据链断裂)。`,
2388 2448 gateFlake ? `- **flake 标注(编排层已判定)**:attempt 数 = ${gAttempts} 且末次 green → § ⑤ 顶部必须写 \`flake-detected: r1 red, r${gAttempts} green\`,并附首次失败用例与末次绿色记录链接。` : '',
  2449 + // 报告必须说清"这次到底测了多宽"——增量模式下 e2e 只覆盖本轮改动,报告若仍写"全量回归通过"
  2450 + // 就是伪造覆盖面。全量回归改由用户按需跑 /erp-workflow:full-test,报告只如实记录本次范围。
  2451 + `- **本次 e2e 范围(编排层给定,如实写进 § ⑤,不要拔高成"全量回归")**:\`${gate.e2eScope || (TEST_SCOPE === 'full' ? 'full' : '(子代理未回填,按证据文件里的 e2eScope 行为准)')}\`${TEST_SCOPE === 'full' ? '(全量)' : `(增量模式:只跑本轮改动关联的 e2e${Number.isInteger(gate.e2ePassed) ? `,通过 ${gate.e2ePassed} 条` : ''};未覆盖部分由 \`/erp-workflow:full-test\` 按需兜底)`}。`,
2389 2452 fe
2390 2453 ? `- 阶段级行为门已 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、无复验文件)'}。`
2391 2454 : '',
... ... @@ -3132,15 +3195,26 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath, c, { flipDoc
3132 3195  
3133 3196 // flake 重试:每个 attempt 写独立证据文件 `<id>-test-gate-r<attempt>.md`,不覆盖前一次 red 证据(report § ⑤ 用得到)。
3134 3197 // red 是硬正确性边界——**绝不** continue 跳过;只让仲裁在"再跑一次辨 flake"与"确属真失败 → halt"间裁决。
  3198 +// 空跑守卫(affected 模式专用):e2e 收窄的唯一真风险是"范围推导错 → 0 条用例 → exit 0",
  3199 +// 表现与真绿完全一致。子代理侧已被要求禁止空跑,但那是自律;这里再做一次**编排层硬判**——
  3200 +// green 且 e2ePassed 明确为 0 一律翻成 red,走既有 retry/仲裁链(重跑一次通常会带出正确范围)。
  3201 +// e2ePassed 缺席(旧行为 / 未回填)不翻——只惩罚"明确说了 0 条",不猜。
  3202 +function guardEmptyE2e(g) {
  3203 + if (TEST_SCOPE === 'full') return g
  3204 + if (g.status !== 'green' || g.e2ePassed !== 0) return g
  3205 + return { ...g, status:'red',
  3206 + failures: [...(g.failures || []), `e2e 收窄范围匹配到 0 条用例(e2eScope="${g.e2eScope || '?'}")——空跑不算绿,范围推导有误`] }
  3207 +}
  3208 +
3135 3209 async function testGate(module, phase, c) {
3136 3210 let attempt = 1
3137 3211 const site = `gate:${phase}:${module.id}`
3138   - etaLog(site, 1, '全量回归 + 冷起栈')
3139   - let g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  3212 + etaLog(site, 1, TEST_SCOPE === 'full' ? '全量回归 + 冷起栈' : '增量回归(e2e 收窄)+ 冷起栈')
  3213 + let g = guardEmptyE2e(await agentR(gatePrompt(module, phase, attempt, c), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}))
3140 3214 if (g.status === 'red') { // 自动重试 1 次(防 flaky)
3141 3215 attempt = 2
3142 3216 etaLog(site, 2, 'red → 防 flaky 自动重跑')
3143   - g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  3217 + g = guardEmptyE2e(await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}))
3144 3218 }
3145 3219 // 仍 red:经仲裁辨识 flake。allowContinue:false → 红色不可跳过,仲裁只在 retry / halt 间选。
3146 3220 for (let adj = 1; g.status === 'red' && adj <= ADJUDICATE_MAX; adj++) {
... ... @@ -3150,7 +3224,7 @@ async function testGate(module, phase, c) {
3150 3224 throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${verdict.rationale || (g.failures||[]).join('; ')}`)
3151 3225 attempt += 1 // retry:再跑一个独立 attempt 证据文件
3152 3226 etaLog(site, attempt, '仲裁判 retry(辨 flake)')
3153   - g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA})
  3227 + g = guardEmptyE2e(await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA}))
3154 3228 }
3155 3229 if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${ADJUDICATE_MAX} 轮仲裁后仍 red:${(g.failures||[]).join('; ')}`)
3156 3230 // attempts 回传给 module-report:报告不必再 Glob + 通读全部 attempt 证据去自证"最后一份绿",
... ... @@ -3747,13 +3821,13 @@ async function runModule(module, c) {
3747 3821 await featureLoop(module.feItems, 'frontend', c, module.feStage || 'all')
3748 3822 // 阶段级行为门(v3):整个前端阶段只跑一次行为验收——起全栈 + 演示/sentinel 种子,按全部 FE spec 聚合
3749 3823 // 作用域并集验「按钮真生效/文字对」;硬问题转 must-fix→fix→单测复验→重跑门(≤BEHAVIOR_STAGE_MAX 轮)。
3750   - // 放在 testGate 之前:行为 fix 改动 frontend/ 源码,绿后由 testGate 全量回归兜底,不让回归证据过期。
  3824 + // 放在 testGate 之前:行为 fix 改动 frontend/ 源码,绿后由 testGate 兜底(单测全量 + 本轮 e2e),不让回归证据过期。
3751 3825 if (c.lane == null) phase('Behavior')
3752 3826 // 行为门虽也冷起全栈,但只在 frontend-phase 终波(宽 1、主根)跑——无兄弟 lane 可互杀,
3753 3827 // 不进起栈互斥(补 9 范围仅 Seed / testGate)。
3754 3828 reportFacts.behavior = await runBehaviorGate(module.feItems, c)
3755 3829 if (c.lane == null) phase('Gate')
3756   - // 阶段级 testGate(全量回归 vitest+playwright),与行为门职责正交;起栈互斥(补 9)同后端段整段包锁。
  3830 + // 阶段级 testGate(vitest 全量 + playwright 按 TEST_SCOPE 定宽),与行为门职责正交;起栈互斥(补 9)同后端段整段包锁。
3757 3831 reportFacts.gate = await withStackLock(() => testGate(module, 'frontend', c))
3758 3832 }
3759 3833 if (c.lane == null) phase('Milestone')
... ...