From 947cc95f0feccf5327241f4b14ab5449acb25928 Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:50:12 +0800 Subject: [PATCH] workflow: add demo seed data generation + injection (Seed stage, seed-demo-data.mjs, e2e baseline) --- README.md | 15 ++++++++++----- lib/seed-demo-data-template.test.mjs | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ skills/plan/db-init/SKILL.md | 14 +++++++++++++- skills/plan/project-init/templates/CLAUDE-template.md | 9 +++++++++ skills/plan/skeleton-gen/SKILL.md | 4 ++++ skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md | 8 ++++++++ skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs | 235 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ workflows/coding.mjs | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- 8 files changed, 510 insertions(+), 16 deletions(-) create mode 100644 lib/seed-demo-data-template.test.mjs create mode 100644 skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs diff --git a/README.md b/README.md index 2144459..4179042 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,10 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 │ checkout/create → confirm HEAD(5 微 agent) │ → featureLoop(后端):spec → plan → tdd → verify → review(有界 5 轮修复, │ throw 自然冒泡到模块主循环 try → fail-fast) - │ → testGate(backend) → runCrossModule(JS 编排:diff → 分类 → 写日志) + │ → testGate(backend) + │ → Seed stage(testGate green 后:生成 sql/seed/NN__.sql 演示种子 + │ + 冷起栈真跑验证:空库→Flyway→seed-demo-data.mjs 注入→按 -- expect: 行对账) + │ → runCrossModule(JS 编排:diff → 分类 → 写日志) │ → reportPrompt(LLM 12 节叙述) │ → runMilestone(JS 编排:wt → default → 已合入? → merge → 字段当前值? │ → 写字段 → tag 已存在? → 打 tag → 报告 § ⑫ 当前值? → 替换占位; @@ -44,10 +47,11 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 │ └─ B-前端(后端全部打里程碑后,整体 1 个里程碑 tag) runBranchSetup(frontend-phase) - → 前端骨架占位阶段(router 全量 lazy 路由表 + FeStub 占位,保证中途任意时刻可构建可起) + → 前端骨架占位阶段(router 全量 lazy 路由表 + FeStub 占位,保证中途任意时刻可构建可起; + 含 e2e 基线脚手架:Playwright globalSetup 按注入时序注种子 + admin 登录 storageState) → featureLoop(前端,FE-NN,路径限 frontend/):spec → plan → tdd → verify → review 循环内并入 per-FE 行为验收 approve 子门(reviewer approve 时才起本 FE 全栈 - +种子 sentinel,枚举本 FE 路由控件/文字两层断言;交互失效/sentinel 错转可 fix + +演示种子+sentinel,枚举本 FE 路由控件/文字两层断言;交互失效/sentinel 错转可 fix must-fix→重验,软文字按来源仲裁,行为 green 才打 req-done/) → testGate(frontend,全量回归 vitest+playwright,与 per-FE 行为验收职责正交) → runMilestone(milestone/frontend-phase) @@ -141,7 +145,7 @@ erp-workflow-plugin/ |---|---|---| | `code-reviewer` | 统一 reviewer。`phase=backend` 跑通用代码审查维度;`phase=frontend` 附加前端 7 维 checklist(prototype 一致性 / design tokens / a11y / 响应式 / 业务校验前端复刻 / API 一致性 / 状态机覆盖,主观维度仅标记明显问题不触发 request-changes)。非交互,返回结构化 verdict,绝不弹窗 | `workflows/coding.mjs` 的 review stage:`agent(..., {agentType:'erp-workflow:code-reviewer'})`(必须带 `erp-workflow:` 插件命名空间——裸 `code-reviewer` 会与其它插件的同名 agent 歧义) | -## Templates 清单(25 份) +## Templates 清单(26 份) | 所属 Skill | 模板文件 | 用途 | |---|---|---| @@ -154,6 +158,7 @@ erp-workflow-plugin/ | scope-lock | `config-vars-template.yaml` | 仓库根 `config-vars.yaml` 骨架(跨栈中立):项目**全部配置**——非敏感(包名/端口/前端包名/初始账号)+ 敏感凭据(database / admin_init.password / secrets);A1 E.2 锁定,随项目提交 | | skeleton-gen | `docs-04-skeleton-template.md` | docs/04 § 一+ 编码规范大纲(HTML 注释引导 LLM) | | skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台 drop + create 空库脚本(内联极简 YAML 读 config-vars.yaml database: 段);schema apply 交给 Flyway | +| skeleton-gen | `scripts-seed-demo-data-template.mjs` | 演示种子注入脚本(schema 建好后按 `sql/seed/__.sql` 文件名升序幂等注入;`_demo_seed_history` 账本表记已应用文件跳过;同走 mysql;调用方 = 前端 e2e globalSetup / 行为门 / 里程碑后人工验收) | | skeleton-gen | `scripts-test-template.mjs` | test.mjs 骨架(命令槽位按后端/前端/build/lint/test/e2e 分开,`spawnSync(shell:true)` 跨平台执行) | | skeleton-gen | `gitignore-append-template` | 插件推荐忽略项(`.tmp/`、构建产物等;config-vars.yaml 随项目提交,不忽略) | | skeleton-gen | `styles-tokens-template.css` | 前端 design tokens CSS 变量骨架 | @@ -167,7 +172,7 @@ erp-workflow-plugin/ ## 前置依赖 - **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 自动安装,装不上再停下提示用户 -- **MySQL 8.x** 实例已就绪(host / 库名 / 凭据取自 `config-vars.yaml` 的 `database:` 段,由你填写并完全信任;本项目只面向开发/沙盒环境,`setup-test-db.mjs` 会按该值 DROP+CREATE) +- **MySQL 8.x** 实例已就绪(host / 库名 / 凭据取自 `config-vars.yaml` 的 `database:` 段,由你填写并完全信任;本项目只面向开发/沙盒环境,`setup-test-db.mjs` 会按该值 DROP+CREATE)。生成的 `scripts/seed-demo-data.mjs`(演示种子注入)同走 `mysql` CLI、同读该 `database:` 段,故 `mysql` 客户端须在 PATH - **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/apply-ddl.mjs` 用 mysql2 连接 + 解析 config-vars.yaml `database:` 段 apply V1;生成的 `scripts/setup-test-db.mjs` 在测试闸门前后 drop+create 空库 - **Spring Boot + Flyway**(**必需**):build.gradle 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。本插件生成的 `setup-test-db.mjs` 只清库,schema 必须由 Flyway 应用 - **本地 git 仓库**(纯本地,无需远程):A0 `project-init` 执行 `git init`;B 阶段每模块由 `coding.mjs` 的 milestone stage 本地 `git merge --no-ff` 进默认分支并 `git tag -a milestone/`,完成信号由 `git tag -l` 判定。**不依赖任何远程仓库 / push / GitLab** diff --git a/lib/seed-demo-data-template.test.mjs b/lib/seed-demo-data-template.test.mjs new file mode 100644 index 0000000..6247191 --- /dev/null +++ b/lib/seed-demo-data-template.test.mjs @@ -0,0 +1,116 @@ +// lib/seed-demo-data-template.test.mjs — 校验生成模板 scripts/seed-demo-data.mjs 的演示种子注入逻辑。 +// 跑的是真实模板产物:复制到临时 scripts/ 下、写一个 ../config-vars.yaml、可选写 sql/seed/*.sql、再 node 执行。 +// 所有会触达 DB 的用例 host/port 故意指向 127.0.0.1:1(必拒连),不会触碰真实库。 +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs', import.meta.url)) + +// 搭一个临时目标项目:scripts/seed-demo-data.mjs + config-vars.yaml + 可选 sql/seed/*.sql,然后跑脚本。 +// seedFiles 为 null 表示不创建 sql/seed 目录;为对象 { name: content } 表示创建目录并写入这些文件({} = 空目录)。 +function run({ + host = '127.0.0.1', + port = '1', + user = 'root', + password = 'x', + schemaLine = 'schema: erp_dev', + seedFiles = null, +} = {}) { + const dir = mkdtempSync(join(tmpdir(), 'erp-seed-')) + mkdirSync(join(dir, 'scripts')) + copyFileSync(TEMPLATE, join(dir, 'scripts', 'seed-demo-data.mjs')) + writeFileSync( + join(dir, 'config-vars.yaml'), + ['database:', ` host: ${host}`, ` port: ${port}`, ` user: ${user}`, ` password: ${password}`, ' ' + schemaLine, ''].join('\n'), + ) + if (seedFiles !== null) { + const seedDir = join(dir, 'sql', 'seed') + mkdirSync(seedDir, { recursive: true }) + for (const [name, content] of Object.entries(seedFiles)) { + writeFileSync(join(seedDir, name), content) + } + } + return spawnSync('node', [join(dir, 'scripts', 'seed-demo-data.mjs')], { encoding: 'utf8' }) +} + +test('seed-demo-data: unfilled 【人工填写】 config placeholders fail before mysql is invoked', () => { + for (const cfg of [ + { host: '【人工填写:MySQL host】' }, + { port: '【人工填写:MySQL port】' }, + { user: '【人工填写:账号】' }, + { password: '【人工填写:密码】' }, + { schemaLine: 'schema: 【人工填写:schema 名】' }, + ]) { + const r = run(cfg) + assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) + assert.match(r.stderr, /仍是占位/, 'stderr: ' + r.stderr) + } +}) + +test('seed-demo-data: empty schema fails before mysql is invoked', () => { + const r = run({ schemaLine: 'schema:' }) + assert.equal(r.status, 1) + assert.match(r.stderr, /database\.schema/, '应是 schema 缺失报错而非连库失败 — stderr: ' + r.stderr) +}) + +test('seed-demo-data: missing sql/seed directory exits 0 with no-seed message', () => { + const r = run({ seedFiles: null }) + assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) + assert.match(r.stdout, /无种子文件/, 'stdout: ' + r.stdout) +}) + +test('seed-demo-data: empty sql/seed directory exits 0 with no-seed message', () => { + const r = run({ seedFiles: {} }) + assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) + assert.match(r.stdout, /无种子文件/, 'stdout: ' + r.stdout) +}) + +test('seed-demo-data: illegal seed filename fails before mysql is invoked', () => { + const r = run({ seedFiles: { '01 bad.sql': '-- expect: t=0\n' } }) + assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) + assert.match(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr) + assert.match(r.stderr, /01 bad\.sql/, '应列出违规文件名 — stderr: ' + r.stderr) + // 文件名校验在任何 mysql 调用之前 —— 不应出现已进入 DB 阶段的日志。 + assert.doesNotMatch(r.stdout, /目标库/, 'stdout: ' + r.stdout) +}) + +// 缺 __ 结构的文件名(会被旧宽松正则放过,但违反 __.sql 契约)必须被拒。 +test('seed-demo-data: filenames missing the __ structure are rejected', () => { + for (const bad of [ + 'inventory.sql', // 无 NN__ 前缀 + '1__x.sql', // 单位数 NN + '001__x.sql', // 三位数 NN + '01-x.sql', // 连字符而非双下划线 + '01_x.sql', // 单下划线而非双下划线 + '01__.sql', // module_id 为空 + '01__bad-id.sql', // module_id 含连字符(非 [A-Za-z0-9_]) + ]) { + const r = run({ seedFiles: { [bad]: '-- expect: t=0\n' } }) + assert.equal(r.status, 1, `${bad} 应被拒 — stdout: ${r.stdout} stderr: ${r.stderr}`) + assert.match(r.stderr, /非法种子文件名/, `${bad} — stderr: ${r.stderr}`) + // 文件名校验在任何 mysql 调用之前 —— 不应进入 DB 阶段。 + assert.doesNotMatch(r.stdout, /目标库/, `${bad} — stdout: ${r.stdout}`) + } +}) + +// 合法 __.sql 通过文件名校验后进入 DB 阶段(验证收紧后的正则不误伤合法名)。 +test('seed-demo-data: well-formed __.sql passes filename check', () => { + const r = run({ seedFiles: { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } }) + assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) + assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr) + assert.match(r.stdout, /目标库 erp_dev/, '应已进入 DB 阶段 — stdout: ' + r.stdout) +}) + +test('seed-demo-data: valid seed + port 1 passes local checks then fails at DB connect', () => { + const r = run({ seedFiles: { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } }) + assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) + // 已通过全部本地校验、进入 DB 阶段:应打印目标库摘要,且报错是连库失败(非占位/非法名)。 + assert.match(r.stdout, /目标库 erp_dev/, '应已进入 DB 阶段 — stdout: ' + r.stdout) + assert.doesNotMatch(r.stderr, /仍是占位/, 'stderr: ' + r.stderr) + assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr) +}) diff --git a/skills/plan/db-init/SKILL.md b/skills/plan/db-init/SKILL.md index 8518b73..830f03c 100644 --- a/skills/plan/db-init/SKILL.md +++ b/skills/plan/db-init/SKILL.md @@ -75,12 +75,24 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" config-vars.yaml sql/migrations/V ``` 退出码与处理: -- `0` → 成功,进入步骤 D +- `0` → 成功,进入步骤 B.3 - `1` → 失败:打印 stderr 并停下 - `2` → 用法错(路径找不到),打印路径并停下 勾选:` - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行` +#### B.3 清库交还 Flyway + +B.1 → B.2 是脱离 Flyway 的 DDL 烟测(直接 mysql2 灌入 V1,**绕过 Flyway**,库里没有 `flyway_schema_history` 表)。这种状态会卡住后端首次启动:若后端集成测试连真库,Spring Boot 启动时 Flyway 会对一个**非空且无 schema 历史表**的库报 `Found non-empty schema "" without schema history table`。所以烟测做完要把产物清掉、把 schema 交还 Flyway,让它在后端启动时自行重放 migration。 + +再跑一次 `setup-test-db.mjs`(DROP+CREATE,把 B.2 灌入的烟测产物连同 schema 一并清空): + +```bash +node scripts/setup-test-db.mjs +``` + +> B.2 的 DDL 烟测语义不变——它仍是「V1 能否被真库接受」的一次性验证;B.3 只是把烟测留下的非 Flyway 状态清掉,schema 由后端启动时的 Flyway 重新建立。 + ### C. 勾选 docs/08 进度 + 进入 A5 1. 勾选 A4 顶层(5 维一致已由 A.3 的 `validate-ddl.mjs` 校验过,apply 不改 V1,无需复校): diff --git a/skills/plan/project-init/templates/CLAUDE-template.md b/skills/plan/project-init/templates/CLAUDE-template.md index 1a1c63f..c1ff32e 100644 --- a/skills/plan/project-init/templates/CLAUDE-template.md +++ b/skills/plan/project-init/templates/CLAUDE-template.md @@ -38,6 +38,15 @@ --- +## 🌱 演示数据(demo seed) + +1. **人工验收/演示三步走**:`node scripts/setup-test-db.mjs`(DROP+CREATE 空库)→ 起后端(Spring Boot 启动时 Flyway 建 schema)→ `node scripts/seed-demo-data.mjs`(注入演示种子) +2. **种子来源**:`sql/seed/*.sql` 由 B 阶段每个后端模块完成后自动生成、随 git 提交;演示数据用确定性显式主键,区间 `1000–9999` +3. **数据不持久**:库会被各测试闸门 DROP+CREATE 随时重建——种子不保证存活,只保证随时可复现;重建后按上述时序重新注入即可 +4. **幂等账本**:账本表 `_demo_seed_history` 由 `seed-demo-data.mjs` 自建自管,重复执行自动跳过已应用文件 + +--- + ## 🗂️ Git 提交规范 每次提交必须遵循以下格式: diff --git a/skills/plan/skeleton-gen/SKILL.md b/skills/plan/skeleton-gen/SKILL.md index 646833e..ec32a82 100644 --- a/skills/plan/skeleton-gen/SKILL.md +++ b/skills/plan/skeleton-gen/SKILL.md @@ -44,8 +44,11 @@ docs/04 已由 scope-lock 写入 § 零。本步骤追加 § 一 ~ 三: | 模板 | 目标路径 | |---|---| | `${CLAUDE_SKILL_DIR}/templates/scripts-setup-test-db-template.mjs` | `scripts/setup-test-db.mjs` | +| `${CLAUDE_SKILL_DIR}/templates/scripts-seed-demo-data-template.mjs` | `scripts/seed-demo-data.mjs` | | `${CLAUDE_SKILL_DIR}/templates/styles-tokens-template.css` | `src/styles/tokens.css` | +> `scripts/seed-demo-data.mjs`:把 Coding 阶段逐模块生成的 `sql/seed/*.sql` 演示种子注入**已建好 schema** 的库;用 `_demo_seed_history` 账本表记录已应用文件以保证幂等(重复执行自动跳过);调用方 = 前端 e2e 的 globalSetup / 行为门 / 里程碑后人工验收。 + > `sql/migrations/` 目录不在此预建:A4 `db-init` 写 `V1__initial_schema.sql` 时 `Write` 会自动创建。 > 凭据 / 配置不在此生成:项目**全部配置**(含 DB 凭据 / 密钥)已由 A1 `scope-lock` 写入仓库根 `config-vars.yaml`;`scripts/setup-test-db.mjs` 运行时按 2 层 map 直接读它。 @@ -122,6 +125,7 @@ QA 横幅涵盖:产出文件清单(docs/04 + scripts/*.mjs + .gitignore;co - `${CLAUDE_SKILL_DIR}/templates/docs-04-skeleton-template.md` - `${CLAUDE_SKILL_DIR}/templates/scripts-test-template.mjs` - `${CLAUDE_SKILL_DIR}/templates/scripts-setup-test-db-template.mjs` +- `${CLAUDE_SKILL_DIR}/templates/scripts-seed-demo-data-template.mjs` - `config-vars.yaml`(A1 产出,含 DB 凭据;setup-test-db.mjs 运行时读取) - `${CLAUDE_SKILL_DIR}/templates/gitignore-append-template` - `${CLAUDE_SKILL_DIR}/templates/styles-tokens-template.css` diff --git a/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md b/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md index 5e4f4da..23f0c35 100644 --- a/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md +++ b/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md @@ -35,3 +35,11 @@ ### 3.3 日期与金额 ### 3.4 数据访问规约 + +### 3.5 数据基线与演示种子 +- 演示种子 SQL 放 `sql/seed/`,命名 `__.sql`(NN=两位序号,按模块构建顺序;随 git 提交)。 +- 注入由 `scripts/seed-demo-data.mjs` 负责:B 阶段每个后端模块完成后生成对应 seed 文件,脚本逐文件按名升序应用。 +- 主键区间约定:`1–999`=初始数据(admin_init 等)/ `1000–9999`=演示种子 / `≥100000`=行为验收 sentinel;三段互不重叠,演示数据值不得含 `_S<数字>` 编码串(预留给 sentinel)。 +- 注入时序恒为:`node scripts/setup-test-db.mjs`(DROP+CREATE 空库)→ 起后端(Flyway 建 schema)→ `node scripts/seed-demo-data.mjs`。 +- e2e 基线 = 演示种子已注入(前端 Playwright globalSetup 走上述时序);后端单测/集成测基线 = 空库,不注入种子。 +- 幂等账本表 `_demo_seed_history` 由 `seed-demo-data.mjs` 自建自管,已应用文件自动跳过;库被各测试闸门 DROP+CREATE 重建后按上述时序重新注入即可(数据可复现、不持久)。 diff --git a/skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs b/skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs new file mode 100644 index 0000000..a4ffba0 --- /dev/null +++ b/skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +// scripts/seed-demo-data.mjs —— 演示假数据(demo seed)的注入脚本。 +// +// 用途(四个调用方,时序均为:空库重建 → 起后端让 Flyway 建 schema → 本脚本注入): +// 1) 前端 e2e(Playwright)globalSetup —— e2e 基线 = 空库 + Flyway schema + 演示种子; +// 2) coding.mjs 行为门 step2.3 —— 行为验收前注入演示数据; +// 3) 里程碑后人工验收 / 演示 —— 手动跑一次即可复现演示态; +// 4) coding.mjs Seed stage —— 模块种子生成后冷起栈真跑验证。 +// +// 前提:schema 必须已由 Flyway 在 Spring Boot 启动时建好——本脚本绝不建 schema,只灌数据。 +// 幂等机制:已应用的种子文件记入账本表 _demo_seed_history(file 为主键),再次运行自动跳过。 +// 主键区间约定:1–999=初始数据(admin_init 等)/ 1000–9999=演示种子 / ≥100000=行为门 sentinel。 +// +// DB 凭据从仓库根 config-vars.yaml 的 database: 段读取;host / user / password 信任该文件,port 仅校验范围。 +// 纯 mysql CLI(spawnSync),零 npm 依赖。退出码:0 成功(含全跳过 / 无文件),1 失败。 + +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const PROJECT_ROOT = join(SCRIPT_DIR, '..') +const CONFIG_FILE = join(PROJECT_ROOT, 'config-vars.yaml') +const SEED_DIR = join(PROJECT_ROOT, 'sql', 'seed') + +const LOG = '[seed-demo-data]' + +// 极简 YAML 读取(2 层 map + 标量;与 scripts/setup-test-db.mjs 同规则,内联以免运行时依赖)。 +function parseScalar(raw) { + let s = String(raw).trim() + if (s === '' || s[0] === '#') return '' + const q = s[0] + if (q === '"' || q === "'") { + const end = s.indexOf(q, 1) + if (end !== -1) return s.slice(1, end) + } + const hash = s.indexOf(' #') + if (hash !== -1) s = s.slice(0, hash).trim() + return s +} +function parseYamlConfig(text) { + const root = {} + let section = null + for (const rawLine of text.split('\n')) { + const line = rawLine.replace(/\r$/, '') + const trimmed = line.trim() + if (trimmed === '' || trimmed[0] === '#') continue + const colon = line.indexOf(':') + if (colon === -1) continue + const key = line.slice(0, colon).trim() + if (key === '') continue + const indent = line.length - line.replace(/^\s+/, '').length + const value = parseScalar(line.slice(colon + 1)) + if (indent === 0) { + if (value === '') { + section = {} + root[key] = section + } else { + root[key] = value + section = null + } + } else if (section) { + section[key] = value + } else { + root[key] = value + } + } + return root +} + +// 单引号字符串字面量转义(用于喂给 mysql 的 SQL 值,如 table_schema / file 名)。 +function quoteSqlString(value) { + return "'" + String(value).replaceAll('\\', '\\\\').replaceAll("'", "''") + "'" +} + +// ────────────────────────────────────────────────────────────────────────── +// ① config-vars 校验(占位拒绝 / port 范围 / schema 非空,照抄 setup-test-db 模式) +// 所有本地校验前置于任何 mysql 调用,保证离线可测。 +// ────────────────────────────────────────────────────────────────────────── + +if (!existsSync(CONFIG_FILE)) { + console.error(`${LOG} config-vars.yaml 不存在(${CONFIG_FILE})`) + process.exit(1) +} + +const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {} + +const DB_HOST = db.host ?? '' +const DB_PORT = db.port ?? '3306' +const DB_USER = db.user ?? '' +const DB_PASSWORD = db.password ?? '' +const DB_SCHEMA = db.schema ?? '' + +function rejectPlaceholder(key, value) { + if (typeof value === 'string' && value.includes('【人工填写')) { + console.error(`${LOG} database.${key} 仍是占位,请先在 config-vars.yaml 填真实值(database.password 可填 '' 表示空密码)`) + process.exit(1) + } +} + +for (const [key, value] of [['host', DB_HOST], ['port', DB_PORT], ['user', DB_USER], ['password', DB_PASSWORD], ['schema', DB_SCHEMA]]) { + rejectPlaceholder(key, value) +} + +if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) { + console.error(`${LOG} database.port 非法: ${DB_PORT}(必须是 1..65535 的整数)`) + process.exit(1) +} + +if (String(DB_SCHEMA).trim() === '') { + console.error(`${LOG} database.schema 未填`) + process.exit(1) +} + +// ────────────────────────────────────────────────────────────────────────── +// ② 列 sql/seed/*.sql 升序(确定性显式 .sort());校验文件名。 +// ────────────────────────────────────────────────────────────────────────── + +if (!existsSync(SEED_DIR)) { + console.log(`${LOG} 无种子文件(目录不存在: ${SEED_DIR}),无需注入`) + process.exit(0) +} + +// 文件名契约:__.sql —— NN 为两位序号、module_id 为 [A-Za-z0-9_]+,中间双下划线分隔。 +const SEED_NAME_RE = /^[0-9]{2}__[A-Za-z0-9_]+\.sql$/ +const seedFiles = readdirSync(SEED_DIR).filter((name) => name.toLowerCase().endsWith('.sql')).sort() + +if (seedFiles.length === 0) { + console.log(`${LOG} 无种子文件(${SEED_DIR} 为空),无需注入`) + process.exit(0) +} + +const illegal = seedFiles.filter((name) => !SEED_NAME_RE.test(name)) +if (illegal.length > 0) { + console.error(`${LOG} 发现非法种子文件名(要求匹配 __.sql,即 /^[0-9]{2}__[A-Za-z0-9_]+\\.sql$/):`) + for (const name of illegal) console.error(`${LOG} - ${name}`) + process.exit(1) +} + +// ────────────────────────────────────────────────────────────────────────── +// 以下进入 DB 阶段。目标库名作为 mysql 位置参数原样传值。 +// ────────────────────────────────────────────────────────────────────────── + +// 查询型调用:mysql -N -B -e,捕获 stdout(utf8);目标库作为位置参数。 +function mysqlQuery(sql) { + return spawnSync( + 'mysql', + [`--host=${DB_HOST}`, `--port=${DB_PORT}`, `--user=${DB_USER}`, `--password=${DB_PASSWORD}`, '-N', '-B', '-e', sql, DB_SCHEMA], + { encoding: 'utf8' }, + ) +} + +// 应用型调用:把文件内容喂 stdin(--comments 防剥注释);目标库作为位置参数。 +function mysqlApply(sqlText) { + return spawnSync( + 'mysql', + [`--host=${DB_HOST}`, `--port=${DB_PORT}`, `--user=${DB_USER}`, `--password=${DB_PASSWORD}`, '--comments', DB_SCHEMA], + { input: sqlText, encoding: 'utf8' }, + ) +} + +function fatalMysql(res, label) { + if (res.error) { + console.error(`${LOG} FATAL: 无法执行 mysql(请确认其在 PATH 中): ${res.error.message}`) + process.exit(1) + } + if (res.status !== 0) { + if (res.stderr) process.stderr.write(res.stderr) + console.error(`${LOG} FAIL (${label}): mysql exit=${res.status}`) + process.exit(res.status === null ? 1 : res.status) + } +} + +console.log(`${LOG} 目标库 ${DB_SCHEMA} on ${DB_HOST}:${DB_PORT},待处理种子 ${seedFiles.length} 个`) + +// ────────────────────────────────────────────────────────────────────────── +// ③ 查 flyway_schema_history(information_schema)是否存在 —— 不存在说明 schema 未建。 +// ────────────────────────────────────────────────────────────────────────── + +const flywayCheck = mysqlQuery( + `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ${quoteSqlString(DB_SCHEMA)} AND table_name = 'flyway_schema_history'`, +) +fatalMysql(flywayCheck, 'check-flyway') +if (flywayCheck.stdout.trim() !== '1') { + console.error(`${LOG} schema 未初始化(${DB_SCHEMA} 中找不到 flyway_schema_history)——请先起后端让 Flyway 建 schema,再注入种子`) + process.exit(1) +} + +// ────────────────────────────────────────────────────────────────────────── +// ④ 账本表 _demo_seed_history(已应用文件账本,幂等核心)。 +// ────────────────────────────────────────────────────────────────────────── + +const createLedger = mysqlApply( + 'CREATE TABLE IF NOT EXISTS _demo_seed_history (' + + ' file VARCHAR(255) NOT NULL PRIMARY KEY,' + + ' applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP' + + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;', +) +fatalMysql(createLedger, 'create-ledger') + +// 读出已应用文件集合。 +const appliedRes = mysqlQuery('SELECT file FROM _demo_seed_history') +fatalMysql(appliedRes, 'read-ledger') +const applied = new Set( + appliedRes.stdout.split('\n').map((s) => s.trim()).filter((s) => s !== ''), +) + +// ────────────────────────────────────────────────────────────────────────── +// ⑤ 逐文件按文件名升序应用(已应用跳过;失败 exit 1 透传 stderr;成功后写账本)。 +// ────────────────────────────────────────────────────────────────────────── + +let appliedCount = 0 +let skippedCount = 0 + +for (const name of seedFiles) { + if (applied.has(name)) { + console.log(`${LOG} 跳过(已应用): ${name}`) + skippedCount += 1 + continue + } + + console.log(`${LOG} 应用: ${name}`) + const sqlText = readFileSync(join(SEED_DIR, name), 'utf8') + // 账本 INSERT 拼到同一批 SQL 末尾、同一次 mysql 调用执行:mysql 批处理遇错即停, + // 账本行只在前面全部种子语句成功后才落——杜绝「已应用未记账 → 重跑重复插入」的半截状态。 + const body = sqlText.trimEnd() + const sep = /;$/.test(body) ? '\n' : ';\n' + const applyRes = mysqlApply(`${body}${sep}INSERT INTO _demo_seed_history (file) VALUES (${quoteSqlString(name)});`) + fatalMysql(applyRes, `apply ${name}`) + + appliedCount += 1 +} + +console.log(`${LOG} done — applied=${appliedCount} skipped=${skippedCount}(共 ${seedFiles.length} 个)`) diff --git a/workflows/coding.mjs b/workflows/coding.mjs index cc01c27..d573d05 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -9,7 +9,7 @@ export const meta = { description: 'Run the entire ERP coding phase autonomously and silently: per-module backend+frontend feature loops, test gate, milestone tag.', phases: [ { title: 'Router' }, { title: 'Backend' }, { title: 'Frontend' }, - { title: 'Gate' }, { title: 'Milestone' }, + { title: 'Gate' }, { title: 'Seed' }, { title: 'Milestone' }, ], // 注:'Behavior' phase 已删除——前端行为验收并入 per-FE reviewWithFixLoop 的 approve 子门, // 所有行为相关 agent()/adjudicate() 的 phase 入参统一用 'Frontend'(与 reviewWithFixLoop grp 一致)。 @@ -391,6 +391,9 @@ function tddPrompt(id, phase, planPath) { ? '- jsdom 类型用 vitest/jest 写组件单测;e2e 类型在 `frontend/e2e/` 写 Playwright(headless)。实现时:色值用 `var(--color-*)`(不硬编码 hex),业务校验按 spec 在 form-level 复刻。' : '', fe + ? '- **e2e 基线约束**:e2e 跑在「空库重建 + Flyway schema + 演示种子」基线上(骨架 globalSetup 已注入 `sql/seed`,无需测试自行建库/起栈)。e2e 断言**优先**定位**演示种子已知主键行**(1000–9999)或**测试自建数据**;**禁止**「全表恰好 N 行」式依赖全局计数的脆弱断言(演示种子行数会随后续模块种子增长,全局计数断言必然 flaky)。' + : '', + fe ? `- **占位替换(保证中途可构建 + per-FE 行为门可达本 FE 路由)**:前端骨架阶段已在 router 里为本 FE 路由声明 lazy import 但指向占位组件 \`FeStub\`。本 FE 实现完成后,**必须**把 router 中本 FE 路由的 import 从 \`FeStub\` 改为本 FE 真组件(用 Grep 在 \`${ROOT}/frontend/\` router 定位本 FE 路由 path 的 import 行;仍在 \`frontend/\` 路径内,不破坏护栏)。改完确保 router 该路由 lazy import 指向真组件、可构建可达。` : '', '', @@ -546,6 +549,96 @@ function gatePrompt(module, phase, attempt = 1) { ].filter(Boolean).join('\n') } +// ---- 演示种子生成 stage(Seed)---- +// 设计:每个后端模块 testGate green 之后生成本模块演示假数据(demo seed)并冷起栈真跑验证。 +// 与 behaviorGateContract 同属「跨栈 stage 不套 featureStageContract」的**第三类 stage**:本门要**运行** +// scripts/setup-test-db.mjs / 起后端 / scripts/seed-demo-data.mjs(featureStageContract('backend') 的路径护栏 +// 会把 scripts/ 命中越界硬停,与本门必须运行这些脚本自相矛盾),故另起 seedStageContract 自带契约。 +// 锁定契约(与 A2/A4/test.mjs/行为门一致):种子文件 `sql/seed/__.sql`(随 git 提交);头部 +// 机器可读行 `-- demo-seed: ` + 每表一行 `-- expect: =`;主键区间 1000–9999; +// 演示数据值绝不含 `_S<数字>` 样式(预留行为门 sentinel);注入脚本 scripts/seed-demo-data.mjs(A2 已生成)。 + +// seedStageContract:种子 stage 的硬约束。非交互;证据报告用中文但 SQL/标识符可英文(受控格式); +// 作用域例外——允许**运行**(不可写)scripts/setup-test-db.mjs / 起后端 / scripts/seed-demo-data.mjs / mysql 只读查询, +// 唯一**可写** = sql/seed/ + .tmp/seed-gen//(跑完即弃)+ docs/superpowers/module-reports/-seed-verify.md; +// 改 backend//frontend//scripts/ 源码即越界硬停。 +function seedStageContract() { + return [ + '## 硬约束(非交互演示种子子代理)', + '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', + '- 你的职责 = **为本模块生成演示种子(demo seed)并冷起栈真跑验证**——**不是**实现功能、**不是**改源码、**不是**改 schema。', + '- 缺值查找顺序:`config-vars.yaml` → `docs/03-数据库设计文档.md` → `docs/01-需求清单/` 各 REQ 卡(业务语义)→ 既有 `sql/seed/*`(跨模块 FK 引用前序模块种子的已知主键)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。', + `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下——\`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)、起后端服务(gradle bootRun 等,Flyway 在此建 schema)、\`node ${ROOT}/scripts/seed-demo-data.mjs\`(注入种子)、mysql **只读** COUNT/查询;唯一允许**写入**的路径是 \`${ROOT}/sql/seed/\`(种子文件,随 git 提交)+ \`${ROOT}/.tmp/seed-gen//\`(一次性 runner,跑完即弃)+ 证据报告 \`${ROOT}/docs/superpowers/module-reports/-seed-verify.md\`。`, + `- **越界硬停**:**绝不**编辑 \`backend/\` / \`frontend/\` / \`scripts/\` 下的任何源码文件(只许**运行** scripts/setup-test-db.mjs 与 scripts/seed-demo-data.mjs,不许改它们)。区分「运行 backend 服务 / 运行脚本」(允许)与「写 backend 实现 / 改脚本」(越界)。命中越界即以 \`status:halt\` 写清阻塞点结束。`, + '- **确定性红线(关键)**:种子值一律**显式主键**(1000–9999 区间)+ **固定历史日期**(写死字面量,如 `2024-03-15`),**绝不**依赖时间戳 / `NOW()` / 随机数 / 自增主键的隐式取值。', + '- **区间隔离红线**:演示数据值**绝不含 `_S<数字>` 样式编码串**(如 `CUST_NAME_S001`)——该样式预留给行为门 sentinel;数值主键固定落 1000–9999(1–999=初始数据 / ≥100000=sentinel)。', + '- 红线:**绝不**伪造验证通过;**绝不**留 `TBD` / `TODO` / `【人工填写:】`;自主默认必须可被现有证据支撑且记入 `decisions[]`。', + '- 证据报告 / 注释 / 提示**使用中文**;SQL / 标识符 / 表名可用英文(受控 `[A-Za-z0-9_]` 格式)。', + ].join('\n') +} + +// seedGenPrompt:单模块演示种子生成 + 冷起栈真跑验证的完整流水线提示。 +// module:本后端模块(含 id);本 stage 在该模块 testGate green 之后跑(schema 含 tdd 新增 V 已终态全绿)。 +function seedGenPrompt(module) { + const id = module?.id ?? '' + const tmpDir = `${ROOT}/.tmp/seed-gen/${id}` + const evidence = `docs/superpowers/module-reports/${id}-seed-verify.md` + return [ + `# seed — 演示种子生成 + 冷起栈真跑验证(模块 ${id})`, + '', + seedStageContract(), + '', + '## 目标', + `为本模块 \`${id}\` 生成**演示假数据(demo seed)**并冷起栈真跑验证:生成 → \`node ${ROOT}/scripts/setup-test-db.mjs\` → 起后端(Flyway 建 schema)→ \`node ${ROOT}/scripts/seed-demo-data.mjs\` → mysql 只读 COUNT 对账。`, + '种子产物随 git 提交(不保证「存活」,保证「随时可复现」——三处 DROP+CREATE 各在自己时序里固定重注入)。', + '', + '## 输入', + `- \`${ROOT}/docs/03-数据库设计文档.md\`:本模块各表结构(列 / 类型 / enum 值域 / FK / NOT NULL / UNIQUE 约束)。`, + `- \`${ROOT}/docs/01-需求清单//\` 本模块 REQ 卡:业务语义(让假数据有真实感、符合业务取值)。`, + `- 既有 \`${ROOT}/sql/seed/*.sql\`:跨模块 FK 引用前序模块种子的**已知确定性主键**(你的 FK 列必须引用这些已存在的主键,不可悬空)。`, + `- \`${ROOT}/config-vars.yaml\`:database 段凭据(seed-demo-data.mjs / setup-test-db.mjs 自行读取,你只需确保起栈参数一致)。`, + '', + '## 幂等(resume 安全)', + `- 用 Glob 查 \`${ROOT}/sql/seed/*__${id}.sql\`。**已存在** → **Edit 复用该文件**(保留原 \`NN\` 序号,不另起新文件);按需补齐/修正内容。`, + `- **不存在** → 新建 \`sql/seed/__${id}.sql\`,其中 \`NN\` = 既有 \`sql/seed/*.sql\` 文件名最大序号 + 1(两位补零,如既有最大为 \`03\` → 本文件用 \`04\`;无任何既有文件 → \`01\`)。`, + '', + '## 生成规则', + '- **FK 有序**:同一文件内 INSERT 先父后子;跨模块 FK 列引用既有 `sql/seed/*` 中前序模块种子的已知主键。', + '- **显式主键**:本模块种子行主键固定落 **1000–9999** 区间(避开 1–999 初始数据 / ≥100000 sentinel);同表内主键唯一、确定性。', + '- **真实感中文业务数据**:依 REQ 卡业务语义取值(人名 / 机构 / 金额 / 状态等),不要 `测试1`/`aaa` 占位;但**绝不含 `_S<数字>` 样式编码**(预留 sentinel)。', + '- **enum 取值域**:enum 列只从 `docs/03` 声明的值域取值(越界即数据类失败)。', + '- **固定历史日期**:日期/时间列写死固定历史字面量(如 `2024-03-15 10:00:00`),绝不 `NOW()` / 时间戳。', + '- **行数**:主业务列表表(页面会分页展示的)给 **15–30 行**(够触发分页 + 行级操作);字典/配置类小表按需少量(够 FK 引用 + 下拉非空)。', + `- **头部注释(机器可读,验证对账依赖)**:文件头第一行 \`-- demo-seed: ${id}\`;随后**每张被本文件 INSERT 的表各一行** \`-- expect:
=\`(rows = 本文件向该表插入的行数)。`, + `- **本模块无可种表**(纯计算/无表模块)→ **不建文件**,直接 \`status:ok\` + summary 说明「模块 ${id} 无可种表,跳过」(跳过下面的验证与 commit)。`, + '', + '## 运行验证(写一次性 runner,仿行为门冷起栈纪律的简化版)', + `- **入口清目录**:先用确定性、跨平台方式重建 \`${tmpDir}/\`(\`fs.rmSync(tmpDir,{recursive:true,force:true})\` 后 \`fs.mkdirSync(tmpDir,{recursive:true})\`),仅限该受控路径,绝不删其它路径。`, + `- 在 \`${tmpDir}/\` 写一次性 runner \`run.mjs\`,依序:`, + ` 1) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。`, + ` 2) **起后端**:从 \`${ROOT}/config-vars.yaml\` 取端口;起栈前先探测端口占用并按 \`${tmpDir}/*.pid\` / 既知端口回收上一次残留 pid;spawn 到后台进程树 + 轮询健康端点(\`/actuator/health\` 或登录端点 200)就绪(Flyway 在此 apply 建 schema)。`, + ` 3) \`node ${ROOT}/scripts/seed-demo-data.mjs\`(注入种子;幂等账本 \`_demo_seed_history\` 自动跳过已应用文件)。`, + ' 4) **mysql 只读 COUNT 对账**:对本模块种子涉及的**每张表**,跑 `SELECT COUNT(*) ... WHERE <主键列> BETWEEN 1000 AND 9999`(**只数演示种子区间**——后端启动可能把 admin_init 等初始数据 bootstrap 进共表,其键落 1–999,不计入 expect),与「全部 `sql/seed/*.sql` 文件头 `-- expect:
=` 之和」逐表比对(同一张表可能被多个种子文件插入,必须求和后再比)。', + ' - `finally` **硬要求 kill 本 stage 起的全部子进程**(绝不让 gradle bootRun 挂死会话)。', + '- **失败归类(reason 里必须分清)**:', + ' - **环境类**(端口占用 / 起栈超时 / setup-test-db 失败 / 健康端点不就绪)→ reason 标 `env-error` + 端口/pid。', + ' - **数据类**(撞主键/唯一键 / FK 错序或悬空 / enum 越界 / 类型截断 / COUNT 不符)→ reason 标 `data-error` + 具体表与根因(这是种子本身的 bug,必须修种子文件后重验)。', + '', + '## 证据落盘', + `- 写 \`${evidence}\`(中文):逐表「期望行数 / 实际行数 / 结论(match/mismatch)」表格 + 本模块种子文件路径 + 起栈端口 + 关键决策。`, + '- 若验证失败(环境类或数据类)→ 证据**头部用红字标注根因**(区分环境类 vs 数据类)。', + '', + commitBlock(`sql/seed ${evidence}`, `chore(seed:${id}): 演示种子数据`, + '- commit 失败 → halt,把 stderr 摘要写进 reason(仍要返回已写入的种子/证据路径)。'), + '', + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', + `- 成功(含验证 COUNT 全部对账通过):\`{ "status": "ok", "artifactPath": "sql/seed/__${id}.sql", "summary": "<种子表数 / 总行数 / 验证结论 ≤ 200 字>" }\`。`, + `- 本模块无可种表:\`{ "status": "ok", "summary": "模块 ${id} 无可种表,跳过" }\`(artifactPath 可省)。`, + '- 验证失败 / 越界 / 缺值(无法自洽决策)→ `{ "status": "halt", "reason": "", "artifactPath": "<已写入的种子路径(如有)>" }`。', + '- `artifactPath`(如有)必须为项目根相对路径;做过自主默认 → `decisions[]` 逐条登记;schema 是 `additionalProperties:false`,不要返回额外字段。', + ].filter(Boolean).join('\n') +} + // ---- 前端行为验收(per-FE behavior 子门)---- // 设计权威:docs/design/2026-06-02-frontend-behavior-in-review-loop.md。 // 不再是阶段级末尾独立门——并入 per-FE reviewWithFixLoop 的 approve 子门:某轮 reviewer 判 approve 时才触发, @@ -562,7 +655,7 @@ function behaviorGateContract() { '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', '- 你是**跨栈只读验证门**:用真实运行(起后端 + 起前端 headless + Playwright 枚举)证明「本 FE 每个按钮/点击真的生效、每段文字显示正确内容」,**不是**实现功能、**不是**改源码。', '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md § 零` → `docs/05-API接口契约.md` → `docs/03-数据库设计文档.md` → `prototype/`(前端布局/交互权威)→ `frontend/`(router 配置 / package.json)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。', - `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下命令——\`node ${ROOT}/scripts/setup-test-db.mjs\`、起后端服务(spring-boot:run 等)、起前端 headless(vite / playwright)、跑 Playwright;唯一允许**写入**的路径是 \`${ROOT}/.tmp/behavior-gate//r/\`(spec/种子 SQL/runner,跑完即弃)+ 证据报告 \`${ROOT}/docs/superpowers/reviews/--behavior-r-a.md\` + 其 assets(截图归档到 \`${ROOT}/docs/superpowers/reviews/assets/...\`)。`, + `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下命令——\`node ${ROOT}/scripts/setup-test-db.mjs\`、起后端服务(spring-boot:run 等)、\`node ${ROOT}/scripts/seed-demo-data.mjs\`(只运行注入演示种子,不修改脚本)、起前端 headless(vite / playwright)、跑 Playwright;唯一允许**写入**的路径是 \`${ROOT}/.tmp/behavior-gate//r/\`(spec/种子 SQL/runner,跑完即弃)+ 证据报告 \`${ROOT}/docs/superpowers/reviews/--behavior-r-a.md\` + 其 assets(截图归档到 \`${ROOT}/docs/superpowers/reviews/assets/...\`)。`, `- **越界硬停**:**绝不**编辑 \`frontend/\` / \`backend/\` / \`sql/\` 下的任何源码文件,也**绝不**编辑 \`${ROOT}/scripts/\` 下的脚本——只许**运行** scripts/setup-test-db.mjs。区分「运行 backend 服务」(允许)与「写 backend 实现」(越界)。命中越界即以 \`status:red\` + \`envError\` 或写清阻塞点结束。`, '- **per-FE 中途态豁免(关键)**:本门在 **per-FE 模式**下运行——`frontend/` 中**本 FE 之外**的路由/组件可能尚未实现,属预期中途态。遇到指向未建路由的链接 / 404 / 编译缺件(兄弟 FE 或骨架占位未覆盖),一律记 `coverageGaps[reason="build-failed-sibling-unimpl"]` 或 `envError.kind="build-failed"`(按根因路径归属,见 step0/step2),**绝不**归为本 FE 的 `interactionFailures`。**本 FE 路由清单(feScope.routes)是唯一断言作用域**;白名单外 / 共享控件归 coverageGap,不算本 FE 缺陷。', '- 红线:**绝不**伪造断言通过;**绝不**留 `TBD` / `TODO`;自主默认必须可被现有证据支撑且记入 `decisions[]`。', @@ -611,15 +704,16 @@ function behaviorGatePrompt(id, specPath, behaviorRound, attempt) { '## step1 路由真值发现(覆盖率分母 = 本 FE 路由,不数 router 全部)', '- 分母来源 = spec `## 行为验收作用域` 小节的 `关联路由:` 清单(**只数本 FE 路由**);`routesPlanned` = 本 FE 关联路由数。**不要**把 router 全部路由计入分母(router 含兄弟 FE + 占位路由)。', '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**本 FE 每路由的预期控件与文字来源**;每路由标注所需登录角色。', - '- 带参动态路由用**种子已知主键**实例化;无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。', + '- 带参动态路由用**种子已知主键**实例化(可用**演示种子已知主键**(1000–9999)或 **sentinel 主键**(≥100000));无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。', '- **未建兄弟路由既不计入分母也不计 coverageGap**(属预期中途态,按 step0 归 build-failed 短路)。', '', - '## step2 起栈四段严格时序(schema 由 Flyway 在后端启动时才建)', + '## step2 起栈五段严格时序(schema 由 Flyway 在后端启动时才建)', `1) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`, '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。', - '3) **此时才跑种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** 种子(先父后子)。失败 → `envError.kind="seed-error"` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断),**不**混进交互 RED。', - ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——字符串列逐字段唯一编码(如 `CUST_NAME_S001`,抓绑错字段)+ 行序号保 UNIQUE;数值列用高位魔数;enum 列从 docs/03 值域取并标注。插入前扫 Flyway / config-vars 既有初始数据(admin_init 等)键,sentinel 主键偏移到不冲突区;断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。', - '4) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。', + `3) **注入演示种子**:\`node ${ROOT}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过已应用文件,把 \`sql/seed/*.sql\` 演示数据注入空库)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`, + '4) **此时才跑 sentinel 种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** sentinel 种子(先父后子;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)。失败 → `envError.kind="seed-error"` + 结构化根因,**不**混进交互 RED。', + ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——数值主键**一律 ≥100000**(固定区间,不再动态扫描既有键:初始数据 1–999 / 演示种子 1000–9999 已由区间约定隔离,sentinel 落 ≥100000 天然不冲突);字符串列**仍逐字段唯一编码**(`_S` 样式,如 `CUST_NAME_S001`,抓绑错字段——演示数据已被禁用该样式,故 sentinel 独占)+ 行序号保 UNIQUE;enum 列从 docs/03 值域取并标注。断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。', + '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。', '- `finally` **硬要求 kill 本 FE 起的全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。', '', '## step2.5 鉴权 bootstrap(确定性前置)', @@ -697,6 +791,11 @@ function frontendSkeletonPrompt(feItems) { ' - 路由 path 取自上面推导的 FE→path 映射;带参路由用 `:id` 等占位。', '3. **占位组件 `FeStub`**:`frontend/src/views/_stub/FeStub.vue`(framework 非 Vue 时落对应等价文件,如 `FeStub.tsx`),最小渲染一个带 `data-fe-stub` 属性的元素(如 `
占位
`;行为门据 `data-fe-stub` 识别占位态)。**不实现任何业务逻辑**。', '4. **共享布局/导航**:导航链接**全部指向已在 router 声明的路由 path**(不指向任何不存在的 path),保证任意时刻无悬空链接。', + '5. **e2e 基线脚手架(全部落 `frontend/` 内)**:', + ' - **Playwright 配置**(按 docs/04 § 零 `frontend.e2e_runner` 约定,如 `frontend/playwright.config.*`):声明 `globalSetup` / `globalTeardown` 入口 + 共享 `storageState`。', + ` - **globalSetup**(如 \`frontend/e2e/global-setup.*\`):冷起后端 + 轮询健康端点就绪(Flyway 建 schema)→ 执行 \`node ${ROOT}/scripts/seed-demo-data.mjs\`(注入演示种子)→ 用 \`config-vars.yaml\` 的 \`admin_init\` 凭据经 \`docs/05-API接口契约.md\` 登录端点取 JWT,写 \`storageState\`(admin 登录态供 e2e 复用)。`, + ' - **globalTeardown**(如 `frontend/e2e/global-teardown.*`):kill globalSetup 起的后端进程树。', + ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 空库重建 + Flyway schema + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐。', '- **lazy 硬护栏**:router 表里**任何** FE 路由都不得用顶部静态 `import`;必须 `() => import(...)`。自检:Grep 路由文件,确认每个 FE 路由的 `component` 都是动态 import 形态。', '- **路径硬护栏**:所有产出文件必须以 `frontend/` 开头;命中 `backend/` / `sql/` / `scripts/` → 越界硬停。', '', @@ -722,9 +821,9 @@ function frontendSkeletonStatePromptM(feItems) { '# 检测前端骨架是否已建(router 已声明全部 FE 路由 + 全 lazy)', microStepContract(), '', - `用 Grep / Read 检查 \`${ROOT}/frontend/\`:是否已存在 router 配置文件,且其中**本阶段全部 FE 路由**(对应 FE:${list})都已声明、全部为 lazy import(\`() => import(...)\`),且占位组件 \`FeStub\`(\`frontend/src/views/_stub/FeStub.*\`)存在。`, - '- 全部满足(骨架已建齐)→ `{ "exists": true }`', - '- 任一缺失(无 router / 缺某 FE 路由 / 存在 eager import / 无 FeStub)→ `{ "exists": false }`', + `用 Grep / Read 检查 \`${ROOT}/frontend/\`:是否已存在 router 配置文件,且其中**本阶段全部 FE 路由**(对应 FE:${list})都已声明、全部为 lazy import(\`() => import(...)\`),占位组件 \`FeStub\`(\`frontend/src/views/_stub/FeStub.*\`)存在,**且 e2e 基线脚手架存在**——Playwright 配置文件(\`frontend/playwright.config.*\`)+ globalSetup 文件(如 \`frontend/e2e/global-setup.*\`)。`, + '- 全部满足(骨架已建齐,含 e2e 基线脚手架)→ `{ "exists": true }`', + '- 任一缺失(无 router / 缺某 FE 路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup)→ `{ "exists": false }`', '## 输出(EXISTS_SCHEMA)', ].join('\n') } @@ -1892,6 +1991,12 @@ for (const [idx, module] of todo.entries()) { await featureLoop(module.reqs, 'backend') phase('Gate') await testGate(module, 'backend') + // 演示种子生成 stage(Seed):在 testGate 后跑——此时本模块 schema(含 tdd 新增的 V migration) + // 已终态且全绿,按它生成的种子才不会撞结构。allowContinue:false——e2e 基线(globalSetup 注入)与行为门 + // step2 子项③(演示种子注入)都依赖该种子,坏种子放行会让整个前端阶段(行为验收/e2e)在脏数据上全线误判。 + phase('Seed') + await runStage(g => seedGenPrompt(module) + g, + { site:`seed:${module.id}`, grp:'Seed', label:`seed:${module.id}`, allowContinue: false }) phase('Milestone') await runCrossModule(module) // 替代被删 hook,JS 编排:diff → 分类 → 写日志 } -- libgit2 0.22.2