Commit e779913ab31e541c31c9822d7cffd9677f5b7e06

Authored by yanghl
1 parent 163161ec

fix(coding): feItems 是本轮待实现清单而非全量 FE + 前端 dev server 就绪门

Router 下发的 feItems 一直是「缺 req-done/<FE> tag 的 FE」(见 routerPrompt),
但下游三处都按「项目全部 FE」解读,增量重跑(add-req 新增少数 FE)时后果实质:

- fe-skeleton:会把往轮已实现 FE 的路由改回 FeStub,等于把已验收功能打回占位态,
  行为门当回归红灯。改为两个清单分工——路由表覆盖按 docs/08 § 三全量声明(含已 [x]),
  只有本轮 feItems 指向 FeStub,清单外一律保留现有真组件指向;自检加一条
  git diff 查「真组件 → FeStub」误伤。
- behavior gate:断言作用域收敛到本轮清单;清单外路由(往轮已验收)记证据但不入分母、
  不记 coverageGaps,跨 FE 连带回归由阶段末 testGate 的全量 e2e 兜底。
- fe-skeleton 幂等判据只看本轮清单——拿全量当判据会在增量重跑时误判为缺、白跑一次骨架。

另修前端 e2e 连接竞态:后端起栈一直有「轮询健康端点」硬约束,前端 dev server 却从没被
规定由谁起、何时算就绪,生成的项目普遍「并行起 vite + 直接跑 playwright」。实测代价:
后端 testGate 连红 4 次(attempt 1-4 全是 dev server 未起 / 连接被拒,49 分钟),
第 5 次同代码变绿,还因此被误判成 flaky。现在 playwright.config 必须声明 webServer 段
(url + timeout ≥120s)或 globalSetup 显式轮询至可连;已存在但缺就绪门的**必须补齐**,
不属于「已存在就不动」的范围,state 判据同步从严。行为门 runner 侧补同口径就绪判定,
超时归 envError.kind="stack-not-ready" 走 retry,不记成 interactionFailures。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Showing 1 changed file with 54 additions and 29 deletions
workflows/coding.mjs
@@ -902,7 +902,7 @@ function prototypePreviewContract(c) { @@ -902,7 +902,7 @@ function prototypePreviewContract(c) {
902 } 902 }
903 903
904 // prototypePreviewPrompt:静态原型渲染门子代理完整流水线(step0-3 + schema)。 904 // prototypePreviewPrompt:静态原型渲染门子代理完整流水线(step0-3 + schema)。
905 -// feItems:本前端阶段全部 FE-NN(仅作分组参考,不强制与原型页一一对应);attempt:环境 race 重试序号(1..)。 905 +// feItems:本轮待实现的 FE-NN(缺 req-done tag 的那些;仅作分组参考,不强制与原型页一一对应);attempt:环境 race 重试序号(1..)。
906 function prototypePreviewPrompt(feItems, attempt, c) { 906 function prototypePreviewPrompt(feItems, attempt, c) {
907 const feList = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无 FE 清单)' 907 const feList = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无 FE 清单)'
908 const tmpDir = `${c.root}/.tmp/prototype-preview` 908 const tmpDir = `${c.root}/.tmp/prototype-preview`
@@ -949,8 +949,8 @@ function prototypePreviewPrompt(feItems, attempt, c) { @@ -949,8 +949,8 @@ function prototypePreviewPrompt(feItems, attempt, c) {
949 949
950 // ---- 前端行为验收(阶段级行为门,v3)---- 950 // ---- 前端行为验收(阶段级行为门,v3)----
951 // 设计权威:docs/design/2026-06-05-frontend-behavior-stage-gate.md。 951 // 设计权威:docs/design/2026-06-05-frontend-behavior-stage-gate.md。
952 -// 时机:featureLoop(frontend) 全部 FE 通过静态 review(req-done tag 已打)之后、testGate 之前,  
953 -// 整个前端阶段只跑**一次**行为验收:起全栈 + 演示/sentinel 种子,按全部 FE spec 聚合的作用域并集 952 +// 时机:featureLoop(frontend) 把本轮 feItems 全部跑完静态 review(req-done tag 已打)之后、testGate 之前,
  953 +// 整个前端阶段只跑**一次**行为验收:起全栈 + 演示/sentinel 种子,按本轮 feItems 各 FE spec 聚合的作用域并集
954 // 枚举路由控件/文字,硬问题转可 fix must-fix→fix→复验→重跑门(≤BEHAVIOR_STAGE_MAX 轮),green 才进 testGate。 954 // 枚举路由控件/文字,硬问题转可 fix must-fix→fix→复验→重跑门(≤BEHAVIOR_STAGE_MAX 轮),green 才进 testGate。
955 // 门是**跨栈只读验证 + 临时产物**的第三类 stage:不套 featureStageContract('frontend') 955 // 门是**跨栈只读验证 + 临时产物**的第三类 stage:不套 featureStageContract('frontend')
956 // (其路径护栏命中 backend/sql/scripts 即越界硬停,与门必须运行 setup-test-db / 起后端 / 生成 SQL 种子自相矛盾)。 956 // (其路径护栏命中 backend/sql/scripts 即越界硬停,与门必须运行 setup-test-db / 起后端 / 生成 SQL 种子自相矛盾)。
@@ -969,7 +969,7 @@ function behaviorGateContract(c) { @@ -969,7 +969,7 @@ function behaviorGateContract(c) {
969 '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md § 零` → `docs/05-API接口契约.md` → `docs/03-数据库设计文档.md` → `prototype/`(前端布局/交互权威)→ `frontend/`(router 配置 / package.json)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。', 969 '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md § 零` → `docs/05-API接口契约.md` → `docs/03-数据库设计文档.md` → `prototype/`(前端布局/交互权威)→ `frontend/`(router 配置 / package.json)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。',
970 `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下命令——\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)、起后端服务(gradle bootRun 等)、\`node ${c.root}/scripts/seed-demo-data.mjs\`(只运行注入演示种子,不修改脚本)、\`node ${c.root}/scripts/drop-test-db.mjs\`(teardown 删副本)、起前端 headless(vite / playwright)、跑 Playwright;唯一允许**写入**的路径是 \`${c.root}/.tmp/behavior-gate/frontend-phase/r<behaviorRound>/\`(种子 SQL/runner,跑完即弃)+ 证据报告 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r<behaviorRound>-a<attempt>.md\` + 其 assets(截图归档到 \`${c.root}/docs/superpowers/module-reports/assets/...\`)。`, 970 `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下命令——\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)、起后端服务(gradle bootRun 等)、\`node ${c.root}/scripts/seed-demo-data.mjs\`(只运行注入演示种子,不修改脚本)、\`node ${c.root}/scripts/drop-test-db.mjs\`(teardown 删副本)、起前端 headless(vite / playwright)、跑 Playwright;唯一允许**写入**的路径是 \`${c.root}/.tmp/behavior-gate/frontend-phase/r<behaviorRound>/\`(种子 SQL/runner,跑完即弃)+ 证据报告 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r<behaviorRound>-a<attempt>.md\` + 其 assets(截图归档到 \`${c.root}/docs/superpowers/module-reports/assets/...\`)。`,
971 `- **越界硬停**:**绝不**编辑 \`frontend/\` / \`backend/\` / \`sql/\` 下的任何源码文件,也**绝不**编辑 \`${c.root}/scripts/\` 下的脚本——只许**运行** scripts/setup-test-db.mjs / scripts/seed-demo-data.mjs / scripts/drop-test-db.mjs。区分「运行 backend 服务」(允许)与「写 backend 实现」(越界)。命中越界即以 \`status:red\` + \`envError\` 或写清阻塞点结束。`, 971 `- **越界硬停**:**绝不**编辑 \`frontend/\` / \`backend/\` / \`sql/\` 下的任何源码文件,也**绝不**编辑 \`${c.root}/scripts/\` 下的脚本——只许**运行** scripts/setup-test-db.mjs / scripts/seed-demo-data.mjs / scripts/drop-test-db.mjs。区分「运行 backend 服务」(允许)与「写 backend 实现」(越界)。命中越界即以 \`status:red\` + \`envError\` 或写清阻塞点结束。`,
972 - '- **全量终态前提(关键)**:本门跑在**全部 FE 已实现并通过静态 review 之后**——`frontend/` 不应再有未实现路由 / FeStub 占位。某路由仍渲染 `data-fe-stub` 占位 → 这是硬缺陷(tdd 漏做占位替换),归 `interactionFailures[kind="no-observable-effect"]`,locator 指向 router 文件该路由 import 行,detail 写明「路由仍指向 FeStub 占位」。**断言作用域 = 全部 FE spec 的 `## 行为验收作用域` 小节并集**;白名单外控件记证据不入断言集。', 972 + '- **全量终态前提(关键)**:本门跑在**全部 FE 已实现并通过静态 review 之后**——`frontend/` 不应再有未实现路由 / FeStub 占位。某路由仍渲染 `data-fe-stub` 占位 → 这是硬缺陷(tdd 漏做占位替换),归 `interactionFailures[kind="no-observable-effect"]`,locator 指向 router 文件该路由 import 行,detail 写明「路由仍指向 FeStub 占位」。**断言作用域 = 下方「本阶段 FE 清单」内每个 FE 的 spec `## 行为验收作用域` 小节并集**(增量重跑时该清单是项目 FE 的子集,以清单为准);白名单外控件记证据不入断言集。',
973 '- 红线:**绝不**伪造断言通过;**绝不**留 `TBD` / `TODO`;自主默认必须可被现有证据支撑且记入 `decisions[]`。', 973 '- 红线:**绝不**伪造断言通过;**绝不**留 `TBD` / `TODO`;自主默认必须可被现有证据支撑且记入 `decisions[]`。',
974 '- 证据报告**使用中文**;spec / sentinel 标识符 / SQL 可用英文(`[A-Za-z0-9_]`,受控格式,不取任意文本)。', 974 '- 证据报告**使用中文**;spec / sentinel 标识符 / SQL 可用英文(`[A-Za-z0-9_]`,受控格式,不取任意文本)。',
975 '- **运行时确定性**:sentinel 值 / 端口 / 临时目录名一律由你确定性派生(按列类型 / config-vars 端口 / behaviorRound / attempt 序号),**绝不**依赖时间戳 / 随机数。', 975 '- **运行时确定性**:sentinel 值 / 端口 / 临时目录名一律由你确定性派生(按列类型 / config-vars 端口 / behaviorRound / attempt 序号),**绝不**依赖时间戳 / 随机数。',
@@ -977,7 +977,9 @@ function behaviorGateContract(c) { @@ -977,7 +977,9 @@ function behaviorGateContract(c) {
977 } 977 }
978 978
979 // behaviorGatePrompt:阶段级行为验收子代理的完整流水线提示(step0-6 + schema)。 979 // behaviorGatePrompt:阶段级行为验收子代理的完整流水线提示(step0-6 + schema)。
980 -// feItems:本前端阶段全部 FE-NN(作用域聚合的清单真值,来自 Router frontend-phase 模块); 980 +// feItems:本轮待跑的 FE-NN(作用域聚合的清单真值,来自 Router frontend-phase 模块 = 缺 req-done tag 的那些)。
  981 +// 增量重跑时这只是项目 FE 的子集,门也只覆盖它们——未受影响 FE 的行为验收证据沿用上一轮(见 add-req SKILL
  982 +// 步骤 5「收窄的是重跑范围,不是验收强度」),跨 FE 连带回归由阶段末 testGate 的全量 e2e 兜底。
981 // behaviorRound:阶段门内的行为 fix 轮(1..BEHAVIOR_STAGE_MAX);attempt:本轮内环境 race 重试序号(1..)。 983 // behaviorRound:阶段门内的行为 fix 轮(1..BEHAVIOR_STAGE_MAX);attempt:本轮内环境 race 重试序号(1..)。
982 // 每 (behaviorRound × attempt) 独立 .tmp 子目录 + 独立证据文件,绝不互相覆盖(不丢 flake 信号)。 984 // 每 (behaviorRound × attempt) 独立 .tmp 子目录 + 独立证据文件,绝不互相覆盖(不丢 flake 信号)。
983 function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { 985 function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
@@ -996,14 +998,16 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { @@ -996,14 +998,16 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
996 behaviorGateContract(c), 998 behaviorGateContract(c),
997 '', 999 '',
998 '## 目标', 1000 '## 目标',
999 - '用真实全栈运行证明**全部 FE** 的「每个按钮/点击都真的生效、每段文字都显示正确内容(right context)」。整个前端阶段只跑这一道行为门(featureLoop 全部 FE 已过静态 review)。', 1001 + '用真实全栈运行证明**本次清单内每个 FE** 的「每个按钮/点击都真的生效、每段文字都显示正确内容(right context)」。整个前端阶段只跑这一道行为门(featureLoop 已把清单内 FE 全部过完静态 review)。',
  1002 + '- **作用域 = 下面这份清单,不是项目里的全部 FE**:增量重跑时清单只含本轮受影响的 FE,清单外的 FE 已在往轮验收过、**不在本门职责内**——不要去枚举它们的路由、不要因为它们没被断言就记 `coverageGaps`。跨 FE 的连带回归由阶段末 testGate 的全量 e2e 兜。',
1000 '单个子会话内**收敛完成**:冷起栈 → 逐路由枚举(全 FE 作用域并集)+ 两层断言 → teardown。期望即时推导(prototype/ + REQ + docs/05),**不**持久化为契约,但推导期望写进已提交证据报告。', 1003 '单个子会话内**收敛完成**:冷起栈 → 逐路由枚举(全 FE 作用域并集)+ 两层断言 → teardown。期望即时推导(prototype/ + REQ + docs/05),**不**持久化为契约,但推导期望写进已提交证据报告。',
1001 `- 本阶段 FE 清单:${feList}。`, 1004 `- 本阶段 FE 清单:${feList}。`,
1002 `- 断言作用域真值 = **每个 FE** 的 spec(\`${c.root}/docs/superpowers/specs/<date>-<FE-NN>.md\`,同一 FE 多份取最新日期)头部的 \`## 行为验收作用域\` 小节(\`关联路由:\` + \`负责控件白名单:\`)。先逐 FE Read 取出并**聚合为并集**(路由去重、逐路由标注归属 FE);某 FE 缺 spec 或缺该小节 → 记 \`coverageGaps[reason="scope-missing", page="<FE-NN>"]\`(该 FE 路由不计入分母,**绝不**静默跳过)。`, 1005 `- 断言作用域真值 = **每个 FE** 的 spec(\`${c.root}/docs/superpowers/specs/<date>-<FE-NN>.md\`,同一 FE 多份取最新日期)头部的 \`## 行为验收作用域\` 小节(\`关联路由:\` + \`负责控件白名单:\`)。先逐 FE Read 取出并**聚合为并集**(路由去重、逐路由标注归属 FE);某 FE 缺 spec 或缺该小节 → 记 \`coverageGaps[reason="scope-missing", page="<FE-NN>"]\`(该 FE 路由不计入分母,**绝不**静默跳过)。`,
1003 behaviorRound > 1 || attempt > 1 ? `- 本次 = behaviorRound ${behaviorRound} / attempt ${attempt}(上一次 red / envError / fix 后重验);证据**写到独立文件 r${behaviorRound}-a${attempt}** 不要覆盖前一次。` : '', 1006 behaviorRound > 1 || attempt > 1 ? `- 本次 = behaviorRound ${behaviorRound} / attempt ${attempt}(上一次 red / envError / fix 后重验);证据**写到独立文件 r${behaviorRound}-a${attempt}** 不要覆盖前一次。` : '',
1004 '', 1007 '',
1005 '## 运行机制(无常驻进程跨会话;冷起栈→跑→teardown 收敛进单 runner)', 1008 '## 运行机制(无常驻进程跨会话;冷起栈→跑→teardown 收敛进单 runner)',
1006 - '- **冷起栈(运行时硬约束)**:本项目**无既有 e2e webServer / playwright.config 复用入口**——runner 必须**自负冷起后端 + 前端**,behaviorRound / attempt 之间**绝不复用运行栈、无 HMR**,每次从头 spawn 起栈→跑→teardown。', 1009 + '- **冷起栈(运行时硬约束)**:本门**不经 playwright.config 跑**(项目的 `webServer` 只管前端,起不了后端 + 测试库副本 + 种子),故**没有可复用的起栈入口**——runner 必须**自负冷起后端 + 前端**,behaviorRound / attempt 之间**绝不复用运行栈、无 HMR**,每次从头 spawn 起栈→跑→teardown。',
  1010 + '- **前端就绪判定照抄后端那套**:spawn 前端后**必须轮询到端口真正可连**才开始驱动页面,不允许起完就跑。前端 dev server 未就绪却直接连,会产出一批「控件找不到 / 页面空白」的假 RED,归因时极难与真缺陷区分——这是本项目 e2e 侧最贵的一类假红灯。超时未就绪 → `envError.kind="stack-not-ready"`(走 retry),**不要**记成 `interactionFailures`。',
1007 `- **入口清目录(跑前第一步,去串味)**:${behaviorRound === 1 && attempt === 1 1011 `- **入口清目录(跑前第一步,去串味)**:${behaviorRound === 1 && attempt === 1
1008 ? `本次是本阶段首轮首次 → 先删除整个 \`${c.root}/.tmp/behavior-gate/frontend-phase/\` 目录(清掉历史残留 runner/种子/carry),再新建本轮子目录 \`${tmpDir}/\` 与派生物暂存区 \`${carryDir}/\`。` 1012 ? `本次是本阶段首轮首次 → 先删除整个 \`${c.root}/.tmp/behavior-gate/frontend-phase/\` 目录(清掉历史残留 runner/种子/carry),再新建本轮子目录 \`${tmpDir}/\` 与派生物暂存区 \`${carryDir}/\`。`
1009 : `本次 behaviorRound=${behaviorRound} / attempt=${attempt} → 仅删除/清空本轮子目录 \`${tmpDir}/\`(幂等,不动其它 round 的临时残留),再新建;**\`${carryDir}/\` 绝不删**——它装着上一次已推导好的 runner/种子/期望,本次要复用(见下方「派生物复用」)。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`, 1013 : `本次 behaviorRound=${behaviorRound} / attempt=${attempt} → 仅删除/清空本轮子目录 \`${tmpDir}/\`(幂等,不动其它 round 的临时残留),再新建;**\`${carryDir}/\` 绝不删**——它装着上一次已推导好的 runner/种子/期望,本次要复用(见下方「派生物复用」)。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`,
@@ -1039,8 +1043,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { @@ -1039,8 +1043,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1039 '', 1043 '',
1040 `## step1 路由真值发现(覆盖率分母 = 全部 FE 作用域路由并集)${reuse ? ' —— **本次复用 carry,整节跳过**' : ''}`, 1044 `## step1 路由真值发现(覆盖率分母 = 全部 FE 作用域路由并集)${reuse ? ' —— **本次复用 carry,整节跳过**' : ''}`,
1041 reuse ? `- 本次已从 \`${carryDir}/expectations.md\` 取得分母与逐路由归属,**不要**重新逐 FE Read spec、不要重新对账 router(那是上一次已做完的确定性推导,重做只是重复烧时间)。仅当 carry 缺失/不可用时才回退执行本节。` : '', 1045 reuse ? `- 本次已从 \`${carryDir}/expectations.md\` 取得分母与逐路由归属,**不要**重新逐 FE Read spec、不要重新对账 router(那是上一次已做完的确定性推导,重做只是重复烧时间)。仅当 carry 缺失/不可用时才回退执行本节。` : '',
1042 - '- 分母来源 = 全部 FE spec `## 行为验收作用域` 小节 `关联路由:` 清单的**并集(去重)**;`routesPlanned` = 并集路由数。逐路由标注归属 FE(证据分小节与硬问题归因用)。',  
1043 - `- 与 \`${c.root}/frontend/\` router 配置对账:FE 作用域声明但 router 缺失的路由 → \`coverageGaps[reason="unreachable-no-route"]\`;router 声明但不属任何 FE 作用域的路由记证据(不入分母、不断言)。`, 1046 + '- 分母来源 = **本次清单内每个 FE** 的 spec `## 行为验收作用域` 小节 `关联路由:` 清单的**并集(去重)**;`routesPlanned` = 并集路由数。逐路由标注归属 FE(证据分小节与硬问题归因用)。',
  1047 + `- 与 \`${c.root}/frontend/\` router 配置对账:清单内 FE 的作用域声明但 router 缺失的路由 → \`coverageGaps[reason="unreachable-no-route"]\`;router 声明但不属清单内任何 FE 作用域的路由记证据(不入分母、不断言)——**增量重跑时往轮已验收 FE 的路由就落在这一类,属正常,不是缺口**。`,
1044 '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**每路由的预期控件与文字来源**;每路由标注所需登录角色。', 1048 '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**每路由的预期控件与文字来源**;每路由标注所需登录角色。',
1045 '- 带参动态路由用**种子已知主键**实例化(可用**演示种子已知主键**(1000–9999)或 **sentinel 主键**(≥100000));无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。', 1049 '- 带参动态路由用**种子已知主键**实例化(可用**演示种子已知主键**(1000–9999)或 **sentinel 主键**(≥100000));无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。',
1046 '', 1050 '',
@@ -1057,7 +1061,7 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { @@ -1057,7 +1061,7 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1057 '- 用 config-vars `admin_init` 或种子已知凭据,经 `docs/05` 登录端点**真实登录**拿 JWT,注入 Playwright `storageState`;`authState` 记角色覆盖(覆盖 / 未覆盖角色集)。', 1061 '- 用 config-vars `admin_init` 或种子已知凭据,经 `docs/05` 登录端点**真实登录**拿 JWT,注入 Playwright `storageState`;`authState` 记角色覆盖(覆盖 / 未覆盖角色集)。',
1058 '- 登录失败 = `envError.kind="auth-failed"`(环境 race,走 retry),**绝不**当成死控件。', 1062 '- 登录失败 = `envError.kind="auth-failed"`(环境 race,走 retry),**绝不**当成死控件。',
1059 '', 1063 '',
1060 - '## step3 枚举(可达性驱动 + 分母对账,非首帧快照;驱动全部 FE 作用域并集)', 1064 + '## step3 枚举(可达性驱动 + 分母对账,非首帧快照;驱动清单内 FE 的作用域并集)',
1061 '- **枚举/驱动 step1 聚合的全部路由 + 各 FE 控件白名单并集**。每路由带 `storageState` 加载,收集 DOM 真实控件与文字区域。分母 = step1 聚合清单,分子 = live 枚举。', 1065 '- **枚举/驱动 step1 聚合的全部路由 + 各 FE 控件白名单并集**。每路由带 `storageState` 加载,收集 DOM 真实控件与文字区域。分母 = step1 聚合清单,分子 = live 枚举。',
1062 '- **FeStub 残留检测**:每路由加载后检查 `data-fe-stub` 元素;仍渲染占位 → 该 FE 的 tdd 漏做占位替换(硬缺陷),归 `interactionFailures[kind="no-observable-effect"]`(locator=router 文件该路由 import 行,detail 写「路由仍指向 FeStub 占位」)。', 1066 '- **FeStub 残留检测**:每路由加载后检查 `data-fe-stub` 元素;仍渲染占位 → 该 FE 的 tdd 漏做占位替换(硬缺陷),归 `interactionFailures[kind="no-observable-effect"]`(locator=router 文件该路由 import 行,detail 写「路由仍指向 FeStub 占位」)。',
1063 '- 分母有但首帧无的控件:runner 尝试**驱动到出现态**(种子保列表非空触发行级操作 / 进多步流程下屏 / 展开 dropdown / 切 tab 后二次枚举);仍不可达 → `coverageGaps[reason="deep-control-not-driven"]`,不静默判 green。到不了的路由 → `coverageGaps[reason="unreachable-auth"|"unreachable-no-route"]`,与「到达了但控件死」严格区分。', 1067 '- 分母有但首帧无的控件:runner 尝试**驱动到出现态**(种子保列表非空触发行级操作 / 进多步流程下屏 / 展开 dropdown / 切 tab 后二次枚举);仍不可达 → `coverageGaps[reason="deep-control-not-driven"]`,不静默判 green。到不了的路由 → `coverageGaps[reason="unreachable-auth"|"unreachable-no-route"]`,与「到达了但控件死」严格区分。',
@@ -1094,7 +1098,7 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { @@ -1094,7 +1098,7 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1094 '', 1098 '',
1095 '## 输出(必须符合下发的 BEHAVIOR_GATE JSON schema)', 1099 '## 输出(必须符合下发的 BEHAVIOR_GATE JSON schema)',
1096 '- `status`: `green`(交互层无失败 + 文字层无 sentinel 类失败 + **样式层无失败** + 无阻断性 envError + 覆盖非空)| `red`。', 1100 '- `status`: `green`(交互层无失败 + 文字层无 sentinel 类失败 + **样式层无失败** + 无阻断性 envError + 覆盖非空)| `red`。',
1097 - '- `routesPlanned` / `routesReached` / `controlsEnumerated`: 整数,据实填(**只数全部 FE 作用域并集**;空覆盖必须可见)。', 1101 + '- `routesPlanned` / `routesReached` / `controlsEnumerated`: 整数,据实填(**只数清单内 FE 的作用域并集**;空覆盖必须可见)。',
1098 '- `interactionFailures` / `textIssues` / `styleIssues` / `coverageGaps`: 见 schema 的 kind / source / reason 枚举;硬问题 A 类带 `locator`(含 `source="sentinel"` 的 textIssue 与全部 styleIssues)。', 1102 '- `interactionFailures` / `textIssues` / `styleIssues` / `coverageGaps`: 见 schema 的 kind / source / reason 枚举;硬问题 A 类带 `locator`(含 `source="sentinel"` 的 textIssue 与全部 styleIssues)。',
1099 '- `envError`: 无环境问题填 `{ "kind": "none" }`;有则填对应 kind + detail + ports + pids;`build-failed` 时填 `rootCausePath`。', 1103 '- `envError`: 无环境问题填 `{ "kind": "none" }`;有则填对应 kind + detail + ports + pids;`build-failed` 时填 `rootCausePath`。',
1100 '- 做过任何自主默认 → `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。', 1104 '- 做过任何自主默认 → `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。',
@@ -1135,7 +1139,10 @@ function behaviorReverifyPrompt(behaviorRound, fixedCount, c) { @@ -1135,7 +1139,10 @@ function behaviorReverifyPrompt(behaviorRound, fixedCount, c) {
1135 // 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(未实现 FE 路由指向 FeStub 占位) 1139 // 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(未实现 FE 路由指向 FeStub 占位)
1136 // + 不指悬空 path 的共享导航——保证「前端只建了一部分」的任意时刻 app 仍可构建可起、每个 FE 路由可达。 1140 // + 不指悬空 path 的共享导航——保证「前端只建了一部分」的任意时刻 app 仍可构建可起、每个 FE 路由可达。
1137 // 由此逐 FE 的 verify(e2e) 与阶段末尾行为门的「可构建前提」成立、tddPrompt 的占位替换有真值起点。 1141 // 由此逐 FE 的 verify(e2e) 与阶段末尾行为门的「可构建前提」成立、tddPrompt 的占位替换有真值起点。
1138 -// feItems:本前端阶段的全部 FE-NN(来自 Router 的 frontend-phase 聚合模块),即 router 全量路由表的清单。 1142 +// feItems:**本轮待实现**的 FE-NN(来自 Router 的 frontend-phase 聚合模块 = 缺 `req-done/<FE-NN>` tag 的那些)。
  1143 +// 注意它**不等于** router 全量路由表:增量场景(add-req 新增一个 FE)下 feItems 只含新 FE,而全量路由表
  1144 +// 的真值是 docs/08 §三 `功能:` 下的**全部** FE 行(含已 `[x]` 完成的)。故本 builder 里两者分工固定——
  1145 +// 全量路由表按 docs/08 声明齐;feItems 内的路由指向 FeStub 占位,feItems 外(已实现)的路由保持真组件不动。
1139 // lane 注入豁免(附录补 10 审计口径):本 builder 仅 frontend-phase 调用(终波恒宽 1、主根执行), 1146 // lane 注入豁免(附录补 10 审计口径):本 builder 仅 frontend-phase 调用(终波恒宽 1、主根执行),
1140 // globalSetup 文案里的 seed-demo-data.mjs 字样不需要 ERP_TEST_DB_SCHEMA 注入分支。 1147 // globalSetup 文案里的 seed-demo-data.mjs 字样不需要 ERP_TEST_DB_SCHEMA 注入分支。
1141 function frontendSkeletonPrompt(feItems, c) { 1148 function frontendSkeletonPrompt(feItems, c) {
@@ -1149,62 +1156,79 @@ function frontendSkeletonPrompt(feItems, c) { @@ -1149,62 +1156,79 @@ function frontendSkeletonPrompt(feItems, c) {
1149 '在逐 FE 实现开始**之前**,一次性建出前端「可构建可起」的骨架:App 外壳 + router **全量** lazy 路由表(每个 FE 路由都声明,未实现的指向占位组件 `FeStub`)+ 不指悬空 path 的共享导航。', 1156 '在逐 FE 实现开始**之前**,一次性建出前端「可构建可起」的骨架:App 外壳 + router **全量** lazy 路由表(每个 FE 路由都声明,未实现的指向占位组件 `FeStub`)+ 不指悬空 path 的共享导航。',
1150 '保证后续「只建了一部分 FE」的任意时刻 `vite build` / dev server 都能起、每个 FE 路由都可达(加载到占位);逐 FE 实现时再把对应路由的 import 从 `FeStub` 换成真组件。', 1157 '保证后续「只建了一部分 FE」的任意时刻 `vite build` / dev server 都能起、每个 FE 路由都可达(加载到占位);逐 FE 实现时再把对应路由的 import 从 `FeStub` 换成真组件。',
1151 '', 1158 '',
1152 - `## 本前端阶段 FE 清单(router 全量路由表必须覆盖的全部 FE)`,  
1153 - `- ${list}`, 1159 + '## 两个清单,别混(增量重跑时区别是实质性的)',
  1160 + `- **全量路由表清单** = \`${c.root}/docs/08-模块任务管理.md § 三\` \`功能:\` 项下的**全部** \`FE-NN\` 行(**含**已勾 \`[x]\` 的)。router 必须把它们**全部**声明——这是"全量"的唯一真值来源。`,
  1161 + `- **本轮待实现清单** = 下面这几个(编排层按缺 \`req-done/<FE-NN>\` tag 算出):${list}。**只有**它们的路由指向 \`FeStub\` 占位。`,
  1162 + '- **不在本轮清单里的 FE**(docs/08 有、上面没有 = 往轮已实现):其路由**保持现状指向真组件**——**绝不**改回 `FeStub`、**绝不**删除、**绝不**因"统一风格"重写。改了就是把已验收的功能打回占位态,行为门会当成回归红灯。',
  1163 + '- 首轮跑时两个清单恰好相等(全部 FE 都没 tag),此时"全部指向 FeStub"是对的;**增量重跑**(add-req 新增/改动少数 FE)时两者不等,按上面分工处理。',
1154 '', 1164 '',
1155 '## 收集上下文(确定技术栈 + 目录约定 + 路由)', 1165 '## 收集上下文(确定技术栈 + 目录约定 + 路由)',
1156 `- \`${c.root}/docs/04-技术规范.md § 零\`(\`frontend.ui_lib\` / framework / 构建工具)+ \`§ 二 前端规范\`(§ 2.1 目录约定 = 落盘位置 / 路由库 / 入口文件名)。`, 1166 `- \`${c.root}/docs/04-技术规范.md § 零\`(\`frontend.ui_lib\` / framework / 构建工具)+ \`§ 二 前端规范\`(§ 2.1 目录约定 = 落盘位置 / 路由库 / 入口文件名)。`,
1157 - `- \`${c.root}/docs/08-模块任务管理.md § 三\`(前端阶段元数据 + \`功能:\` 下全部 \`FE-NN\` 行;与上面清单核对,以本提示给出的清单为准)。`,  
1158 - `- \`${c.root}/docs/01-需求清单/\` 各 FE 关联 REQ + \`${c.root}/prototype/\`(页面/路由结构权威)+ \`${c.root}/docs/05-API接口契约.md\`,据此推导每个 FE-NN 对应的**路由 path**(带参动态路由保留 \`:id\` 占位)。`,  
1159 - `- 用 Grep 在 \`${c.root}/frontend/\` 探测现有 App 外壳 / 入口 / router 是否已存在(幂等:已存在则按需补齐,不重复创建/不覆盖已实现的真组件)。`, 1167 + `- \`${c.root}/docs/08-模块任务管理.md § 三\`(前端阶段元数据 + \`功能:\` 下**全部** \`FE-NN\` 行)——**这是全量路由表清单的权威来源**,逐行取出(\`[ ]\` 与 \`[x]\` 都要)。本提示给出的"本轮待实现清单"只决定**哪些指向 FeStub**,不决定路由表覆盖范围;两者若不等,是增量重跑的正常态,不是矛盾。`,
  1168 + `- \`${c.root}/docs/01-需求清单/\` 各 FE 关联 REQ + \`${c.root}/prototype/\`(页面/路由结构权威)+ \`${c.root}/docs/05-API接口契约.md\`,据此推导**每个(docs/08 全量清单里的每一个)** FE-NN 对应的**路由 path**(带参动态路由保留 \`:id\` 占位)。`,
  1169 + `- 用 Grep 在 \`${c.root}/frontend/\` 探测现有 App 外壳 / 入口 / router 是否已存在(幂等:已存在则按需补齐,不重复创建/不覆盖已实现的真组件)。已存在的 router 表要**逐路由记下现有 component 指向**(真组件 vs \`FeStub\`),据此判断哪些该原样保留。`,
1160 '', 1170 '',
1161 '## 产出(全部落在 `frontend/` 路径内——遵守前端阶段路径作用域护栏)', 1171 '## 产出(全部落在 `frontend/` 路径内——遵守前端阶段路径作用域护栏)',
1162 '1. **App 外壳 + 入口**:`frontend/src/App.*` 与入口 `frontend/src/main.*`(按 framework / docs/04 约定的扩展名;不存在才创建)。挂载共享布局 + `<router-view>`(或等价 outlet)。', 1172 '1. **App 外壳 + 入口**:`frontend/src/App.*` 与入口 `frontend/src/main.*`(按 framework / docs/04 约定的扩展名;不存在才创建)。挂载共享布局 + `<router-view>`(或等价 outlet)。',
1163 '2. **router 全量路由表**(按 docs/04 § 2.1 约定的路由文件位置,如 `frontend/src/router/index.*`):', 1173 '2. **router 全量路由表**(按 docs/04 § 2.1 约定的路由文件位置,如 `frontend/src/router/index.*`):',
1164 - ' - **每个** FE-NN 对应路由都声明,**全部用 lazy import**(`component: () => import(...)` 或 framework 等价的动态 import;**绝不** eager `import X from ...` 顶部静态引入,否则未建组件会让整表编译失败)。',  
1165 - ' - **未实现的 FE 路由全部指向占位组件 `FeStub`**:`component: () => import("../views/_stub/FeStub.vue")`(或 framework 等价)。逐 FE 实现后由 tdd stage 把对应路由 import 换成真组件。', 1174 + ' - **docs/08 全量清单里的每个** FE-NN 对应路由都声明,**全部用 lazy import**(`component: () => import(...)` 或 framework 等价的动态 import;**绝不** eager `import X from ...` 顶部静态引入,否则未建组件会让整表编译失败)。',
  1175 + ' - **本轮待实现清单里的 FE 路由指向占位组件 `FeStub`**:`component: () => import("../views/_stub/FeStub.vue")`(或 framework 等价)。逐 FE 实现后由 tdd stage 把对应路由 import 换成真组件。',
  1176 + ' - **清单外(往轮已实现)的 FE 路由:一律原样保留现有真组件指向**,只在它压根没声明时才补一条。这是增量重跑的关键——把它们改成 `FeStub` 等于抹掉已验收功能。',
1166 ' - 路由 path 取自上面推导的 FE→path 映射;带参路由用 `:id` 等占位。', 1177 ' - 路由 path 取自上面推导的 FE→path 映射;带参路由用 `:id` 等占位。',
1167 '3. **占位组件 `FeStub`**:`frontend/src/views/_stub/FeStub.vue`(framework 非 Vue 时落对应等价文件,如 `FeStub.tsx`),最小渲染一个带 `data-fe-stub` 属性的元素(如 `<div data-fe-stub>占位</div>`;行为门据 `data-fe-stub` 识别占位态)。**不实现任何业务逻辑**。', 1178 '3. **占位组件 `FeStub`**:`frontend/src/views/_stub/FeStub.vue`(framework 非 Vue 时落对应等价文件,如 `FeStub.tsx`),最小渲染一个带 `data-fe-stub` 属性的元素(如 `<div data-fe-stub>占位</div>`;行为门据 `data-fe-stub` 识别占位态)。**不实现任何业务逻辑**。',
1168 '4. **共享布局/导航**:导航链接**全部指向已在 router 声明的路由 path**(不指向任何不存在的 path),保证任意时刻无悬空链接。', 1179 '4. **共享布局/导航**:导航链接**全部指向已在 router 声明的路由 path**(不指向任何不存在的 path),保证任意时刻无悬空链接。',
1169 '5. **e2e 基线脚手架(全部落 `frontend/` 内)**:', 1180 '5. **e2e 基线脚手架(全部落 `frontend/` 内)**:',
1170 ' - **Playwright 配置**(按 docs/04 § 零 `frontend.e2e_runner` 约定,如 `frontend/playwright.config.*`):声明 `globalSetup` / `globalTeardown` 入口 + 共享 `storageState`。', 1181 ' - **Playwright 配置**(按 docs/04 § 零 `frontend.e2e_runner` 约定,如 `frontend/playwright.config.*`):声明 `globalSetup` / `globalTeardown` 入口 + 共享 `storageState`。',
  1182 + // 前端 dev server 就绪竞态(实测最贵的一类 flake):后端起栈一直有「轮询健康端点」的硬约束,
  1183 + // 前端 dev server 却从没被规定由谁起、何时算就绪——生成的项目普遍用「并行起 vite + 直接跑 playwright」,
  1184 + // 于是 e2e 在 dev server 监听之前就发连接。实测代价:某次后端 testGate 连红 4 次(attempt 1-4 全是
  1185 + // 「5180 dev server 未起 / 连接被拒」,49 分钟),第 5 次同代码变绿;前端 testGate 同型再来一次。
  1186 + // 每次红都是整轮 e2e 白跑,且因为「同代码重跑会绿」而被误判成 flaky,掩盖了真实根因。
  1187 + ' - **前端 dev server 必须由 Playwright 自己拥有并做就绪门(关键,防 e2e 连接竞态)**:在同一份配置里声明 `webServer` 段——`command` = 起前端 dev/preview 的命令(取自 `frontend/package.json` scripts),`url` = 前端 baseURL,`reuseExistingServer: true`,`timeout` 给足(≥120000)。Playwright 会**轮询 `url` 直到真正可连**才开始跑用例,这正是竞态的解药。',
  1188 + ' - **绝不**用「并行起 vite + 直接跑 playwright」(如 `concurrently` / `&` 后台起再立刻跑)这类**无就绪门**的写法——那是本项目历史上最贵的一类假红灯。若项目约定必须自起,则**必须**在 `globalSetup` 里显式轮询前端端口直到返回 2xx/3xx 再返回,超时抛明确错误(`前端 dev server 在 <N>s 内未就绪 @ <url>`),**不允许**静默继续让用例去撞连接拒绝。',
1171 ` - **globalSetup**(如 \`frontend/e2e/global-setup.*\`):\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)→ 冷起后端 + 轮询健康端点就绪(Flyway 在此把本轮新迁移 apply 到副本)→ 执行 \`node ${c.root}/scripts/seed-demo-data.mjs\`(灌演示种子进副本)→ 用 \`config-vars.yaml\` 的 \`admin_init\` 凭据经 \`docs/05-API接口契约.md\` 登录端点取 JWT,写 \`storageState\`(admin 登录态供 e2e 复用)。`, 1189 ` - **globalSetup**(如 \`frontend/e2e/global-setup.*\`):\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)→ 冷起后端 + 轮询健康端点就绪(Flyway 在此把本轮新迁移 apply 到副本)→ 执行 \`node ${c.root}/scripts/seed-demo-data.mjs\`(灌演示种子进副本)→ 用 \`config-vars.yaml\` 的 \`admin_init\` 凭据经 \`docs/05-API接口契约.md\` 登录端点取 JWT,写 \`storageState\`(admin 登录态供 e2e 复用)。`,
1172 // globalTeardown 只 kill 进程、**不删副本**:test.mjs 的晋升(promote-to-source)在 e2e 之后跑、 1190 // globalTeardown 只 kill 进程、**不删副本**:test.mjs 的晋升(promote-to-source)在 e2e 之后跑、
1173 // 靠读副本的 flyway_schema_history 算 delta——teardown 若删副本,promote 会静默判"无迁移可晋升", 1191 // 靠读副本的 flyway_schema_history 算 delta——teardown 若删副本,promote 会静默判"无迁移可晋升",
1174 // 晋升链路断且无声。副本清理归各闸门的 finally(test.mjs / seed runner / 行为门 runner)在 promote 后做; 1192 // 晋升链路断且无声。副本清理归各闸门的 finally(test.mjs / seed runner / 行为门 runner)在 promote 后做;
1175 // tdd 内循环单独跑 e2e 时副本留存到下次 globalSetup 重新复制,无害。 1193 // tdd 内循环单独跑 e2e 时副本留存到下次 globalSetup 重新复制,无害。
1176 ' - **globalTeardown**(如 `frontend/e2e/global-teardown.*`):kill globalSetup 起的后端进程树(**不**删副本——副本清理由外层闸门在晋升后负责)。', 1194 ' - **globalTeardown**(如 `frontend/e2e/global-teardown.*`):kill globalSetup 起的后端进程树(**不**删副本——副本清理由外层闸门在晋升后负责)。',
1177 - ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐。', 1195 + ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐;**已存在但缺 `webServer` 就绪门(或用了无就绪门的并行起法)→ 必须补齐/改正**,这是已知会造成整轮 e2e 假红的缺陷,不属于"已存在就不动"的范围。',
1178 '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/ 镜像路径后方可收窄)。', 1196 '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/ 镜像路径后方可收窄)。',
1179 '- **lazy 硬护栏**:router 表里**任何** FE 路由都不得用顶部静态 `import`;必须 `() => import(...)`。自检:Grep 路由文件,确认每个 FE 路由的 `component` 都是动态 import 形态。', 1197 '- **lazy 硬护栏**:router 表里**任何** FE 路由都不得用顶部静态 `import`;必须 `() => import(...)`。自检:Grep 路由文件,确认每个 FE 路由的 `component` 都是动态 import 形态。',
1180 '- **路径硬护栏**:所有产出文件必须以 `frontend/` 开头;命中 `backend/` / `sql/` / `scripts/` → 越界硬停。', 1198 '- **路径硬护栏**:所有产出文件必须以 `frontend/` 开头;命中 `backend/` / `sql/` / `scripts/` → 越界硬停。',
1181 '', 1199 '',
1182 '## 自检(可构建)', 1200 '## 自检(可构建)',
1183 - '- 推断本项目前端 build / typecheck 命令(docs/04 § 零 / `frontend/package.json` scripts)。若可在子会话内安全跑(不挂死),**派 Agent 子会话**跑一次 build / dev-server 就绪探测确认骨架可构建可起;不可行则至少静态核对「全部 FE 路由已声明 + 全 lazy + 导航无悬空 path + FeStub 存在」。', 1201 + '- 推断本项目前端 build / typecheck 命令(docs/04 § 零 / `frontend/package.json` scripts)。若可在子会话内安全跑(不挂死),**派 Agent 子会话**跑一次 build / dev-server 就绪探测确认骨架可构建可起;不可行则至少静态核对「docs/08 全量清单的每个 FE 路由都已声明 + 全 lazy + 导航无悬空 path + FeStub 存在」。',
  1202 + '- **增量回归自检**:若本次是在已有 router 上补齐(往轮已实现过 FE),再核对一遍「本轮待实现清单**之外**的 FE 路由,component 指向与改动前一致」——用 `git diff` 看 router 文件,出现「真组件 → FeStub」的行即为误伤,必须改回。',
1184 '- 占位符扫描:`TBD` / `TODO` / `【人工填写:】` → 命中即修。', 1203 '- 占位符扫描:`TBD` / `TODO` / `【人工填写:】` → 命中即修。',
1185 '', 1204 '',
1186 commitBlock('frontend/', 'feat(fe-skeleton): App 外壳 + router 全量 lazy 路由表 + FeStub 占位', 1205 commitBlock('frontend/', 'feat(fe-skeleton): App 外壳 + router 全量 lazy 路由表 + FeStub 占位',
1187 '- commit 失败 → halt,把 stderr 摘要写进 reason。', c), 1206 '- commit 失败 → halt,把 stderr 摘要写进 reason。', c),
1188 '', 1207 '',
1189 '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', 1208 '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
1190 - '- 成功:`{ "status": "ok", "summary": "<已声明的 FE 路由数 / 入口与 router 文件路径摘要>" }`(artifactPath 可省)。', 1209 + '- 成功:`{ "status": "ok", "summary": "<路由表总条数 / 其中指向 FeStub 的条数 / 保留真组件的条数 / 入口与 router 文件路径摘要>" }`(artifactPath 可省)。',
1191 '- 任一护栏 / 缺值(如无法推导某 FE 的路由 path 且无任何旁证)→ `{ "status": "halt", "reason": "<具体阻塞点>" }`。', 1210 '- 任一护栏 / 缺值(如无法推导某 FE 的路由 path 且无任何旁证)→ `{ "status": "halt", "reason": "<具体阻塞点>" }`。',
1192 '- 做过自主默认 → `decisions[]` 逐条登记;schema 是 `additionalProperties:false`,不要返回额外字段。', 1211 '- 做过自主默认 → `decisions[]` 逐条登记;schema 是 `additionalProperties:false`,不要返回额外字段。',
1193 ].filter(Boolean).join('\n') 1212 ].filter(Boolean).join('\n')
1194 } 1213 }
1195 1214
1196 -// fe-skeleton 幂等判定:检测 router 是否已声明本阶段全部 FE 路由(全量 + 全 lazy)。 1215 +// fe-skeleton 幂等判定:检测**本轮待实现**的 FE 路由是否已在 router 声明(+ 全 lazy + 基线脚手架齐)。
  1216 +// 判据只看 feItems(不看 docs/08 全量)——往轮已实现的 FE,其路由必然已声明且指向真组件,不构成缺口;
  1217 +// 拿全量当判据反而会在增量重跑时误判为"缺",白跑一次骨架 stage。
1197 // router/state 是骨架真实完成态;fe-skeleton-done tag 只作补记,避免陈旧 tag 跳过缺失骨架。 1218 // router/state 是骨架真实完成态;fe-skeleton-done tag 只作补记,避免陈旧 tag 跳过缺失骨架。
1198 function frontendSkeletonStatePromptM(feItems, c) { 1219 function frontendSkeletonStatePromptM(feItems, c) {
1199 const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无)' 1220 const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无)'
1200 return [ 1221 return [
1201 - '# 检测前端骨架是否已建(router 已声明全部 FE 路由 + 全 lazy)', 1222 + '# 检测前端骨架是否已建(本轮待实现的 FE 路由已声明 + 全 lazy + 基线齐)',
1202 microStepContract(c.root), 1223 microStepContract(c.root),
1203 '', 1224 '',
1204 - `用 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.*\`),**且单测基线存在**——vitest 配置 \`include\` 限定 \`tests/**/*.test.*\`。`, 1225 + `用 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.*\`。`,
  1226 + '- **就绪门判据从严**:只要找不到 `webServer.url`、也找不到 globalSetup 内对前端端口的显式轮询(哪怕 e2e 目前跑得通)→ 判 `exists:false`。这是已知会让整轮 e2e 假红 40+ 分钟的竞态缺陷,值得多跑一次骨架 stage 去补。',
  1227 + '- 判据**只针对上面列出的 FE**:router 里其它 FE 路由(往轮已实现,指向真组件)一律视为满足,**不要**因为它们没指向 `FeStub` 就判缺失。',
  1228 + '- 上面列出的 FE,其路由若已指向**真组件**(resume:上次已实现到一半)同样视为满足——本检测只问"路由在不在、是不是 lazy",不问指向谁。',
1205 `- **legacy 豁免**:若 \`${c.root}/frontend/src/\` 内已存在测试文件(\`*.test.*\` / \`*.spec.*\`,旧约定 colocation 残留),vitest include 项**不作为缺失判据**(视为满足)——该状态须人工迁移决策,绝不由骨架重跑静默收窄 include 停跑旧测试。`, 1229 `- **legacy 豁免**:若 \`${c.root}/frontend/src/\` 内已存在测试文件(\`*.test.*\` / \`*.spec.*\`,旧约定 colocation 残留),vitest include 项**不作为缺失判据**(视为满足)——该状态须人工迁移决策,绝不由骨架重跑静默收窄 include 停跑旧测试。`,
1206 '- 全部满足(骨架已建齐,含 e2e 基线脚手架 + 单测基线)→ `{ "exists": true }`', 1230 '- 全部满足(骨架已建齐,含 e2e 基线脚手架 + 单测基线)→ `{ "exists": true }`',
1207 - '- 任一缺失(无 router / 缺某 FE 路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup / vitest include 未限定 tests/**/*.test.*(且非 legacy 豁免))→ `{ "exists": false }`', 1231 + '- 任一缺失(无 router / 缺上面某个待实现 FE 的路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup / **缺前端 dev server 就绪门** / vitest include 未限定 tests/**/*.test.*(且非 legacy 豁免))→ `{ "exists": false }`',
1208 '## 输出(EXISTS_SCHEMA)', 1232 '## 输出(EXISTS_SCHEMA)',
1209 ].join('\n') 1233 ].join('\n')
1210 } 1234 }
@@ -2497,15 +2521,16 @@ async function runCrossModule(module, c) { @@ -2497,15 +2521,16 @@ async function runCrossModule(module, c) {
2497 2521
2498 // ---- runFrontendSkeleton:前端骨架占位 stage 的 JS 编排(设计 § 2,前置依赖 A)---- 2522 // ---- runFrontendSkeleton:前端骨架占位 stage 的 JS 编排(设计 § 2,前置依赖 A)----
2499 // 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(FeStub 占位)+ 无悬空导航。 2523 // 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(FeStub 占位)+ 无悬空导航。
  2524 +// feItems = 本轮待实现的 FE(缺 req-done tag)——只决定哪些路由指向 FeStub;路由表覆盖范围按 docs/08 全量。
2500 // 幂等(resume 安全):router/state 是唯一真实完成态;fe-skeleton-done 只作补记,避免陈旧 tag 跳过缺失骨架。 2525 // 幂等(resume 安全):router/state 是唯一真实完成态;fe-skeleton-done 只作补记,避免陈旧 tag 跳过缺失骨架。
2501 async function runFrontendSkeleton(feItems, c) { 2526 async function runFrontendSkeleton(feItems, c) {
2502 const lbl = (k) => `fe-skeleton:${k}` 2527 const lbl = (k) => `fe-skeleton:${k}`
2503 2528
2504 - // step 1: 检查 router 是否已声明全 FE 路由;已建则只确保补记 tag 存在。 2529 + // step 1: 检查本轮待实现的 FE 路由是否已在 router 声明;已建则只确保补记 tag 存在。
2505 const state = await agentR(frontendSkeletonStatePromptM(feItems, c), 2530 const state = await agentR(frontendSkeletonStatePromptM(feItems, c),
2506 {label: lbl('state?'), phase: 'Frontend', schema: EXISTS_SCHEMA}) 2531 {label: lbl('state?'), phase: 'Frontend', schema: EXISTS_SCHEMA})
2507 if (state.exists) { 2532 if (state.exists) {
2508 - log('fe-skeleton: router 已声明全部 FE 路由,确保 fe-skeleton-done tag 存在') 2533 + log('fe-skeleton: router 已声明本轮待实现的全部 FE 路由,确保 fe-skeleton-done tag 存在')
2509 await runAction(g => createFeSkeletonTagPromptM(c) + g, 2534 await runAction(g => createFeSkeletonTagPromptM(c) + g,
2510 {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag'), dec: c.decisions, root: c.root}) 2535 {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag'), dec: c.decisions, root: c.root})
2511 return 2536 return
@@ -2519,7 +2544,7 @@ async function runFrontendSkeleton(feItems, c) { @@ -2519,7 +2544,7 @@ async function runFrontendSkeleton(feItems, c) {
2519 await runAction(g => createFeSkeletonTagPromptM(c) + g, 2544 await runAction(g => createFeSkeletonTagPromptM(c) + g,
2520 {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag'), dec: c.decisions, root: c.root}) 2545 {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag'), dec: c.decisions, root: c.root})
2521 2546
2522 - log(`fe-skeleton: 已生成前端骨架(覆盖 ${(feItems || []).length} 个 FE 路由),打 fe-skeleton-done tag`) 2547 + log(`fe-skeleton: 已生成/补齐前端骨架(本轮待实现 ${(feItems || []).length} 个 FE 指向 FeStub;路由表按 docs/08 全量声明),打 fe-skeleton-done tag`)
2523 } 2548 }
2524 2549
2525 // ============================================================================ 2550 // ============================================================================