Commit 8a8e65a9e73f2ef3e2ef2d8e2c53574d456e0627
1 parent
778861b9
workflow: trim dev-only overengineering
Showing
13 changed files
with
156 additions
and
604 deletions
README.md
| @@ -128,7 +128,7 @@ erp-workflow-plugin/ | @@ -128,7 +128,7 @@ erp-workflow-plugin/ | ||
| 128 | | A1 | `scope-lock` | • 引导填项目概述 / 技术栈 / 需求索引<br>• 按 `docs/01-需求清单/<module>/{_module.md, REQ-*.md}` 子目录结构生成 REQ 卡片(CC 据 index.md 填 `{{req_id/title/goal/rules/constraints/acceptance}}` 6 个占位,模板其余内容含输入/输出示例字段表原样复制)<br>• **A1 终结校验**:REQ 6 个占位均填真实数据、无 `{{` 残留、`config-vars.yaml` **全部配置**(包名 / 端口 / 初始账号 + DB 凭据 / 密钥占位)已锁、各 stack 的 build/lint/unit/e2e 命令写入 docs/04 § 零;缺失则在此(Plan 期)用 `AskUserQuestion` 问清(敏感凭据由用户自填,不进会话)<br>• 据模板直接 `Write` 生成 `_module.md` / `REQ-*.md`<br>• 终结校验通过后**自动**调用 `Skill(skeleton-gen)` 进入 A2(不停下) | A0 | | 128 | | A1 | `scope-lock` | • 引导填项目概述 / 技术栈 / 需求索引<br>• 按 `docs/01-需求清单/<module>/{_module.md, REQ-*.md}` 子目录结构生成 REQ 卡片(CC 据 index.md 填 `{{req_id/title/goal/rules/constraints/acceptance}}` 6 个占位,模板其余内容含输入/输出示例字段表原样复制)<br>• **A1 终结校验**:REQ 6 个占位均填真实数据、无 `{{` 残留、`config-vars.yaml` **全部配置**(包名 / 端口 / 初始账号 + DB 凭据 / 密钥占位)已锁、各 stack 的 build/lint/unit/e2e 命令写入 docs/04 § 零;缺失则在此(Plan 期)用 `AskUserQuestion` 问清(敏感凭据由用户自填,不进会话)<br>• 据模板直接 `Write` 生成 `_module.md` / `REQ-*.md`<br>• 终结校验通过后**自动**调用 `Skill(skeleton-gen)` 进入 A2(不停下) | A0 | |
| 129 | | A2 | `skeleton-gen` | • 生成架构文档:docs/04 § 一+<br>• 生成跨平台工具脚本:`scripts/*.mjs`(**无 chmod**;凭据 / 配置统一在 A1 产出的 config-vars.yaml)<br>• 据 `gitignore-append-template` 用 Read/Write 并入项目 .gitignore | `plan-start` | | 129 | | A2 | `skeleton-gen` | • 生成架构文档:docs/04 § 一+<br>• 生成跨平台工具脚本:`scripts/*.mjs`(**无 chmod**;凭据 / 配置统一在 A1 产出的 config-vars.yaml)<br>• 据 `gitignore-append-template` 用 Read/Write 并入项目 .gitignore | `plan-start` | |
| 130 | | A3 | `db-design-gen` | • 套用固定 ERP 约定(列前缀 `i/s/t`、`iIncrement` 主键、`sBrandsId`/`sSubsidiaryId` 租户列)从 docs/01 REQ 卡片正向设计 `docs/03-数据库设计文档.md`(schema SSoT)<br>• 回填 REQ 卡片依赖表(`TBD(A3 自动补)` → 实际表名)<br>• **停下**等人工审阅 docs/03,审阅完毕用 `/plan-start` 续进 A4 | A2 | | 130 | | A3 | `db-design-gen` | • 套用固定 ERP 约定(列前缀 `i/s/t`、`iIncrement` 主键、`sBrandsId`/`sSubsidiaryId` 租户列)从 docs/01 REQ 卡片正向设计 `docs/03-数据库设计文档.md`(schema SSoT)<br>• 回填 REQ 卡片依赖表(`TBD(A3 自动补)` → 实际表名)<br>• **停下**等人工审阅 docs/03,审阅完毕用 `/plan-start` 续进 A4 | A2 | |
| 131 | -| A4 | `db-init` | • LLM 解析 docs/03 → `sql/migrations/V1__initial_schema.sql`(DDL only)<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 校验 DDL ↔ docs/03(5 维:表/列名/列类型/索引/FK),fail-closed<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs config-vars.yaml V1.sql`(解析 config-vars.yaml database: 段 + mysql2 apply,**无 shell-source**) | A3 | | 131 | +| A4 | `db-init` | • LLM 解析 docs/03 → `sql/migrations/V1__initial_schema.sql`(DDL only)<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 校验 DDL ↔ docs/03(5 维:表/列名/列类型/索引/FK),fail-closed<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs config-vars.yaml V1.sql`(读取 config-vars.yaml database: 段 + mysql2 apply) | A3 | |
| 132 | | A5 | `downstream-gen` | • 一次性生成 docs/02 / docs/05<br>• 回填 REQ 卡片依赖接口(`TBD(A5 自动补)` → 实际 endpoint)<br>• 追加模块清单到 docs/08 § 二<br>• **docs/05 + docs/02 评审闸**:用 `AskUserQuestion` 让用户确认 API 端点/字段无误 + 构建顺序可接受,未确认不勾 A5<br>• **prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三**(原 A6 已并入;无 prototype 则问「无前端」→ § 三 留空)<br>• 最终占位符 + 结构残留扫描 | A4 | | 132 | | A5 | `downstream-gen` | • 一次性生成 docs/02 / docs/05<br>• 回填 REQ 卡片依赖接口(`TBD(A5 自动补)` → 实际 endpoint)<br>• 追加模块清单到 docs/08 § 二<br>• **docs/05 + docs/02 评审闸**:用 `AskUserQuestion` 让用户确认 API 端点/字段无误 + 构建顺序可接受,未确认不勾 A5<br>• **prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三**(原 A6 已并入;无 prototype 则问「无前端」→ § 三 留空)<br>• 最终占位符 + 结构残留扫描 | A4 | |
| 133 | 133 | ||
| 134 | ### Coding 阶段(1 个 Workflow,非 skill) | 134 | ### Coding 阶段(1 个 Workflow,非 skill) |
| @@ -153,7 +153,7 @@ erp-workflow-plugin/ | @@ -153,7 +153,7 @@ erp-workflow-plugin/ | ||
| 153 | | scope-lock | `_module-template.md` | 模块子目录的 `_module.md` 模块头(模块代码-名 / 简述 / 依赖模块 TBD / 涉及表 TBD) | | 153 | | scope-lock | `_module-template.md` | 模块子目录的 `_module.md` 模块头(模块代码-名 / 简述 / 依赖模块 TBD / 涉及表 TBD) | |
| 154 | | scope-lock | `config-vars-template.yaml` | 仓库根 `config-vars.yaml` 骨架(跨栈中立):项目**全部配置**——非敏感(包名/端口/前端包名/初始账号)+ 敏感凭据(database / admin_init.password / secrets);A1 E.2 锁定,随项目提交 | | 154 | | scope-lock | `config-vars-template.yaml` | 仓库根 `config-vars.yaml` 骨架(跨栈中立):项目**全部配置**——非敏感(包名/端口/前端包名/初始账号)+ 敏感凭据(database / admin_init.password / secrets);A1 E.2 锁定,随项目提交 | |
| 155 | | skeleton-gen | `docs-04-skeleton-template.md` | docs/04 § 一+ 编码规范大纲(HTML 注释引导 LLM) | | 155 | | skeleton-gen | `docs-04-skeleton-template.md` | docs/04 § 一+ 编码规范大纲(HTML 注释引导 LLM) | |
| 156 | -| skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台 drop + create 空库脚本(内联极简 YAML 读 config-vars.yaml database: 段,无 shell-source);schema apply 交给 Flyway | | 156 | +| skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台 drop + create 空库脚本(内联极简 YAML 读 config-vars.yaml database: 段);schema apply 交给 Flyway | |
| 157 | | skeleton-gen | `scripts-test-template.mjs` | test.mjs 骨架(命令槽位按后端/前端/build/lint/test/e2e 分开,`spawnSync(shell:true)` 跨平台执行) | | 157 | | skeleton-gen | `scripts-test-template.mjs` | test.mjs 骨架(命令槽位按后端/前端/build/lint/test/e2e 分开,`spawnSync(shell:true)` 跨平台执行) | |
| 158 | | skeleton-gen | `gitignore-append-template` | 插件推荐忽略项(`.tmp/`、构建产物等;config-vars.yaml 随项目提交,不忽略) | | 158 | | skeleton-gen | `gitignore-append-template` | 插件推荐忽略项(`.tmp/`、构建产物等;config-vars.yaml 随项目提交,不忽略) | |
| 159 | | skeleton-gen | `styles-tokens-template.css` | 前端 design tokens CSS 变量骨架 | | 159 | | skeleton-gen | `styles-tokens-template.css` | 前端 design tokens CSS 变量骨架 | |
| @@ -167,7 +167,7 @@ erp-workflow-plugin/ | @@ -167,7 +167,7 @@ erp-workflow-plugin/ | ||
| 167 | ## 前置依赖 | 167 | ## 前置依赖 |
| 168 | 168 | ||
| 169 | - **Node.js ≥ 18**:`lib/*.mjs` 助手 + 生成进目标项目的 `scripts/*.mjs` 为 Node ESM;`workflows/coding.mjs` 是 Claude Workflow 运行时脚本(由 `Workflow` 工具执行,不作为普通 `node` CLI 入口)。A0 `project-init` 检测 git / mysql / node 在 PATH,缺失则按 OS 自动安装,装不上再停下提示用户 | 169 | - **Node.js ≥ 18**:`lib/*.mjs` 助手 + 生成进目标项目的 `scripts/*.mjs` 为 Node ESM;`workflows/coding.mjs` 是 Claude Workflow 运行时脚本(由 `Workflow` 工具执行,不作为普通 `node` CLI 入口)。A0 `project-init` 检测 git / mysql / node 在 PATH,缺失则按 OS 自动安装,装不上再停下提示用户 |
| 170 | -- **MySQL 8.x** 实例已就绪(host / 库名 / 凭据取自 `config-vars.yaml` 的 `database:` 段,由你填写并完全信任——`setup-test-db.mjs` 会按该值无条件 DROP+CREATE,请确保 schema 指向测试库而非生产库) | 170 | +- **MySQL 8.x** 实例已就绪(host / 库名 / 凭据取自 `config-vars.yaml` 的 `database:` 段,由你填写并完全信任;本项目只面向开发/沙盒环境,`setup-test-db.mjs` 会按该值 DROP+CREATE) |
| 171 | - **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/apply-ddl.mjs` 用 mysql2 连接 + 解析 config-vars.yaml `database:` 段 apply V1;生成的 `scripts/setup-test-db.mjs` 在测试闸门前后 drop+create 空库 | 171 | - **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/apply-ddl.mjs` 用 mysql2 连接 + 解析 config-vars.yaml `database:` 段 apply V1;生成的 `scripts/setup-test-db.mjs` 在测试闸门前后 drop+create 空库 |
| 172 | - **Spring Boot + Flyway**(**必需**):pom.xml 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。本插件生成的 `setup-test-db.mjs` 只清库,schema 必须由 Flyway 应用 | 172 | - **Spring Boot + Flyway**(**必需**):pom.xml 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。本插件生成的 `setup-test-db.mjs` 只清库,schema 必须由 Flyway 应用 |
| 173 | - **本地 git 仓库**(纯本地,无需远程):A0 `project-init` 执行 `git init`;B 阶段每模块由 `coding.mjs` 的 milestone stage 本地 `git merge --no-ff` 进默认分支并 `git tag -a milestone/<id>`,完成信号由 `git tag -l` 判定。**不依赖任何远程仓库 / push / GitLab** | 173 | - **本地 git 仓库**(纯本地,无需远程):A0 `project-init` 执行 `git init`;B 阶段每模块由 `coding.mjs` 的 milestone stage 本地 `git merge --no-ff` 进默认分支并 `git tag -a milestone/<id>`,完成信号由 `git tag -l` 判定。**不依赖任何远程仓库 / push / GitLab** |
docs/design/2026-06-02-frontend-behavior-gate.md
| 1 | -# 前端行为门(behavior-gate)— 最终设计(综合评审后) | 1 | +# 前端行为门(旧阶段级设计,已作废) |
| 2 | 2 | ||
| 3 | -> ⚠️ **已作废(SUPERSEDED)** —— 本文描述的是**阶段级、只读、red 即 halt** 的行为门(frontend-phase 末尾跑一次)。 | ||
| 4 | -> 该设计已被 **per-FE 版**取代:行为验收并入每个 FE 的 `reviewWithFixLoop`、成为可 fix 的验收维度(verify→fix→重验循环), | ||
| 5 | -> 并新增前端骨架占位阶段(`runFrontendSkeleton` + `FeStub` 全量 lazy 路由)保证中途可构建。 | ||
| 6 | -> **现行设计见 [`2026-06-02-frontend-behavior-in-review-loop.md`](./2026-06-02-frontend-behavior-in-review-loop.md)**;本文仅作历史保留,勿据此实现。 | 3 | +> 状态:SUPERSEDED。当前实现依据见 [`2026-06-02-frontend-behavior-in-review-loop.md`](./2026-06-02-frontend-behavior-in-review-loop.md)。 |
| 7 | 4 | ||
| 8 | -> 本文是 5 维对抗式评审后的收敛版。锁定决策(用户拍板)默认保留;评审给出的有依据调整已并入; | ||
| 9 | -> 无依据 / 过度工程的建议在文末「拒绝的建议」记明理由。所有改动可追溯到 changeLog。 | 5 | +本文原先描述的是“frontend-phase 末尾跑一次、只读、red 即 halt”的阶段级行为门。该方案已被 per-FE 方案取代:行为验收并入每个 FE 的 `reviewWithFixLoop` approve 子门,行为硬问题带 locator 后进入 fix→重验循环。 |
| 10 | 6 | ||
| 11 | -## 用户目标 | ||
| 12 | -确保前端「每个按钮 / 点击都真的生效、每段文字都显示正确内容(right context)」。在全自动静默的 | ||
| 13 | -`coding.mjs` 编码阶段中,新增一个 headless 自动化门来达成。 | 7 | +保留的历史结论: |
| 14 | 8 | ||
| 15 | -## 锁定决策(用户拍板,默认不可推翻) | ||
| 16 | -1. 机制 / 位置:headless,新增 behavior-gate stage 嵌入 `coding.mjs`;阶段级,跑在 frontend | ||
| 17 | - `testGate` 变绿之后、`report`/`runMilestone` 之前;仅作用于末尾的 `frontend-phase` 聚合模块。 | ||
| 18 | -2. 期望来源:门运行时**即时推导**(从 `prototype/` + REQ 卡片 + `docs/05`),不预先持久化为契约; | ||
| 19 | - 但把推导出的期望写进已提交的证据报告供事后审计。 | ||
| 20 | -3. 数据:全栈 + 种子库。`setup-test-db` DROP+CREATE → Flyway 建 schema,门用 `docs/03` 生成 FK 有序 | ||
| 21 | - INSERT 种子(带可辨识 sentinel 值),起后端 + 前端,断言动态文字等于 sentinel。 | ||
| 22 | -4. 失败语义:两层。交互缺陷(死控件 / 点击触发 JS 或 console 错误 / 应发的 docs05 调用未发)= 硬 RED | ||
| 23 | - halt;文字 / 内容不符 = 走既有 `adjudicate` 并记入 `decisions`;渲染出的绑定垃圾归为交互层。 | ||
| 24 | -5. 证据:即时推导但证据落盘。门把推导期望 + 逐控件判定 + 截图写入 | ||
| 25 | - `docs/superpowers/module-reports/frontend-phase-behavior-gate-r<attempt>.md` 并 commit | ||
| 26 | - (与 `*-test-gate-r*.md` 同构)。 | ||
| 27 | -6. 生成测试:临时。生成的 Playwright spec + 种子 + runner 写入已被 `.gitignore` 忽略的 | ||
| 28 | - `.tmp/behavior-gate/`,跑完即弃;只提交证据报告(避免生成套件悄悄变成永久契约源)。 | 9 | +- 阶段级末尾门会把所有 FE 的问题堆到最后,定位与修复成本高。 |
| 10 | +- per-FE 行为门必须只验当前 FE 的路由和控件,避免兄弟 FE 未实现污染归因。 | ||
| 11 | +- 起栈顺序仍是空库→后端/Flyway→sentinel 种子→前端 headless。 | ||
| 29 | 12 | ||
| 30 | -> **关于锁定决策 4 的两处微调(不推翻,仅细化,见 changeLog C8 / C11)**: | ||
| 31 | -> - 「绑定垃圾 = 硬 halt」收窄为**高置信子集**(`null`/`undefined`/`[object Object]`/`NaN`/lorem 出现在 | ||
| 32 | -> 数据绑定位)才硬 halt;「双花括号未渲染 / 空占位 `—` / 疑似 i18n key」降级为 textIssue 走 adjudicate | ||
| 33 | -> (否则误杀合法文案,且与「文字不符走 adjudicate」自相矛盾)。 | ||
| 34 | -> - 「sentinel 文字不符」从「一律走 adjudicate」改为**按来源二分**:绑定 sentinel 的动态文字不符是客观 | ||
| 35 | -> 可验证 bug,`allowContinue:false`(仲裁只许 retry/halt);i18n / 字面 / 语义等价类才 `allowContinue:true`。 | ||
| 36 | -> 这两处都是把锁定决策落到「不误报 + 不放行真 bug」的可实现状态,不改变其交互硬 / 文字软的总分层。 | ||
| 37 | - | ||
| 38 | ---- | ||
| 39 | - | ||
| 40 | -## 0. 运行时硬事实(设计成立的前提,已核查代码) | ||
| 41 | - | ||
| 42 | -这些是评审核查出的、与原 DESIGN 隐含假设冲突的事实,**最终设计必须自洽**: | ||
| 43 | - | ||
| 44 | -- **F1(无既有 e2e 起栈可复用)**:项目**不存在** `playwright.config` / `webServer` / `reuseExistingServer` | ||
| 45 | - / `e2e:ci`(全仓 grep 无生成物)。唯一 e2e 契约是 `scripts/test.mjs` 第 65 行的单条 shell 命令 | ||
| 46 | - `{{e2e_cmd}}`,来源是 scope-lock E.3 用 `AskUserQuestion` 填进 `docs/04 §零` 的**自由字符串**(无则记 | ||
| 47 | - `无`)。`coding.mjs` 里的 `pnpm e2e:ci` 只是 prompt 的 fallback 默认值,**无任何 Plan 产物保证它存在或 | ||
| 48 | - 自带 webServer**。→ 「复用既有 e2e 起栈」是空对象,必须显式探测 + 自负起栈。 | ||
| 49 | -- **F2(无常驻进程原语)**:所有命令执行都经 `agent()` 派子会话,子会话跑命令、返回 exit_code 后**即结束**。 | ||
| 50 | - 前台 `mvn spring-boot:run` / `vite` 永不退出会把子会话挂死。`Bash` 工具的 `run_in_background` 句柄随子 | ||
| 51 | - 会话结束而失去,跨 `agent()` 不可见。→ 门**绝不**在 JS 编排层跨多个 `agent()` 管理常驻进程;起栈→跑→ | ||
| 52 | - teardown 必须收敛进**单个子会话内的一条命令**。 | ||
| 53 | -- **F3(schema 由 Flyway 在 Spring Boot 启动时才建)**:`setup-test-db.mjs` 只 DROP+CREATE **空库** | ||
| 54 | - (模板第 110 行注释 + README 第 166 行 + db-init SKILL)。Spring Boot 启动时 Flyway 才 apply | ||
| 55 | - `sql/migrations/V*.sql`。→ 种子 INSERT 必须排在**后端起好(Flyway apply 完 + 健康检查就绪)之后**,否则 | ||
| 56 | - 「table doesn't exist」**确定性失败**。 | ||
| 57 | -- **F4(schema 守卫不判测试库)**:`setup-test-db.mjs` 第 81 行只校验 schema 匹配 `/^[A-Za-z0-9_$]+$/` | ||
| 58 | - 标识符,**不判它是不是测试库**;config-vars 第 20 行 schema 是自由文本 + 口头建议「推荐含 test/_dev」; | ||
| 59 | - README 第 164 行明说「按该值无条件 DROP+CREATE」。同一 schema 同时驱动开发期 `apply-ddl`。→ 「config 已 | ||
| 60 | - 把守测试库」是假前提,**删除**该措辞,测试库判定由门自负(见 §5)。 | ||
| 61 | -- **F5(无种子机检)**:db-init 的 `lib/validate-ddl.mjs` 只对 **DDL↔docs/03** 做 5 维机检(表/列/类型/ | ||
| 62 | - 索引/外键),**没有任何工具校验种子 INSERT**。FK 拓扑序 / NOT NULL / UNIQUE / enum 值域 / 列类型长度全靠 | ||
| 63 | - 门子代理推导。→ 种子失败必须单独归类(`seedError`),不混进交互层 RED。 | ||
| 64 | -- **F6(运行时禁用 time/random builtin)**:`coding.mjs` 由 Workflow 运行时执行,禁用 `Date.now()` / | ||
| 65 | - `Math.random()` / `new Date()`;「今天」交子代理解析(见 `dateFromArtifactPath` 注释,第 134 行);顶层 | ||
| 66 | - `return` 是结果通道;`agent`/`phase`/`parallel`/`log`/`adjudicate` 是注入全局。→ sentinel / 端口 / 临时 | ||
| 67 | - 目录名等**不得**在 mjs 编排层用 time/random 拼,由子代理在自身上下文确定性生成。 | ||
| 68 | -- **F7(证据命名不带日期前缀)**:`frontend-phase-behavior-gate-r<attempt>.md` 与 `*-test-gate-r*.md` | ||
| 69 | - 同构、**不带 `YYYY-MM-DD` 前缀**,正好绕开 `dateFromArtifactPath` 的解析。`.tmp/` 已被 gitignore 模板 | ||
| 70 | - 忽略(gitignore-append-template)。→ 无需改 `.gitignore`;无需解析「今天」。 | ||
| 71 | -- **F8(adjudicate 签名)**:`adjudicate(site, context, grp, round)` 四参;`context` 内 `allowContinue:false` | ||
| 72 | - 时仲裁不得选 continue(第 528 行);`ADJUDICATE_MAX = 3`。`recordDecisions(site, decisions)` 把 stage | ||
| 73 | - 自主决策汇总进全局 `autonomousDecisions`。 | ||
| 74 | - | ||
| 75 | ---- | ||
| 76 | - | ||
| 77 | -## 1. 插桩点(精确到行,避免误触发后端模块) | ||
| 78 | - | ||
| 79 | -顶层循环现状(`coding.mjs:1357-1362`): | ||
| 80 | - | ||
| 81 | -```js | ||
| 82 | -if (module.feItems.length) { // 前端段(仅末尾 frontend-phase 聚合模块) | ||
| 83 | - phase('Frontend') | ||
| 84 | - await featureLoop(module.feItems, 'frontend') | ||
| 85 | - phase('Gate') | ||
| 86 | - await testGate(module, 'frontend') | ||
| 87 | -} // ← behavior-gate 插在这里(if 闭合前) | ||
| 88 | -``` | ||
| 89 | - | ||
| 90 | -**改为**(在 `testGate(module,'frontend')` 之后、`if` 闭合之前插入): | ||
| 91 | - | ||
| 92 | -```js | ||
| 93 | -if (module.feItems.length) { | ||
| 94 | - phase('Frontend') | ||
| 95 | - await featureLoop(module.feItems, 'frontend') | ||
| 96 | - phase('Gate') | ||
| 97 | - await testGate(module, 'frontend') | ||
| 98 | - phase('Behavior') | ||
| 99 | - await runBehaviorGate(module) // 仅 frontend-phase 段;testGate 绿后跑 | ||
| 100 | -} | ||
| 101 | -``` | ||
| 102 | - | ||
| 103 | -- 放进 `if (module.feItems.length)` 块内 → 纯后端模块(`feItems` 恒空)**不会**触发,与「仅作用于末尾 | ||
| 104 | - frontend-phase 聚合模块」一致。 | ||
| 105 | -- `runBehaviorGate` 入口加二次保险守卫(与 `runMilestone`/`reportPrompt` 的 `id==='frontend-phase'` 判别 | ||
| 106 | - 惯例一致):`const fe = module?.id === 'frontend-phase'; if (!fe) { log('behavior-gate skip: 非 frontend-phase'); return }`。 | ||
| 107 | -- `meta.phases` 增 `{ title:'Behavior' }`(插在 `{ title:'Gate' }` 与 `{ title:'Milestone' }` 之间)。 | ||
| 108 | - | ||
| 109 | ---- | ||
| 110 | - | ||
| 111 | -## 2. 新增 schema(不杂交 GATE × STAGE_RESULT,复用既有词汇) | ||
| 112 | - | ||
| 113 | -评审 C5 指出原 schema 把 GATE 的 `status:green|red` 与 STAGE 的 `decisions[]`/`artifactPath` 杂交、且 | ||
| 114 | -`decisions` 重复定义。**收敛做法**:复用 STAGE_RESULT 已有的 `decisions[]` 形状与 `artifactPath` 命名 | ||
| 115 | -(不另起 `evidencePath`),只新增行为门**特有的两层结果数组**。 | ||
| 116 | - | ||
| 117 | -```js | ||
| 118 | -const BEHAVIOR_GATE_SCHEMA = { type:'object', additionalProperties:false, | ||
| 119 | - required:['status','routesPlanned','routesReached','controlsEnumerated'], | ||
| 120 | - properties:{ | ||
| 121 | - status:{ type:'string', enum:['green','red'] }, | ||
| 122 | - // 覆盖率计数(C20:空覆盖必须可见,绝不静默放行) | ||
| 123 | - routesPlanned:{ type:'integer' }, // step1 路由真值(router 配置)声明的路由数 | ||
| 124 | - routesReached:{ type:'integer' }, // 实际成功导航到达(鉴权后非登录页 / 非空壳)的路由数 | ||
| 125 | - controlsEnumerated:{ type:'integer' }, // 枚举到的非 inert 可交互控件总数 | ||
| 126 | - authState:{ type:'string' }, // C12:以何角色登录、覆盖了哪些角色、未覆盖角色集 | ||
| 127 | - // 交互层失败(硬边界);kind 细分让仲裁能区分「门自身能力不足」与「真死控件」 | ||
| 128 | - interactionFailures:{ type:'array', items:{ type:'object', additionalProperties:false, | ||
| 129 | - required:['page','control','kind','detail'], | ||
| 130 | - properties:{ | ||
| 131 | - page:{type:'string'}, control:{type:'string'}, | ||
| 132 | - kind:{type:'string', enum:[ | ||
| 133 | - 'no-observable-effect', // 点击无任何可观测效果(真死控件) | ||
| 134 | - 'js-error', // 点击触发未捕获 JS 异常 | ||
| 135 | - 'console-error', // 点击触发 console.error | ||
| 136 | - 'missing-docs05-call', // 应发的 docs/05 端点调用未发 | ||
| 137 | - 'binding-garbage' ]}, // 高置信渲染垃圾(null/undefined/[object Object]/NaN/lorem 在绑定位) | ||
| 138 | - detail:{type:'string'} } } }, | ||
| 139 | - // 文字层问题(软边界,按 source 在 JS 侧分流 allowContinue) | ||
| 140 | - textIssues:{ type:'array', items:{ type:'object', additionalProperties:false, | ||
| 141 | - required:['page','region','expected','actual','source'], | ||
| 142 | - properties:{ | ||
| 143 | - page:{type:'string'}, region:{type:'string'}, | ||
| 144 | - expected:{type:'string'}, actual:{type:'string'}, | ||
| 145 | - source:{type:'string', enum:['sentinel','i18n','literal','semantic']} } } }, | ||
| 146 | - // 覆盖缺口(C13/C15/C18:到不了的路由 / 多步深层控件未达 / 动态路由无种子可实例化) | ||
| 147 | - coverageGaps:{ type:'array', items:{ type:'object', additionalProperties:false, | ||
| 148 | - required:['page','reason'], | ||
| 149 | - properties:{ | ||
| 150 | - page:{type:'string'}, | ||
| 151 | - reason:{type:'string', enum:['unreachable-auth','unreachable-no-route','deep-control-not-driven','dynamic-route-no-seed']}, | ||
| 152 | - detail:{type:'string'} } } }, | ||
| 153 | - // 起栈 / 种子 / 鉴权环境失败(与业务断言失败严格区分;走 retry 不当死控件) | ||
| 154 | - envError:{ type:'object', additionalProperties:false, | ||
| 155 | - required:['kind'], | ||
| 156 | - properties:{ | ||
| 157 | - kind:{type:'string', enum:['port-conflict','stack-not-ready','seed-error','auth-failed','timeout','none']}, | ||
| 158 | - detail:{type:'string'}, | ||
| 159 | - ports:{type:'string'}, pids:{type:'string'} } }, // C17/C19:写进证据便于人工清理残留 | ||
| 160 | - decisions:{ type:'array', items:{ type:'object', additionalProperties:false, | ||
| 161 | - required:['question','choice','rationale'], | ||
| 162 | - properties:{ question:{type:'string'}, choice:{type:'string'}, | ||
| 163 | - rationale:{type:'string'}, confidence:{type:'string', enum:['high','medium','low']} } } }, | ||
| 164 | - artifactPath:{ type:'string' } } } // 证据报告路径(复用 STAGE_RESULT 命名,不叫 evidencePath) | ||
| 165 | -``` | ||
| 166 | - | ||
| 167 | -`additionalProperties:false`。`decisions[]` 逐项形状与 STAGE_RESULT 完全一致,由 `recordDecisions` 汇总。 | ||
| 168 | - | ||
| 169 | ---- | ||
| 170 | - | ||
| 171 | -## 3. 门内部流水线(runBehaviorGate,JS 编排,每 attempt 一个子会话) | ||
| 172 | - | ||
| 173 | -> 关键运行时收敛(F2):**整个「探测 → setup-db → 起后端 → 等就绪 → 种子 → 起前端 → 鉴权 → 枚举 → 断言 → | ||
| 174 | -> teardown」必须由门生成的临时 runner(`.tmp/behavior-gate/r<attempt>/run.mjs`)在 *单个子会话内的一条命令* | ||
| 175 | -> 里完成**,runner 用 `spawn` 起进程树、轮询就绪、`finally` 里 kill 全部子进程并透传结构化结果。JS 编排层只 | ||
| 176 | -> 负责:渲染 prompt → 派子会话跑 runner → 收 `BEHAVIOR_GATE_SCHEMA` → 控制流(flake/halt/adjudicate)。 | ||
| 177 | - | ||
| 178 | -`behaviorGatePrompt(module, attempt)` 指示门子代理在子会话内执行: | ||
| 179 | - | ||
| 180 | -### step 0 — 探测起栈能力(F1) | ||
| 181 | -读 `docs/04 §零`(e2e 命令)+ `frontend/package.json` + `frontend/playwright.config.*`(若存在)+ | ||
| 182 | -`config-vars.yaml`(端口 / 凭据)。判定: | ||
| 183 | -- (a) 存在 `playwright.config` 且含 `webServer`/`reuseExistingServer` → runner 复用 playwright 自带 | ||
| 184 | - webServer 起栈,门只负责 setup-db + 起后端 + 种子(前端交给 playwright)。 | ||
| 185 | -- (b) 不存在 → runner 自负起后端 + 前端(见 step 2)。 | ||
| 186 | -- 无法判定 / 探测失败 → 写 `envError.kind='stack-not-ready'`,**走 adjudicate(allowContinue:false)**, | ||
| 187 | - 不静默假设默认命令。 | ||
| 188 | - | ||
| 189 | -### step 1 — 路由真值发现(推导 + 运行时校验对账,C16/C18) | ||
| 190 | -- **主来源 = `frontend/` 的 router 配置**(Vue Router / React Router 的 `routes` 定义,Grep 即可)——SPA 的 | ||
| 191 | - 运行时路由真值。`prototype/` + REQ + `docs/05` 用于**推导每条路由的预期控件清单与文字来源**(作为覆盖率 | ||
| 192 | - 分母),不作为路由真值。 | ||
| 193 | -- 每条路由标注**所需角色**(C12);带参路由(`/orders/:id`)用 step 3 种子的已知主键实例化具体 URL,无种子 | ||
| 194 | - 可实例化的记 `coverageGaps[reason='dynamic-route-no-seed']`。 | ||
| 195 | -- `routesPlanned` = router 声明的全部路由数。 | ||
| 196 | - | ||
| 197 | -### step 2 — 安全护栏 + setup-db + 起后端 + 等就绪 + 种子 + 起前端(严格时序,F3/F4) | ||
| 198 | -runner 内严格四段时序(**种子在 schema 存在之后注入**): | ||
| 199 | -1. **测试库安全护栏(确定性,先于一切,F4)**:读 `config-vars.yaml database.schema`,若不匹配测试库命名 | ||
| 200 | - 约定(含/结尾 `test`/`_test`/`_dev`/`_local`)→ runner 立即非零退出,门返回 `envError` 并由 JS 层 | ||
| 201 | - `throw HALT`(**不经 adjudicate**,与 `assertSafeId` 同级硬安全边界)。要求人工显式确认或改用物理隔离的 | ||
| 202 | - `<schema>_behavior_gate`。 | ||
| 203 | -2. `node scripts/setup-test-db.mjs`(DROP+CREATE 空库)。**DROP 前确保无旧后端连着该库**:先按 | ||
| 204 | - `.tmp/behavior-gate/*.pid` 优雅回收上一轮残留进程(C9/C19)。 | ||
| 205 | -3. **起后端**:runner `spawn` 后端进程;轮询健康探针(`/actuator/health` 200 或登录端点 200,带宽超时)直到 | ||
| 206 | - 就绪——Flyway 在此窗口 apply schema(数十秒)。端口取 `config-vars.yaml` 的 `backend.http_port`,**但先 | ||
| 207 | - 探测占用**:占用则先尝试回收残留 pid,仍占用则改用动态空闲端口并把 `baseURL` 注入 playwright(C17)。 | ||
| 208 | -4. **此时**才跑 `docs/03` 派生的 **FK 有序 INSERT 种子**(schema 已存在)。种子失败 → `envError.kind='seed-error'` | ||
| 209 | - + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断),**不混进交互层 RED**(F5/C7)。 | ||
| 210 | -5. **起前端**(headless):(a) 分支用 playwright webServer;(b) 分支 runner `spawn` 前端 dev/preview, | ||
| 211 | - 轮询 ready,端口同样先探测占用 + 动态回退。 | ||
| 212 | -- runner 的 `finally` **硬要求** kill 全部子进程(覆盖超时 / 异常 / 断言失败路径),把端口 + pid 写进结果 | ||
| 213 | - (`envError.ports`/`envError.pids`),避免 attempt 间 / 跨 coding-start 端口冲突(C3/C9/C19)。 | ||
| 214 | -- **种子 sentinel 规则(C10/C14,确定性 + 类型合法 + 不撞既有数据)**: | ||
| 215 | - - 按列类型派生类型合法可辨识值:字符串列用 `字段名编码 + 行序号`(如 `CUST_NAME_S001`,**逐字段唯一**以抓 | ||
| 216 | - 「绑错字段」);数值列用约定高位魔数(如 `999001`);enum 列只能从 `docs/03` 值域取一个并在证据标注 | ||
| 217 | - 「enum 列无法 sentinel,改用值域成员校验」;手机/邮箱/金额等带格式列派生格式合法的可辨识值。 | ||
| 218 | - - 多行场景 sentinel 带行序号保证 UNIQUE 不撞。 | ||
| 219 | - - **插入前扫描 Flyway migration / config-vars 里既有初始数据键**(如 `admin_init.username=admin`、字典 | ||
| 220 | - 数据),sentinel 主键 / 唯一键偏移到不冲突区间;文字断言按 sentinel 行的**已知主键定位**,而非断言整页 | ||
| 221 | - 第一条(避免被既有初始数据行误判)。 | ||
| 222 | - - **安全(C19)**:所有拼进 SQL 的值用参数化 / 严格转义(不裸字符串拼 INSERT;用占位符或对值做白名单); | ||
| 223 | - sentinel 用门自生成的受控格式(`[A-Za-z0-9_]`),**不**从文档 / DOM 取任意文本拼 SQL。 | ||
| 224 | - | ||
| 225 | -### step 2.5 — 鉴权 bootstrap(C2/C11/C12,确定性前置步骤,非「风险记录」) | ||
| 226 | -ERP 绝大多数路由在登录后才可达(Spring Security / JWT 已在模板确认)。runner 在枚举前: | ||
| 227 | -- 用 `config-vars.yaml admin_init`(A1 已锁的已知账号)或种子里写入的已知凭据,经 `docs/05` 登录端点**真实 | ||
| 228 | - 登录**拿 JWT,注入 Playwright `storageState`。 | ||
| 229 | -- `authState` 记录「以何角色登录、覆盖了哪些角色、未覆盖角色集」。多角色权限分叉至少覆盖 admin 一遍。 | ||
| 230 | -- **登录失败归类为 `envError.kind='auth-failed'`(环境 race,走 retry)**,绝不当死控件 halt。 | ||
| 231 | - | ||
| 232 | -### step 3 — 枚举(可达性驱动,分母对账,非首帧快照,C13/C15) | ||
| 233 | -- 每条路由用 Playwright 加载(带 storageState)后收集 DOM 真实存在的全部可交互控件 | ||
| 234 | - (`button/a/input/select/[role=button]/@click` 等)与可见文字区域。 | ||
| 235 | -- **覆盖判据 = 可达性驱动有界探索 + 来源对账**(不是首帧快照): | ||
| 236 | - - 分母 = step 1 推导的预期控件清单(从 prototype 链接/表单 action + FE spec 的 5 态状态机)。 | ||
| 237 | - - 分子 = live 枚举到的控件。 | ||
| 238 | - - 分母有、首帧无的控件,runner **尝试驱动到其出现态**:种子保证列表非空以触发行级操作、点击进入多步流程 | ||
| 239 | - 下一屏 / 展开 dropdown / 切 tab 后做**二次枚举**。仍无法到达的记 `coverageGaps[reason='deep-control-not-driven']`, | ||
| 240 | - **不静默判 green**。 | ||
| 241 | - - 到达不了的路由(被重定向回登录 / 空壳)记 `coverageGaps[reason='unreachable-auth'|'unreachable-no-route']`, | ||
| 242 | - 与「到达了但控件死」**严格区分**(前者 coverage-gap,后者才是 interactionFailure 硬 halt)。 | ||
| 243 | -- **inert 过滤(C8)**:`disabled` / `[aria-disabled=true]` / `fieldset[disabled]` 内 / 计算样式 | ||
| 244 | - `pointer-events:none` 的控件归为 `intentionally-inert`,**不纳入「必须有可观测效果」断言集**,但记入证据覆盖 | ||
| 245 | - 清单(标注 inert + 推断禁用原因)。增强:对 disabled 提交类按钮,先用 sentinel 种子 / 输入把表单填到合法 | ||
| 246 | - 态,观察是否解除 disabled——能解除即证明是活的且有正确门控;始终 disabled 且 spec 未说明的进 textIssues | ||
| 247 | - 走 adjudicate,**不一律硬 halt**。 | ||
| 248 | -- `routesReached` / `controlsEnumerated` 据实填。 | ||
| 249 | - | ||
| 250 | -### step 4 — 推导期望 | ||
| 251 | -每控件给出预期可观测效果;每文字区域给出预期内容 + 来源(`literal` / `sentinel` / `i18n` / `semantic`)。 | ||
| 252 | - | ||
| 253 | -### step 5 — 断言(两层 + 可观测效果白名单,C6) | ||
| 254 | -- **交互层**:点击 / 交互要求**可观测效果**,白名单(C6 扩充,避免误判生效按钮为死控件): | ||
| 255 | - - URL 变化 / `docs05` 网络调用(`page.on('request')` 比对预期端点)/ DOM 变更 / 校验信息 / 弹层 / toast; | ||
| 256 | - - **原生对话框**:枚举前 runner 必须注册 `page.on('dialog')`,「弹出原生 confirm/alert/beforeunload」本身 | ||
| 257 | - 计为一类合法可观测效果(危险操作的 `confirm()` 不处理会阻塞 → 误判 missing-docs05-call); | ||
| 258 | - - **下载** `page.on('download')` / **新标签** `page.on('popup')`/`target=_blank` 也是合法效果。 | ||
| 259 | - - 无任何可观测效果 → `interactionFailures[kind='no-observable-effect']`;点击触发 JS 异常 → | ||
| 260 | - `js-error`;console.error → `console-error`;应发未发 docs05 → `missing-docs05-call`。 | ||
| 261 | - - 断言用 Playwright 的 **auto-waiting / `expect.poll`**(不用固定 sleep),从机制压低渲染时序 flake(C4)。 | ||
| 262 | -- **文字层**:渲染对比推导期望。动态格对比对应字段的**唯一 sentinel**(不仅「等于某 sentinel」,而是「等于 | ||
| 263 | - 该 region 推导期望字段的那个唯一 sentinel」,以抓绑错字段,C14)。 | ||
| 264 | -- **绑定垃圾分级(C8,收窄锁定决策 4)**:`null`/`undefined`/`[object Object]`/`NaN`/lorem 出现在数据绑定位 | ||
| 265 | - → `interactionFailures[kind='binding-garbage']`(高置信硬 halt);「双花括号未渲染 / 空占位 `—` / 疑似 | ||
| 266 | - i18n key(含点号标识符且无对应文案)」→ `textIssues[source='i18n'|'literal']` 走 adjudicate。i18n 场景额外 | ||
| 267 | - 要求 runner 加载真实 locale 资源比对。 | ||
| 268 | - | ||
| 269 | -### step 6 — 证据落盘 + commit(F7) | ||
| 270 | -写 `docs/superpowers/module-reports/frontend-phase-behavior-gate-r<attempt>.md`(含推导期望、逐控件判定、 | ||
| 271 | -覆盖率计数 `routesPlanned/routesReached/controlsEnumerated`、`authState`、`coverageGaps`、截图)并 commit。 | ||
| 272 | -- **截图归档(C19)**:要 commit 的截图落到**已纳入版本管理的**目录(如 | ||
| 273 | - `docs/superpowers/module-reports/assets/frontend-phase-behavior-gate/r<attempt>/`),证据报告只引用已提交 | ||
| 274 | - 路径,**不**引用 `.tmp/`(避免 commit 后链接断链)。`.tmp/behavior-gate/r<attempt>/` 只放 spec/种子/runner/ | ||
| 275 | - 原始截图,跑完即弃。 | ||
| 276 | - | ||
| 277 | ---- | ||
| 278 | - | ||
| 279 | -## 4. 失败 / 控制流(runBehaviorGate,与 testGate 骨架对齐,C2/C21/C22) | ||
| 280 | - | ||
| 281 | -> 评审 C21(失败语义与安全维度)指出原文「interactionFailures 非空即硬 throw」与「RED 自动重试 1 次辨 flake」 | ||
| 282 | -> 自相矛盾——若一返回带 interactionFailures 就 throw,flake 重试根本没机会跑。**最终控制流显式分段**,并把交互 | ||
| 283 | -> 层硬边界纳入 adjudicate 框架(`allowContinue:false`),与全仓「halt 经 adjudicator」收敛架构一致(C24)。 | ||
| 284 | - | ||
| 285 | -``` | ||
| 286 | -1. attempt=1:派子会话跑 runner,收 BEHAVIOR_GATE_SCHEMA。 | ||
| 287 | -2. 若 envError.kind != 'none'(端口冲突/起栈未就绪/种子错/鉴权失败/超时): | ||
| 288 | - 归类为环境 race → 与 testGate 同款:attempt=2 重跑一次;仍 envError → adjudicate(allowContinue:false, | ||
| 289 | - 只在 retry/halt 间裁),retry 再起独立 attempt。绝不把环境 race 当死控件。 | ||
| 290 | - 测试库护栏触发的 HALT 例外:不重试、不仲裁,直接 throw(assertSafeId 同级)。 | ||
| 291 | -3. 空覆盖检查(C20):frontend-phase 存在但 (controlsEnumerated==0 || routesReached==0) → | ||
| 292 | - 绝不 green,归为 envError(stack/auth/seed 起不来) 走 step 2 的 retry/halt;证据报告头部红字标注 | ||
| 293 | - 「本次门未覆盖任何控件,原因=<...>」。 | ||
| 294 | -4. interactionFailures(交互硬边界): | ||
| 295 | - - attempt=1 出现时【不立刻 throw】,先按 testGate 同款跑 attempt=2(独立证据文件 r2,辨 flake)。 | ||
| 296 | - - 仅当 attempt=2 后 interactionFailures 仍非空,经 adjudicate(allowContinue:false) 在 retry/halt 间裁; | ||
| 297 | - retry 用于「断言类红可在同一次起栈内重试单断言 / 环境抖动」,halt 用于真死控件。绝不 continue。 | ||
| 298 | - - 仲裁可据 interactionFailures[].kind 区分「门自身未处理的弹窗类型 / 环境未就绪」与「真死控件」, | ||
| 299 | - 前者倾向 retry,后者 halt。 | ||
| 300 | -5. textIssues(文字软边界,按 source 分流 allowContinue,C11/C22): | ||
| 301 | - for-of textIssues: | ||
| 302 | - - source=='sentinel':actual≠唯一 sentinel 是客观 bug(门自己灌的确定值,非推导误报)→ | ||
| 303 | - adjudicate(`behavior-text:${page}:${region}`, {expected,actual,source,allowContinue:false}, 'Behavior', round) | ||
| 304 | - 仲裁只许 retry/halt,绝不 continue 放行绑错字段/显示错记录。 | ||
| 305 | - - source ∈ {i18n,literal,semantic}:推导文案有误报风险 → | ||
| 306 | - adjudicate(..., {allowContinue:true}, ...) retry=重判该条 / continue=recordDecisions 记入 | ||
| 307 | - autonomousDecisions / halt=真内容 bug。 | ||
| 308 | - - round 计数与 ADJUDICATE_MAX 上限按既有 adjudicate 循环惯例配(每条 site 独立计 round,上限 ADJUDICATE_MAX)。 | ||
| 309 | -6. coverageGaps:写进证据报告 + recordDecisions(不单独 halt;空覆盖已在 step 3 兜底为 envError)。 | ||
| 310 | -7. 全部通过(interactionFailures 空、sentinel textIssues 全消解、覆盖非空)→ 返回 { status:'green', ... }。 | ||
| 311 | -8. 行为门 RED 发生在 milestone tag 之前,沿用 report 的 allowContinue:false 纪律,throw 自然冒泡到顶层 | ||
| 312 | - try/catch → break,绝不带红进里程碑。 | ||
| 313 | -``` | ||
| 314 | - | ||
| 315 | -**与既有原语的对齐**:交互层借 `testGate()` 同款 attempt=1→2 retry + adjudicate(allowContinue:false) | ||
| 316 | -骨架;文字层借 `reviewWithFixLoop` 的逐条 for-of + adjudicate 先例(非 testGate 单裁面)。可选更省实现:把整门 | ||
| 317 | -收敛为一次 `runStage(...)` 调用、让 `runStage` 现成的 adjudicate(retry/continue/halt)+ADJUDICATE_MAX 兜底 | ||
| 318 | -软边界,交互硬边界单独前置判定 throw——但首选上面的显式分段以保证「环境 race 走 retry、死控件走 halt」的清晰。 | ||
| 319 | - | ||
| 320 | ---- | ||
| 321 | - | ||
| 322 | -## 5. behaviorGateContract(不直接套 featureStageContract('frontend'),C1/C6/C26) | ||
| 323 | - | ||
| 324 | -评审 C1/C6/C26 指出:`featureStageContract('frontend')` 的路径护栏明文「实现文件必须落 `frontend/`;命中 | ||
| 325 | -`backend/`/`sql/`/`scripts/` 即越界硬停」(第 161-163 行),而行为门必须**运行** `scripts/setup-test-db.mjs`、 | ||
| 326 | -起后端、生成 sql 语义种子——忠实执行该 contract 的子代理会把这些判为越界并自相矛盾。且 contract 还含「全部输出 | ||
| 327 | -中文 / 缺值查找顺序 / 绝不留 TBD」等与门职责冲突的条款(门要写英文 sentinel / Playwright spec)。 | ||
| 328 | - | ||
| 329 | -**新增 `behaviorGateContract()`**(第三类:跨栈只读验证 + 临时产物),只保留真正通用的硬约束: | ||
| 330 | - | ||
| 331 | -``` | ||
| 332 | -## 硬约束(行为门——只读验证门,非交互子代理) | ||
| 333 | -- 你是 Workflow 派生的非交互子代理,物理上无法弹问,绝不尝试问人。 | ||
| 334 | -- 全部输出文档使用中文(证据报告);但生成的 Playwright spec / sentinel / SQL 种子可用英文标识符。 | ||
| 335 | -- 作用域例外(关键):本门为【只读验证门】。允许【运行(不可写)】scripts/setup-test-db.mjs、起后端/前端服务、 | ||
| 336 | - 跑 playwright;唯一可写位置 = .tmp/behavior-gate/r<attempt>/(spec/种子/runner)+ 证据报告及其 assets 目录。 | ||
| 337 | - 改动 frontend/ / backend/ / sql/ 任何【源码】即越界硬停。把「运行 backend 服务」与「写 backend 实现」显式区分。 | ||
| 338 | -- 缺值时优先自主决策继续并记入 decisions[](与 featureStageContract 同口径);仅无法自洽的硬事实才 halt。 | ||
| 339 | -- sentinel / 端口 / 临时目录名由你在自身上下文确定性生成;【绝不】依赖 mjs 编排层提供 time/random(编排层禁用)。 | ||
| 340 | -``` | ||
| 341 | - | ||
| 342 | -`behaviorGatePrompt(module, attempt)` = `behaviorGateContract()` + `commitBlock(证据路径+assets, 'docs(behavior-gate:frontend-phase:r<attempt>): 行为门证据')` + step 0-6 指令 + `BEHAVIOR_GATE_SCHEMA` 输出契约。 | ||
| 343 | - | ||
| 344 | ---- | ||
| 345 | - | ||
| 346 | -## 6. report 前置接入(C23,闭合「按钮生效/文字正确」证据链) | ||
| 347 | - | ||
| 348 | -`reportPrompt` 前端分支(`coding.mjs:947`)现仅 Glob `${phaseId}-test-gate-r*.md` 并要求「最后一份 green」。 | ||
| 349 | -behavior-gate 证据未进绿前置 → milestone tag 指向的 commit 报告对行为门「视而不见」。**扩展**: | ||
| 350 | - | ||
| 351 | -- 前端分支绿前置 Glob **追加** `frontend-phase-behavior-gate-r*.md`,按 attempt 升序,**最后一份必须 | ||
| 352 | - status 非 RED**(与 test-gate 同纪律);最后一份 red 立即 halt。 | ||
| 353 | -- §⑤ flake 汇总纳入 behavior-gate 各 attempt(红→绿切换标注 flake)。 | ||
| 354 | -- §⑧ 偏离清单纳入行为门的 `coverageGaps` + textIssues continue 记录 + 逐控件判定摘要 + `authState` 未覆盖 | ||
| 355 | - 角色集,让 milestone 真覆盖「按钮生效 / 文字正确」的证据。 | ||
| 356 | - | ||
| 357 | ---- | ||
| 358 | - | ||
| 359 | -## 7. resume / 幂等(C25,接受全量重跑但收敛非确定性) | ||
| 360 | - | ||
| 361 | -- behavior-gate 夹在 frontend `testGate` 与 `report` 之间,**不自打独立 tag**(完成真值仍是 | ||
| 362 | - `milestone/frontend-phase`)。接受代价:halt 后人工修复重跑 `coding-start` 时,Router 见 `frontend-phase` | ||
| 363 | - 无 milestone tag → 整段重跑,各 FE 因 `req-done/<FE>` 已存在而 skip(OK),但 frontend `testGate` 与 | ||
| 364 | - behavior-gate 完整重跑(含重起栈 + 全量枚举)。 | ||
| 365 | -- **收敛即时推导的逐次漂移**:门入口先清 `.tmp/behavior-gate/` 整目录(避免跨 resume 串味);每 attempt 用 | ||
| 366 | - 独立子目录 `.tmp/behavior-gate/r<attempt>/`(与证据文件 `-r<attempt>` 对齐),跑前清空保证幂等。证据报告里 | ||
| 367 | - 已落盘的推导期望可供人工审计;**不**要求 resume 复用上次推导(接受重推,但风险节记明漂移代价)。 | ||
| 368 | -- **源码修复路径语义**(对齐 `coding.mjs:1148`):behavior-gate 触发的修复若需改 FE 源码,须**先手动删除对应 | ||
| 369 | - `req-done/<FE>` tag** 才会在重跑时重走 review;否则该 FE 跳过 review。门**自身不改源码**(只读验证门),文字 | ||
| 370 | - bug 经 adjudicate;真要改码留给人工 / 重跑,设计在此显式提示。 | ||
| 371 | - | ||
| 372 | ---- | ||
| 373 | - | ||
| 374 | -## 8. coding.mjs 新增面汇总(实现清单) | ||
| 375 | - | ||
| 376 | -| 新增 | 位置 | 说明 | | ||
| 377 | -|---|---|---| | ||
| 378 | -| `BEHAVIOR_GATE_SCHEMA` | 与其他 schema 同段(第 64 行 GATE_SCHEMA 附近) | §2 形状,`additionalProperties:false`,复用 decisions[]/artifactPath 词汇 | | ||
| 379 | -| `behaviorGateContract()` | `microStepContract`/`featureStageContract` 附近 | §5,只读验证门作用域例外 | | ||
| 380 | -| `behaviorGatePrompt(module, attempt)` | `gatePrompt` 附近 | §3+§5,contract + commitBlock + step 0-6 + schema | | ||
| 381 | -| `runBehaviorGate(module)` | `testGate` 附近 | §4 控制流:frontend-phase 守卫 → 清 .tmp → attempt 循环(envError retry / 空覆盖兜底 / interactionFailures attempt2→adjudicate(false) / textIssues 按 source 分流) | | ||
| 382 | -| `meta.phases += {title:'Behavior'}` | 第 10-13 行 | Gate 与 Milestone 之间 | | ||
| 383 | -| 顶层插桩 | `coding.mjs:1361` 之后、if 闭合前 | `phase('Behavior'); await runBehaviorGate(module)` | | ||
| 384 | -| `reportPrompt` 前端分支扩展 | `coding.mjs:947/956/957` | §6 绿前置 + §⑤/§⑧ 纳入 behavior-gate | | ||
| 385 | - | ||
| 386 | -**运行时约束自洽(F6)**:`runBehaviorGate` 编排层不调用 `Date.now()`/`Math.random()`/`new Date()`;sentinel/ | ||
| 387 | -端口/目录名由子代理生成;顶层 `return` 完好;用注入全局 `agent`/`phase`/`log`/`adjudicate`/`recordDecisions`。 | ||
| 388 | -所有拼进 git/shell 的标识符(`attempt` 经 `Number()`+整数校验后拼路径)仍过既有安全口径;BEHAVIOR_GATE_SCHEMA | ||
| 389 | -`additionalProperties:false`。 | ||
| 390 | - | ||
| 391 | ---- | ||
| 392 | - | ||
| 393 | -## 9. 残留风险(已知、接受或缓解) | ||
| 394 | - | ||
| 395 | -- 全栈 headless 起栈 + 逐路由枚举 + 鉴权 + 多步驱动,单 attempt 墙钟可能数分钟到十几分钟;flake ×2 + | ||
| 396 | - adjudicate 再拉长。缓解:仅 testGate 绿后跑 + **runner 内单次起栈跑完所有路由(硬约束,绝不每路由重起栈)** | ||
| 397 | - + auto-waiting + 整体墙钟上限(超时归 envError 走 retry)。无法把墙钟压到很低,接受。 | ||
| 398 | -- 即时推导的路由分母 / 文字期望仍有 LLM 非确定性;sentinel 绑定类已收为确定性硬比对,i18n/literal/semantic | ||
| 399 | - 类保留 adjudicate(设计自身权衡)。逐次推导漂移已记入 §7。 | ||
| 400 | -- 多角色权限分叉:至少覆盖 admin,未覆盖角色集显式记 `authState` + §⑧;非 admin 角色专属按钮可能漏测。 | ||
| 401 | -- 动态参数路由无种子可实例化时记 `coverageGaps[dynamic-route-no-seed]`,可降级但显式可见,不当已覆盖。 | ||
| 402 | -- 测试库护栏依赖命名约定(含 test/_dev/_local);若项目用非常规测试库名(如 `staging`)会被护栏挡下要求人工 | ||
| 403 | - 确认——宁可误挡不可误删,接受。 | ||
| 404 | - | ||
| 405 | ---- | ||
| 406 | - | ||
| 407 | -## 拒绝的建议(无依据 / 过度工程,记明理由) | ||
| 408 | - | ||
| 409 | -- **「为 behavior-gate 自打独立幂等 tag(behavior-gate-pass/frontend-phase)」**(C25 选项):拒绝。会新增 | ||
| 410 | - 一类 resume 真值,与现有「milestone/req-done 两级 tag」体系叠加复杂度;用户锁定决策未要求更细断点,且全量 | ||
| 411 | - 重跑成本已被「仅 testGate 绿后跑」摊薄。改为接受全量重跑 + 在 §7/§9 记明代价。 | ||
| 412 | -- **「种子改为确定性 JS 生成(读 docs/03 解析后排序)而非 LLM 推导」**(C-确定性 issue B 选项):部分采纳为 | ||
| 413 | - *方向*但不强制实现。理由:锁定决策 2 明确「门运行时即时推导」,且新建确定性种子生成器是独立大工程(需复刻 | ||
| 414 | - db-init 的 docs/03 解析 + FK 拓扑),超出本门范围。采纳其可落地内核——种子失败单独归类 `seedError` + | ||
| 415 | - 结构化根因 + retry 带 guidance(§3 step2.4 / §4),把「同 docs/03 → 同种子」的非确定性收敛到「失败可诊断、 | ||
| 416 | - sentinel 规则确定」,不强制改推导机制本身。 | ||
| 417 | -- **「textIssues 一律升级 allowContinue:false」**:拒绝。会让 i18n/语义等价类误报变成不可恢复 halt,与锁定 | ||
| 418 | - 决策 4「文字走 adjudicate 不硬 halt」冲突。改为按 source 二分(sentinel 硬、其余软),既守真 bug 又不卡死。 | 13 | +旧文档中的测试库命名护栏、阶段级行为门控制流和历史评审细节不再作为实现依据。 |
docs/design/2026-06-02-frontend-behavior-in-review-loop.md
| @@ -13,7 +13,7 @@ | @@ -13,7 +13,7 @@ | ||
| 13 | - **仅前端 FE** 有此维度;后端 REQ 分支(无 UI)逐字不变。 | 13 | - **仅前端 FE** 有此维度;后端 REQ 分支(无 UI)逐字不变。 |
| 14 | - 接受**每个 FE 起一次(或少数几次)全栈**的代价。 | 14 | - 接受**每个 FE 起一次(或少数几次)全栈**的代价。 |
| 15 | 15 | ||
| 16 | -本设计在守住上述方向的前提下,落实了 5 维评审里 **确凿的 blocker**:中途可构建性(头号)、起栈成本笛卡尔积爆炸、起栈不可跨子会话复用、locator 不可靠降级=放行、缺 locator 硬问题被现有 filter 静默吞、删阶段门后 report 失去绿前置锚点、测试库护栏只在 LLM 层。 | 16 | +本设计在守住上述方向的前提下,落实了 5 维评审里 **确凿的 blocker**:中途可构建性(头号)、起栈成本笛卡尔积爆炸、起栈不可跨子会话复用、locator 不可靠降级=放行、缺 locator 硬问题被现有 filter 静默吞、删阶段门后 report 失去绿前置锚点。 |
| 17 | 17 | ||
| 18 | --- | 18 | --- |
| 19 | 19 | ||
| @@ -68,7 +68,7 @@ reviewWithFixLoop(FE): | @@ -68,7 +68,7 @@ reviewWithFixLoop(FE): | ||
| 68 | 68 | ||
| 69 | 落点与时序: | 69 | 落点与时序: |
| 70 | - 在顶层循环 `if (module.feItems.length)` 段、`phase('Frontend')` 之后、`featureLoop` 之前调用 `await runFrontendSkeleton(module)`。 | 70 | - 在顶层循环 `if (module.feItems.length)` 段、`phase('Frontend')` 之后、`featureLoop` 之前调用 `await runFrontendSkeleton(module)`。 |
| 71 | -- **幂等**:以 git tag `fe-skeleton-done` 或检测 router 文件存在 + 全 FE 路由已声明为 ground truth;已建则 skip(resume 安全)。子代理产出后自行 commit(沿用 commitBlock 习惯)。 | 71 | +- **幂等**:以检测 router 文件存在 + 全 FE 路由已声明为 ground truth;`fe-skeleton-done` 只作补记标记,避免陈旧 tag 跳过缺失骨架。子代理产出后自行 commit(沿用 commitBlock 习惯)。 |
| 72 | - FE-N 实现时(tddPrompt),把对应路由的占位 import 替换为真组件——这要求 **tddPrompt 前端分支补一句**:「若 router 中本 FE 路由仍指向 `FeStub`,实现完成后把该路由 import 改为本 FE 真组件」(属 frontend/ 路径内,不破坏护栏)。 | 72 | - FE-N 实现时(tddPrompt),把对应路由的占位 import 替换为真组件——这要求 **tddPrompt 前端分支补一句**:「若 router 中本 FE 路由仍指向 `FeStub`,实现完成后把该路由 import 改为本 FE 真组件」(属 frontend/ 路径内,不破坏护栏)。 |
| 73 | 73 | ||
| 74 | > **为何这是根因解**:让 router 始终 lazy + 占位齐全 → 任意时刻 `vite build` / dev server 可起、每个 FE 路由可达 → 把「中途起不来」从高频降为罕见 → per-FE 行为验收的 flake/误判面收敛、`build-failed`(依赖 B)成为罕见兜底而非常态。 | 74 | > **为何这是根因解**:让 router 始终 lazy + 占位齐全 → 任意时刻 `vite build` / dev server 可起、每个 FE 路由可达 → 把「中途起不来」从高频降为罕见 → per-FE 行为验收的 flake/误判面收敛、`build-failed`(依赖 B)成为罕见兜底而非常态。 |
| @@ -84,7 +84,7 @@ reviewWithFixLoop(FE): | @@ -84,7 +84,7 @@ reviewWithFixLoop(FE): | ||
| 84 | **v2 方案**: | 84 | **v2 方案**: |
| 85 | 85 | ||
| 86 | 1. `BEHAVIOR_GATE_SCHEMA.envError.kind` 枚举**新增 `build-failed`**(确定性失败语义;`route-not-buildable` 不单列,统一用 `build-failed` + detail 区分)。 | 86 | 1. `BEHAVIOR_GATE_SCHEMA.envError.kind` 枚举**新增 `build-failed`**(确定性失败语义;`route-not-buildable` 不单列,统一用 `build-failed` + detail 区分)。 |
| 87 | -2. **控制流**(在 per-FE 行为门 helper 内):`build-failed` 经**确定性前置校验**后才 green-by-skip 放行(既不 retry 也不 halt);不满足前置 = 「脏」build-failed → 过 `adjudicate(allowContinue:false)` retry/halt,**绝不静默放行**。前置(评审加固,见下「评审加固」):(a) 必须有 `rootCausePath`;(b) 不得同时携带交互/`sentinel` 硬问题。干净放行时记 `coverageGap`(reason `build-failed-sibling-unimpl`)+ recordDecisions(「后续 FE 未实现」的预期中途态,非 FE-N 的 bug;§2 骨架占位让这种情况罕见)。 | 87 | +2. **控制流**(在 per-FE 行为门 helper 内):`build-failed` 经**轻量前置校验**后才 green-by-skip 放行(既不 retry 也不 halt);不满足前置 = 「脏」build-failed → 过 `adjudicate(allowContinue:false)` retry/halt,**绝不静默放行**。前置(评审加固,见下「评审加固」):(a) 必须有 `rootCausePath`;(b) 不得同时携带交互/`sentinel` 硬问题。干净放行时记 `coverageGap`(reason `build-failed-sibling-unimpl`)+ recordDecisions(「后续 FE 未实现」的预期中途态,非 FE-N 的 bug;§2 骨架占位让这种情况罕见)。 |
| 88 | 3. `behaviorGatePrompt`(per-FE 版)step0/step2 **明确归因指令**:先 `build` / 起 dev server;若失败,先用 `git` / `Grep` 判断报错根因文件路径—— | 88 | 3. `behaviorGatePrompt`(per-FE 版)step0/step2 **明确归因指令**:先 `build` / 起 dev server;若失败,先用 `git` / `Grep` 判断报错根因文件路径—— |
| 89 | - 落在**非本 FE 的 frontend/ 路径**(兄弟 FE / 占位未覆盖)→ 判 `envError.kind="build-failed"`(预期中途态)。 | 89 | - 落在**非本 FE 的 frontend/ 路径**(兄弟 FE / 占位未覆盖)→ 判 `envError.kind="build-failed"`(预期中途态)。 |
| 90 | - 落在**本 FE 路径** → 才可能是本 FE 引入的真构建 bug → 归 `interactionFailures[kind="js-error"]` 或带 locator must-fix。 | 90 | - 落在**本 FE 路径** → 才可能是本 FE 引入的真构建 bug → 归 `interactionFailures[kind="js-error"]` 或带 locator must-fix。 |
| @@ -115,25 +115,16 @@ reviewWithFixLoop(FE): | @@ -115,25 +115,16 @@ reviewWithFixLoop(FE): | ||
| 115 | 115 | ||
| 116 | --- | 116 | --- |
| 117 | 117 | ||
| 118 | -## 5. 实现前置依赖 D(blocker / 安全):测试库护栏下沉到 setup-test-db.mjs 模板自身 | 118 | +## 5. DB reset 约束:不做测试库命名护栏 |
| 119 | 119 | ||
| 120 | -**问题(已核实)**:`scripts-setup-test-db-template.mjs` 只校验 schema 是合法标识符(`/^[A-Za-z0-9_$]+$/`),**不判它是不是测试库**,DROP+CREATE 无条件执行。测试库命名护栏(库名须含 `test/_test/_dev/_local`)当前只在**门子代理生成的 runner 内**(LLM 级 prompt 检查)。per-FE × per-behaviorRound 反复 DROP+CREATE,任一轮子代理漏写护栏 → 对 config-vars 指向的库(可能=开发库)无条件 DROP,反复次数越多撞上漏写的概率越高。真实数据销毁风险。 | 120 | +用户已明确:本项目触达的库均为开发或沙盒环境,因此 `setup-test-db.mjs` 不再按库名是否包含 `test/_dev/_local` 做 fail-closed,也不使用测试库命名相关的环境变量或运行时标记。 |
| 121 | 121 | ||
| 122 | -**v2 方案**:把测试库命名护栏**下沉到 `setup-test-db.mjs` 模板**(确定性 JS 边界,不依赖每个子代理记得复述):在现有标识符校验后追加—— | 122 | +保留的仅是流程正确性: |
| 123 | 123 | ||
| 124 | -```js | ||
| 125 | -// 测试库命名护栏:DROP+CREATE 只允许作用于明确的测试/本地库,防误删开发/生产库。 | ||
| 126 | -const ALLOW = process.env.ALLOW_NONTEST_DROP === '1' | ||
| 127 | -if (!ALLOW && !/(^|_)(test|dev|local)$|(^|_)test_|^test_/.test(DB_SCHEMA) && !/test|_dev|_local/.test(DB_SCHEMA)) { | ||
| 128 | - console.error(`[setup-test-db] 拒绝:schema=${JSON.stringify(DB_SCHEMA)} 不像测试库(须含 test/_test/_dev/_local),设 ALLOW_NONTEST_DROP=1 显式放行`) | ||
| 129 | - process.exit(1) | ||
| 130 | -} | ||
| 131 | -``` | ||
| 132 | -(具体正则以实现为准,语义=库名须含 `test`/`_test`/`_dev`/`_local` 之一,否则 fail-closed。) | ||
| 133 | - | ||
| 134 | -- 这样不论被行为门调用多少次都安全。 | ||
| 135 | -- coding.mjs 行为门控制流里,对「测试库护栏触发的红」**不重试不仲裁直接 throw** 的硬边界语义现已落地为确定性机制(评审加固):门子代理在 `envError.detail` 以固定标记 `TESTDB_GUARD_MARK`(`[TESTDB-GUARD]`)开头,`runBehaviorGateOnce` 拿到首个结果即 `behaviorTestDbGuardTripped` 命中 → 立即 throw HALT,绝不进入 attempt 重试 / adjudicate(此前仅 step2 prompt 承诺、JS 无兑现,护栏红会误入 stack-not-ready 通用重试路径空转约 5 次)。 | ||
| 136 | -- 这是 skeleton-gen 模板的一次性改动,**不属于 coding.mjs 改造**,但列为本设计前置(否则反复起栈的安全暴露面不可接受)。 | 124 | +- `database.schema` 为空时脚本失败(避免生成无意义 SQL)。 |
| 125 | +- `database.*` 仍含 `【人工填写】` 时脚本失败(配置未完成,继续连库只会制造错误起栈)。 | ||
| 126 | +- schema 作为 MySQL 标识符引用,反引号按 MySQL 规则转义,保证 DROP+CREATE 语句可正确构造。 | ||
| 127 | +- `setup-test-db.mjs` 失败统一归普通 `envError.kind="stack-not-ready"`,由 `runBehaviorGateOnce` 的 env retry / adjudicate 控制流处理。 | ||
| 137 | 128 | ||
| 138 | --- | 129 | --- |
| 139 | 130 | ||
| @@ -169,8 +160,8 @@ reviewer.verdict==='approve' | @@ -169,8 +160,8 @@ reviewer.verdict==='approve' | ||
| 169 | async function behaviorSubGate(id, specPath, feScope): | 160 | async function behaviorSubGate(id, specPath, feScope): |
| 170 | // feScope = {routes:[...], controlWhitelist:[...]}(来自 §4 spec 结构化小节) | 161 | // feScope = {routes:[...], controlWhitelist:[...]}(来自 §4 spec 结构化小节) |
| 171 | for behaviorRound in 1..BEHAVIOR_FE_MAX(=3): | 162 | for behaviorRound in 1..BEHAVIOR_FE_MAX(=3): |
| 172 | - bg = await runBehaviorGateOnce(id, behaviorRound, feScope) // 见 §7,内含 testdb-guard 直接 halt + envError attempt 重试 | ||
| 173 | - // 1) build-failed:经前置校验后短路(依赖 B)。脏(无 rootCausePath / 携带交互|sentinel 硬问题) → adjudicate(allowContinue:false),绝不静默放行 | 163 | + bg = await runBehaviorGateOnce(id, behaviorRound, feScope) // 见 §7,内含 envError attempt 重试 |
| 164 | + // 1) build-failed:经轻量前置校验后短路(依赖 B)。脏(无 rootCausePath / 携带交互|sentinel 硬问题) → adjudicate(allowContinue:false),绝不静默放行 | ||
| 174 | if (bg.envError.kind === 'build-failed') { | 165 | if (bg.envError.kind === 'build-failed') { |
| 175 | if (dirty(bg)) { v=adjudicate(allowContinue:false); v!=='retry' → throw HALT; else 下一轮重跑 } | 166 | if (dirty(bg)) { v=adjudicate(allowContinue:false); v!=='retry' → throw HALT; else 下一轮重跑 } |
| 176 | else { recordDecisions; return {green:true, skipped:true} } // 干净:兄弟未实现,green-by-skip | 167 | else { recordDecisions; return {green:true, skipped:true} } // 干净:兄弟未实现,green-by-skip |
| @@ -179,8 +170,8 @@ async function behaviorSubGate(id, specPath, feScope): | @@ -179,8 +170,8 @@ async function behaviorSubGate(id, specPath, feScope): | ||
| 179 | if (envBlocked(bg)) { adjudicate; 仍 blocked → throw HALT } | 170 | if (envBlocked(bg)) { adjudicate; 仍 blocked → throw HALT } |
| 180 | // 3) 软文字:for-of 走 adjudicate;continue→recordDecisions + 加入跨轮 softPassed;sentinel→并入 behaviorHard;retry/halt 同现 | 171 | // 3) 软文字:for-of 走 adjudicate;continue→recordDecisions + 加入跨轮 softPassed;sentinel→并入 behaviorHard;retry/halt 同现 |
| 181 | processTextIssues(bg, softPassed) // softPassed 提升到 reviewWithFixLoop 顶层作用域,跨 behaviorRound 持久 | 172 | processTextIssues(bg, softPassed) // softPassed 提升到 reviewWithFixLoop 顶层作用域,跨 behaviorRound 持久 |
| 182 | - // 3.6) 覆盖率对账(确定性兜底):未被路由级 coverageGap 解释的漏达路由(routesReached<routesPlanned) → adjudicate(allowContinue:false) | ||
| 183 | - if (planned>0 && planned-reached-routeGapCount > 0) { v=adjudicate(allowContinue:false); v!=='retry' → throw HALT; else 下一轮重跑 } | 173 | + // 3.6) 覆盖率对账(确定性兜底):未被不同路由级 coverageGap 解释的漏达路由(routesReached<routesPlanned) → adjudicate(allowContinue:false) |
| 174 | + if (planned>0 && planned-reached-distinctRouteGapCount > 0) { v=adjudicate(allowContinue:false); v!=='retry' → throw HALT; else 下一轮重跑 } | ||
| 184 | // 4) behaviorHard = interactionFailures + sentinel textIssues | 175 | // 4) behaviorHard = interactionFailures + sentinel textIssues |
| 185 | if (behaviorHard.length === 0) return {green:true} | 176 | if (behaviorHard.length === 0) return {green:true} |
| 186 | // 5) 分流 | 177 | // 5) 分流 |
| @@ -230,7 +221,7 @@ async function behaviorSubGate(id, specPath, feScope): | @@ -230,7 +221,7 @@ async function behaviorSubGate(id, specPath, feScope): | ||
| 230 | 221 | ||
| 231 | - `id` 入参从写死 `frontend-phase` 改为本 FE id;新增入参 `specPath` / `behaviorRound` / `attempt` / `feScope`。 | 222 | - `id` 入参从写死 `frontend-phase` 改为本 FE id;新增入参 `specPath` / `behaviorRound` / `attempt` / `feScope`。 |
| 232 | - **起栈**:runner 自起后端+前端(项目无既有 e2e webServer/playwright.config——已核实 F1,**删除「复用既有 webServer」这条死路暗示**,避免实现者照已证伪的假设做;只走「冷起栈」,**明确写死 round 间不复用运行栈、无 HMR**,这是现运行时硬约束,采纳成本维度 blocker#2)。 | 223 | - **起栈**:runner 自起后端+前端(项目无既有 e2e webServer/playwright.config——已核实 F1,**删除「复用既有 webServer」这条死路暗示**,避免实现者照已证伪的假设做;只走「冷起栈」,**明确写死 round 间不复用运行栈、无 HMR**,这是现运行时硬约束,采纳成本维度 blocker#2)。 |
| 233 | -- **四段时序不变**:空库→起后端等 Flyway 建 schema+健康就绪→sentinel 种子(FK 有序)→起前端 headless。测试库护栏现由模板兜底(§5),runner 仍可复述但不再是唯一防线。 | 224 | +- **四段时序不变**:空库→起后端等 Flyway 建 schema+健康就绪→sentinel 种子(FK 有序)→起前端 headless。DB reset 不做测试库命名护栏(§5),失败按普通起栈问题处理。 |
| 234 | - **step0/step2 build 归因**(依赖 B):先 build / 起 dev server,失败按根因路径归 `build-failed`(非本 FE) 或本 FE 真 bug。 | 225 | - **step0/step2 build 归因**(依赖 B):先 build / 起 dev server,失败按根因路径归 `build-failed`(非本 FE) 或本 FE 真 bug。 |
| 235 | - **step1 路由真值**:`routesPlanned` 只数 `feScope.routes`(本 FE 路由),不数 router 全部(依赖 C)。 | 226 | - **step1 路由真值**:`routesPlanned` 只数 `feScope.routes`(本 FE 路由),不数 router 全部(依赖 C)。 |
| 236 | - **枚举**:只驱动 `feScope.routes` + `feScope.controlWhitelist`;非白名单 / 共享未 approve FE 控件 → coverageGap,不归本 FE。 | 227 | - **枚举**:只驱动 `feScope.routes` + `feScope.controlWhitelist`;非白名单 / 共享未 approve FE 控件 → coverageGap,不归本 FE。 |
| @@ -283,13 +274,12 @@ async function behaviorSubGate(id, specPath, feScope): | @@ -283,13 +274,12 @@ async function behaviorSubGate(id, specPath, feScope): | ||
| 283 | 274 | ||
| 284 | ## 11. 实现顺序建议 | 275 | ## 11. 实现顺序建议 |
| 285 | 276 | ||
| 286 | -1. 前置 D(setup-test-db 模板护栏)——独立、最安全、先做。 | ||
| 287 | -2. 前置 C(deriveSpecPrompt FE→路由结构化小节 + reviewer 校验)——为 feScope 入参铺路。 | ||
| 288 | -3. 前置 A(runFrontendSkeleton 骨架占位 stage + tddPrompt 占位替换指令)——保证中途可构建。 | ||
| 289 | -4. 前置 B(BEHAVIOR_GATE_SCHEMA 增 build-failed + 归因控制流)。 | ||
| 290 | -5. 主改造(reviewWithFixLoop 加 behaviorSubGate / runBehaviorGate→runBehaviorGateOnce per-FE / 二维计数 / softPassed 提升 / contract 分离)。 | ||
| 291 | -6. 删除阶段级门 + reportPrompt 改写 + meta.phases 删 Behavior + phase 入参改 Frontend。 | ||
| 292 | -7. README / coding-start SKILL 文案。 | 277 | +1. 前置 C(deriveSpecPrompt FE→路由结构化小节 + reviewer 校验)——为 feScope 入参铺路。 |
| 278 | +2. 前置 A(runFrontendSkeleton 骨架占位 stage + tddPrompt 占位替换指令)——保证中途可构建。 | ||
| 279 | +3. 前置 B(BEHAVIOR_GATE_SCHEMA 增 build-failed + 归因控制流)。 | ||
| 280 | +4. 主改造(reviewWithFixLoop 加 behaviorSubGate / runBehaviorGate→runBehaviorGateOnce per-FE / 二维计数 / softPassed 提升 / contract 分离)。 | ||
| 281 | +5. 删除阶段级门 + reportPrompt 改写 + meta.phases 删 Behavior + phase 入参改 Frontend。 | ||
| 282 | +6. README / coding-start SKILL 文案。 | ||
| 293 | 283 | ||
| 294 | 每步后端分支必须逐字不变(diff 校验);运行时红线(time/random builtin / 顶层 return / 注入全局)每步复核。 | 284 | 每步后端分支必须逐字不变(diff 校验);运行时红线(time/random builtin / 顶层 return / 注入全局)每步复核。 |
| 295 | 285 | ||
| @@ -297,10 +287,11 @@ async function behaviorSubGate(id, specPath, feScope): | @@ -297,10 +287,11 @@ async function behaviorSubGate(id, specPath, feScope): | ||
| 297 | 287 | ||
| 298 | ## 12. 评审加固(实现后多代理评审产出,已落地) | 288 | ## 12. 评审加固(实现后多代理评审产出,已落地) |
| 299 | 289 | ||
| 300 | -实现后用多代理评审(6 维 + 逐发现对抗复核)核验本设计的逻辑与目标达成。控制流(approve 合取不变量、fix 轮边界、无 stale-green、残留清除)与运行时红线(确定性 builtin / 顶层 return / 16 schema 合法)全部通过。目标维度(「每控件可用 / 每文字正确」)判为 mostly-achieves,确认了若干 escapability 缺口,其中确定性、低风险的三项已加固: | 290 | +实现后用多代理评审(6 维 + 逐发现对抗复核)核验本设计的逻辑与目标达成。控制流(approve 合取不变量、fix 轮边界、无 stale-green、残留清除)与运行时红线(确定性 builtin / 顶层 return / schema 合法)全部通过。目标维度(「每控件可用 / 每文字正确」)判为 mostly-achieves,确认了若干 escapability 缺口,其中确定性、低风险的四项已加固: |
| 301 | 291 | ||
| 302 | -1. **`build-failed` 短路加确定性前置(头号 must-fix)**:`behaviorSubGate` 在 green-by-skip 前校验 (a) `rootCausePath` 非空、(b) 无交互/`sentinel` 硬问题搭车;任一不满足 → `adjudicate(allowContinue:false)` retry/halt,绝不凭未校验的 LLM 归因静默放行。骨架(lazy router + FeStub)令合法的兄弟未实现 build-failed 极罕见,故一个 build-failed 更可能是本 FE 真共享代码回归——这是此前 comment §107-108 声称 load-bearing 却无 JS 兜底的边界。 | ||
| 303 | -2. **覆盖率对账兜底(§3.6)**:空覆盖此前只兜 `==0`;新增 `routesReached < routesPlanned` 且缺口未被「路由级 coverageGap」解释时 → `adjudicate(allowContinue:false)`,堵住「部分覆盖假绿」(只数路由级 reason,过计只抑制本门、绝不误 halt)。 | ||
| 304 | -3. **测试库护栏直接 halt 落地**:用 `TESTDB_GUARD_MARK` 标记 + `behaviorTestDbGuardTripped` 兑现 step2「不重试不仲裁直接 halt」承诺(详见 §5)。 | 292 | +1. **`build-failed` 短路加前置校验(头号 must-fix)**:`behaviorSubGate` 在 green-by-skip 前校验 (a) `rootCausePath` 非空、(b) 无交互/`sentinel` 硬问题搭车;任一不满足 → `adjudicate(allowContinue:false)` retry/halt,绝不凭未校验的 LLM 归因静默放行。骨架(lazy router + FeStub)令合法的兄弟未实现 build-failed 极罕见,故一个 build-failed 更可能是本 FE 真共享代码回归——这是此前 comment §107-108 声称 load-bearing 却无 JS 兜底的边界。 |
| 293 | +2. **sentinel 文字 locator 补齐**:`textIssues[source="sentinel"]` 是客观绑定错字段,转 must-fix 时需要 locator;schema 与 prompt 现允许并要求携带 locator,反查不到则归 `coverageGaps[reason="locator-not-resolvable"]`。 | ||
| 294 | +3. **覆盖率对账兜底(§3.6)**:空覆盖此前只兜 `==0`;新增 `routesReached < routesPlanned` 且缺口未被「不同路由级 coverageGap」解释时 → `adjudicate(allowContinue:false)`,堵住「部分覆盖假绿」(同一路由的重复 gap 不再能抵扣多个漏达路由)。 | ||
| 295 | +4. **未分类 red 兜底**:`status:red` 但没有 envError / interactionFailures / textIssues / coverageGaps 任一解释时,拒绝把未分类红灯判 green,进入 `adjudicate(allowContinue:false)` retry/halt。 | ||
| 305 | 296 | ||
| 306 | 未改(确认为可接受的设计取舍,留作后续):白名单/路由作用域自证(非从 live router/DOM 独立枚举)、非数据文字按 source 软分流、disabled 控件仅提交类有 should-work 复核——均为有意取舍,记于此供后续权衡。 | 297 | 未改(确认为可接受的设计取舍,留作后续):白名单/路由作用域自证(非从 live router/DOM 独立枚举)、非数据文字按 source 软分流、disabled 控件仅提交类有 should-work 复核——均为有意取舍,记于此供后续权衡。 |
lib/apply-ddl.mjs
| @@ -83,11 +83,9 @@ export function resolveDbConfig(config, cfgPath = 'config-vars.yaml') { | @@ -83,11 +83,9 @@ export function resolveDbConfig(config, cfgPath = 'config-vars.yaml') { | ||
| 83 | if (!Number.isInteger(port) || port <= 0 || port > 65535) { | 83 | if (!Number.isInteger(port) || port <= 0 || port > 65535) { |
| 84 | throw new Error(`apply-ddl: 端口非法 — ${cfgPath} 的 database.port 必须是 1..65535 的整数`) | 84 | throw new Error(`apply-ddl: 端口非法 — ${cfgPath} 的 database.port 必须是 1..65535 的整数`) |
| 85 | } | 85 | } |
| 86 | - // DDL-7:lib 自保护——拒绝把未填的「【人工填写】」凭据占位直连 MySQL。 | ||
| 87 | - // db-init 步骤 B 已用 LLM 文本检查把关,这里在真正建连的那一层再加一道防御(占位文本绝不应连库)。 | ||
| 88 | for (const [k, v] of [['host', host], ['user', user], ['password', password], ['schema', database]]) { | 86 | for (const [k, v] of [['host', host], ['user', user], ['password', password], ['schema', database]]) { |
| 89 | if (typeof v === 'string' && v.includes('【人工填写')) { | 87 | if (typeof v === 'string' && v.includes('【人工填写')) { |
| 90 | - throw new Error(`apply-ddl: ${cfgPath} 的 database.${k} 仍是「【人工填写】」占位 — 请先填真实凭据`) | 88 | + throw new Error(`apply-ddl: ${cfgPath} 的 database.${k} 仍是占位,请先填真实值(database.password 可填 '' 表示空密码)`) |
| 91 | } | 89 | } |
| 92 | } | 90 | } |
| 93 | return { host, port, user, password, database } | 91 | return { host, port, user, password, database } |
lib/apply-ddl.test.mjs
| @@ -39,17 +39,17 @@ test('resolveDbConfig rejects invalid ports', () => { | @@ -39,17 +39,17 @@ test('resolveDbConfig rejects invalid ports', () => { | ||
| 39 | assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: '70000' } }), /database\.port/) | 39 | assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: '70000' } }), /database\.port/) |
| 40 | }) | 40 | }) |
| 41 | 41 | ||
| 42 | -// ── DDL-7:lib 自保护——未填的 【人工填写】 凭据占位不得直连 MySQL ────────── | ||
| 43 | -test('resolveDbConfig rejects unfilled 【人工填写】 placeholders in credentials (DDL-7)', () => { | 42 | +test('resolveDbConfig rejects unfilled 【人工填写】 placeholders as incomplete config', () => { |
| 44 | const base = { host: 'h', port: '3306', user: 'u', password: 'p', schema: 's' } | 43 | const base = { host: 'h', port: '3306', user: 'u', password: 'p', schema: 's' } |
| 45 | - assert.throws(() => resolveDbConfig({ database: { ...base, schema: '【人工填写:schema 名】' } }), /人工填写/) | ||
| 46 | - assert.throws(() => resolveDbConfig({ database: { ...base, host: '【人工填写:MySQL host】' } }), /人工填写/) | ||
| 47 | - assert.throws(() => resolveDbConfig({ database: { ...base, user: '【人工填写:账号】' } }), /人工填写/) | ||
| 48 | - assert.throws(() => resolveDbConfig({ database: { ...base, password: '【人工填写:密码】' } }), /人工填写/) | 44 | + assert.throws(() => resolveDbConfig({ database: { ...base, schema: '【人工填写:schema 名】' } }), /仍是占位/) |
| 45 | + assert.throws(() => resolveDbConfig({ database: { ...base, host: '【人工填写:MySQL host】' } }), /仍是占位/) | ||
| 46 | + assert.throws(() => resolveDbConfig({ database: { ...base, user: '【人工填写:账号】' } }), /仍是占位/) | ||
| 47 | + assert.throws(() => resolveDbConfig({ database: { ...base, password: '【人工填写:密码】' } }), /仍是占位/) | ||
| 49 | }) | 48 | }) |
| 50 | 49 | ||
| 51 | -test('resolveDbConfig still accepts a fully-filled real config (DDL-7 no false positive)', () => { | ||
| 52 | - const c = resolveDbConfig({ database: { host: '127.0.0.1', port: '3306', user: 'root', password: 'p@ss', schema: 'erp_dev' } }) | 50 | +test('resolveDbConfig allows an explicit empty database.password', () => { |
| 51 | + const c = resolveDbConfig({ database: { host: '127.0.0.1', port: '3306', user: 'root', password: '', schema: 'erp_dev' } }) | ||
| 52 | + assert.equal(c.password, '') | ||
| 53 | assert.equal(c.database, 'erp_dev') | 53 | assert.equal(c.database, 'erp_dev') |
| 54 | }) | 54 | }) |
| 55 | 55 |
lib/setup-test-db-template.test.mjs
| 1 | -// lib/setup-test-db-template.test.mjs — 校验生成模板 scripts/setup-test-db.mjs 的 schema 守卫。 | 1 | +// lib/setup-test-db-template.test.mjs — 校验生成模板 scripts/setup-test-db.mjs 的最小 DB reset 逻辑。 |
| 2 | // 跑的是真实模板产物:复制到临时 scripts/ 下、写一个 ../config-vars.yaml、再 node 执行。 | 2 | // 跑的是真实模板产物:复制到临时 scripts/ 下、写一个 ../config-vars.yaml、再 node 执行。 |
| 3 | -// 所有用例的 host/port 故意指向 127.0.0.1:1(必拒连),即便守卫缺失也绝不触碰真实库。 | 3 | +// 所有用例的 host/port 故意指向 127.0.0.1:1(必拒连),不会触碰真实库。 |
| 4 | import { test } from 'node:test' | 4 | import { test } from 'node:test' |
| 5 | import assert from 'node:assert/strict' | 5 | import assert from 'node:assert/strict' |
| 6 | import { spawnSync } from 'node:child_process' | 6 | import { spawnSync } from 'node:child_process' |
| @@ -11,63 +11,53 @@ import { fileURLToPath } from 'node:url' | @@ -11,63 +11,53 @@ import { fileURLToPath } from 'node:url' | ||
| 11 | 11 | ||
| 12 | const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs', import.meta.url)) | 12 | const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs', import.meta.url)) |
| 13 | 13 | ||
| 14 | -function runWithSchema(schemaLine, env = {}) { | 14 | +function runWithSchema(schemaLine) { |
| 15 | + return runWithDb({ schemaLine }) | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +function runWithDb({ host = '127.0.0.1', port = '1', user = 'root', password = 'x', schemaLine = 'schema: erp_dev' }) { | ||
| 15 | const dir = mkdtempSync(join(tmpdir(), 'erp-stdb-')) | 19 | const dir = mkdtempSync(join(tmpdir(), 'erp-stdb-')) |
| 16 | mkdirSync(join(dir, 'scripts')) | 20 | mkdirSync(join(dir, 'scripts')) |
| 17 | copyFileSync(TEMPLATE, join(dir, 'scripts', 'setup-test-db.mjs')) | 21 | copyFileSync(TEMPLATE, join(dir, 'scripts', 'setup-test-db.mjs')) |
| 18 | writeFileSync( | 22 | writeFileSync( |
| 19 | join(dir, 'config-vars.yaml'), | 23 | join(dir, 'config-vars.yaml'), |
| 20 | - ['database:', ' host: 127.0.0.1', ' port: 1', ' user: root', ' password: x', ' ' + schemaLine, ''].join('\n'), | 24 | + ['database:', ` host: ${host}`, ` port: ${port}`, ` user: ${user}`, ` password: ${password}`, ' ' + schemaLine, ''].join('\n'), |
| 21 | ) | 25 | ) |
| 22 | - return spawnSync('node', [join(dir, 'scripts', 'setup-test-db.mjs')], { encoding: 'utf8', env: { ...process.env, ...env } }) | 26 | + return spawnSync('node', [join(dir, 'scripts', 'setup-test-db.mjs')], { encoding: 'utf8' }) |
| 23 | } | 27 | } |
| 24 | 28 | ||
| 25 | -// ROBUST-3:空 schema 不应进到 DROP DATABASE `` —— 守卫应先拦下。 | ||
| 26 | -test('setup-test-db: empty schema fails closed with a schema message (ROBUST-3)', () => { | 29 | +test('setup-test-db: empty schema fails before mysql is invoked', () => { |
| 27 | const r = runWithSchema('schema:') | 30 | const r = runWithSchema('schema:') |
| 28 | assert.equal(r.status, 1) | 31 | assert.equal(r.status, 1) |
| 29 | - assert.match(r.stderr, /schema/, '应是 schema 守卫报错而非连库失败 — stderr: ' + r.stderr) | 32 | + assert.match(r.stderr, /database\.schema/, '应是 schema 缺失报错而非连库失败 — stderr: ' + r.stderr) |
| 30 | }) | 33 | }) |
| 31 | 34 | ||
| 32 | -// ROBUST-3:未填的 【人工填写】 占位不应被当库名。 | ||
| 33 | -test('setup-test-db: 【人工填写】 placeholder schema fails closed (ROBUST-3)', () => { | ||
| 34 | - const r = runWithSchema('schema: 【人工填写:schema 名】') | ||
| 35 | - assert.equal(r.status, 1) | ||
| 36 | - assert.match(r.stderr, /schema/, 'stderr: ' + r.stderr) | 35 | +test('setup-test-db: unfilled 【人工填写】 config placeholders fail before mysql is invoked', () => { |
| 36 | + for (const cfg of [ | ||
| 37 | + { host: '【人工填写:MySQL host】' }, | ||
| 38 | + { port: '【人工填写:MySQL port】' }, | ||
| 39 | + { user: '【人工填写:账号】' }, | ||
| 40 | + { password: '【人工填写:密码】' }, | ||
| 41 | + { schemaLine: 'schema: 【人工填写:schema 名】' }, | ||
| 42 | + ]) { | ||
| 43 | + const r = runWithDb(cfg) | ||
| 44 | + assert.equal(r.status, 1) | ||
| 45 | + assert.match(r.stderr, /仍是占位/, 'stderr: ' + r.stderr) | ||
| 46 | + } | ||
| 37 | }) | 47 | }) |
| 38 | 48 | ||
| 39 | -// DDL-8:含反引号的 schema(标识符注入)应被拒,而不是拼进 DROP/CREATE 语句。 | ||
| 40 | -test('setup-test-db: schema with a backtick is rejected (DDL-8 injection guard)', () => { | 49 | +test('setup-test-db: schema with a backtick is quoted for MySQL instead of rejected', () => { |
| 41 | const r = runWithSchema('schema: ev`il') | 50 | const r = runWithSchema('schema: ev`il') |
| 42 | - assert.equal(r.status, 1) | ||
| 43 | - assert.match(r.stderr, /schema/, 'stderr: ' + r.stderr) | 51 | + assert.match(r.stdout, /`ev``il`/, 'stdout: ' + r.stdout) |
| 52 | + assert.doesNotMatch(r.stderr, /database\.schema/, 'stderr: ' + r.stderr) | ||
| 44 | }) | 53 | }) |
| 45 | 54 | ||
| 46 | -// 正例:合法标识符 schema 应通过守卫并继续到连库阶段(此处连 127.0.0.1:1 必失败, | ||
| 47 | -// 但 stderr 应是连库/mysql 错误,而非 schema 守卫错误)——证明守卫不误伤合法名。 | ||
| 48 | -test('setup-test-db: a valid identifier schema passes the guard (no false positive)', () => { | 55 | +test('setup-test-db: an ordinary dev schema proceeds to mysql', () => { |
| 49 | const r = runWithSchema('schema: erp_dev') | 56 | const r = runWithSchema('schema: erp_dev') |
| 50 | - // 连不上 127.0.0.1:1 → 非零退出;关键是错误不来自 schema 守卫。 | ||
| 51 | - assert.doesNotMatch(r.stderr, /database\.schema 非法|schema 非法或未填/, 'stderr: ' + r.stderr) | 57 | + assert.match(r.stdout, /DROP \+ CREATE `erp_dev`/, 'stdout: ' + r.stdout) |
| 52 | }) | 58 | }) |
| 53 | 59 | ||
| 54 | -// 前置依赖 D(安全):测试库命名护栏——非测试库名默认 fail-closed,防误删开发/生产库。 | ||
| 55 | -test('setup-test-db: a non-test schema fails closed by default (D non-test guard)', () => { | 60 | +test('setup-test-db: non-test names are allowed in sandbox/dev workflows', () => { |
| 56 | const r = runWithSchema('schema: erp_prod') | 61 | const r = runWithSchema('schema: erp_prod') |
| 57 | - assert.equal(r.status, 1) | ||
| 58 | - assert.match(r.stderr, /不像测试库|ALLOW_NONTEST_DROP/, '应是测试库命名护栏报错 — stderr: ' + r.stderr) | ||
| 59 | -}) | ||
| 60 | - | ||
| 61 | -// D:测试库名(含 test / _test / _dev / _local)应通过命名护栏,错误不来自该护栏。 | ||
| 62 | -test('setup-test-db: test-like schema names pass the naming guard (erp_test / erp_dev)', () => { | ||
| 63 | - for (const name of ['erp_test', 'erp_dev', 'erp_local', 'test_db']) { | ||
| 64 | - const r = runWithSchema('schema: ' + name) | ||
| 65 | - assert.doesNotMatch(r.stderr, /不像测试库/, `${name} 不应被命名护栏拒绝 — stderr: ` + r.stderr) | ||
| 66 | - } | ||
| 67 | -}) | ||
| 68 | - | ||
| 69 | -// D:ALLOW_NONTEST_DROP=1 显式放行非测试库名(错误不再来自命名护栏)。 | ||
| 70 | -test('setup-test-db: ALLOW_NONTEST_DROP=1 explicitly bypasses the naming guard', () => { | ||
| 71 | - const r = runWithSchema('schema: erp_prod', { ALLOW_NONTEST_DROP: '1' }) | ||
| 72 | - assert.doesNotMatch(r.stderr, /不像测试库/, '显式放行后不应再被命名护栏拒绝 — stderr: ' + r.stderr) | 62 | + assert.match(r.stdout, /DROP \+ CREATE `erp_prod`/, 'stdout: ' + r.stdout) |
| 73 | }) | 63 | }) |
skills/plan/db-init/SKILL.md
| 1 | --- | 1 | --- |
| 2 | name: db-init | 2 | name: db-init |
| 3 | -description: A4 DB 初始化——LLM 解析 docs/03-数据库设计文档.md → 生成 sql/migrations/V1__initial_schema.sql(DDL only,Flyway 初始 migration)→ 用 lib/validate-ddl.mjs 做 5 维校验(表/列/类型/索引/外键)DDL ↔ docs/03 一致性 → 连库前置预检(mysql2 模块 + mysql 客户端)→ 校验 config-vars.yaml DB 凭据 5 项非空 → 调 scripts/setup-test-db.mjs DROP+CREATE 空库(连不上即失败)→ 用 lib/apply-ddl.mjs apply V1。 | 3 | +description: A4 DB 初始化——LLM 解析 docs/03-数据库设计文档.md → 生成 sql/migrations/V1__initial_schema.sql(DDL only,Flyway 初始 migration)→ 用 lib/validate-ddl.mjs 做 5 维校验(表/列/类型/索引/外键)DDL ↔ docs/03 一致性 → 调 scripts/setup-test-db.mjs DROP+CREATE 空库 → 用 lib/apply-ddl.mjs apply V1。 |
| 4 | user-invocable: false | 4 | user-invocable: false |
| 5 | allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm install mysql2) | 5 | allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm install mysql2) |
| 6 | --- | 6 | --- |
| @@ -56,41 +56,19 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | @@ -56,41 +56,19 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | ||
| 56 | - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成` | 56 | - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成` |
| 57 | - ` - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs)` | 57 | - ` - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs)` |
| 58 | 58 | ||
| 59 | -### B. 数据库环境检查(连库前置——必须在步骤 C 的任何 DROP 之前完成) | 59 | +### B. 自动导入 MySQL |
| 60 | 60 | ||
| 61 | -#### B.1 工具链预检 | ||
| 62 | -A4 连库用到两样东西:`apply-ddl.mjs` 依赖 `mysql2` 模块、`setup-test-db.mjs` 依赖 `mysql` 客户端。两者都在 C 阶段才被调用,而 C.1 第一步就是 `DROP DATABASE`——所以必须**在此先探测、缺则补齐**,避免"删到一半才发现缺工具"。 | 61 | +不做测试库命名预检:本项目只作用于开发或沙盒环境,`setup-test-db.mjs` / `apply-ddl.mjs` 会用同一份 `config-vars.yaml` 直接执行。若 DB 配置仍含 `【人工填写】` 占位则命令直接失败;缺 mysql 客户端、mysql2、凭据或连通性问题由实际命令报错后停下。 |
| 63 | 62 | ||
| 64 | -1. **mysql2 驱动**(`apply-ddl.mjs` 从 config-vars.yaml 所在目录解析 mysql2,而非插件目录;按 A4 既定调用 `apply-ddl.mjs config-vars.yaml …`、cwd = 项目根,该目录即项目根,故下面按 cwd 解析的 `node -e` 探测对此调用具代表性): | ||
| 65 | - ```bash | ||
| 66 | - node -e "import('mysql2/promise').then(()=>process.exit(0),()=>process.exit(1))" | ||
| 67 | - ``` | ||
| 68 | - - 退出 `0` → 已就绪。 | ||
| 69 | - - 退出非 `0` → 在项目根执行 `npm i mysql2`(首次会生成 / 更新根 `package.json` + `node_modules`;`.gitignore` 已忽略 `node_modules`),再重跑上面一行确认;仍失败 → 打印 stderr 并停下。 | ||
| 70 | -2. **mysql 客户端**: | ||
| 71 | - ```bash | ||
| 72 | - node -e "process.exit(require('node:child_process').spawnSync('mysql',['--version']).status===0?0:1)" | ||
| 73 | - ``` | ||
| 74 | - - 退出非 `0` → 打印「未找到 mysql 客户端,请安装并加入 PATH 后重跑 /plan-start」并停下(**不进入 C**)。 | ||
| 75 | - | ||
| 76 | -#### B.2 凭据校验 | ||
| 77 | -用 `Read` 读 `config-vars.yaml` 的 `database:` 段(文件缺失 → 提示重跑 A1 `scope-lock` 并停下),校验 `host` / `port` / `user` / `password` / `schema` 5 项均非空且非 `【人工填写` 占位——任一缺失 → 打印缺失字段并停下。 | ||
| 78 | - | ||
| 79 | -连通性无需在此单独探测:步骤 C.1 的 `setup-test-db.mjs` 会用同一份凭据连同一个 MySQL 跑 `DROP+CREATE`,连不上即报错(认证 / 主机不可达 / 端口拒接);且 `DROP DATABASE IF EXISTS` 在连不上时不破坏任何东西,由 C.1 失败即可。 | ||
| 80 | - | ||
| 81 | -勾选:` - [ ] config-vars.yaml DB 凭据 5 项非空校验通过` | ||
| 82 | - | ||
| 83 | -### C. 自动导入 MySQL | ||
| 84 | - | ||
| 85 | -#### C.1 DROP+CREATE 空库 | 63 | +#### B.1 DROP+CREATE 空库 |
| 86 | 64 | ||
| 87 | ```bash | 65 | ```bash |
| 88 | node scripts/setup-test-db.mjs | 66 | node scripts/setup-test-db.mjs |
| 89 | ``` | 67 | ``` |
| 90 | 68 | ||
| 91 | -#### C.2 把 V1 灌入已清空的 schema | 69 | +#### B.2 把 V1 灌入已清空的 schema |
| 92 | 70 | ||
| 93 | -调 `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`:它用纯 JS 解析 `config-vars.yaml` 的 `database:` 段(**不** shell-source,消除注入),再经 mysql2 把 DDL 灌入 schema。 | 71 | +调 `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`:它读取 `config-vars.yaml` 的 `database:` 段,再经 mysql2 把 DDL 灌入 schema。 |
| 94 | 72 | ||
| 95 | ```bash | 73 | ```bash |
| 96 | node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" config-vars.yaml sql/migrations/V1__initial_schema.sql | 74 | node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" config-vars.yaml sql/migrations/V1__initial_schema.sql |
| @@ -103,7 +81,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" config-vars.yaml sql/migrations/V | @@ -103,7 +81,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" config-vars.yaml sql/migrations/V | ||
| 103 | 81 | ||
| 104 | 勾选:` - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行` | 82 | 勾选:` - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行` |
| 105 | 83 | ||
| 106 | -### D. 勾选 docs/08 进度 + 进入 A5 | 84 | +### C. 勾选 docs/08 进度 + 进入 A5 |
| 107 | 85 | ||
| 108 | 1. 勾选 A4 顶层(5 维一致已由 A.3 的 `validate-ddl.mjs` 校验过,apply 不改 V1,无需复校): | 86 | 1. 勾选 A4 顶层(5 维一致已由 A.3 的 `validate-ddl.mjs` 校验过,apply 不改 V1,无需复校): |
| 109 | - `- [ ] A4 DB 初始化 — db-init` | 87 | - `- [ ] A4 DB 初始化 — db-init` |
| @@ -113,7 +91,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" config-vars.yaml sql/migrations/V | @@ -113,7 +91,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" config-vars.yaml sql/migrations/V | ||
| 113 | ## 参考 | 91 | ## 参考 |
| 114 | 92 | ||
| 115 | - `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs`(A.3 docs/03 ↔ V1.sql 5 维一致性校验,跨平台纯 Node) | 93 | - `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs`(A.3 docs/03 ↔ V1.sql 5 维一致性校验,跨平台纯 Node) |
| 116 | -- `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`(C.2 安全解析 config-vars.yaml 的 database: 段 + mysql2 灌入 DDL,不 shell-source) | 94 | +- `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`(B.2 读取 config-vars.yaml 的 database: 段 + mysql2 灌入 DDL) |
| 117 | - `${CLAUDE_PLUGIN_ROOT}/lib/yaml-config.mjs`(apply-ddl 依赖的极简 YAML 读取) | 95 | - `${CLAUDE_PLUGIN_ROOT}/lib/yaml-config.mjs`(apply-ddl 依赖的极简 YAML 读取) |
| 118 | - `docs/03-数据库设计文档.md`(DDL 翻译输入,SSoT) | 96 | - `docs/03-数据库设计文档.md`(DDL 翻译输入,SSoT) |
| 119 | - `config-vars.yaml`(DB 凭据,A1 产出) | 97 | - `config-vars.yaml`(DB 凭据,A1 产出) |
skills/plan/plan-start/SKILL.md
| @@ -49,7 +49,7 @@ A 阶段 checkbox 全部 `[x]` 后先跑下面 4 项前移闸门; 全过才放 | @@ -49,7 +49,7 @@ A 阶段 checkbox 全部 `[x]` 后先跑下面 4 项前移闸门; 全过才放 | ||
| 49 | - 缺口表述示例:`REQ-USER-001 仍含 TBD / {{title}} 占位未替换`。 | 49 | - 缺口表述示例:`REQ-USER-001 仍含 TBD / {{title}} 占位未替换`。 |
| 50 | 50 | ||
| 51 | 2. **全部配置全锁**(来自 A1 写入 `config-vars.yaml` 的非敏感配置 + 敏感凭据,单一文件) | 51 | 2. **全部配置全锁**(来自 A1 写入 `config-vars.yaml` 的非敏感配置 + 敏感凭据,单一文件) |
| 52 | - - `Read` `config-vars.yaml`(项目全部配置,含敏感凭据,随项目提交):校验所有字段均有真实值,无 `【人工填写`/`TBD`/空值——含非敏感项(`backend`/`frontend` 包名 / 端口、`admin_init.username`、`database.host/port/user/schema`)与敏感项(`database.password`、`admin_init.password`、`secrets.*`)。 | 52 | + - `Read` `config-vars.yaml`(项目全部配置,含敏感凭据,随项目提交):校验所有字段均无 `【人工填写`/`TBD`;除 `database.password` 可显式为空串外,其余字段不得为空——含非敏感项(`backend`/`frontend` 包名 / 端口、`admin_init.username`、`database.host/port/user/schema`)与敏感项(`admin_init.password`、`secrets.*`)。 |
| 53 | - 任一未填即缺口。 | 53 | - 任一未填即缺口。 |
| 54 | 54 | ||
| 55 | 3. **docs/04 §零 命令齐**(来自 A1 收集的每栈构建/lint/单测/e2e 命令) | 55 | 3. **docs/04 §零 命令齐**(来自 A1 收集的每栈构建/lint/单测/e2e 命令) |
| @@ -96,7 +96,7 @@ A 阶段 checkbox 全部 `[x]` 后先跑下面 4 项前移闸门; 全过才放 | @@ -96,7 +96,7 @@ A 阶段 checkbox 全部 `[x]` 后先跑下面 4 项前移闸门; 全过才放 | ||
| 96 | <逐条列出每个缺口,格式:[闸门] 缺口描述 → 回填位置> | 96 | <逐条列出每个缺口,格式:[闸门] 缺口描述 → 回填位置> |
| 97 | 例: | 97 | 例: |
| 98 | [REQ 真实数据] REQ-USER-001 仍含 {{goal}} 占位未替换 → docs/01-需求清单/... | 98 | [REQ 真实数据] REQ-USER-001 仍含 {{goal}} 占位未替换 → docs/01-需求清单/... |
| 99 | - [配置] database.password 未填 → config-vars.yaml | 99 | + [配置] database.password 仍是占位(如本地空密码请显式填 `''`)→ config-vars.yaml |
| 100 | [docs/04 §零] node 栈缺 e2e 命令 → docs/04-技术规范.md §零 | 100 | [docs/04 §零] node 栈缺 e2e 命令 → docs/04-技术规范.md §零 |
| 101 | 101 | ||
| 102 | 补齐后再次运行 /erp-workflow:plan-start 重新校验。 | 102 | 补齐后再次运行 /erp-workflow:plan-start 重新校验。 |
skills/plan/project-init/templates/docs-08-initial-template.md
| @@ -28,7 +28,6 @@ | @@ -28,7 +28,6 @@ | ||
| 28 | - [ ] A4 DB 初始化 — db-init | 28 | - [ ] A4 DB 初始化 — db-init |
| 29 | - [ ] sql/migrations/V1__initial_schema.sql 已生成 | 29 | - [ ] sql/migrations/V1__initial_schema.sql 已生成 |
| 30 | - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs) | 30 | - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs) |
| 31 | - - [ ] config-vars.yaml DB 凭据 5 项非空校验通过 | ||
| 32 | - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行 | 31 | - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行 |
| 33 | 32 | ||
| 34 | - [ ] A5 下游文档生成 — downstream-gen | 33 | - [ ] A5 下游文档生成 — downstream-gen |
skills/plan/scope-lock/SKILL.md
| @@ -64,8 +64,8 @@ allowed-tools: Read Write Edit Grep Glob Skill AskUserQuestion Bash(mkdir *) | @@ -64,8 +64,8 @@ allowed-tools: Read Write Edit Grep Glob Skill AskUserQuestion Bash(mkdir *) | ||
| 64 | #### E.2 全部配置锁进 config-vars.yaml(含敏感凭据,单一文件,随项目提交) | 64 | #### E.2 全部配置锁进 config-vars.yaml(含敏感凭据,单一文件,随项目提交) |
| 65 | 65 | ||
| 66 | 1. 据 `docs/04` § 零 + REQ 卡片盘点配置. | 66 | 1. 据 `docs/04` § 零 + REQ 卡片盘点配置. |
| 67 | -2. `Read` 模板 `${CLAUDE_SKILL_DIR}/templates/config-vars-template.yaml` → `Write` `config-vars.yaml`: 非敏感字段(包名 / 端口 / 前端包名 / 初始账号等)CC 据上下文填默认值, 无技术栈整节删, 不可推断留 `【人工填写】`; 敏感字段(`database.password` / `admin_init.password` / `secrets.*` 等真实凭据)CC **不臆造**, 一律留 `【人工填写:<说明>】` 占位待人工填. | ||
| 68 | -3. `AskUserQuestion` 让用户确认非敏感项齐全, 并提示敏感凭据需用户**自己在 config-vars.yaml 填真实值**. | 67 | +2. `Read` 模板 `${CLAUDE_SKILL_DIR}/templates/config-vars-template.yaml` → `Write` `config-vars.yaml`: 非敏感字段(包名 / 端口 / 前端包名 / 初始账号等)CC 据上下文填默认值, 无技术栈整节删, 不可推断留 `【人工填写】`; 敏感字段(`database.password` / `admin_init.password` / `secrets.*` 等真实凭据)CC **不臆造**, 一律留 `【人工填写:<说明>】` 占位待人工填(本地 MySQL 无密码时,用户可把 `database.password` 明确填成 `''`)。 |
| 68 | +3. `AskUserQuestion` 让用户确认非敏感项齐全, 并提示敏感凭据需用户**自己在 config-vars.yaml 填真实值**(`database.password` 可为显式空串). | ||
| 69 | 69 | ||
| 70 | #### E.3 build / lint / unit / e2e 命令锁进 docs/04 § 零 | 70 | #### E.3 build / lint / unit / e2e 命令锁进 docs/04 § 零 |
| 71 | 71 |
skills/plan/scope-lock/templates/config-vars-template.yaml
| @@ -17,7 +17,7 @@ database: | @@ -17,7 +17,7 @@ database: | ||
| 17 | port: 【人工填写:MySQL port,默认 3306】 | 17 | port: 【人工填写:MySQL port,默认 3306】 |
| 18 | user: 【人工填写:开发账号名】 | 18 | user: 【人工填写:开发账号名】 |
| 19 | password: 【人工填写:对应密码,含特殊字符时用单引号包裹】 | 19 | password: 【人工填写:对应密码,含特殊字符时用单引号包裹】 |
| 20 | - schema: 【人工填写:schema 名,推荐含 test/_dev/_local,例如 erp_dev】 | 20 | + schema: 【人工填写:schema 名,例如 erp_dev】 |
| 21 | 21 | ||
| 22 | admin_init: | 22 | admin_init: |
| 23 | username: admin | 23 | username: admin |
skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | -// scripts/setup-test-db.mjs — DROP + CREATE 测试库。 | 2 | +// scripts/setup-test-db.mjs — DROP + CREATE 开发/沙盒库。 |
| 3 | // 由 coding.mjs 的 test-gate 调用;schema 由 Flyway 在 Spring Boot 启动时重放。 | 3 | // 由 coding.mjs 的 test-gate 调用;schema 由 Flyway 在 Spring Boot 启动时重放。 |
| 4 | -// DB 凭据从仓库根 config-vars.yaml 的 database: 段读取:schema 经标识符校验后才拼进 SQL(防误删 / 注入,见下方守卫); | ||
| 5 | -// host / user / password 信任该文件,port 仅校验范围。 | 4 | +// DB 凭据从仓库根 config-vars.yaml 的 database: 段读取;host / user / password 信任该文件,port 仅校验范围。 |
| 6 | 5 | ||
| 7 | import { spawnSync } from 'node:child_process' | 6 | import { spawnSync } from 'node:child_process' |
| 8 | import { existsSync, readFileSync } from 'node:fs' | 7 | import { existsSync, readFileSync } from 'node:fs' |
| @@ -68,36 +67,38 @@ const DB_USER = db.user ?? '' | @@ -68,36 +67,38 @@ const DB_USER = db.user ?? '' | ||
| 68 | const DB_PASSWORD = db.password ?? '' | 67 | const DB_PASSWORD = db.password ?? '' |
| 69 | const DB_SCHEMA = db.schema ?? '' | 68 | const DB_SCHEMA = db.schema ?? '' |
| 70 | 69 | ||
| 70 | +function rejectPlaceholder(key, value) { | ||
| 71 | + if (typeof value === 'string' && value.includes('【人工填写')) { | ||
| 72 | + console.error(`[setup-test-db] database.${key} 仍是占位,请先在 config-vars.yaml 填真实值(database.password 可填 '' 表示空密码)`) | ||
| 73 | + process.exit(1) | ||
| 74 | + } | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +for (const [key, value] of [['host', DB_HOST], ['port', DB_PORT], ['user', DB_USER], ['password', DB_PASSWORD], ['schema', DB_SCHEMA]]) { | ||
| 78 | + rejectPlaceholder(key, value) | ||
| 79 | +} | ||
| 80 | + | ||
| 71 | if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) { | 81 | if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) { |
| 72 | console.error(`[setup-test-db] database.port 非法: ${DB_PORT}(必须是 1..65535 的整数)`) | 82 | console.error(`[setup-test-db] database.port 非法: ${DB_PORT}(必须是 1..65535 的整数)`) |
| 73 | process.exit(1) | 83 | process.exit(1) |
| 74 | } | 84 | } |
| 75 | 85 | ||
| 76 | -// schema 是被无条件 DROP + CREATE 的标识符——必须严格校验后才拼进 SQL: | ||
| 77 | -// · 空值 → 避免 DROP DATABASE `` 这类无意义/误删语句 | ||
| 78 | -// · 「【人工填写】」占位 → 配置尚未填好,不应连库 | ||
| 79 | -// · 含反引号 → 防止 `erp`; DROP DATABASE `prod` 形态的标识符注入(值来自 config-vars.yaml,按 fail-closed 处理) | ||
| 80 | -// 注:仅接受 ASCII 标识符;非 ASCII schema 名一律拒绝(即便 MySQL / apply-ddl 允许),与推荐的 test/_dev 命名一致 | ||
| 81 | -if (!/^[A-Za-z0-9_$]+$/.test(DB_SCHEMA)) { | ||
| 82 | - console.error(`[setup-test-db] database.schema 非法或未填: ${JSON.stringify(DB_SCHEMA)}(需为 [A-Za-z0-9_$] 标识符;空值 / 「【人工填写】」占位 / 含反引号均拒绝)`) | 86 | +if (String(DB_SCHEMA).trim() === '') { |
| 87 | + console.error('[setup-test-db] database.schema 未填') | ||
| 83 | process.exit(1) | 88 | process.exit(1) |
| 84 | } | 89 | } |
| 85 | 90 | ||
| 86 | -// 测试库命名护栏(确定性 JS 边界,唯一防线):本脚本无条件 DROP + CREATE schema; | ||
| 87 | -// per-FE × per-behaviorRound 反复起栈会反复 DROP,对 config-vars 指向的库(可能 = 开发/生产库) | ||
| 88 | -// 误删风险随次数放大。故只允许作用于"明确像测试/本地库"的库名——库名须含 test / _test / _dev / _local | ||
| 89 | -// 之一(不区分大小写),否则 fail-closed;确需对非测试库执行时,显式设 ALLOW_NONTEST_DROP=1 放行。 | ||
| 90 | -// 不依赖任何调用方(如行为门 runner)记得复述同等检查——模板是唯一防线。 | ||
| 91 | -if (process.env.ALLOW_NONTEST_DROP !== '1' && !/test|_dev|_local/i.test(DB_SCHEMA)) { | ||
| 92 | - console.error(`[setup-test-db] 拒绝:schema=${JSON.stringify(DB_SCHEMA)} 不像测试库(库名须含 test/_test/_dev/_local),设 ALLOW_NONTEST_DROP=1 显式放行`) | ||
| 93 | - process.exit(1) | 91 | +function quoteMySqlIdent(value) { |
| 92 | + return '`' + String(value).replaceAll('`', '``') + '`' | ||
| 94 | } | 93 | } |
| 95 | 94 | ||
| 96 | -console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`) | 95 | +const DB_SCHEMA_SQL = quoteMySqlIdent(DB_SCHEMA) |
| 96 | + | ||
| 97 | +console.log(`[setup-test-db] 即将 DROP + CREATE ${DB_SCHEMA_SQL} on ${DB_HOST}:${DB_PORT}`) | ||
| 97 | 98 | ||
| 98 | const sql = | 99 | const sql = |
| 99 | - `DROP DATABASE IF EXISTS \`${DB_SCHEMA}\`; ` + | ||
| 100 | - `CREATE DATABASE \`${DB_SCHEMA}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;` | 100 | + `DROP DATABASE IF EXISTS ${DB_SCHEMA_SQL}; ` + |
| 101 | + `CREATE DATABASE ${DB_SCHEMA_SQL} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;` | ||
| 101 | 102 | ||
| 102 | const mysqlArgs = [ | 103 | const mysqlArgs = [ |
| 103 | `--host=${DB_HOST}`, | 104 | `--host=${DB_HOST}`, |
workflows/coding.mjs
| @@ -94,7 +94,8 @@ const BEHAVIOR_GATE_SCHEMA = { type:'object', additionalProperties:false, | @@ -94,7 +94,8 @@ const BEHAVIOR_GATE_SCHEMA = { type:'object', additionalProperties:false, | ||
| 94 | properties:{ | 94 | properties:{ |
| 95 | page:{type:'string'}, region:{type:'string'}, | 95 | page:{type:'string'}, region:{type:'string'}, |
| 96 | expected:{type:'string'}, actual:{type:'string'}, | 96 | expected:{type:'string'}, actual:{type:'string'}, |
| 97 | - source:{type:'string', enum:['sentinel','i18n','literal','semantic']} } } }, | 97 | + source:{type:'string', enum:['sentinel','i18n','literal','semantic']}, |
| 98 | + locator:{type:'string'} } } }, | ||
| 98 | // 覆盖率缺口:写证据 + recordDecisions,不单独 halt(空覆盖由 controlsEnumerated==0 兜底) | 99 | // 覆盖率缺口:写证据 + recordDecisions,不单独 halt(空覆盖由 controlsEnumerated==0 兜底) |
| 99 | // build-failed-sibling-unimpl:兄弟 FE 未实现导致本 FE 之外路由/组件编译缺件(预期中途态,不归本 FE 缺陷) | 100 | // build-failed-sibling-unimpl:兄弟 FE 未实现导致本 FE 之外路由/组件编译缺件(预期中途态,不归本 FE 缺陷) |
| 100 | // locator-not-resolvable:行为硬问题连组件文件都反查不出(B 类),计入未覆盖阻断 approve,不静默放行 | 101 | // locator-not-resolvable:行为硬问题连组件文件都反查不出(B 类),计入未覆盖阻断 approve,不静默放行 |
| @@ -613,13 +614,12 @@ function behaviorGatePrompt(id, specPath, behaviorRound, attempt) { | @@ -613,13 +614,12 @@ function behaviorGatePrompt(id, specPath, behaviorRound, attempt) { | ||
| 613 | '- 带参动态路由用**种子已知主键**实例化;无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。', | 614 | '- 带参动态路由用**种子已知主键**实例化;无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。', |
| 614 | '- **未建兄弟路由既不计入分母也不计 coverageGap**(属预期中途态,按 step0 归 build-failed 短路)。', | 615 | '- **未建兄弟路由既不计入分母也不计 coverageGap**(属预期中途态,按 step0 归 build-failed 短路)。', |
| 615 | '', | 616 | '', |
| 616 | - '## step2 安全护栏 + 起栈四段严格时序(schema 由 Flyway 在后端启动时才建)', | ||
| 617 | - `1) **测试库安全护栏**:测试库命名护栏现已下沉到 \`${ROOT}/scripts/setup-test-db.mjs\` 模板自身(确定性 JS 边界,库名须含 test/_test/_dev/_local,否则 fail-closed,\`ALLOW_NONTEST_DROP=1\` 显式放行)。runner 可复述但模板是唯一防线;若模板因测试库护栏非零退出 → 返回 \`status:red\` + \`envError.kind="stack-not-ready"\` + \`envError.detail\` **以固定标记 \`${TESTDB_GUARD_MARK}\` 开头**并写明「测试库护栏触发」+ 库名。上层据此标记**不重试不仲裁直接 halt**(留人工确认)——务必只在确为护栏触发时打此标记,其它起栈失败照常用普通 detail。`, | ||
| 618 | - `2) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程。`, | ||
| 619 | - '3) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。', | ||
| 620 | - '4) **此时才跑种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** 种子(先父后子)。失败 → `envError.kind="seed-error"` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断),**不**混进交互 RED。', | 617 | + '## step2 起栈四段严格时序(schema 由 Flyway 在后端启动时才建)', |
| 618 | + `1) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`, | ||
| 619 | + '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。', | ||
| 620 | + '3) **此时才跑种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** 种子(先父后子)。失败 → `envError.kind="seed-error"` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断),**不**混进交互 RED。', | ||
| 621 | ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——字符串列逐字段唯一编码(如 `CUST_NAME_S001`,抓绑错字段)+ 行序号保 UNIQUE;数值列用高位魔数;enum 列从 docs/03 值域取并标注。插入前扫 Flyway / config-vars 既有初始数据(admin_init 等)键,sentinel 主键偏移到不冲突区;断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。', | 621 | ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——字符串列逐字段唯一编码(如 `CUST_NAME_S001`,抓绑错字段)+ 行序号保 UNIQUE;数值列用高位魔数;enum 列从 docs/03 值域取并标注。插入前扫 Flyway / config-vars 既有初始数据(admin_init 等)键,sentinel 主键偏移到不冲突区;断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。', |
| 622 | - '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。', | 622 | + '4) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。', |
| 623 | '- `finally` **硬要求 kill 本 FE 起的全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。', | 623 | '- `finally` **硬要求 kill 本 FE 起的全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。', |
| 624 | '', | 624 | '', |
| 625 | '## step2.5 鉴权 bootstrap(确定性前置)', | 625 | '## step2.5 鉴权 bootstrap(确定性前置)', |
| @@ -641,7 +641,7 @@ function behaviorGatePrompt(id, specPath, behaviorRound, attempt) { | @@ -641,7 +641,7 @@ function behaviorGatePrompt(id, specPath, behaviorRound, attempt) { | ||
| 641 | ' - 无任何效果 → `interactionFailures[kind="no-observable-effect"]`;JS 异常 → `js-error`;`console.error` → `console-error`;应发未发网络调用 → `missing-docs05-call`。断言用 auto-waiting / `expect.poll`,**不用**固定 sleep。', | 641 | ' - 无任何效果 → `interactionFailures[kind="no-observable-effect"]`;JS 异常 → `js-error`;`console.error` → `console-error`;应发未发网络调用 → `missing-docs05-call`。断言用 auto-waiting / `expect.poll`,**不用**固定 sleep。', |
| 642 | '- **文字层**:动态文字格对比该 region 字段的唯一 sentinel(抓绑错字段)。', | 642 | '- **文字层**:动态文字格对比该 region 字段的唯一 sentinel(抓绑错字段)。', |
| 643 | '- **绑定垃圾分级**:`null` / `undefined` / `[object Object]` / `NaN` / `lorem` 出现在绑定位 → `interactionFailures[kind="binding-garbage"]`;双花括号未渲染 / 空占位 `—` / 疑似 i18n key → `textIssues`(走 adjudicate;i18n 类额外加载真实 locale 比对)。', | 643 | '- **绑定垃圾分级**:`null` / `undefined` / `[object Object]` / `NaN` / `lorem` 出现在绑定位 → `interactionFailures[kind="binding-garbage"]`;双花括号未渲染 / 空占位 `—` / 疑似 i18n key → `textIssues`(走 adjudicate;i18n 类额外加载真实 locale 比对)。', |
| 644 | - '- **文字不符按来源分流到 source**:绑定 sentinel 不符 → `source="sentinel"`(客观 bug,转 must-fix);i18n key / 字面 / 语义类 → `source="i18n"|"literal"|"semantic"`(软文字,走仲裁,永不阻断 approve)。', | 644 | + '- **文字不符按来源分流到 source**:绑定 sentinel 不符 → `source="sentinel"`(客观 bug,转 must-fix,必须带 `locator`;反查不到组件文件则归 `coverageGaps[reason="locator-not-resolvable"]`);i18n key / 字面 / 语义类 → `source="i18n"|"literal"|"semantic"`(软文字,走仲裁,永不阻断 approve)。', |
| 645 | '- **行为硬问题必须带源码 locator(转 must-fix 喂 fix 的前置)**:', | 645 | '- **行为硬问题必须带源码 locator(转 must-fix 喂 fix 的前置)**:', |
| 646 | ' - **A 类(可反查到组件文件)**:经 route → router 配置 → view 组件文件反查到**组件级文件路径**。`interactionFailures[].locator` = `<组件文件路径>`(可附 DOM 选择器 / 绑定文本片段,写进 `detail`);`detail` 写「失败 kind + 期望端点/期望 sentinel 值 + 实际渲染值 + DOM 路径 + 绑定片段」,供 fix 子代理在该组件内 Grep 定位 handler/绑定。binding-garbage / sentinel-mismatch 同样附 DOM 路径 + 绑定片段 + 期望 sentinel + 实际渲染值。', | 646 | ' - **A 类(可反查到组件文件)**:经 route → router 配置 → view 组件文件反查到**组件级文件路径**。`interactionFailures[].locator` = `<组件文件路径>`(可附 DOM 选择器 / 绑定文本片段,写进 `detail`);`detail` 写「失败 kind + 期望端点/期望 sentinel 值 + 实际渲染值 + DOM 路径 + 绑定片段」,供 fix 子代理在该组件内 Grep 定位 handler/绑定。binding-garbage / sentinel-mismatch 同样附 DOM 路径 + 绑定片段 + 期望 sentinel + 实际渲染值。', |
| 647 | ' - **B 类(连组件文件都反查不出)**:**不静默降级放行**——归 `coverageGaps[reason="locator-not-resolvable"]`(计入未覆盖,使本轮不能判 green),或归 `envError.kind="stack-not-ready"` 走 retry。绝不把无 locator 的硬问题塞进 `interactionFailures` 不带 locator(上层会因无 locator 走 adjudicate(allowContinue:false),绝不放行)。', | 647 | ' - **B 类(连组件文件都反查不出)**:**不静默降级放行**——归 `coverageGaps[reason="locator-not-resolvable"]`(计入未覆盖,使本轮不能判 green),或归 `envError.kind="stack-not-ready"` 走 retry。绝不把无 locator 的硬问题塞进 `interactionFailures` 不带 locator(上层会因无 locator 走 adjudicate(allowContinue:false),绝不放行)。', |
| @@ -656,7 +656,7 @@ function behaviorGatePrompt(id, specPath, behaviorRound, attempt) { | @@ -656,7 +656,7 @@ function behaviorGatePrompt(id, specPath, behaviorRound, attempt) { | ||
| 656 | '## 输出(必须符合下发的 BEHAVIOR_GATE JSON schema)', | 656 | '## 输出(必须符合下发的 BEHAVIOR_GATE JSON schema)', |
| 657 | '- `status`: `green`(交互层无失败 + 文字层无 sentinel 类失败 + 无阻断性 envError + 本 FE 覆盖非空)| `red`。', | 657 | '- `status`: `green`(交互层无失败 + 文字层无 sentinel 类失败 + 无阻断性 envError + 本 FE 覆盖非空)| `red`。', |
| 658 | '- `routesPlanned` / `routesReached` / `controlsEnumerated`: 整数,据实填(**只数本 FE feScope**;空覆盖必须可见)。', | 658 | '- `routesPlanned` / `routesReached` / `controlsEnumerated`: 整数,据实填(**只数本 FE feScope**;空覆盖必须可见)。', |
| 659 | - '- `interactionFailures` / `textIssues` / `coverageGaps`: 见 schema 的 kind / source / reason 枚举;硬问题 A 类带 `locator`。', | 659 | + '- `interactionFailures` / `textIssues` / `coverageGaps`: 见 schema 的 kind / source / reason 枚举;硬问题 A 类带 `locator`(含 `source="sentinel"` 的 textIssue)。', |
| 660 | '- `envError`: 无环境问题填 `{ "kind": "none" }`;有则填对应 kind + detail + ports + pids;`build-failed` 时填 `rootCausePath`。', | 660 | '- `envError`: 无环境问题填 `{ "kind": "none" }`;有则填对应 kind + detail + ports + pids;`build-failed` 时填 `rootCausePath`。', |
| 661 | '- 做过任何自主默认 → `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。', | 661 | '- 做过任何自主默认 → `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。', |
| 662 | '- 不要返回额外字段(schema 是 `additionalProperties:false`)。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。', | 662 | '- 不要返回额外字段(schema 是 `additionalProperties:false`)。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。', |
| @@ -715,8 +715,7 @@ function frontendSkeletonPrompt(feItems) { | @@ -715,8 +715,7 @@ function frontendSkeletonPrompt(feItems) { | ||
| 715 | } | 715 | } |
| 716 | 716 | ||
| 717 | // fe-skeleton 幂等判定:检测 router 是否已声明本阶段全部 FE 路由(全量 + 全 lazy)。 | 717 | // fe-skeleton 幂等判定:检测 router 是否已声明本阶段全部 FE 路由(全量 + 全 lazy)。 |
| 718 | -// fe-skeleton-done tag 是首选 ground truth(下面 runFrontendSkeleton 先查 tag);此 prompt 用于 tag 缺失时 | ||
| 719 | -// 的二次确认(resume / tag 被手工删除场景),避免无谓重建已建好的骨架。 | 718 | +// router/state 是骨架真实完成态;fe-skeleton-done tag 只作补记,避免陈旧 tag 跳过缺失骨架。 |
| 720 | function frontendSkeletonStatePromptM(feItems) { | 719 | function frontendSkeletonStatePromptM(feItems) { |
| 721 | const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无)' | 720 | const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无)' |
| 722 | return [ | 721 | return [ |
| @@ -747,7 +746,7 @@ function microStepContract() { | @@ -747,7 +746,7 @@ function microStepContract() { | ||
| 747 | // 设计:原先每个"缺值 / 结构违约 / 重试耗尽"点都直接 throw HALT 让整阶段 fail-fast。 | 746 | // 设计:原先每个"缺值 / 结构违约 / 重试耗尽"点都直接 throw HALT 让整阶段 fail-fast。 |
| 748 | // 现在改为先经 adjudicate() 仲裁——retry(带 guidance 重跑)/ continue(降级前进)/ halt(确属不可恢复)。 | 747 | // 现在改为先经 adjudicate() 仲裁——retry(带 guidance 重跑)/ continue(降级前进)/ halt(确属不可恢复)。 |
| 749 | // stage 自身也被要求优先自主决策继续(见 featureStageContract),其默认/解读记入 decisions[] 汇总。 | 748 | // stage 自身也被要求优先自主决策继续(见 featureStageContract),其默认/解读记入 decisions[] 汇总。 |
| 750 | -// 仅 git 树冲突 / 配置错 / 安全护栏(assertSafeId)保持硬 halt——这些不可由 LLM 安全代决。 | 749 | +// 仅 git 树冲突 / 配置错 / id 形状错(assertSafeId)保持硬 halt——这些不可由 LLM 代决。 |
| 751 | // ============================================================================ | 750 | // ============================================================================ |
| 752 | 751 | ||
| 753 | const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限;超出则确定性 halt(防无限循环) | 752 | const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限;超出则确定性 halt(防无限循环) |
| @@ -757,8 +756,6 @@ const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限 | @@ -757,8 +756,6 @@ const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限 | ||
| 757 | // - BEHAVIOR_ATTEMPT_MAX = 单个 behaviorRound 内的环境 race 重起上限(沿用 testGate attempt 1→2 思路)。 | 756 | // - BEHAVIOR_ATTEMPT_MAX = 单个 behaviorRound 内的环境 race 重起上限(沿用 testGate attempt 1→2 思路)。 |
| 758 | const BEHAVIOR_FE_MAX = 3 | 757 | const BEHAVIOR_FE_MAX = 3 |
| 759 | const BEHAVIOR_ATTEMPT_MAX = 2 | 758 | const BEHAVIOR_ATTEMPT_MAX = 2 |
| 760 | -// 测试库护栏触发的确定性标记:门子代理在 envError.detail 以此开头,JS 据此「不重试不仲裁直接 halt」(兑现 step2 第 1 条承诺)。 | ||
| 761 | -const TESTDB_GUARD_MARK = '[TESTDB-GUARD]' | ||
| 762 | const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : '' | 759 | const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : '' |
| 763 | 760 | ||
| 764 | // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记在此,随结果回传供人工事后审阅。 | 761 | // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记在此,随结果回传供人工事后审阅。 |
| @@ -807,9 +804,10 @@ async function adjudicate(site, context, grp, round) { | @@ -807,9 +804,10 @@ async function adjudicate(site, context, grp, round) { | ||
| 807 | // runStage:跑一个 STAGE_RESULT 派生 stage(spec/plan/tdd/verify/fix/report)。 | 804 | // runStage:跑一个 STAGE_RESULT 派生 stage(spec/plan/tdd/verify/fix/report)。 |
| 808 | // ① 登记 decisions[];② status:halt 或 validate() 报结构问题 → 经 adjudicate 决定 retry/continue/halt。 | 805 | // ① 登记 decisions[];② status:halt 或 validate() 报结构问题 → 经 adjudicate 决定 retry/continue/halt。 |
| 809 | // makePrompt(guidanceTail) 接收仲裁追加指令串(adjGuidance 已格式化);validate(res) 返回 null=通过 / 问题串。 | 806 | // makePrompt(guidanceTail) 接收仲裁追加指令串(adjGuidance 已格式化);validate(res) 返回 null=通过 / 问题串。 |
| 807 | +// allowContinue=true 只用于后续 reviewer / behavior 会再次兜底的软 stage;流程前提默认不可 continue。 | ||
| 810 | // allowContinue=false:本 stage 的 halt 代表**硬正确性边界**(功能测试红色 verify/reverify、路径越界/卡死 tdd、 | 808 | // allowContinue=false:本 stage 的 halt 代表**硬正确性边界**(功能测试红色 verify/reverify、路径越界/卡死 tdd、 |
| 811 | // test-gate 红 report),仲裁只许 retry/halt,**绝不 continue 放行**残缺/越界状态去 approve / milestone。 | 809 | // test-gate 红 report),仲裁只许 retry/halt,**绝不 continue 放行**残缺/越界状态去 approve / milestone。 |
| 812 | -async function runStage(makePrompt, { site, grp, label, validate, allowContinue = true }) { | 810 | +async function runStage(makePrompt, { site, grp, label, validate, allowContinue = false }) { |
| 813 | let guidance = '' | 811 | let guidance = '' |
| 814 | for (let round = 1; round <= ADJUDICATE_MAX; round++) { | 812 | for (let round = 1; round <= ADJUDICATE_MAX; round++) { |
| 815 | const res = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: STAGE_RESULT_SCHEMA}) | 813 | const res = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: STAGE_RESULT_SCHEMA}) |
| @@ -1097,10 +1095,10 @@ function createTagPromptM(phaseId, fe) { | @@ -1097,10 +1095,10 @@ function createTagPromptM(phaseId, fe) { | ||
| 1097 | ].join('\n') | 1095 | ].join('\n') |
| 1098 | } | 1096 | } |
| 1099 | 1097 | ||
| 1100 | -// fe-skeleton-done:前端骨架占位 stage 的幂等真值 tag(runFrontendSkeleton resume 跳过用)。 | 1098 | +// fe-skeleton-done:前端骨架占位 stage 的补记 tag;真实完成态以 router/state 检测为准。 |
| 1101 | function createFeSkeletonTagPromptM() { | 1099 | function createFeSkeletonTagPromptM() { |
| 1102 | return [ | 1100 | return [ |
| 1103 | - '# 打 annotated tag `fe-skeleton-done`(前端骨架占位已建,幂等真值)', | 1101 | + '# 打 annotated tag `fe-skeleton-done`(前端骨架占位已建)', |
| 1104 | microStepContract(), | 1102 | microStepContract(), |
| 1105 | '', | 1103 | '', |
| 1106 | `先用 \`git -C ${ROOT} tag -l fe-skeleton-done\` 检查;已存在则视为成功(幂等)直接返回 success。`, | 1104 | `先用 \`git -C ${ROOT} tag -l fe-skeleton-done\` 检查;已存在则视为成功(幂等)直接返回 success。`, |
| @@ -1408,31 +1406,25 @@ async function runCrossModule(module) { | @@ -1408,31 +1406,25 @@ async function runCrossModule(module) { | ||
| 1408 | 1406 | ||
| 1409 | // ---- runFrontendSkeleton:前端骨架占位 stage 的 JS 编排(设计 § 2,前置依赖 A)---- | 1407 | // ---- runFrontendSkeleton:前端骨架占位 stage 的 JS 编排(设计 § 2,前置依赖 A)---- |
| 1410 | // 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(FeStub 占位)+ 无悬空导航。 | 1408 | // 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(FeStub 占位)+ 无悬空导航。 |
| 1411 | -// 幂等(resume 安全):先查 git tag `fe-skeleton-done`,已存在则 skip;tag 缺失时再二次确认 router 是否已声明 | ||
| 1412 | -// 全 FE 路由(手工删 tag / 残留场景),已建则补打 tag 后 skip;都未建才派子代理生成,成功后打 tag。 | 1409 | +// 幂等(resume 安全):router/state 是唯一真实完成态;fe-skeleton-done 只作补记,避免陈旧 tag 跳过缺失骨架。 |
| 1413 | async function runFrontendSkeleton(feItems) { | 1410 | async function runFrontendSkeleton(feItems) { |
| 1414 | const lbl = (k) => `fe-skeleton:${k}` | 1411 | const lbl = (k) => `fe-skeleton:${k}` |
| 1415 | 1412 | ||
| 1416 | - // step 1: tag 幂等(首选 ground truth) | ||
| 1417 | - const tag = await agent(checkTagExistsPromptM('fe-skeleton-done'), | ||
| 1418 | - {label: lbl('tag?'), phase: 'Frontend', schema: EXISTS_SCHEMA}) | ||
| 1419 | - if (tag.exists) { log('fe-skeleton: tag fe-skeleton-done 已存在,跳过骨架生成'); return } | ||
| 1420 | - | ||
| 1421 | - // step 2: tag 缺失时二次确认 router 是否已声明全 FE 路由(手工删 tag / resume 残留);已建则只补打 tag。 | 1413 | + // step 1: 检查 router 是否已声明全 FE 路由;已建则只确保补记 tag 存在。 |
| 1422 | const state = await agent(frontendSkeletonStatePromptM(feItems), | 1414 | const state = await agent(frontendSkeletonStatePromptM(feItems), |
| 1423 | {label: lbl('state?'), phase: 'Frontend', schema: EXISTS_SCHEMA}) | 1415 | {label: lbl('state?'), phase: 'Frontend', schema: EXISTS_SCHEMA}) |
| 1424 | if (state.exists) { | 1416 | if (state.exists) { |
| 1425 | - log('fe-skeleton: router 已声明全部 FE 路由(tag 缺失但骨架已建),补打 fe-skeleton-done tag') | 1417 | + log('fe-skeleton: router 已声明全部 FE 路由,确保 fe-skeleton-done tag 存在') |
| 1426 | await runAction(g => createFeSkeletonTagPromptM() + g, | 1418 | await runAction(g => createFeSkeletonTagPromptM() + g, |
| 1427 | - {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag-backfill')}) | 1419 | + {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag')}) |
| 1428 | return | 1420 | return |
| 1429 | } | 1421 | } |
| 1430 | 1422 | ||
| 1431 | - // step 3: 派子代理生成骨架(成功后子代理自行 commit;此处仅经 runStage 仲裁 halt 收敛)。 | 1423 | + // step 2: 派子代理生成骨架(成功后子代理自行 commit;此处仅经 runStage 仲裁 halt 收敛)。 |
| 1432 | await runStage(g => frontendSkeletonPrompt(feItems) + g, | 1424 | await runStage(g => frontendSkeletonPrompt(feItems) + g, |
| 1433 | {site:'fe-skeleton', grp:'Frontend', label: lbl('gen')}) | 1425 | {site:'fe-skeleton', grp:'Frontend', label: lbl('gen')}) |
| 1434 | 1426 | ||
| 1435 | - // step 4: 打 fe-skeleton-done tag(幂等真值,resume 跳过)。 | 1427 | + // step 3: 打 fe-skeleton-done 补记 tag。 |
| 1436 | await runAction(g => createFeSkeletonTagPromptM() + g, | 1428 | await runAction(g => createFeSkeletonTagPromptM() + g, |
| 1437 | {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag')}) | 1429 | {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag')}) |
| 1438 | 1430 | ||
| @@ -1517,7 +1509,7 @@ async function featureLoop(items, phase) { | @@ -1517,7 +1509,7 @@ async function featureLoop(items, phase) { | ||
| 1517 | // reviewer,仲裁不得在仍有未修 must-fix 时凌驾它放行);绝对硬上限 REVIEW_HARD_ROUNDS 防无限循环。 | 1509 | // reviewer,仲裁不得在仍有未修 must-fix 时凌驾它放行);绝对硬上限 REVIEW_HARD_ROUNDS 防无限循环。 |
| 1518 | // - reviewer 契约小瑕疵不再直接 halt:缺 locator 的 issue 降级为口头建议丢弃;若一条可定位 issue 都不剩(无可执行 | 1510 | // - reviewer 契约小瑕疵不再直接 halt:缺 locator 的 issue 降级为口头建议丢弃;若一条可定位 issue 都不剩(无可执行 |
| 1519 | // must-fix),经仲裁决定 continue(视为无 must-fix → approve)/ retry(带 guidance 重判)/ halt。 | 1511 | // must-fix),经仲裁决定 continue(视为无 must-fix → approve)/ retry(带 guidance 重判)/ halt。 |
| 1520 | -// - fix 经 runStage(默认仲裁,可 continue 跳过——未修的 must-fix 由后续 reviewer 重新 flag 兜底); | 1512 | +// - fix 经 runStage(显式 allowContinue,可 continue 跳过——未修的 must-fix 由后续 reviewer 重新 flag 兜底); |
| 1521 | // reverify 经 runStage 但 allowContinue:false(复验红色 = 修复没生效,绝不放行)。 | 1513 | // reverify 经 runStage 但 allowContinue:false(复验红色 = 修复没生效,绝不放行)。 |
| 1522 | // - approve 后的 docs/08 checkbox 是纯可视化副作用(req-done tag 才是完成真值),缺失/写失败一律 log 跳过不 halt。 | 1514 | // - approve 后的 docs/08 checkbox 是纯可视化副作用(req-done tag 才是完成真值),缺失/写失败一律 log 跳过不 halt。 |
| 1523 | const REVIEW_SOFT_ROUNDS = 5 | 1515 | const REVIEW_SOFT_ROUNDS = 5 |
| @@ -1584,7 +1576,7 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) { | @@ -1584,7 +1576,7 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) { | ||
| 1584 | lastIssuesCount = issues.length | 1576 | lastIssuesCount = issues.length |
| 1585 | 1577 | ||
| 1586 | await runStage(g => fixPrompt(id, phase, issues) + g, { | 1578 | await runStage(g => fixPrompt(id, phase, issues) + g, { |
| 1587 | - site:`fix:${phase}:${id}:r${round}`, grp, label:`fix:${phase}:${id}:r${round}`, | 1579 | + site:`fix:${phase}:${id}:r${round}`, grp, label:`fix:${phase}:${id}:r${round}`, allowContinue: true, |
| 1588 | }) | 1580 | }) |
| 1589 | 1581 | ||
| 1590 | // reverify allowContinue:false:fix 后复验红色 = 修复没真正生效,绝不 continue 放行去 approve。 | 1582 | // reverify allowContinue:false:fix 后复验红色 = 修复没真正生效,绝不 continue 放行去 approve。 |
| @@ -1648,11 +1640,6 @@ function behaviorEnvBlocked(r) { | @@ -1648,11 +1640,6 @@ function behaviorEnvBlocked(r) { | ||
| 1648 | return { ev, emptyCov, blocked: !!ev || emptyCov } | 1640 | return { ev, emptyCov, blocked: !!ev || emptyCov } |
| 1649 | } | 1641 | } |
| 1650 | function behaviorIfails(r) { return Array.isArray(r.interactionFailures) ? r.interactionFailures : [] } | 1642 | function behaviorIfails(r) { return Array.isArray(r.interactionFailures) ? r.interactionFailures : [] } |
| 1651 | -// 测试库护栏触发判定:门子代理按 step2 第 1 条在 envError.detail 打 TESTDB_GUARD_MARK;命中即确定性 halt(不重试不仲裁)。 | ||
| 1652 | -function behaviorTestDbGuardTripped(r) { | ||
| 1653 | - const d = (r.envError && r.envError.detail) || '' | ||
| 1654 | - return typeof d === 'string' && d.includes(TESTDB_GUARD_MARK) | ||
| 1655 | -} | ||
| 1656 | 1643 | ||
| 1657 | // runBehaviorGateOnce:跑一次本 FE 行为验收(含内部 envError attempt 重试 + 空覆盖兜底)。 | 1644 | // runBehaviorGateOnce:跑一次本 FE 行为验收(含内部 envError attempt 重试 + 空覆盖兜底)。 |
| 1658 | // 返回最终 bg(BEHAVIOR_GATE_SCHEMA);不在内部收敛交互/文字(交给外层 behaviorSubGate 推进)。 | 1645 | // 返回最终 bg(BEHAVIOR_GATE_SCHEMA);不在内部收敛交互/文字(交给外层 behaviorSubGate 推进)。 |
| @@ -1664,10 +1651,6 @@ async function runBehaviorGateOnce(id, specPath, grp, behaviorRound) { | @@ -1664,10 +1651,6 @@ async function runBehaviorGateOnce(id, specPath, grp, behaviorRound) { | ||
| 1664 | {label: lbl(attempt), phase: grp, schema: BEHAVIOR_GATE_SCHEMA}) | 1651 | {label: lbl(attempt), phase: grp, schema: BEHAVIOR_GATE_SCHEMA}) |
| 1665 | recordDecisions(`behavior:${id}`, bg.decisions) | 1652 | recordDecisions(`behavior:${id}`, bg.decisions) |
| 1666 | 1653 | ||
| 1667 | - // 测试库护栏触发 → 不重试不仲裁直接 halt(兑现 step2 第 1 条承诺;首个结果即拦截,绝不进重试/仲裁烧预算)。 | ||
| 1668 | - if (behaviorTestDbGuardTripped(bg)) | ||
| 1669 | - throw new Error(`HALT behavior-testdb-guard ${id}: 测试库护栏触发(库名非测试库),不重试不仲裁直接 halt 待人工确认 — ${(bg.envError || {}).detail || ''}`) | ||
| 1670 | - | ||
| 1671 | // build-failed 短路:根因落非本 FE 路径(兄弟未实现)→ 直接返回(外层据此放行 approve),不重试不仲裁。 | 1654 | // build-failed 短路:根因落非本 FE 路径(兄弟未实现)→ 直接返回(外层据此放行 approve),不重试不仲裁。 |
| 1672 | const isBuildFailedShortCircuit = (r) => r.envError && r.envError.kind === 'build-failed' | 1655 | const isBuildFailedShortCircuit = (r) => r.envError && r.envError.kind === 'build-failed' |
| 1673 | if (isBuildFailedShortCircuit(bg)) return bg | 1656 | if (isBuildFailedShortCircuit(bg)) return bg |
| @@ -1709,7 +1692,7 @@ async function behaviorSubGate(id, specPath, grp, softPassed) { | @@ -1709,7 +1692,7 @@ async function behaviorSubGate(id, specPath, grp, softPassed) { | ||
| 1709 | 1692 | ||
| 1710 | // 1) build-failed 短路(依赖 B):兄弟未实现 / 占位未覆盖 → green-by-skip 放行。但骨架(lazy router + FeStub) | 1693 | // 1) build-failed 短路(依赖 B):兄弟未实现 / 占位未覆盖 → green-by-skip 放行。但骨架(lazy router + FeStub) |
| 1711 | // 令「合法的兄弟未实现 build-failed」极罕见,故一个 build-failed 更可能是本 FE 引入的真共享代码回归; | 1694 | // 令「合法的兄弟未实现 build-failed」极罕见,故一个 build-failed 更可能是本 FE 引入的真共享代码回归; |
| 1712 | - // 绝不凭未校验的 LLM 归因静默放行——先过确定性前置校验(comment §107-108 声称 load-bearing 的边界,此前无 JS 兜底): | 1695 | + // 绝不凭未校验的 LLM 归因静默放行——先过轻量前置校验(comment §107-108 声称 load-bearing 的边界,此前无 JS 兜底): |
| 1713 | // a) 必须有 rootCausePath(否则无从判定根因落点); | 1696 | // a) 必须有 rootCausePath(否则无从判定根因落点); |
| 1714 | // b) 不得同时携带交互硬问题(interactionFailures / source=sentinel 文字)——那是真缺陷搭车。 | 1697 | // b) 不得同时携带交互硬问题(interactionFailures / source=sentinel 文字)——那是真缺陷搭车。 |
| 1715 | // 任一不满足 = 「脏」build-failed → 不短路,过 adjudicate(allowContinue:false) retry/halt,绝不 green-by-skip。 | 1698 | // 任一不满足 = 「脏」build-failed → 不短路,过 adjudicate(allowContinue:false) retry/halt,绝不 green-by-skip。 |
| @@ -1784,11 +1767,15 @@ async function behaviorSubGate(id, specPath, grp, softPassed) { | @@ -1784,11 +1767,15 @@ async function behaviorSubGate(id, specPath, grp, softPassed) { | ||
| 1784 | const planned = Number(bg.routesPlanned) || 0 | 1767 | const planned = Number(bg.routesPlanned) || 0 |
| 1785 | const reached = Number(bg.routesReached) || 0 | 1768 | const reached = Number(bg.routesReached) || 0 |
| 1786 | const ROUTE_GAP = new Set(['unreachable-auth', 'unreachable-no-route', 'dynamic-route-no-seed', 'build-failed-sibling-unimpl']) | 1769 | const ROUTE_GAP = new Set(['unreachable-auth', 'unreachable-no-route', 'dynamic-route-no-seed', 'build-failed-sibling-unimpl']) |
| 1787 | - const routeGapCount = (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : []).filter(cg => cg && ROUTE_GAP.has(cg.reason)).length | ||
| 1788 | - const unaccounted = planned - reached - routeGapCount | 1770 | + const routeGapPages = new Set((Array.isArray(bg.coverageGaps) ? bg.coverageGaps : []) |
| 1771 | + .filter(cg => cg && ROUTE_GAP.has(cg.reason) && typeof cg.page === 'string' && cg.page.trim()) | ||
| 1772 | + .map(cg => cg.page.trim())) | ||
| 1773 | + const routeGapCount = routeGapPages.size | ||
| 1774 | + const missedRoutes = Math.max(0, planned - reached) | ||
| 1775 | + const unaccounted = Math.max(0, missedRoutes - routeGapCount) | ||
| 1789 | if (planned > 0 && unaccounted > 0) { | 1776 | if (planned > 0 && unaccounted > 0) { |
| 1790 | const verdict = await adjudicate(`behavior-undercoverage:${id}`, | 1777 | const verdict = await adjudicate(`behavior-undercoverage:${id}`, |
| 1791 | - { problem:`本 FE 路由覆盖不足:routesPlanned=${planned} routesReached=${reached},仅 ${routeGapCount} 条有路由级 coverageGap 解释,尚有 ${unaccounted} 条漏达路由无证据(绝不带静默漏达判 green)`, | 1778 | + { problem:`本 FE 路由覆盖不足:routesPlanned=${planned} routesReached=${reached},仅 ${routeGapCount} 条不同路由有路由级 coverageGap 解释,尚有 ${unaccounted} 条漏达路由无证据(绝不带静默漏达判 green)`, |
| 1792 | coverageGaps: bg.coverageGaps || [], allowContinue: false }, grp, behaviorRound) | 1779 | coverageGaps: bg.coverageGaps || [], allowContinue: false }, grp, behaviorRound) |
| 1793 | if (verdict.action !== 'retry') throw new Error(`HALT behavior-undercoverage ${id}: ${verdict.rationale || `${unaccounted} 条漏达路由无证据`}`) | 1780 | if (verdict.action !== 'retry') throw new Error(`HALT behavior-undercoverage ${id}: ${verdict.rationale || `${unaccounted} 条漏达路由无证据`}`) |
| 1794 | continue // retry → 下一 behaviorRound 重跑整门 | 1781 | continue // retry → 下一 behaviorRound 重跑整门 |
| @@ -1800,6 +1787,19 @@ async function behaviorSubGate(id, specPath, grp, softPassed) { | @@ -1800,6 +1787,19 @@ async function behaviorSubGate(id, specPath, grp, softPassed) { | ||
| 1800 | .map(t => ({ page:t.page, control:t.region, kind:'binding-garbage', detail:`sentinel 不符 期望=${t.expected} 实际=${t.actual}`, locator:t.locator })) | 1787 | .map(t => ({ page:t.page, control:t.region, kind:'binding-garbage', detail:`sentinel 不符 期望=${t.expected} 实际=${t.actual}`, locator:t.locator })) |
| 1801 | const behaviorHard = [...behaviorIfails(bg), ...sentinelHard] | 1788 | const behaviorHard = [...behaviorIfails(bg), ...sentinelHard] |
| 1802 | 1789 | ||
| 1790 | + const hasEnvSignal = !!(bg.envError && bg.envError.kind && bg.envError.kind !== 'none') | ||
| 1791 | + const hasAnyClassifiedSignal = hasEnvSignal | ||
| 1792 | + || behaviorHard.length > 0 | ||
| 1793 | + || (Array.isArray(bg.textIssues) && bg.textIssues.length > 0) | ||
| 1794 | + || (Array.isArray(bg.coverageGaps) && bg.coverageGaps.length > 0) | ||
| 1795 | + if (bg.status === 'red' && !hasAnyClassifiedSignal) { | ||
| 1796 | + const verdict = await adjudicate(`behavior-red-unclassified:${id}`, | ||
| 1797 | + { problem:'behavior 返回 status:red,但没有 envError / interactionFailures / textIssues / coverageGaps 可解释该 red;拒绝把未分类红灯判 green', | ||
| 1798 | + behaviorResult: bg, allowContinue:false }, grp, behaviorRound) | ||
| 1799 | + if (verdict.action !== 'retry') throw new Error(`HALT behavior-red-unclassified ${id}: ${verdict.rationale || 'status:red 无分类原因'}`) | ||
| 1800 | + continue | ||
| 1801 | + } | ||
| 1802 | + | ||
| 1803 | // 5) green 判定:behaviorHard 为空 ∧ 无 B 类未覆盖 ∧ 覆盖非空(已兜底)∧ 无未解释漏达路由(§3.6 已兜底)→ 子门 green 放行。 | 1803 | // 5) green 判定:behaviorHard 为空 ∧ 无 B 类未覆盖 ∧ 覆盖非空(已兜底)∧ 无未解释漏达路由(§3.6 已兜底)→ 子门 green 放行。 |
| 1804 | if (behaviorHard.length === 0) { | 1804 | if (behaviorHard.length === 0) { |
| 1805 | log(`behavior ${id} green(behaviorRound=${behaviorRound} routesPlanned=${bg.routesPlanned} routesReached=${bg.routesReached} controls=${bg.controlsEnumerated} authState=${bg.authState || '?'})`) | 1805 | log(`behavior ${id} green(behaviorRound=${behaviorRound} routesPlanned=${bg.routesPlanned} routesReached=${bg.routesReached} controls=${bg.controlsEnumerated} authState=${bg.authState || '?'})`) |
| @@ -1826,7 +1826,7 @@ async function behaviorSubGate(id, specPath, grp, softPassed) { | @@ -1826,7 +1826,7 @@ async function behaviorSubGate(id, specPath, grp, softPassed) { | ||
| 1826 | severity: 'high', | 1826 | severity: 'high', |
| 1827 | })) | 1827 | })) |
| 1828 | await runStage(g => fixPrompt(id, 'frontend', fixIssues) + g, { | 1828 | await runStage(g => fixPrompt(id, 'frontend', fixIssues) + g, { |
| 1829 | - site:`behavior-fix:${id}:r${behaviorRound}`, grp, label:`behavior-fix:${id}:r${behaviorRound}`, | 1829 | + site:`behavior-fix:${id}:r${behaviorRound}`, grp, label:`behavior-fix:${id}:r${behaviorRound}`, allowContinue: true, |
| 1830 | }) | 1830 | }) |
| 1831 | 1831 | ||
| 1832 | // 8) fix 后功能复验(allowContinue:false):behaviorSubGate 的 fix 改的是 frontend/ UI 源码,可能引入功能回归—— | 1832 | // 8) fix 后功能复验(allowContinue:false):behaviorSubGate 的 fix 改的是 frontend/ UI 源码,可能引入功能回归—— |