Commit 56c4d89124d3fdebb7c3b72b684b365531237f80

Authored by zichun
1 parent c9d636d3

测试库模型重构:复制源库副本替代 DROP 重建,源库永不被测试 drop

编码期测试的 DB 时序从「DROP+CREATE 空库 → Flyway 从零重建 → seed」
改为「mysqldump 复制源库→一次性副本 → Flyway 只 apply 本轮新迁移 →
跑测试 → 绿后晋升新迁移到源库 → 无论红绿删副本」。

- 新模板 scripts/drop-test-db.mjs(删副本,硬拒删源库)、
  scripts/promote-to-source.mjs(移植 flyway 历史行+重放迁移到源库;
  跨 lane 串行由编排层 testGate 起栈互斥保证,文件锁兜底同树重跑)
- setup-test-db:DROP 空库 → 复制源库到副本;test.mjs:try/finally
  删副本 + 绿后晋升 + 串行缺省显式导出副本名 <schema>__test
- 命名约定(5 脚本共享):COPY = env > marker > <source>__test,
  每脚本 COPY===SOURCE 即拒绝;application*.yml 占位缺省改为副本名
- 新 lib/bootstrap-source-flyway.mjs:db-init 建源库+apply V1+写
  checksum 与 Flyway 一致的历史行(Plan 期无 app 可起,手算 CRC32);
  db-init B 段三步换一次 bootstrap 调用
- coding.mjs 各闸时序对齐;globalTeardown 只 kill 不删副本(teardown
  删副本会让其后的 promote 查不到副本历史而静默跳过晋升);行为门/
  seed 契约允许清单补 drop-test-db;前端 testGate 补收尾删副本
- 模板测试重写 + 新增 drop/promote/bootstrap 测试(133 全绿,全离线)

已知残留:bootstrap 手算 Flyway checksum 未真机验证(若不一致首次
起后端报 checksum mismatch,改 flywayChecksum 一处即可);演示种子
固定主键区间 1000–9999 与源库真实自增数据增长存在长期碰撞风险。
README.md
... ... @@ -45,7 +45,8 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
45 45 │ throw 自然冒泡到模块主循环 try → fail-fast)
46 46 │ → testGate(backend)
47 47 │ → Seed stage(testGate green 后:生成 sql/seed/NN__<module>.sql 演示种子
48   - │ + 冷起栈真跑验证:空库→Flyway→seed-demo-data.mjs 注入→按 -- expect: 行对账)
  48 + │ + 冷起栈真跑验证:复制源库→副本→Flyway apply 新迁移→seed-demo-data.mjs 注入
  49 + │ →按 -- expect: 行对账→drop-test-db.mjs 删副本)
49 50 │ → runCrossModule(JS 编排:diff → 分类 → 写日志)
50 51 │ → reportPrompt(LLM 12 节叙述)
51 52 │ → runMilestone(JS 编排:wt → default → 已合入? → merge → 字段当前值?
... ... @@ -101,7 +102,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
101 102 Plan 阶段**两段式**执行,中间有一个人工审阅断点(docs/03 数据库 schema):
102 103  
103 104 - **第一段(首次运行)**:执行 **A0 → A1 → A2 → A3**(创建骨架 / 锁技术栈 / 填需求 / 生成 REQ 卡片 / 生成项目骨架 / 从 REQ 正向设计 `docs/03-数据库设计文档.md` 并回填 REQ 依赖表)后**停下**,等你审阅 docs/03 的表 / 字段 / 索引 / 语义引用关系(人工关口:数据库 schema —— A4 会基于它翻译 DDL 并 apply 到 MySQL)。A1 的 REQ 卡片由 CC 据 index.md 填 6 个占位、字段表按模板原样复制,**不再单独停下审阅**
104   - - **第二段(docs/03 审阅完重新运行)**:执行 **A4 → A5**(解析 docs/03 → 生成 V1 migration → 自动 `DROP+CREATE` 本地 schema 并 apply → 生成下游文档 → **docs/05 + docs/02 评审闸** → prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三),通过 **Plan 终结硬闸** 后再次**停下**(前端布局/交互以 `prototype/` 为权威,不另设 UI 规范文档)
  105 + - **第二段(docs/03 审阅完重新运行)**:执行 **A4 → A5**(解析 docs/03 → 生成 V1 migration → `bootstrap-source-flyway` 把源库建立成 Flyway 托管态(建库 + apply V1 + 写 flyway 历史;已托管则幂等跳过)→ 生成下游文档 → **docs/05 + docs/02 评审闸** → prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三),通过 **Plan 终结硬闸** 后再次**停下**(前端布局/交互以 `prototype/` 为权威,不另设 UI 规范文档)
105 106  
106 107 Plan 完成后不会自动进入编码,需手动 /erp-workflow:coding-start。
107 108  
... ... @@ -187,7 +188,9 @@ erp-workflow-plugin/
187 188 | scope-lock | `_module-template.md` | 模块子目录的 `_module.md` 模块头(模块代码-名 / 简述 / 依赖模块 TBD / 涉及表 TBD) |
188 189 | scope-lock | `config-vars-template.yaml` | 仓库根 `config-vars.yaml` 骨架(跨栈中立):项目**全部配置**——非敏感(包名/端口/前端包名/初始账号)+ 敏感凭据(database / admin_init.password / secrets);A1 E.2 锁定,随项目提交 |
189 190 | skeleton-gen | `docs-04-skeleton-template.md` | docs/04 § 一+ 编码规范大纲(HTML 注释引导 LLM) |
190   -| skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台 drop + create 空库脚本(内联极简 YAML 读 config-vars.yaml database: 段);schema apply 交给 Flyway |
  191 +| skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台"复制源库→一次性测试副本"脚本(mysqldump 管道;内联极简 YAML 读 config-vars.yaml database: 段;COPY≠SOURCE 守卫,源库绝不被 drop);本轮新迁移的 apply 交给起后端时的 Flyway |
  192 +| skeleton-gen | `scripts-drop-test-db-template.mjs` | 测试收尾删副本脚本(幂等 DROP IF EXISTS;硬拒删源库) |
  193 +| skeleton-gen | `scripts-promote-to-source-template.mjs` | 测试绿后把副本上已验证的新迁移晋升到源库(移植 flyway 历史行 + 重放迁移 SQL;幂等;跨 lane 由编排层起栈互斥串行) |
191 194 | skeleton-gen | `scripts-seed-demo-data-template.mjs` | 演示种子注入脚本(schema 建好后按 `sql/seed/<NN>__<module>.sql` 文件名升序幂等注入;`_demo_seed_history` 账本表记已应用文件跳过;同走 mysql;调用方 = 前端 e2e globalSetup / 行为门 / 里程碑后人工验收) |
192 195 | skeleton-gen | `scripts-test-template.mjs` | test.mjs 骨架(命令槽位按后端/前端/build/lint/test/e2e 分开,`spawnSync(shell:true)` 跨平台执行) |
193 196 | skeleton-gen | `gitignore-append-template` | 插件推荐忽略项(`.tmp/`、构建产物等;config-vars.yaml 随项目提交,不忽略) |
... ... @@ -202,9 +205,9 @@ erp-workflow-plugin/
202 205 ## 前置依赖
203 206  
204 207 - **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 自动安装,装不上再停下提示用户
205   -- **MySQL 8.x** 实例已就绪(host / 库名 / 凭据取自 `config-vars.yaml` 的 `database:` 段,由你填写并完全信任;本项目只面向开发/沙盒环境,`setup-test-db.mjs` 会按该值 DROP+CREATE)。生成的 `scripts/seed-demo-data.mjs`(演示种子注入)同走 `mysql` CLI、同读该 `database:` 段,故 `mysql` 客户端须在 PATH
206   -- **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/apply-ddl.mjs` 用 mysql2 连接 + 解析 config-vars.yaml `database:` 段 apply V1;生成的 `scripts/setup-test-db.mjs` 在测试闸门前后 drop+create 空库
207   -- **Spring Boot + Flyway**(**必需**):build.gradle 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。本插件生成的 `setup-test-db.mjs` 只清库,schema 必须由 Flyway 应用
  208 +- **MySQL 8.x** 实例已就绪(host / 库名 / 凭据取自 `config-vars.yaml` 的 `database:` 段,由你填写并完全信任)。`database.schema` = **源库**(真实持久库):测试**永不 drop 它**,每次测试由 `setup-test-db.mjs` 把它 `mysqldump` 复制成一次性副本(`<schema>__test` / lane 副本),跑完 `drop-test-db.mjs` 删副本,绿后 `promote-to-source.mjs` 把新迁移晋升回源库。这些脚本与 `seed-demo-data.mjs` 同走 `mysql`/`mysqldump` CLI,故两者须在 PATH
  209 +- **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/bootstrap-source-flyway.mjs` 用 mysql2 建源库 + apply V1 + 写 checksum 一致的 flyway 历史(源库首次 Flyway 托管化;已托管则幂等跳过)
  210 +- **Spring Boot + Flyway**(**必需**):build.gradle 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。副本自带源库 flyway 历史,起后端时 Flyway 只 apply 本轮新迁移到副本
208 211 - **本地 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**
209 212 - **本地可运行 `./gradlew test` / `pnpm test`**:测试命令由 A1 写入 docs/04 § 零,生成的 `scripts/test.mjs` 由 `skeleton-gen` 产出,`coding.mjs` 的 testGate stage 调用
210 213  
... ...
lib/bootstrap-source-flyway.mjs 0 → 100644
  1 +// lib/bootstrap-source-flyway.mjs —— 把**源库**建立成 Flyway 托管态(Plan 期 db-init 调用)。
  2 +//
  3 +// 复制测试模型需要一个已存在、且被 Flyway 托管(有 flyway_schema_history)的源库才能复制。但 Plan 阶段
  4 +// 没有后端 app 可起、跑不了真 Flyway。本脚本用纯 mysql2 完成等效初始化:
  5 +// ① CREATE DATABASE IF NOT EXISTS <source>(源库);
  6 +// ② 若已有 flyway_schema_history → 幂等跳过(已托管,绝不重灌 / 绝不 DROP);
  7 +// ③ 否则:建 flyway_schema_history(Flyway 10 的表结构)→ 按版本序 apply sql/migrations/V*.sql →
  8 +// 为每个迁移写一条历史行,**checksum 用与 Flyway 完全一致的 CRC32 复算**,使首次起后端时 Flyway
  9 +// 校验通过(不会报 checksum mismatch)、且只 apply 之后新增的迁移。
  10 +//
  11 +// apply V1 本身就是 db-init 的「V1 DDL 烟测」:V1 有 DDL 错会在此 apply 失败暴露。
  12 +// 凭据从 config-vars.yaml 的 database: 段读取。mysql2 从目标项目解析(见 apply-ddl.resolveMysql2Path)。
  13 +
  14 +import { parseYamlConfig } from './yaml-config.mjs'
  15 +import { resolveMysql2Path, resolveDbConfig, MysqlUnavailableError } from './apply-ddl.mjs'
  16 +import { readFileSync, readdirSync, existsSync } from 'node:fs'
  17 +import { crc32 } from 'node:zlib'
  18 +import { pathToFileURL } from 'node:url'
  19 +import { dirname, resolve as resolvePath, join } from 'node:path'
  20 +
  21 +/**
  22 + * Flyway SQL-migration checksum:与 Flyway(org.flywaydb)对 SQL 迁移的算法一致——
  23 + * 按行读取(BufferedReader.readLine 语义:\n / \r / \r\n 均为行终止符且被剥离;首行剥 UTF-8 BOM),
  24 + * 对每行的 UTF-8 字节累进 CRC32,最终以**有符号 int32** 返回(Flyway 的 checksum 列是 signed int)。
  25 + * 因按行读取,CRLF 与 LF 版本的同一内容 checksum 相同(行终止符归一化)。
  26 + * @param {string} content 迁移文件文本
  27 + * @returns {number} 有符号 32 位校验和
  28 + */
  29 +export function flywayChecksum(content) {
  30 + let text = content
  31 + if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1) // 剥首行 UTF-8 BOM
  32 + const lines = text.split(/\r\n|\r|\n/)
  33 + // 文件以行终止符结尾时 split 会多出一个尾部空串——BufferedReader 不会返回这个"最后的空行",去掉。
  34 + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
  35 + let crc = 0
  36 + for (const line of lines) crc = crc32(Buffer.from(line, 'utf8'), crc)
  37 + return crc | 0 // 转有符号 int32
  38 +}
  39 +
  40 +/** 解析 `V<version>__<description>.sql` → { version, description }(下划线在版本里当分隔符、在描述里当空格)。 */
  41 +export function parseMigrationName(filename) {
  42 + const m = filename.match(/^V(\d+(?:[._]\d+)*)__(.+)\.sql$/i)
  43 + if (!m) return null
  44 + return { version: m[1].replace(/_/g, '.'), description: m[2].replace(/_/g, ' ') }
  45 +}
  46 +
  47 +/** 版本号数值序比较(逐段比较,与 Flyway 一致;段数不同时短的在前)。 */
  48 +function compareVersions(a, b) {
  49 + const pa = a.split('.').map(Number)
  50 + const pb = b.split('.').map(Number)
  51 + for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
  52 + const d = (pa[i] ?? 0) - (pb[i] ?? 0)
  53 + if (d !== 0) return d
  54 + }
  55 + return 0
  56 +}
  57 +
  58 +// Flyway 10 (MySQL) 的 flyway_schema_history 建表 DDL —— 结构与 Flyway 自建一致,使其在首次起后端时识别复用。
  59 +const FLYWAY_HISTORY_DDL =
  60 + 'CREATE TABLE `flyway_schema_history` (' +
  61 + '`installed_rank` INT NOT NULL,' +
  62 + '`version` VARCHAR(50),' +
  63 + '`description` VARCHAR(200) NOT NULL,' +
  64 + '`type` VARCHAR(20) NOT NULL,' +
  65 + '`script` VARCHAR(1000) NOT NULL,' +
  66 + '`checksum` INT,' +
  67 + '`installed_by` VARCHAR(100) NOT NULL,' +
  68 + '`installed_on` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,' +
  69 + '`execution_time` INT NOT NULL,' +
  70 + '`success` BOOL NOT NULL,' +
  71 + 'CONSTRAINT `flyway_schema_history_pk` PRIMARY KEY (`installed_rank`)' +
  72 + ') ENGINE=InnoDB'
  73 +
  74 +function quoteIdent(value) {
  75 + return '`' + String(value).replaceAll('`', '``') + '`'
  76 +}
  77 +
  78 +/**
  79 + * 把源库建立成 Flyway 托管态。幂等:已有 flyway_schema_history 即跳过。
  80 + * @param {{configPath: string, migrationsDir: string}} opts
  81 + * @returns {Promise<{bootstrapped: boolean, reason?: string, applied?: string[]}>}
  82 + */
  83 +export async function bootstrapSourceFlyway({ configPath, migrationsDir }) {
  84 + const config = parseYamlConfig(readFileSync(configPath, 'utf8'))
  85 + const { host, port, user, password, database } = resolveDbConfig(config, configPath)
  86 +
  87 + let mysql
  88 + try {
  89 + const resolved = resolveMysql2Path(dirname(resolvePath(configPath)))
  90 + ;({ default: mysql } = await import(pathToFileURL(resolved).href))
  91 + } catch {
  92 + throw new MysqlUnavailableError()
  93 + }
  94 +
  95 + // ① 无 database 连接,建源库(存在则不动)。
  96 + const admin = await mysql.createConnection({ host, port, user, password })
  97 + try {
  98 + await admin.query(`CREATE DATABASE IF NOT EXISTS ${quoteIdent(database)} CHARACTER SET utf8 COLLATE utf8_general_ci`)
  99 + } finally {
  100 + await admin.end()
  101 + }
  102 +
  103 + const conn = await mysql.createConnection({ host, port, user, password, database, multipleStatements: true })
  104 + try {
  105 + // ② 已被 Flyway 管理 → 幂等跳过(绝不重灌、绝不 DROP 源库数据)。
  106 + const [rows] = await conn.query(
  107 + "SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = ? AND table_name = 'flyway_schema_history'",
  108 + [database],
  109 + )
  110 + if (Number(rows[0].n) > 0) {
  111 + return { bootstrapped: false, reason: 'already-managed' }
  112 + }
  113 +
  114 + // 源库有业务表但无 flyway 历史 = 非 Flyway 托管的既有库——不安全 bootstrap(apply V1 会撞已存在的表)。
  115 + const [tblRows] = await conn.query(
  116 + 'SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = ?',
  117 + [database],
  118 + )
  119 + if (Number(tblRows[0].n) > 0) {
  120 + throw new Error(
  121 + `源库 ${database} 已有表但无 flyway_schema_history —— 疑为非 Flyway 托管的既有库。` +
  122 + `拒绝自动 bootstrap(apply V1 会撞已存在的表)。请人工确认后清空该库、或改用已托管的库。`,
  123 + )
  124 + }
  125 +
  126 + // ③ 建历史表 + 按版本序 apply 迁移 + 移植正确 checksum 的历史行。
  127 + await conn.query(FLYWAY_HISTORY_DDL)
  128 +
  129 + const files = existsSync(migrationsDir)
  130 + ? readdirSync(migrationsDir).filter((f) => /^V.*\.sql$/i.test(f))
  131 + : []
  132 + const parsed = files
  133 + .map((f) => ({ file: f, meta: parseMigrationName(f) }))
  134 + .filter((x) => x.meta)
  135 + .sort((a, b) => compareVersions(a.meta.version, b.meta.version))
  136 +
  137 + const applied = []
  138 + let rank = 0
  139 + for (const { file, meta } of parsed) {
  140 + const sql = readFileSync(join(migrationsDir, file), 'utf8')
  141 + await conn.query(sql) // apply DDL(同时是 V<n> 的 DDL 烟测)
  142 + rank += 1
  143 + await conn.query(
  144 + 'INSERT INTO flyway_schema_history ' +
  145 + '(installed_rank, version, description, type, script, checksum, installed_by, installed_on, execution_time, success) ' +
  146 + 'VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), ?, 1)',
  147 + [rank, meta.version, meta.description, 'SQL', file, flywayChecksum(sql), 'bootstrap', 0],
  148 + )
  149 + applied.push(meta.version)
  150 + }
  151 + return { bootstrapped: true, applied }
  152 + } finally {
  153 + await conn.end()
  154 + }
  155 +}
  156 +
  157 +// CLI entry guard(与 apply-ddl 同款 pathToFileURL 规范化)。
  158 +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  159 + const [configPath, migrationsDir] = process.argv.slice(2)
  160 + if (!configPath || !migrationsDir) {
  161 + console.error('usage: node lib/bootstrap-source-flyway.mjs <configPath> <migrationsDir>')
  162 + process.exit(2)
  163 + }
  164 + if (!existsSync(configPath)) { console.error(`bootstrap-source-flyway: 配置文件不存在: ${configPath}`); process.exit(2) }
  165 + try {
  166 + const r = await bootstrapSourceFlyway({ configPath, migrationsDir })
  167 + if (r.bootstrapped) console.log(`bootstrap-source-flyway: 源库已 Flyway 托管化,apply 迁移 ${r.applied.length} 个: ${r.applied.map((v) => 'V' + v).join(', ') || '(无)'}`)
  168 + else console.log(`bootstrap-source-flyway: 跳过(${r.reason === 'already-managed' ? '源库已有 flyway_schema_history,已托管' : r.reason})`)
  169 + } catch (e) {
  170 + if (e instanceof MysqlUnavailableError) {
  171 + console.error('bootstrap-source-flyway: mysql2 not found. Please run `npm i mysql2` in the target project.')
  172 + process.exit(1)
  173 + }
  174 + console.error(`bootstrap-source-flyway: failed — ${e?.message || e}`)
  175 + process.exit(1)
  176 + }
  177 +}
... ...
lib/bootstrap-source-flyway.test.mjs 0 → 100644
  1 +// lib/bootstrap-source-flyway.test.mjs — 纯函数单测(checksum / 迁移名解析)。
  2 +// 完整 bootstrap 需真实 mysql2 + DB,不在离线范围;此处只锁定与 Flyway 对齐的关键不变量。
  3 +import { test } from 'node:test'
  4 +import assert from 'node:assert/strict'
  5 +import { flywayChecksum, parseMigrationName } from './bootstrap-source-flyway.mjs'
  6 +
  7 +test('flywayChecksum: deterministic and returns a signed int32', () => {
  8 + const a = flywayChecksum('CREATE TABLE t (id INT);\n')
  9 + const b = flywayChecksum('CREATE TABLE t (id INT);\n')
  10 + assert.equal(a, b)
  11 + assert.equal(Number.isInteger(a), true)
  12 + assert.ok(a >= -2147483648 && a <= 2147483647, 'signed int32 range')
  13 +})
  14 +
  15 +// Flyway 按行读取(剥行终止符)——CRLF 与 LF 的同一内容 checksum 必须相同(跨平台 checkout 稳定)。
  16 +test('flywayChecksum: CRLF and LF of the same content match (line-ending normalized)', () => {
  17 + const lf = 'line1\nline2\nline3\n'
  18 + const crlf = 'line1\r\nline2\r\nline3\r\n'
  19 + assert.equal(flywayChecksum(lf), flywayChecksum(crlf))
  20 +})
  21 +
  22 +// 尾部有无换行不改变行集合末尾(BufferedReader 不返回"最后的空行")——单个尾换行与无尾换行一致。
  23 +test('flywayChecksum: a single trailing newline does not change the checksum', () => {
  24 + assert.equal(flywayChecksum('a\nb'), flywayChecksum('a\nb\n'))
  25 +})
  26 +
  27 +// 内容不同 → checksum 不同(基本区分性)。
  28 +test('flywayChecksum: different content yields different checksum', () => {
  29 + assert.notEqual(flywayChecksum('SELECT 1;'), flywayChecksum('SELECT 2;'))
  30 +})
  31 +
  32 +// 首行 UTF-8 BOM 被剥离(与 Flyway 一致)——带 BOM 与不带 BOM 的同一内容一致。
  33 +test('flywayChecksum: leading UTF-8 BOM is stripped', () => {
  34 + assert.equal(flywayChecksum('CREATE TABLE t (id INT);\n'), flywayChecksum('CREATE TABLE t (id INT);\n'))
  35 +})
  36 +
  37 +test('parseMigrationName: V1__initial_schema.sql → version 1, description "initial schema"', () => {
  38 + assert.deepEqual(parseMigrationName('V1__initial_schema.sql'), { version: '1', description: 'initial schema' })
  39 +})
  40 +
  41 +test('parseMigrationName: dotted/underscored versions normalize to dots', () => {
  42 + assert.deepEqual(parseMigrationName('V2_1__add_index.sql'), { version: '2.1', description: 'add index' })
  43 + assert.deepEqual(parseMigrationName('V10.3__foo.sql'), { version: '10.3', description: 'foo' })
  44 +})
  45 +
  46 +test('parseMigrationName: non-migration filenames return null', () => {
  47 + assert.equal(parseMigrationName('seed.sql'), null)
  48 + assert.equal(parseMigrationName('V1_no_double_underscore.sql'), null)
  49 + assert.equal(parseMigrationName('R__repeatable.sql'), null)
  50 +})
... ...
lib/drop-test-db-template.test.mjs 0 → 100644
  1 +// lib/drop-test-db-template.test.mjs — 校验生成模板 scripts/drop-test-db.mjs 的"只删副本、硬拒删源库"逻辑。
  2 +// 跑真实模板产物:复制到临时 scripts/、写 ../config-vars.yaml、再 node 执行。host/port 指向 127.0.0.1:1(必拒连)。
  3 +import { test } from 'node:test'
  4 +import assert from 'node:assert/strict'
  5 +import { spawnSync } from 'node:child_process'
  6 +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs'
  7 +import { tmpdir } from 'node:os'
  8 +import { join } from 'node:path'
  9 +import { fileURLToPath } from 'node:url'
  10 +
  11 +const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-drop-test-db-template.mjs', import.meta.url))
  12 +
  13 +function runWithDb({ host = '127.0.0.1', port = '1', user = 'root', password = 'x', schemaLine = 'schema: erp_dev', env = {}, laneMarker = null } = {}) {
  14 + const dir = mkdtempSync(join(tmpdir(), 'erp-drtb-'))
  15 + mkdirSync(join(dir, 'scripts'))
  16 + copyFileSync(TEMPLATE, join(dir, 'scripts', 'drop-test-db.mjs'))
  17 + writeFileSync(
  18 + join(dir, 'config-vars.yaml'),
  19 + ['database:', ` host: ${host}`, ` port: ${port}`, ` user: ${user}`, ` password: ${password}`, ' ' + schemaLine, ''].join('\n'),
  20 + )
  21 + if (laneMarker !== null) {
  22 + mkdirSync(join(dir, '.tmp'))
  23 + writeFileSync(join(dir, '.tmp', 'erp-lane-db'), laneMarker)
  24 + }
  25 + const childEnv = { ...process.env, ...env }
  26 + if (!('ERP_TEST_DB_SCHEMA' in env)) delete childEnv.ERP_TEST_DB_SCHEMA
  27 + return spawnSync('node', [join(dir, 'scripts', 'drop-test-db.mjs')], { encoding: 'utf8', env: childEnv })
  28 +}
  29 +
  30 +test('drop-test-db: unfilled placeholders fail before mysql', () => {
  31 + const r = runWithDb({ schemaLine: 'schema: 【人工填写:schema 名】' })
  32 + assert.equal(r.status, 1)
  33 + assert.match(r.stderr, /仍是占位/, 'stderr: ' + r.stderr)
  34 +})
  35 +
  36 +test('drop-test-db: empty source schema fails before mysql', () => {
  37 + const r = runWithDb({ schemaLine: 'schema:' })
  38 + assert.equal(r.status, 1)
  39 + assert.match(r.stderr, /database\.schema/, 'stderr: ' + r.stderr)
  40 +})
  41 +
  42 +// **安全红线**:待删副本 = 源库 → 拒绝(绝不删源库)。守卫先于任何 mysql 调用。
  43 +test('drop-test-db: refuses to drop when copy name equals source (protects source)', () => {
  44 + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev' } })
  45 + assert.equal(r.status, 1)
  46 + assert.match(r.stderr, /待删副本名与源库名相同/, 'stderr: ' + r.stderr)
  47 + assert.doesNotMatch(r.stdout, /删除一次性副本/, '拒绝应先于 DROP — stdout: ' + r.stdout)
  48 +})
  49 +
  50 +test('drop-test-db: serial default targets <source>__test copy', () => {
  51 + const r = runWithDb()
  52 + assert.match(r.stdout, /删除一次性副本 erp_dev__test/, 'stdout: ' + r.stdout)
  53 + assert.notEqual(r.status, 0, '应在连库阶段失败(127.0.0.1:1)')
  54 +})
  55 +
  56 +test('drop-test-db: ERP_TEST_DB_SCHEMA env names the copy to drop', () => {
  57 + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } })
  58 + assert.match(r.stdout, /删除一次性副本 erp_dev_lane2/, 'stdout: ' + r.stdout)
  59 +})
  60 +
  61 +test('drop-test-db: .tmp/erp-lane-db marker names the copy when env absent', () => {
  62 + const r = runWithDb({ laneMarker: 'erp_dev_lane3\n' })
  63 + assert.match(r.stdout, /删除一次性副本 erp_dev_lane3/, 'stdout: ' + r.stdout)
  64 +})
... ...
lib/promote-to-source-template.test.mjs 0 → 100644
  1 +// lib/promote-to-source-template.test.mjs — 校验生成模板 scripts/promote-to-source.mjs 的离线守卫。
  2 +// 只覆盖任何 mysql 调用 / 文件锁之前的本地校验(占位 / 源库名空 / COPY===SOURCE 守卫);
  3 +// delta 计算与迁移重放需真实 DB,不在离线测试范围。host/port 指向 127.0.0.1:1(必拒连)。
  4 +import { test } from 'node:test'
  5 +import assert from 'node:assert/strict'
  6 +import { spawnSync } from 'node:child_process'
  7 +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs'
  8 +import { tmpdir } from 'node:os'
  9 +import { join } from 'node:path'
  10 +import { fileURLToPath } from 'node:url'
  11 +
  12 +const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-promote-to-source-template.mjs', import.meta.url))
  13 +
  14 +function runWithDb({ host = '127.0.0.1', port = '1', user = 'root', password = 'x', schemaLine = 'schema: erp_dev', env = {} } = {}) {
  15 + const dir = mkdtempSync(join(tmpdir(), 'erp-prom-'))
  16 + mkdirSync(join(dir, 'scripts'))
  17 + copyFileSync(TEMPLATE, join(dir, 'scripts', 'promote-to-source.mjs'))
  18 + writeFileSync(
  19 + join(dir, 'config-vars.yaml'),
  20 + ['database:', ` host: ${host}`, ` port: ${port}`, ` user: ${user}`, ` password: ${password}`, ' ' + schemaLine, ''].join('\n'),
  21 + )
  22 + const childEnv = { ...process.env, ...env }
  23 + if (!('ERP_TEST_DB_SCHEMA' in env)) delete childEnv.ERP_TEST_DB_SCHEMA
  24 + return spawnSync('node', [join(dir, 'scripts', 'promote-to-source.mjs')], { encoding: 'utf8', env: childEnv })
  25 +}
  26 +
  27 +test('promote-to-source: unfilled placeholders fail before lock/mysql', () => {
  28 + const r = runWithDb({ schemaLine: 'schema: 【人工填写:schema 名】' })
  29 + assert.equal(r.status, 1)
  30 + assert.match(r.stderr, /仍是占位/, 'stderr: ' + r.stderr)
  31 +})
  32 +
  33 +test('promote-to-source: empty source schema fails before lock/mysql', () => {
  34 + const r = runWithDb({ schemaLine: 'schema:' })
  35 + assert.equal(r.status, 1)
  36 + assert.match(r.stderr, /database\.schema/, 'stderr: ' + r.stderr)
  37 +})
  38 +
  39 +// COPY===SOURCE 守卫先于文件锁与任何 mysql 调用。
  40 +test('promote-to-source: copy name equal to source is rejected before lock/mysql', () => {
  41 + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev' } })
  42 + assert.equal(r.status, 1)
  43 + assert.match(r.stderr, /副本名与源库名相同/, 'stderr: ' + r.stderr)
  44 +})
... ...
lib/seed-demo-data-template.test.mjs
... ... @@ -113,19 +113,19 @@ test(&#39;seed-demo-data: well-formed &lt;NN&gt;__&lt;module_id&gt;.sql passes filename check&#39;,
113 113 const r = run({ seedFiles: { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } })
114 114 assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
115 115 assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr)
116   - assert.match(r.stdout, /目标库 erp_dev/, '应已进入 DB 阶段 — stdout: ' + r.stdout)
  116 + assert.match(r.stdout, /目标库 erp_dev__test/, '串行缺省目标应为副本 erp_dev__test(非源库)— stdout: ' + r.stdout)
117 117 })
118 118  
119 119 test('seed-demo-data: valid seed + port 1 passes local checks then fails at DB connect', () => {
120 120 const r = run({ seedFiles: { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } })
121 121 assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
122 122 // 已通过全部本地校验、进入 DB 阶段:应打印目标库摘要,且报错是连库失败(非占位/非法名)。
123   - assert.match(r.stdout, /目标库 erp_dev/, '应已进入 DB 阶段 — stdout: ' + r.stdout)
  123 + assert.match(r.stdout, /目标库 erp_dev__test/, '串行缺省目标应为副本 erp_dev__test(非源库)— stdout: ' + r.stdout)
124 124 assert.doesNotMatch(r.stderr, /仍是占位/, 'stderr: ' + r.stderr)
125 125 assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr)
126 126 })
127 127  
128   -// ── 测试 DB lane 化:取值顺序 env > .tmp/erp-lane-db marker > config ──
  128 +// ── 注入目标副本 lane 化:取值顺序 env > .tmp/erp-lane-db marker > `<source>__test` ──
129 129  
130 130 const LANE_SEED = { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' }
131 131  
... ... @@ -145,6 +145,14 @@ test(&#39;seed-demo-data: env wins over marker; blank marker falls through to config
145 145 assert.match(both.stdout, /目标库 erp_dev_lane2/, 'stdout: ' + both.stdout)
146 146 assert.doesNotMatch(both.stdout, /lane DB marker 生效/, 'stdout: ' + both.stdout)
147 147 const blank = run({ seedFiles: LANE_SEED, laneMarker: '\n' })
148   - assert.match(blank.stdout, /目标库 erp_dev/, 'stdout: ' + blank.stdout)
  148 + assert.match(blank.stdout, /目标库 erp_dev__test/, '空 marker 应回退到副本 erp_dev__test — stdout: ' + blank.stdout)
149 149 assert.doesNotMatch(blank.stdout, /lane DB marker 生效/, 'stdout: ' + blank.stdout)
150 150 })
  151 +
  152 +// **安全红线**:注入目标 = 源库 → 拒绝(演示种子绝不写源库)。守卫先于任何 mysql 调用。
  153 +test('seed-demo-data: injection target equal to source schema is rejected before mysql', () => {
  154 + const r = run({ seedFiles: LANE_SEED, env: { ERP_TEST_DB_SCHEMA: 'erp_dev' } }) // 与源库同名
  155 + assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
  156 + assert.match(r.stderr, /注入目标副本名与源库名相同/, 'stderr: ' + r.stderr)
  157 + assert.doesNotMatch(r.stdout, /目标库/, '拒绝应发生在进入 DB 阶段之前 — stdout: ' + r.stdout)
  158 +})
... ...
lib/setup-test-db-template.test.mjs
1   -// lib/setup-test-db-template.test.mjs — 校验生成模板 scripts/setup-test-db.mjs 的最小 DB reset 逻辑。
  1 +// lib/setup-test-db-template.test.mjs — 校验生成模板 scripts/setup-test-db.mjs 的"复制源库→一次性副本"逻辑。
2 2 // 跑的是真实模板产物:复制到临时 scripts/ 下、写一个 ../config-vars.yaml、再 node 执行。
3   -// 所有用例的 host/port 故意指向 127.0.0.1:1(必拒连),不会触碰真实库
  3 +// 所有用例的 host/port 故意指向 127.0.0.1:1(必拒连),不会触碰真实库;本地校验先于任何 mysql 调用
4 4 import { test } from 'node:test'
5 5 import assert from 'node:assert/strict'
6 6 import { spawnSync } from 'node:child_process'
... ... @@ -34,7 +34,7 @@ function runWithDb({ host = &#39;127.0.0.1&#39;, port = &#39;1&#39;, user = &#39;root&#39;, password = &#39;
34 34 return spawnSync('node', [join(dir, 'scripts', 'setup-test-db.mjs')], { encoding: 'utf8', env: childEnv })
35 35 }
36 36  
37   -test('setup-test-db: empty schema fails before mysql is invoked', () => {
  37 +test('setup-test-db: empty schema (source) fails before mysql is invoked', () => {
38 38 const r = runWithSchema('schema:')
39 39 assert.equal(r.status, 1)
40 40 assert.match(r.stderr, /database\.schema/, '应是 schema 缺失报错而非连库失败 — stderr: ' + r.stderr)
... ... @@ -54,48 +54,52 @@ test(&#39;setup-test-db: unfilled 【人工填写】 config placeholders fail before
54 54 }
55 55 })
56 56  
57   -test('setup-test-db: schema with a backtick is quoted for MySQL instead of rejected', () => {
58   - const r = runWithSchema('schema: ev`il')
59   - assert.match(r.stdout, /`ev``il`/, 'stdout: ' + r.stdout)
60   - assert.doesNotMatch(r.stderr, /database\.schema/, 'stderr: ' + r.stderr)
  57 +// **安全红线**:副本名 = 源库名 → 拒绝(否则 DROP 副本 = DROP 源库)。守卫先于任何 mysql 调用。
  58 +test('setup-test-db: copy name equal to source is rejected before mysql (protects source)', () => {
  59 + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev' } }) // 与 config 的源库同名
  60 + assert.equal(r.status, 1)
  61 + assert.match(r.stderr, /副本库名与源库名相同/, 'stderr: ' + r.stderr)
  62 + assert.doesNotMatch(r.stdout, /复制源库/, '拒绝应发生在打印复制计划之前 — stdout: ' + r.stdout)
61 63 })
62 64  
63   -test('setup-test-db: an ordinary dev schema proceeds to mysql', () => {
  65 +test('setup-test-db: an ordinary dev schema copies source → <source>__test copy', () => {
64 66 const r = runWithSchema('schema: erp_dev')
65   - assert.match(r.stdout, /DROP \+ CREATE `erp_dev`/, 'stdout: ' + r.stdout)
  67 + assert.match(r.stdout, /复制源库 erp_dev → 一次性副本 erp_dev__test/, 'stdout: ' + r.stdout)
  68 + assert.notEqual(r.status, 0, '应在连库阶段失败(127.0.0.1:1 拒连)')
66 69 })
67 70  
68   -test('setup-test-db: non-test names are allowed in sandbox/dev workflows', () => {
69   - const r = runWithSchema('schema: erp_prod')
70   - assert.match(r.stdout, /DROP \+ CREATE `erp_prod`/, 'stdout: ' + r.stdout)
  71 +test('setup-test-db: a schema with a backtick still resolves and reaches the copy step', () => {
  72 + const r = runWithSchema('schema: ev`il')
  73 + assert.match(r.stdout, /复制源库 ev`il → 一次性副本 ev`il__test/, 'stdout: ' + r.stdout)
  74 + assert.doesNotMatch(r.stderr, /database\.schema/, 'stderr: ' + r.stderr)
71 75 })
72 76  
73   -// ── 测试 DB lane 化:取值顺序 env > .tmp/erp-lane-db marker > config ──
  77 +// ── 副本库名 lane 化:取值顺序 env > .tmp/erp-lane-db marker > `<source>__test` ──
74 78  
75   -test('setup-test-db: ERP_TEST_DB_SCHEMA env overrides config schema', () => {
  79 +test('setup-test-db: ERP_TEST_DB_SCHEMA env names the copy', () => {
76 80 const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } })
77   - assert.match(r.stdout, /DROP \+ CREATE `erp_dev_lane2`/, 'stdout: ' + r.stdout)
  81 + assert.match(r.stdout, /复制源库 erp_dev → 一次性副本 erp_dev_lane2/, 'stdout: ' + r.stdout)
78 82 })
79 83  
80   -test('setup-test-db: empty ERP_TEST_DB_SCHEMA env falls through to config schema', () => {
  84 +test('setup-test-db: empty ERP_TEST_DB_SCHEMA env falls through to <source>__test', () => {
81 85 const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: '' } })
82   - assert.match(r.stdout, /DROP \+ CREATE `erp_dev`/, 'stdout: ' + r.stdout)
  86 + assert.match(r.stdout, /一次性副本 erp_dev__test/, 'stdout: ' + r.stdout)
83 87 })
84 88  
85   -test('setup-test-db: .tmp/erp-lane-db marker overrides config when env is absent, with audit log', () => {
  89 +test('setup-test-db: .tmp/erp-lane-db marker names the copy when env is absent, with audit log', () => {
86 90 const r = runWithDb({ laneMarker: 'erp_dev_lane3\n' })
87 91 assert.match(r.stdout, /lane DB marker 生效: erp_dev_lane3/, 'stdout: ' + r.stdout)
88   - assert.match(r.stdout, /DROP \+ CREATE `erp_dev_lane3`/, 'stdout: ' + r.stdout)
  92 + assert.match(r.stdout, /一次性副本 erp_dev_lane3/, 'stdout: ' + r.stdout)
89 93 })
90 94  
91 95 test('setup-test-db: env wins over marker (marker log absent)', () => {
92 96 const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' }, laneMarker: 'erp_dev_lane3\n' })
93   - assert.match(r.stdout, /DROP \+ CREATE `erp_dev_lane2`/, 'stdout: ' + r.stdout)
  97 + assert.match(r.stdout, /一次性副本 erp_dev_lane2/, 'stdout: ' + r.stdout)
94 98 assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout)
95 99 })
96 100  
97   -test('setup-test-db: blank marker falls through to config schema', () => {
  101 +test('setup-test-db: blank marker falls through to <source>__test', () => {
98 102 const r = runWithDb({ laneMarker: '\n' })
99   - assert.match(r.stdout, /DROP \+ CREATE `erp_dev`/, 'stdout: ' + r.stdout)
  103 + assert.match(r.stdout, /一次性副本 erp_dev__test/, 'stdout: ' + r.stdout)
100 104 assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout)
101 105 })
... ...
lib/test-template.test.mjs
1   -// lib/test-template.test.mjs — 校验生成模板 scripts/test.mjs 的测试 DB lane 化贯通逻辑。
2   -// D5 的要害是 env 必须真正打穿到后端测试子进程(gradle fork / e2e globalSetup→bootRun 均靠继承 env,
3   -// 由 application*.yml 的 ${ERP_TEST_DB_SCHEMA:...} 占位消费)——故用探针命令替换 {{...}} 占位
4   -// (模拟 skeleton-gen 的 Plan 期填充),直接断言子进程眼中的 ERP_TEST_DB_SCHEMA。
  1 +// lib/test-template.test.mjs — 校验生成模板 scripts/test.mjs 的副本库贯通 + 晋升/删副本时序。
  2 +// 要害:① 副本库名(ERP_TEST_DB_SCHEMA)必须真正打穿到每个测试子进程(gradle fork / e2e globalSetup→bootRun
  3 +// 均靠继承 env,由 application*.yml 的 ${ERP_TEST_DB_SCHEMA:<source>__test} 占位消费);② 绿后晋升、无论红绿删副本。
  4 +// 用探针/桩命令替换 {{...}} 占位(模拟 skeleton-gen 的 Plan 期填充),直接断言子进程眼中的 ERP_TEST_DB_SCHEMA。
5 5 import { test } from 'node:test'
6 6 import assert from 'node:assert/strict'
7 7 import { spawnSync } from 'node:child_process'
... ... @@ -12,24 +12,39 @@ import { fileURLToPath } from &#39;node:url&#39;
12 12  
13 13 const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-test-template.mjs', import.meta.url))
14 14  
15   -// 探针脚本:以子进程身份打印自己继承到的 ERP_TEST_DB_SCHEMA(unset 显式可断言)。
16   -// 占位填充值不能带引号(模板里占位嵌在 JS 单引号字符串内),故用脚本文件而非内联 node -e。
  15 +// 探针:以子进程身份打印自己继承到的 ERP_TEST_DB_SCHEMA(unset 显式可断言)。用于 setup-db + build/lint/test + e2e。
17 16 const PROBE_SOURCE = `console.log('PROBE_SCHEMA=' + (process.env.ERP_TEST_DB_SCHEMA ?? '(unset)'))\n`
  17 +// 晋升 / 删副本桩:打印各自可辨识的标记(用于断言"绿才晋升、无论红绿都删")。
  18 +const PROMOTE_STUB = `console.log('PROMOTE_RAN=' + (process.env.ERP_TEST_DB_SCHEMA ?? '(unset)'))\n`
  19 +const DROP_STUB = `console.log('DROP_RAN=' + (process.env.ERP_TEST_DB_SCHEMA ?? '(unset)'))\n`
18 20  
19   -// env:传给闸门进程的增量环境变量;laneMarker:非 null 时写 .tmp/erp-lane-db 兜底标记。
  21 +// env:传给闸门进程的增量环境变量;laneMarker:非 null 时写 .tmp/erp-lane-db 兜底标记;
  22 +// sourceSchema:非 null 时写 config-vars.yaml(database.schema),触发串行缺省副本派生;
  23 +// failBackendTest:把后端单测桩换成必失败命令,验证红路径(跳晋升、仍删副本)。
20 24 // 基础环境强制剔除 ERP_TEST_DB_SCHEMA——外层 shell 若恰好设了该变量,"缺省行为"用例会被污染。
21   -function runGate({ env = {}, laneMarker = null } = {}) {
  25 +function runGate({ env = {}, laneMarker = null, sourceSchema = null, failBackendTest = false } = {}) {
22 26 const dir = mkdtempSync(join(tmpdir(), 'erp-gate-'))
23 27 mkdirSync(join(dir, 'scripts'))
24 28 mkdirSync(join(dir, 'backend'))
25   - // backend_* 在 backend/ 子目录下执行、e2e 在仓库根执行,相对路径相应不同(frontend_* 因无 frontend/ 被跳过)。
26   - const filled = readFileSync(TEMPLATE, 'utf8')
  29 + // backend_* 在 backend/ 子目录下执行、e2e / promote / drop 在仓库根执行(相对路径相应不同;frontend_* 因无 frontend/ 被跳过)。
  30 + let filled = readFileSync(TEMPLATE, 'utf8')
  31 + if (failBackendTest) filled = filled.replace('{{backend_test}}', 'node ../scripts/fail.mjs')
  32 + filled = filled
27 33 .replace(/\{\{(backend|frontend)_[a-z_]+\}\}/g, 'node ../scripts/probe.mjs')
28 34 .replace(/\{\{e2e_cmd\}\}/g, 'node scripts/probe.mjs')
29 35 writeFileSync(join(dir, 'scripts', 'test.mjs'), filled)
30 36 writeFileSync(join(dir, 'scripts', 'probe.mjs'), PROBE_SOURCE)
31   - // setup-test-db stub:真模板会连库失败中断闸门、且自身也读 marker,无法隔离 test.mjs 的回填路径。
  37 + writeFileSync(join(dir, 'scripts', 'fail.mjs'), 'process.exit(1)\n')
  38 + // 真模板会连库失败中断闸门,无法隔离 test.mjs 的编排逻辑——故 setup/promote/drop 全用桩。
32 39 writeFileSync(join(dir, 'scripts', 'setup-test-db.mjs'), PROBE_SOURCE)
  40 + writeFileSync(join(dir, 'scripts', 'promote-to-source.mjs'), PROMOTE_STUB)
  41 + writeFileSync(join(dir, 'scripts', 'drop-test-db.mjs'), DROP_STUB)
  42 + if (sourceSchema !== null) {
  43 + writeFileSync(
  44 + join(dir, 'config-vars.yaml'),
  45 + ['database:', ' host: 127.0.0.1', ' port: 3306', ' user: root', ' password: x', ` schema: ${sourceSchema}`, ''].join('\n'),
  46 + )
  47 + }
33 48 if (laneMarker !== null) {
34 49 mkdirSync(join(dir, '.tmp'))
35 50 writeFileSync(join(dir, '.tmp', 'erp-lane-db'), laneMarker)
... ... @@ -39,11 +54,17 @@ function runGate({ env = {}, laneMarker = null } = {}) {
39 54 return spawnSync('node', [join(dir, 'scripts', 'test.mjs')], { encoding: 'utf8', env: childEnv })
40 55 }
41 56  
42   -test('test.mjs: ERP_TEST_DB_SCHEMA env reaches every child process', () => {
  57 +// 核心 5 段(setup-db + backend build/lint/test + e2e)各打印一次 PROBE_SCHEMA;晋升/删副本另有自己的标记。
  58 +function countProbe(stdout, value) {
  59 + return (stdout.match(new RegExp('PROBE_SCHEMA=' + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length
  60 +}
  61 +
  62 +test('test.mjs: ERP_TEST_DB_SCHEMA env reaches every child, then promotes + drops on green', () => {
43 63 const r = runGate({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } })
44 64 assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
45   - // setup-db / build / lint / unit / e2e 五步各打印一次。
46   - assert.equal((r.stdout.match(/PROBE_SCHEMA=erp_dev_lane2/g) || []).length, 5, 'stdout: ' + r.stdout)
  65 + assert.equal(countProbe(r.stdout, 'erp_dev_lane2'), 5, '5 段核心应各见一次副本名 — stdout: ' + r.stdout)
  66 + assert.match(r.stdout, /PROMOTE_RAN=erp_dev_lane2/, '绿后应晋升 — stdout: ' + r.stdout)
  67 + assert.match(r.stdout, /DROP_RAN=erp_dev_lane2/, '应删副本 — stdout: ' + r.stdout)
47 68 assert.match(r.stdout, /GREEN/, 'stdout: ' + r.stdout)
48 69 })
49 70  
... ... @@ -51,19 +72,41 @@ test(&#39;test.mjs: .tmp/erp-lane-db marker backfills env for children when env is a
51 72 const r = runGate({ laneMarker: 'erp_dev_lane3\n' })
52 73 assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
53 74 assert.match(r.stdout, /lane DB marker 生效: ERP_TEST_DB_SCHEMA=erp_dev_lane3/, 'stdout: ' + r.stdout)
54   - assert.equal((r.stdout.match(/PROBE_SCHEMA=erp_dev_lane3/g) || []).length, 5, 'stdout: ' + r.stdout)
  75 + assert.equal(countProbe(r.stdout, 'erp_dev_lane3'), 5, 'stdout: ' + r.stdout)
  76 + assert.match(r.stdout, /PROMOTE_RAN=erp_dev_lane3/, 'stdout: ' + r.stdout)
  77 + assert.match(r.stdout, /DROP_RAN=erp_dev_lane3/, 'stdout: ' + r.stdout)
55 78 })
56 79  
57 80 test('test.mjs: env wins over marker (marker log absent)', () => {
58 81 const r = runGate({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' }, laneMarker: 'erp_dev_lane3\n' })
59 82 assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
60   - assert.equal((r.stdout.match(/PROBE_SCHEMA=erp_dev_lane2/g) || []).length, 5, 'stdout: ' + r.stdout)
  83 + assert.equal(countProbe(r.stdout, 'erp_dev_lane2'), 5, 'stdout: ' + r.stdout)
61 84 assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout)
62 85 })
63 86  
64   -test('test.mjs: no env and no marker leaves children unset (serial behavior unchanged)', () => {
  87 +// 串行缺省:无 env/marker 但有 config-vars → 从 database.schema 派生副本 `<schema>__test` 并显式导出到所有子进程。
  88 +test('test.mjs: serial default derives <source>__test from config and exports it to every child', () => {
  89 + const r = runGate({ sourceSchema: 'erp_dev' })
  90 + assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
  91 + assert.match(r.stdout, /串行缺省副本库: ERP_TEST_DB_SCHEMA=erp_dev__test/, 'stdout: ' + r.stdout)
  92 + assert.equal(countProbe(r.stdout, 'erp_dev__test'), 5, '子进程应连副本而非源库 — stdout: ' + r.stdout)
  93 + assert.match(r.stdout, /PROMOTE_RAN=erp_dev__test/, 'stdout: ' + r.stdout)
  94 + assert.match(r.stdout, /DROP_RAN=erp_dev__test/, 'stdout: ' + r.stdout)
  95 +})
  96 +
  97 +test('test.mjs: no env, no marker, no config leaves children unset (graceful degrade)', () => {
65 98 const r = runGate()
66 99 assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
67   - assert.equal((r.stdout.match(/PROBE_SCHEMA=\(unset\)/g) || []).length, 5, 'stdout: ' + r.stdout)
  100 + assert.equal(countProbe(r.stdout, '(unset)'), 5, 'stdout: ' + r.stdout)
68 101 assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout)
  102 + assert.doesNotMatch(r.stdout, /串行缺省副本库/, 'stdout: ' + r.stdout)
  103 +})
  104 +
  105 +// 红路径:某段失败 → 不晋升(源库不被污染),但 finally 仍删副本。
  106 +test('test.mjs: a failing stage skips promotion but still drops the copy', () => {
  107 + const r = runGate({ sourceSchema: 'erp_dev', failBackendTest: true })
  108 + assert.notEqual(r.status, 0, '失败段应让闸门非零退出 — stdout: ' + r.stdout + ' stderr: ' + r.stderr)
  109 + assert.doesNotMatch(r.stdout, /PROMOTE_RAN=/, '红则绝不晋升到源库 — stdout: ' + r.stdout)
  110 + assert.match(r.stdout, /DROP_RAN=erp_dev__test/, '无论红绿都删副本 — stdout: ' + r.stdout)
  111 + assert.doesNotMatch(r.stdout, /GREEN/, 'stdout: ' + r.stdout)
69 112 })
... ...
skills/plan/db-init/SKILL.md
1 1 ---
2 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 做 4 维校验(表/列/类型/索引)DDL ↔ docs/03 一致性 → 调 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 做 4 维校验(表/列/类型/索引)DDL ↔ docs/03 一致性 → 调 lib/bootstrap-source-flyway.mjs 把源库建立成 Flyway 托管态(建库 + apply V1 烟测 + 写 flyway 历史)
4 4 user-invocable: false
5 5 allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm install mysql2)
6 6 ---
... ... @@ -67,42 +67,28 @@ node &quot;${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs&quot; \
67 67 - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成`
68 68 - ` - [ ] DDL ↔ docs/03 4 维一致(validate-ddl.mjs)`
69 69  
70   -### B. 自动导入 MySQL
  70 +### B. 把源库建立成 Flyway 托管态(一步 bootstrap)
71 71  
72   -不做测试库命名预检:本项目只作用于开发或沙盒环境,`setup-test-db.mjs` / `apply-ddl.mjs` 会用同一份 `config-vars.yaml` 直接执行。若 DB 配置仍含 `【人工填写】` 占位则命令直接失败;缺 mysql 客户端、mysql2、凭据或连通性问题由实际命令报错后停下。
  72 +不做测试库命名预检:本项目只作用于开发或沙盒环境,命令用同一份 `config-vars.yaml` 直接执行。若 DB 配置仍含 `【人工填写】` 占位则命令直接失败;缺 mysql 客户端、mysql2、凭据或连通性问题由实际命令报错后停下。
73 73  
74   -#### B.1 DROP+CREATE 空库
  74 +**测试模型(源库永不被测试 drop)**:Coding 阶段每次测试从**源库**(`config-vars.yaml` 的 `database.schema`)复制一份一次性副本、在副本上跑、跑完删副本;源库只被复制、绝不被 drop。这要求源库先存在且**被 Flyway 托管**(有 `flyway_schema_history`)。本步骤负责源库的**首次建立**:建库 + apply V1 + 写入正确 checksum 的 Flyway 历史,使源库自此成为 Flyway 托管的持久库。
75 75  
76   -```bash
77   -node scripts/setup-test-db.mjs
78   -```
79   -
80   -#### B.2 把 V1 灌入已清空的 schema
  76 +> **注意**:不再用 `scripts/setup-test-db.mjs` 建库——新模型下它是「复制源库→副本」而非「DROP+CREATE 源库」,不适合 Plan 期建源库。
81 77  
82   -调 `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`:它读取 `config-vars.yaml` 的 `database:` 段,再经 mysql2 把 DDL 灌入 schema
  78 +调 `${CLAUDE_PLUGIN_ROOT}/lib/bootstrap-source-flyway.mjs`:它读 `config-vars.yaml` 的 `database:` 段,用 mysql2 建源库、按版本序 apply `sql/migrations/V*.sql`(apply V1 本身即「V1 DDL 能否被真库接受」的烟测)、并为每个迁移写一条 checksum 与 Flyway 完全一致的历史行(首次起后端时 Flyway 校验通过、不会报 checksum mismatch,只 apply 之后新增的迁移)
83 79  
84 80 ```bash
85   -node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" config-vars.yaml sql/migrations/V1__initial_schema.sql
  81 +node "${CLAUDE_PLUGIN_ROOT}/lib/bootstrap-source-flyway.mjs" config-vars.yaml sql/migrations
86 82 ```
87 83  
88 84 退出码与处理:
89   -- `0` → 成功,进入步骤 B.3
90   -- `1` → 失败:打印 stderr 并停下
  85 +- `0` → 成功(含「源库已有 flyway_schema_history → 幂等跳过」)
  86 +- `1` → 失败:打印 stderr 并停下(如 V1 DDL 错、或源库「有表但无 flyway 历史」的非托管既有库需人工处置)
91 87 - `2` → 用法错(路径找不到),打印路径并停下
92 88  
93   -勾选:` - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行`
94   -
95   -#### B.3 清库交还 Flyway
96   -
97   -B.1 → B.2 是脱离 Flyway 的 DDL 烟测(直接 mysql2 灌入 V1,**绕过 Flyway**,库里没有 `flyway_schema_history` 表)。这种状态会卡住后端首次启动:若后端集成测试连真库,Spring Boot 启动时 Flyway 会对一个**非空且无 schema 历史表**的库报 `Found non-empty schema "<db>" without schema history table`。所以烟测做完要把产物清掉、把 schema 交还 Flyway,让它在后端启动时自行重放 migration。
98   -
99   -再跑一次 `setup-test-db.mjs`(DROP+CREATE,把 B.2 灌入的烟测产物连同 schema 一并清空):
100   -
101   -```bash
102   -node scripts/setup-test-db.mjs
103   -```
  89 +> **幂等 / 安全**:源库已被 Flyway 托管即跳过、绝不重灌或 drop 源库数据;源库有表但无 `flyway_schema_history`(疑为非 Flyway 托管的既有库)→ 拒绝自动 bootstrap 并提示人工确认(避免 apply V1 撞已存在的表)。
104 90  
105   -> B.2 的 DDL 烟测语义不变——它仍是「V1 能否被真库接受」的一次性验证;B.3 只是把烟测留下的非 Flyway 状态清掉,schema 由后端启动时的 Flyway 重新建立。
  91 +勾选:` - [ ] 源库已 Flyway 托管化(bootstrap-source-flyway:建库 + apply V1 + 写 flyway 历史)`
106 92  
107 93 ### C. 勾选 docs/08 进度 + 进入 A5
108 94  
... ... @@ -114,8 +100,9 @@ node scripts/setup-test-db.mjs
114 100 ## 参考
115 101  
116 102 - `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs`(A.3 docs/03 ↔ V1.sql 4 维一致性校验,跨平台纯 Node)
117   -- `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`(B.2 读取 config-vars.yaml 的 database: 段 + mysql2 灌入 DDL)
118   -- `${CLAUDE_PLUGIN_ROOT}/lib/yaml-config.mjs`(apply-ddl 依赖的极简 YAML 读取)
  103 +- `${CLAUDE_PLUGIN_ROOT}/lib/bootstrap-source-flyway.mjs`(B 把源库建立成 Flyway 托管态:建库 + apply V*.sql + 写 checksum 一致的 flyway 历史)
  104 +- `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`(bootstrap 复用其 mysql2 解析与 database 配置读取)
  105 +- `${CLAUDE_PLUGIN_ROOT}/lib/yaml-config.mjs`(极简 YAML 读取)
119 106 - `docs/03-数据库设计文档.md`(DDL 翻译输入,SSoT)
120 107 - `config-vars.yaml`(DB 凭据,A1 产出)
121 108 - 产物:`sql/migrations/V1__initial_schema.sql`(由 Flyway 在 Spring Boot 启动时验证 / apply)
... ...
skills/plan/project-init/templates/CLAUDE-template.md
... ... @@ -31,9 +31,9 @@
31 31  
32 32 1. **文件命名**:`sql/migrations/V<n>__<snake_case_desc>.sql`,例:`V5__add_user_email_unique_index.sql`
33 33 2. **版本号分配**:建文件前 `ls sql/migrations/V*.sql` 查当前最大 n,新文件 `n_max + 1`
34   -3. **Apply 方式**:Spring Boot 启动 / 测试启动时 Flyway 自动 apply(项目必须在 `build.gradle` 声明 `flyway-core` + `flyway-mysql` 依赖)。`scripts/setup-test-db.mjs` 只负责清空库,不做 apply
  34 +3. **Apply 方式**:Spring Boot 启动 / 测试启动时 Flyway 自动 apply(项目必须在 `build.gradle` 声明 `flyway-core` + `flyway-mysql` 依赖)。`scripts/setup-test-db.mjs` 把**源库复制成一次性副本**(不做 apply),Flyway 在起后端时只把源库还没有的新迁移 apply 到副本;测试绿后 `scripts/promote-to-source.mjs` 把新迁移晋升到源库
35 35 4. **已合并的 migration 永不修改**:如果发现错了,写一个补救 migration(如 `V7__fix_V5_index_name.sql`)修正,旧 `V_n.sql` 保持原样、永不回改
36   -5. **临时调试 DDL**:临时在本地试字段/索引可手动 `mysql -e`,但不写 migration;下次 `setup-test-db.mjs` 会 drop+create 清掉
  36 +5. **临时调试 DDL**:临时在本地试字段/索引可手动 `mysql -e`,但不写 migration;下次测试从源库重新复制副本,本地手改不进副本(副本跑完即删)
37 37 6. **A4 生成的 V1**:`V1__initial_schema.sql` 是 A 阶段由 `db-init` 从 `docs/03-数据库设计文档.md`(A3 正向设计的 schema SSoT)翻译生成的初始版本;后续 V2/V3/... 由 B 阶段每个 REQ 按需写入,**同时**反向同步更新 docs/03 对应表小节以保持 SSoT 一致
38 38 7. **增量需求新表/新列**:Plan 完结后用 `/erp-workflow:add-req` 追加需求时,其新表 / 新列同样落为新的 `V_n`(`ls V*.sql` 取 max+1)追加式 migration,并同步 docs/03——**永不改 V1**;schema 由下次 Coding 冷起栈时 Flyway 统一 apply
39 39  
... ... @@ -41,9 +41,9 @@
41 41  
42 42 ## 🌱 演示数据(demo seed)
43 43  
44   -1. **人工验收/演示三步走**:`node scripts/setup-test-db.mjs`(DROP+CREATE 空库)→ 起后端(Spring Boot 启动时 Flyway 建 schema)→ `node scripts/seed-demo-data.mjs`(注入演示种子
  44 +1. **人工验收/演示四步走**:`node scripts/setup-test-db.mjs`(复制源库→一次性副本)→ 起后端(Spring Boot 启动时 Flyway 把新迁移 apply 到副本)→ `node scripts/seed-demo-data.mjs`(把新种子灌进副本)→ 看完 `node scripts/drop-test-db.mjs`(删副本
45 45 2. **种子来源**:`sql/seed/*.sql` 由 B 阶段每个后端模块完成后自动生成、随 git 提交;演示数据用确定性显式主键,区间 `1000–9999`
46   -3. **数据不持久**:库会被各测试闸门 DROP+CREATE 随时重建——种子不保证存活,只保证随时可复现;重建后按上述时序重新注入即可
  46 +3. **数据不持久**:每次测试都从源库复制一份新副本、跑完即删(**源库绝不被 drop**)——种子不保证存活,只保证随时可复现;重新复制副本后按上述时序重新注入即可
47 47 4. **幂等账本**:账本表 `_demo_seed_history` 由 `seed-demo-data.mjs` 自建自管,重复执行自动跳过已应用文件
48 48  
49 49 ---
... ...
skills/plan/project-init/templates/docs-08-initial-template.md
... ... @@ -28,7 +28,7 @@
28 28 - [ ] A4 DB 初始化 — db-init
29 29 - [ ] sql/migrations/V1__initial_schema.sql 已生成
30 30 - [ ] DDL ↔ docs/03 4 维一致(validate-ddl.mjs)
31   - - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行
  31 + - [ ] 源库已 Flyway 托管化(bootstrap-source-flyway:建库 + apply V1 + 写 flyway 历史)
32 32  
33 33 - [ ] A5 下游文档生成 — downstream-gen
34 34 - [ ] docs/02 开发计划已生成
... ...
skills/plan/skeleton-gen/SKILL.md
... ... @@ -39,10 +39,14 @@ docs/04 已由 scope-lock 写入 § 零。本步骤追加 § 一 ~ 三:
39 39 | 模板 | 目标路径 |
40 40 |---|---|
41 41 | `${CLAUDE_SKILL_DIR}/templates/scripts-setup-test-db-template.mjs` | `scripts/setup-test-db.mjs` |
  42 +| `${CLAUDE_SKILL_DIR}/templates/scripts-drop-test-db-template.mjs` | `scripts/drop-test-db.mjs` |
  43 +| `${CLAUDE_SKILL_DIR}/templates/scripts-promote-to-source-template.mjs` | `scripts/promote-to-source.mjs` |
42 44 | `${CLAUDE_SKILL_DIR}/templates/scripts-seed-demo-data-template.mjs` | `scripts/seed-demo-data.mjs` |
43 45 | `${CLAUDE_SKILL_DIR}/templates/styles-tokens-template.css` | `src/styles/tokens.css` |
44 46  
45   -> `scripts/seed-demo-data.mjs`:把 Coding 阶段逐模块生成的 `sql/seed/*.sql` 演示种子注入**已建好 schema** 的库;用 `_demo_seed_history` 账本表记录已应用文件以保证幂等(重复执行自动跳过);调用方 = 前端 e2e 的 globalSetup / 行为门 / 里程碑后人工验收。
  47 +> **测试库模型(源库永不被测试 drop)**:`scripts/setup-test-db.mjs` 把源库(config-vars `database.schema`)复制成一次性副本库(`ERP_TEST_DB_SCHEMA` / marker / `<source>__test`);测试跑在副本上;`scripts/drop-test-db.mjs` 跑完删副本(硬拒删源库);测试绿后 `scripts/promote-to-source.mjs` 把副本上已验证的本轮新迁移晋升到源库(文件锁串行化并行 lane)。四个脚本共享同一命名约定,均自带 `COPY≠SOURCE` 安全守卫。
  48 +
  49 +> `scripts/seed-demo-data.mjs`:把 Coding 阶段逐模块生成的 `sql/seed/*.sql` 演示种子注入**副本库**(副本继承源库 `_demo_seed_history` 账本,源库已灌过的自动跳过、只补新种子);调用方 = 前端 e2e 的 globalSetup / 行为门 / 里程碑后人工验收。
46 50  
47 51 > `sql/migrations/` 目录不在此预建:A4 `db-init` 写 `V1__initial_schema.sql` 时 `Write` 会自动创建。
48 52  
... ... @@ -139,6 +143,8 @@ QA 横幅涵盖:产出文件清单(docs/04 + scripts/*.mjs + .gitignore;co
139 143 - `${CLAUDE_SKILL_DIR}/templates/docs-04-skeleton-template.md`
140 144 - `${CLAUDE_SKILL_DIR}/templates/scripts-test-template.mjs`
141 145 - `${CLAUDE_SKILL_DIR}/templates/scripts-setup-test-db-template.mjs`
  146 +- `${CLAUDE_SKILL_DIR}/templates/scripts-drop-test-db-template.mjs`
  147 +- `${CLAUDE_SKILL_DIR}/templates/scripts-promote-to-source-template.mjs`
142 148 - `${CLAUDE_SKILL_DIR}/templates/scripts-seed-demo-data-template.mjs`
143 149 - `config-vars.yaml`(A1 产出,含 DB 凭据;setup-test-db.mjs 运行时读取)
144 150 - `${CLAUDE_SKILL_DIR}/templates/gitignore-append-template`
... ...
skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md
... ... @@ -4,8 +4,8 @@
4 4 - 【人工填写:一条后端通用约定,按需复制本行新增更多;无则填「无」】
5 5  
6 6 **测试库 env 贯通(锁定约定,生成时原样保留)**:
7   -- 后端 `application*.yml`(含测试 profile)JDBC URL 的 schema 段一律写成 Spring 占位 `${ERP_TEST_DB_SCHEMA:<config-vars.yaml database.schema 字面量>}`(如 `jdbc:mysql://localhost:3306/${ERP_TEST_DB_SCHEMA:erp_dev}?...`)。env 缺省回退原 schema、串行行为不变;并行 lane 经该 env 把整条测试/起栈链指向 lane 库——gradle test fork 的 JVM 与 bootRun 默认继承 env,无需拼 `SPRING_DATASOURCE_URL`。
8   -- Flyway 配置 `out-of-order: true`:并行版本段下版本号**只保唯一、不保连续、不保合并序**;独立模块的 migration 互不引用(正是同波次并行的准入条件),乱序 apply 安全。
  7 +- 后端 `application*.yml`(含测试 profile)JDBC URL 的 schema 段一律写成 Spring 占位 `${ERP_TEST_DB_SCHEMA:<database.schema 字面量>__test}`(如 `jdbc:mysql://localhost:3306/${ERP_TEST_DB_SCHEMA:erp_dev__test}?...`)。**缺省值必须是副本库 `<schema>__test`,绝不能是源库 `<schema>` 本身**——测试永远连一次性副本,绝不连、绝不改源库。测试/起栈链经 ERP_TEST_DB_SCHEMA env 指向本次副本(并行 lane 各自副本);gradle test fork 的 JVM 与 bootRun 默认继承 env,无需拼 `SPRING_DATASOURCE_URL`。
  8 +- Flyway 配置 `out-of-order: true`:副本从源库复制而来、自带源库的 `flyway_schema_history`,起后端时 Flyway 只把源库还没有的**本轮新迁移**apply 到副本(历史里已有的自动跳过)。并行版本段下版本号**只保唯一、不保连续、不保合并序**;独立模块的 migration 互不引用(正是同波次并行的准入条件),乱序 apply 安全。
9 9  
10 10 ### 1.2 分层结构
11 11  
... ... @@ -50,6 +50,6 @@
50 50 - 演示种子 SQL 放 `sql/seed/`,命名 `<NN>__<module_id>.sql`(NN=两位序号,按模块构建顺序;随 git 提交)。
51 51 - 注入由 `scripts/seed-demo-data.mjs` 负责:B 阶段每个后端模块完成后生成对应 seed 文件,脚本逐文件按名升序应用。
52 52 - 主键区间约定:`1–999`=初始数据(admin_init 等)/ `1000–9999`=演示种子 / `≥100000`=行为验收 sentinel;三段互不重叠,演示数据值不得含 `_S<数字>` 编码串(预留给 sentinel)。
53   -- 注入时序恒为:`node scripts/setup-test-db.mjs`(DROP+CREATE 空库)→ 起后端(Flyway 建 schema)→ `node scripts/seed-demo-data.mjs`。
54   -- e2e 基线 = 演示种子已注入(前端 Playwright globalSetup 走上述时序);后端单测/集成测基线 = 空库,不注入种子。
55   -- 幂等账本表 `_demo_seed_history` 由 `seed-demo-data.mjs` 自建自管,已应用文件自动跳过;库被各测试闸门 DROP+CREATE 重建后按上述时序重新注入即可(数据可复现、不持久)。
  53 +- 注入时序恒为:`node scripts/setup-test-db.mjs`(复制源库→一次性副本)→ 起后端(Flyway apply 本轮新迁移到副本)→ `node scripts/seed-demo-data.mjs`(把源库还没有的新种子灌进副本)。
  54 +- e2e 基线 = 源库副本 + 本轮新迁移 + 补充演示种子(前端 Playwright globalSetup 走上述时序);后端单测/集成测基线 = 源库副本 + 本轮新迁移。
  55 +- 幂等账本表 `_demo_seed_history` 由 `seed-demo-data.mjs` 自建自管:副本继承源库账本,源库已灌过的种子自动跳过、只补新种子;副本跑完即删(`scripts/drop-test-db.mjs`),**源库绝不被 drop**(数据可复现、不持久)。测试绿后由 `scripts/promote-to-source.mjs` 把本轮新迁移晋升到源库。
... ...
skills/plan/skeleton-gen/templates/scripts-drop-test-db-template.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +// scripts/drop-test-db.mjs —— 删除一次性测试副本库(跑完收尾;源库绝不被删)。
  3 +//
  4 +// 与 scripts/setup-test-db.mjs 同一命名约定:
  5 +// SOURCE = config-vars.yaml database.schema(真实持久库,本脚本硬拒删除)。
  6 +// COPY = ERP_TEST_DB_SCHEMA(env) > .tmp/erp-lane-db(marker) > `<source>__test`(串行缺省)。
  7 +// 由 test.mjs / seed / 行为门 runner 的 finally 调用(做完测试直接删副本)。幂等:副本不存在也返回 0。
  8 +// 纯 mysql CLI(spawnSync),零 npm 依赖。
  9 +
  10 +import { spawnSync } from 'node:child_process'
  11 +import { existsSync, readFileSync } from 'node:fs'
  12 +import { dirname, join } from 'node:path'
  13 +import { fileURLToPath } from 'node:url'
  14 +
  15 +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
  16 +const CONFIG_FILE = join(SCRIPT_DIR, '..', 'config-vars.yaml')
  17 +
  18 +// 极简 YAML 读取(2 层 map + 标量;与 scripts/setup-test-db.mjs 同规则,内联以免运行时依赖)。
  19 +function parseScalar(raw) {
  20 + let s = String(raw).trim()
  21 + if (s === '' || s[0] === '#') return ''
  22 + const q = s[0]
  23 + if (q === '"' || q === "'") {
  24 + const end = s.indexOf(q, 1)
  25 + if (end !== -1) return s.slice(1, end)
  26 + }
  27 + const hash = s.indexOf(' #')
  28 + if (hash !== -1) s = s.slice(0, hash).trim()
  29 + return s
  30 +}
  31 +function parseYamlConfig(text) {
  32 + const root = {}
  33 + let section = null
  34 + for (const rawLine of text.split('\n')) {
  35 + const line = rawLine.replace(/\r$/, '')
  36 + const trimmed = line.trim()
  37 + if (trimmed === '' || trimmed[0] === '#') continue
  38 + const colon = line.indexOf(':')
  39 + if (colon === -1) continue
  40 + const key = line.slice(0, colon).trim()
  41 + if (key === '') continue
  42 + const indent = line.length - line.replace(/^\s+/, '').length
  43 + const value = parseScalar(line.slice(colon + 1))
  44 + if (indent === 0) {
  45 + if (value === '') {
  46 + section = {}
  47 + root[key] = section
  48 + } else {
  49 + root[key] = value
  50 + section = null
  51 + }
  52 + } else if (section) {
  53 + section[key] = value
  54 + } else {
  55 + root[key] = value
  56 + }
  57 + }
  58 + return root
  59 +}
  60 +
  61 +if (!existsSync(CONFIG_FILE)) {
  62 + console.error(`[drop-test-db] config-vars.yaml 不存在(${CONFIG_FILE})`)
  63 + process.exit(1)
  64 +}
  65 +
  66 +const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {}
  67 +
  68 +function readLaneDbMarker() {
  69 + const markerFile = join(SCRIPT_DIR, '..', '.tmp', 'erp-lane-db')
  70 + if (!existsSync(markerFile)) return ''
  71 + return readFileSync(markerFile, 'utf8').split('\n')[0].trim()
  72 +}
  73 +
  74 +const DB_HOST = db.host ?? ''
  75 +const DB_PORT = db.port ?? '3306'
  76 +const DB_USER = db.user ?? ''
  77 +const DB_PASSWORD = db.password ?? ''
  78 +const SOURCE_SCHEMA = db.schema ?? ''
  79 +
  80 +function rejectPlaceholder(key, value) {
  81 + if (typeof value === 'string' && value.includes('【人工填写')) {
  82 + console.error(`[drop-test-db] database.${key} 仍是占位,请先在 config-vars.yaml 填真实值`)
  83 + process.exit(1)
  84 + }
  85 +}
  86 +
  87 +for (const [key, value] of [['host', DB_HOST], ['port', DB_PORT], ['user', DB_USER], ['password', DB_PASSWORD], ['schema', SOURCE_SCHEMA]]) {
  88 + rejectPlaceholder(key, value)
  89 +}
  90 +
  91 +if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) {
  92 + console.error(`[drop-test-db] database.port 非法: ${DB_PORT}(必须是 1..65535 的整数)`)
  93 + process.exit(1)
  94 +}
  95 +
  96 +if (String(SOURCE_SCHEMA).trim() === '') {
  97 + console.error('[drop-test-db] database.schema(源库名)未填')
  98 + process.exit(1)
  99 +}
  100 +
  101 +const LANE_MARKER_SCHEMA = readLaneDbMarker()
  102 +const COPY_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || LANE_MARKER_SCHEMA || `${SOURCE_SCHEMA}__test`
  103 +
  104 +// **安全红线**:绝不删源库。副本名与源库同名 → 拒绝(说明副本名注入出错)。
  105 +if (COPY_SCHEMA === SOURCE_SCHEMA) {
  106 + console.error(`[drop-test-db] 拒绝:待删副本名与源库名相同(${SOURCE_SCHEMA})——绝不删源库。`)
  107 + process.exit(1)
  108 +}
  109 +
  110 +function quoteMySqlIdent(value) {
  111 + return '`' + String(value).replaceAll('`', '``') + '`'
  112 +}
  113 +
  114 +console.log(`[drop-test-db] 删除一次性副本 ${COPY_SCHEMA} on ${DB_HOST}:${DB_PORT}(源库 ${SOURCE_SCHEMA} 不动)`)
  115 +
  116 +const res = spawnSync(
  117 + 'mysql',
  118 + [`--host=${DB_HOST}`, `--port=${DB_PORT}`, `--user=${DB_USER}`, `--password=${DB_PASSWORD}`, '-e', `DROP DATABASE IF EXISTS ${quoteMySqlIdent(COPY_SCHEMA)};`],
  119 + { stdio: 'inherit' },
  120 +)
  121 +if (res.error) {
  122 + console.error(`[drop-test-db] FATAL: 无法执行 mysql(请确认其在 PATH 中): ${res.error.message}`)
  123 + process.exit(1)
  124 +}
  125 +if (res.status !== 0) {
  126 + console.error(`[drop-test-db] FAIL: mysql exit=${res.status}`)
  127 + process.exit(res.status === null ? 1 : res.status)
  128 +}
  129 +
  130 +console.log('[drop-test-db] done')
... ...
skills/plan/skeleton-gen/templates/scripts-promote-to-source-template.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +// scripts/promote-to-source.mjs —— 绿后把副本上已验证的**新迁移**晋升到源库。
  3 +//
  4 +// 模型:新 V<n> 先在副本上被 Flyway apply 并被测试;测试绿后,本脚本把这些"副本有、源库还没有"的
  5 +// 迁移应用到源库,使源库停在"上一次通过测试的 schema + 累积数据"。源库数据绝不被清、只做 schema 追加。
  6 +//
  7 +// 做法(确定性、纯 mysql CLI、无需起后端):
  8 +// ① 取 delta = 副本 flyway_schema_history 里源库没有的版本行(success=1,按 installed_rank 升序)。
  9 +// ② 逐个:把 sql/migrations/<script> 重放到源库 + 移植该迁移的 flyway 历史行(installed_rank 取源库当前
  10 +// MAX+1,checksum/description/type/script 原样搬用——checksum 由副本上真 Flyway 算好,下次校验必过)。
  11 +// ③ 全程持本仓库根 .tmp/erp-source-promote.lock 文件锁。
  12 +//
  13 +// 串行化:并行 lane 间的真正串行由**编排层**保证——test-gate(跑 test.mjs、内含本脚本)整段被 withStackLock
  14 +// 全局起栈互斥包住,任一时刻只有一个 test-gate 在跑,故对共享源库的晋升天然串行。本脚本的文件锁只兜底
  15 +// **同一 worktree 内**的重复运行(lane worktree 各有独立 .tmp/,文件锁跨不了 worktree)。
  16 +// 幂等:已在源库的版本自动跳过(可重复运行 / 并发下双跑安全)。源库未被 Flyway 管理(无 history 表)→ 跳过并告警。
  17 +// 前提:迁移为 sql/migrations/V<n>__*.sql 纯 SQL(无 Flyway 占位符替换)。由 test.mjs 绿后调用。
  18 +
  19 +import { spawnSync } from 'node:child_process'
  20 +import { existsSync, readFileSync, mkdirSync, rmSync, statSync } from 'node:fs'
  21 +import { dirname, join } from 'node:path'
  22 +import { fileURLToPath } from 'node:url'
  23 +
  24 +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
  25 +const PROJECT_ROOT = join(SCRIPT_DIR, '..')
  26 +const CONFIG_FILE = join(PROJECT_ROOT, 'config-vars.yaml')
  27 +const MIGRATIONS_DIR = join(PROJECT_ROOT, 'sql', 'migrations')
  28 +const LOCK_DIR = join(PROJECT_ROOT, '.tmp', 'erp-source-promote.lock')
  29 +const LOG = '[promote-to-source]'
  30 +const LOCK_STALE_MS = 10 * 60 * 1000 // 持锁 >10min 视为崩溃残留,可窃取
  31 +const LOCK_WAIT_MS = 3 * 60 * 1000 // 等锁上限
  32 +
  33 +// 极简 YAML 读取(2 层 map + 标量;与 scripts/setup-test-db.mjs 同规则,内联以免运行时依赖)。
  34 +function parseScalar(raw) {
  35 + let s = String(raw).trim()
  36 + if (s === '' || s[0] === '#') return ''
  37 + const q = s[0]
  38 + if (q === '"' || q === "'") {
  39 + const end = s.indexOf(q, 1)
  40 + if (end !== -1) return s.slice(1, end)
  41 + }
  42 + const hash = s.indexOf(' #')
  43 + if (hash !== -1) s = s.slice(0, hash).trim()
  44 + return s
  45 +}
  46 +function parseYamlConfig(text) {
  47 + const root = {}
  48 + let section = null
  49 + for (const rawLine of text.split('\n')) {
  50 + const line = rawLine.replace(/\r$/, '')
  51 + const trimmed = line.trim()
  52 + if (trimmed === '' || trimmed[0] === '#') continue
  53 + const colon = line.indexOf(':')
  54 + if (colon === -1) continue
  55 + const key = line.slice(0, colon).trim()
  56 + if (key === '') continue
  57 + const indent = line.length - line.replace(/^\s+/, '').length
  58 + const value = parseScalar(line.slice(colon + 1))
  59 + if (indent === 0) {
  60 + if (value === '') { section = {}; root[key] = section }
  61 + else { root[key] = value; section = null }
  62 + } else if (section) { section[key] = value }
  63 + else { root[key] = value }
  64 + }
  65 + return root
  66 +}
  67 +
  68 +function quoteSqlString(value) {
  69 + return "'" + String(value).replaceAll('\\', '\\\\').replaceAll("'", "''") + "'"
  70 +}
  71 +function quoteMySqlIdent(value) {
  72 + return '`' + String(value).replaceAll('`', '``') + '`'
  73 +}
  74 +const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  75 +
  76 +if (!existsSync(CONFIG_FILE)) {
  77 + console.error(`${LOG} config-vars.yaml 不存在(${CONFIG_FILE})`)
  78 + process.exit(1)
  79 +}
  80 +
  81 +const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {}
  82 +
  83 +function readLaneDbMarker() {
  84 + const markerFile = join(PROJECT_ROOT, '.tmp', 'erp-lane-db')
  85 + if (!existsSync(markerFile)) return ''
  86 + return readFileSync(markerFile, 'utf8').split('\n')[0].trim()
  87 +}
  88 +
  89 +const DB_HOST = db.host ?? ''
  90 +const DB_PORT = db.port ?? '3306'
  91 +const DB_USER = db.user ?? ''
  92 +const DB_PASSWORD = db.password ?? ''
  93 +const SOURCE_SCHEMA = db.schema ?? ''
  94 +
  95 +function rejectPlaceholder(key, value) {
  96 + if (typeof value === 'string' && value.includes('【人工填写')) {
  97 + console.error(`${LOG} database.${key} 仍是占位,请先在 config-vars.yaml 填真实值`)
  98 + process.exit(1)
  99 + }
  100 +}
  101 +for (const [key, value] of [['host', DB_HOST], ['port', DB_PORT], ['user', DB_USER], ['password', DB_PASSWORD], ['schema', SOURCE_SCHEMA]]) {
  102 + rejectPlaceholder(key, value)
  103 +}
  104 +if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) {
  105 + console.error(`${LOG} database.port 非法: ${DB_PORT}`)
  106 + process.exit(1)
  107 +}
  108 +if (String(SOURCE_SCHEMA).trim() === '') {
  109 + console.error(`${LOG} database.schema(源库名)未填`)
  110 + process.exit(1)
  111 +}
  112 +
  113 +const COPY_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || readLaneDbMarker() || `${SOURCE_SCHEMA}__test`
  114 +if (COPY_SCHEMA === SOURCE_SCHEMA) {
  115 + console.error(`${LOG} 拒绝:副本名与源库名相同(${SOURCE_SCHEMA})——无 delta 可取。`)
  116 + process.exit(1)
  117 +}
  118 +
  119 +const CONN_ARGS = [`--host=${DB_HOST}`, `--port=${DB_PORT}`, `--user=${DB_USER}`, `--password=${DB_PASSWORD}`]
  120 +
  121 +// 查询型调用:mysql -N -B(tab 分隔、无表头、NULL 打印为字面 "NULL")。
  122 +function mysqlQuery(sql) {
  123 + return spawnSync('mysql', [...CONN_ARGS, '-N', '-B', '-e', sql], { encoding: 'utf8' })
  124 +}
  125 +// 应用型调用:SQL 喂 stdin,目标库作位置参数(--comments 防剥注释)。
  126 +function mysqlApply(targetSchema, sqlText) {
  127 + return spawnSync('mysql', [...CONN_ARGS, '--comments', targetSchema], { input: sqlText, encoding: 'utf8' })
  128 +}
  129 +function fatal(res, label) {
  130 + if (res.error) {
  131 + console.error(`${LOG} FATAL: 无法执行 mysql(请确认其在 PATH 中): ${res.error.message}`)
  132 + releaseLock()
  133 + process.exit(1)
  134 + }
  135 + if (res.status !== 0) {
  136 + if (res.stderr) process.stderr.write(res.stderr)
  137 + console.error(`${LOG} FAIL (${label}): mysql exit=${res.status}`)
  138 + releaseLock()
  139 + process.exit(res.status === null ? 1 : res.status)
  140 + }
  141 +}
  142 +
  143 +// ── 文件锁:兜底同一 worktree 内重复运行(跨 lane 串行由编排层 withStackLock 保证,见文件头) ──
  144 +let lockHeld = false
  145 +function releaseLock() {
  146 + if (lockHeld) {
  147 + try { rmSync(LOCK_DIR, { recursive: true, force: true }) } catch { /* ignore */ }
  148 + lockHeld = false
  149 + }
  150 +}
  151 +async function acquireLock() {
  152 + mkdirSync(join(PROJECT_ROOT, '.tmp'), { recursive: true })
  153 + const deadline = Date.now() + LOCK_WAIT_MS
  154 + for (;;) {
  155 + try {
  156 + mkdirSync(LOCK_DIR) // 原子:已存在即抛 EEXIST
  157 + lockHeld = true
  158 + return
  159 + } catch (e) {
  160 + if (e?.code !== 'EEXIST') throw e
  161 + // 陈旧锁(持有者疑似崩溃)→ 窃取
  162 + try {
  163 + if (Date.now() - statSync(LOCK_DIR).mtimeMs > LOCK_STALE_MS) {
  164 + rmSync(LOCK_DIR, { recursive: true, force: true })
  165 + continue
  166 + }
  167 + } catch { /* 锁刚被别人释放,重试 */ }
  168 + if (Date.now() > deadline) {
  169 + console.error(`${LOG} 等源库晋升锁超时(${LOCK_WAIT_MS}ms)——疑有其它晋升卡住,请检查 ${LOCK_DIR}`)
  170 + process.exit(1)
  171 + }
  172 + await sleep(300)
  173 + }
  174 + }
  175 +}
  176 +
  177 +// ── 主流程 ──
  178 +await acquireLock()
  179 +try {
  180 + // 源库未被 Flyway 管理 → 无从判定 delta,跳过(不因晋升让已绿的测试闸失败)。
  181 + const srcHasHistory = mysqlQuery(
  182 + `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ${quoteSqlString(SOURCE_SCHEMA)} AND table_name = 'flyway_schema_history'`,
  183 + )
  184 + fatal(srcHasHistory, 'check-source-history')
  185 + if (srcHasHistory.stdout.trim() !== '1') {
  186 + console.log(`${LOG} 源库 ${SOURCE_SCHEMA} 未被 Flyway 管理(无 flyway_schema_history),跳过晋升。`)
  187 + releaseLock()
  188 + process.exit(0)
  189 + }
  190 +
  191 + // 副本无 history(本轮未起后端 / 无迁移)→ 无 delta。
  192 + const copyHasHistory = mysqlQuery(
  193 + `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ${quoteSqlString(COPY_SCHEMA)} AND table_name = 'flyway_schema_history'`,
  194 + )
  195 + fatal(copyHasHistory, 'check-copy-history')
  196 + if (copyHasHistory.stdout.trim() !== '1') {
  197 + console.log(`${LOG} 副本 ${COPY_SCHEMA} 无 flyway_schema_history,无迁移可晋升。`)
  198 + releaseLock()
  199 + process.exit(0)
  200 + }
  201 +
  202 + // delta = 副本有、源库没有的版本行(success=1,按 apply 顺序)。
  203 + const deltaRes = mysqlQuery(
  204 + `SELECT c.version, c.description, c.type, c.script, c.checksum, c.execution_time ` +
  205 + `FROM ${quoteMySqlIdent(COPY_SCHEMA)}.flyway_schema_history c ` +
  206 + `WHERE c.version IS NOT NULL AND c.success = 1 ` +
  207 + `AND NOT EXISTS (SELECT 1 FROM ${quoteMySqlIdent(SOURCE_SCHEMA)}.flyway_schema_history s WHERE s.version = c.version) ` +
  208 + `ORDER BY c.installed_rank`,
  209 + )
  210 + fatal(deltaRes, 'query-delta')
  211 +
  212 + const rows = deltaRes.stdout.split('\n').map((l) => l.trimEnd()).filter((l) => l !== '')
  213 + if (rows.length === 0) {
  214 + console.log(`${LOG} 源库 ${SOURCE_SCHEMA} 已含副本全部迁移,无新迁移可晋升。`)
  215 + releaseLock()
  216 + process.exit(0)
  217 + }
  218 +
  219 + console.log(`${LOG} 待晋升到源库 ${SOURCE_SCHEMA} 的新迁移 ${rows.length} 个(副本 ${COPY_SCHEMA} → 源库)`)
  220 +
  221 + let promoted = 0
  222 + for (const line of rows) {
  223 + const [version, description, type, script, checksum, execTime] = line.split('\t')
  224 + const migFile = join(MIGRATIONS_DIR, script)
  225 + if (!existsSync(migFile)) {
  226 + console.error(`${LOG} 迁移文件不存在,无法晋升: sql/migrations/${script}(version=${version})`)
  227 + releaseLock()
  228 + process.exit(1)
  229 + }
  230 +
  231 + // 锁内二次核对(防并发已被别的进程晋升)。
  232 + const already = mysqlQuery(
  233 + `SELECT COUNT(*) FROM ${quoteMySqlIdent(SOURCE_SCHEMA)}.flyway_schema_history WHERE version = ${quoteSqlString(version)}`,
  234 + )
  235 + fatal(already, 'recheck-version')
  236 + if (already.stdout.trim() !== '0') {
  237 + console.log(`${LOG} 跳过(源库已有): V${version} ${script}`)
  238 + continue
  239 + }
  240 +
  241 + // checksum / execution_time 可能为 NULL(-B 打印字面 "NULL")。
  242 + const checksumSql = (checksum === undefined || checksum === 'NULL' || checksum === '') ? 'NULL' : String(Number(checksum))
  243 + const execSql = (execTime === undefined || execTime === 'NULL' || execTime === '') ? '0' : String(Number(execTime))
  244 +
  245 + // 一次调用内:重放迁移 DDL + 移植历史行(mysql 批处理遇错即停,DDL 失败则历史行不落)。
  246 + const ddl = readFileSync(migFile, 'utf8').trimEnd()
  247 + const sep = /;$/.test(ddl) ? '\n' : ';\n'
  248 + const insert =
  249 + `INSERT INTO flyway_schema_history ` +
  250 + `(installed_rank, version, description, type, script, checksum, installed_by, installed_on, execution_time, success) ` +
  251 + `SELECT COALESCE(MAX(installed_rank), 0) + 1, ${quoteSqlString(version)}, ${quoteSqlString(description ?? '')}, ` +
  252 + `${quoteSqlString(type || 'SQL')}, ${quoteSqlString(script)}, ${checksumSql}, 'promote', NOW(), ${execSql}, 1 ` +
  253 + `FROM flyway_schema_history;`
  254 +
  255 + console.log(`${LOG} 晋升: V${version} ${script}`)
  256 + fatal(mysqlApply(SOURCE_SCHEMA, `${ddl}${sep}${insert}`), `promote V${version}`)
  257 + promoted += 1
  258 + }
  259 +
  260 + console.log(`${LOG} done — 晋升 ${promoted} 个迁移到源库 ${SOURCE_SCHEMA}`)
  261 +} finally {
  262 + releaseLock()
  263 +}
... ...
skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs
1 1 #!/usr/bin/env node
2 2 // scripts/seed-demo-data.mjs —— 演示假数据(demo seed)的注入脚本。
3 3 //
4   -// 用途(四个调用方,时序均为:空库重建 → 起后端让 Flyway 建 schema → 本脚本注入):
5   -// 1) 前端 e2e(Playwright)globalSetup —— e2e 基线 = 空库 + Flyway schema + 演示种子;
  4 +// 目标库 = 一次性**副本库**(ERP_TEST_DB_SCHEMA / marker / `<source>__test`)——副本由 scripts/setup-test-db.mjs
  5 +// 从源库复制而来,已带源库全部数据 + 其 _demo_seed_history 账本,故源库里已灌过的种子在此**自动跳过**,本脚本
  6 +// 只把源库还没有的新种子文件灌进副本。绝不写源库。
  7 +//
  8 +// 用途(四个调用方,时序均为:复制源库→副本 → 起后端让 Flyway apply 本轮新迁移 → 本脚本注入新种子):
  9 +// 1) 前端 e2e(Playwright)globalSetup —— e2e 基线 = 源库副本 + 本轮新迁移 + 补充演示种子;
6 10 // 2) coding.mjs 行为门 step2.3 —— 行为验收前注入演示数据;
7 11 // 3) 里程碑后人工验收 / 演示 —— 手动跑一次即可复现演示态;
8 12 // 4) coding.mjs Seed stage —— 模块种子生成后冷起栈真跑验证。
9 13 //
10   -// 前提:schema 必须已由 Flyway 在 Spring Boot 启动时建好——本脚本绝不建 schema,只灌数据。
  14 +// 前提:schema 必须已存在(副本自带源库 schema + Flyway 历史)——本脚本绝不建 schema,只灌数据。
11 15 // 幂等机制:已应用的种子文件记入账本表 _demo_seed_history(file 为主键),再次运行自动跳过。
12 16 // 主键区间约定:1–999=初始数据(admin_init 等)/ 1000–9999=演示种子 / ≥100000=行为门 sentinel。
  17 +// 注意(新模型):副本继承源库真实数据——若源库在 1000–9999 区间已有非种子行,新种子应避开已占主键。
13 18 //
14 19 // DB 凭据从仓库根 config-vars.yaml 的 database: 段读取;host / user / password 信任该文件,port 仅校验范围。
15 20 // 纯 mysql CLI(spawnSync),零 npm 依赖。退出码:0 成功(含全跳过 / 无文件),1 失败。
... ... @@ -86,9 +91,9 @@ if (!existsSync(CONFIG_FILE)) {
86 91  
87 92 const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {}
88 93  
89   -// lane 兜底 marker:并行波次执行器建好 lane worktree 后在仓库根写 .tmp/erp-lane-db(一行 lane schema 名)。
90   -// 与 scripts/setup-test-db.mjs 同一约定:取值顺序 env > marker > config——提示词链丢前缀时
91   -// 种子仍确定性灌进 lane 库,不污染共享库;主根无 marker 且 env 缺省时走 config,串行行为一字不变。
  94 +// lane 兜底 marker:并行波次执行器建好 lane worktree 后在仓库根写 .tmp/erp-lane-db(一行 lane 副本库名)。
  95 +// 与 scripts/setup-test-db.mjs 同一约定:目标 = 副本库,取值顺序 env > marker > `<source>__test`——提示词链
  96 +// 丢前缀时种子仍确定性灌进 lane 副本,绝不污染源库(COPY≠SOURCE 硬守卫兜底);串行缺省灌 `<source>__test`。
92 97 function readLaneDbMarker() {
93 98 const markerFile = join(PROJECT_ROOT, '.tmp', 'erp-lane-db')
94 99 if (!existsSync(markerFile)) return ''
... ... @@ -99,9 +104,11 @@ const DB_HOST = db.host ?? &#39;&#39;
99 104 const DB_PORT = db.port ?? '3306'
100 105 const DB_USER = db.user ?? ''
101 106 const DB_PASSWORD = db.password ?? ''
102   -// 校验逻辑沿用,作用于生效值。
  107 +// SOURCE = 源库(只用于派生副本缺省名,绝不被本脚本写入)。
  108 +const SOURCE_SCHEMA = db.schema ?? ''
  109 +// DB_SCHEMA = 注入目标副本库。取值顺序 env > marker > `<source>__test`。
103 110 const LANE_MARKER_SCHEMA = readLaneDbMarker()
104   -const DB_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || LANE_MARKER_SCHEMA || (db.schema ?? '')
  111 +const DB_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || LANE_MARKER_SCHEMA || `${SOURCE_SCHEMA}__test`
105 112 // marker 是不可见的兜底路径(env 前缀在命令行上自明),生效时必须留痕便于排查指错库。
106 113 if (!process.env.ERP_TEST_DB_SCHEMA && LANE_MARKER_SCHEMA) {
107 114 console.log(`${LOG} lane DB marker 生效: ${LANE_MARKER_SCHEMA}`)
... ... @@ -114,7 +121,7 @@ function rejectPlaceholder(key, value) {
114 121 }
115 122 }
116 123  
117   -for (const [key, value] of [['host', DB_HOST], ['port', DB_PORT], ['user', DB_USER], ['password', DB_PASSWORD], ['schema', DB_SCHEMA]]) {
  124 +for (const [key, value] of [['host', DB_HOST], ['port', DB_PORT], ['user', DB_USER], ['password', DB_PASSWORD], ['schema', SOURCE_SCHEMA]]) {
118 125 rejectPlaceholder(key, value)
119 126 }
120 127  
... ... @@ -123,8 +130,14 @@ if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) &lt;= 0 || Number(DB_PORT) &gt; 65535) {
123 130 process.exit(1)
124 131 }
125 132  
126   -if (String(DB_SCHEMA).trim() === '') {
127   - console.error(`${LOG} database.schema 未填`)
  133 +if (String(SOURCE_SCHEMA).trim() === '') {
  134 + console.error(`${LOG} database.schema(源库名)未填`)
  135 + process.exit(1)
  136 +}
  137 +
  138 +// **安全红线**:注入目标绝不能是源库——演示种子只灌一次性副本。
  139 +if (DB_SCHEMA === SOURCE_SCHEMA) {
  140 + console.error(`${LOG} 拒绝:注入目标副本名与源库名相同(${SOURCE_SCHEMA})——演示种子绝不写源库。`)
128 141 process.exit(1)
129 142 }
130 143  
... ... @@ -198,7 +211,7 @@ const flywayCheck = mysqlQuery(
198 211 )
199 212 fatalMysql(flywayCheck, 'check-flyway')
200 213 if (flywayCheck.stdout.trim() !== '1') {
201   - console.error(`${LOG} schema 未初始化(${DB_SCHEMA} 中找不到 flyway_schema_history)——请先起后端让 Flyway 建 schema,再注入种子`)
  214 + console.error(`${LOG} 副本 schema 未初始化(${DB_SCHEMA} 中找不到 flyway_schema_history)——请先跑 setup-test-db 复制源库副本并起后端(Flyway apply 迁移),再注入种子`)
202 215 process.exit(1)
203 216 }
204 217  
... ...
skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs
1 1 #!/usr/bin/env node
2   -// scripts/setup-test-db.mjs — DROP + CREATE 开发/沙盒库。
3   -// 由 coding.mjs 的 test-gate 调用;schema 由 Flyway 在 Spring Boot 启动时重放。
4   -// DB 凭据从仓库根 config-vars.yaml 的 database: 段读取;host / user / password 信任该文件,port 仅校验范围。
  2 +// scripts/setup-test-db.mjs —— 准备一个**一次性测试副本库**(不再 DROP 源库)。
  3 +//
  4 +// 新模型(源库永不被测试 drop):
  5 +// SOURCE = config-vars.yaml database.schema —— 真实持久库,只被复制(只读 mysqldump)、绝不 drop。
  6 +// COPY = 本次测试的一次性副本库(跑完由 scripts/drop-test-db.mjs 删除)。
  7 +// 本脚本 = DROP IF EXISTS <copy> → CREATE <copy> → mysqldump <source> | mysql <copy>。
  8 +// 副本带着源库当时的**全部 schema + 数据 + flyway_schema_history**;起后端时 Flyway out-of-order
  9 +// 只把源库还没有的**本轮新迁移**apply 到副本(历史里已有的自动跳过,绝不重建、绝不碰源库)。
  10 +// 绿后由 scripts/promote-to-source.mjs 把这些新迁移晋升到源库。
  11 +//
  12 +// 由 coding.mjs 的 test-gate / seed / 行为门调用。DB 凭据从仓库根 config-vars.yaml 的 database: 段读取;
  13 +// host / user / password 信任该文件,port 仅校验范围。纯 mysql/mysqldump CLI(spawnSync),零 npm 依赖。
5 14  
6 15 import { spawnSync } from 'node:child_process'
7 16 import { existsSync, readFileSync } from 'node:fs'
... ... @@ -61,9 +70,9 @@ if (!existsSync(CONFIG_FILE)) {
61 70  
62 71 const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {}
63 72  
64   -// lane 兜底 marker:并行波次执行器建好 lane worktree 后在仓库根写 .tmp/erp-lane-db(一行 lane schema 名)。
65   -// lane 注入是 prompt-only(提示词链两跳,任何一跳丢 ERP_TEST_DB_SCHEMA 前缀即对共享库 DROP+CREATE),
66   -// 本脚本是最具毁灭性的操作,必须有确定性兜底;主根无 marker 且 env 缺省时走 config,串行行为一字不变。
  73 +// lane 兜底 marker:并行波次执行器建好 lane worktree 后在仓库根写 .tmp/erp-lane-db(一行 lane 副本库名)。
  74 +// 副本名注入是 prompt-only(提示词链两跳),任何一跳丢 ERP_TEST_DB_SCHEMA 前缀即回退到串行缺省副本名,
  75 +// 绝不会指向源库(COPY≠SOURCE 硬守卫兜底);主根无 marker 且 env 缺省时走串行缺省副本名。
67 76 function readLaneDbMarker() {
68 77 const markerFile = join(SCRIPT_DIR, '..', '.tmp', 'erp-lane-db')
69 78 if (!existsSync(markerFile)) return ''
... ... @@ -74,13 +83,8 @@ const DB_HOST = db.host ?? &#39;&#39;
74 83 const DB_PORT = db.port ?? '3306'
75 84 const DB_USER = db.user ?? ''
76 85 const DB_PASSWORD = db.password ?? ''
77   -// 取值顺序 env > marker > config(并行 lane 化;校验逻辑沿用,作用于生效值)。
78   -const LANE_MARKER_SCHEMA = readLaneDbMarker()
79   -const DB_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || LANE_MARKER_SCHEMA || (db.schema ?? '')
80   -// marker 是不可见的兜底路径(env 前缀在命令行上自明),生效时必须留痕便于排查指错库。
81   -if (!process.env.ERP_TEST_DB_SCHEMA && LANE_MARKER_SCHEMA) {
82   - console.log(`[setup-test-db] lane DB marker 生效: ${LANE_MARKER_SCHEMA}`)
83   -}
  86 +// SOURCE = 真实持久库(只被复制,绝不 drop)。
  87 +const SOURCE_SCHEMA = db.schema ?? ''
84 88  
85 89 function rejectPlaceholder(key, value) {
86 90 if (typeof value === 'string' && value.includes('【人工填写')) {
... ... @@ -89,7 +93,7 @@ function rejectPlaceholder(key, value) {
89 93 }
90 94 }
91 95  
92   -for (const [key, value] of [['host', DB_HOST], ['port', DB_PORT], ['user', DB_USER], ['password', DB_PASSWORD], ['schema', DB_SCHEMA]]) {
  96 +for (const [key, value] of [['host', DB_HOST], ['port', DB_PORT], ['user', DB_USER], ['password', DB_PASSWORD], ['schema', SOURCE_SCHEMA]]) {
93 97 rejectPlaceholder(key, value)
94 98 }
95 99  
... ... @@ -98,39 +102,64 @@ if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) &lt;= 0 || Number(DB_PORT) &gt; 65535) {
98 102 process.exit(1)
99 103 }
100 104  
101   -if (String(DB_SCHEMA).trim() === '') {
102   - console.error('[setup-test-db] database.schema 未填')
  105 +if (String(SOURCE_SCHEMA).trim() === '') {
  106 + console.error('[setup-test-db] database.schema(源库名)未填')
103 107 process.exit(1)
104 108 }
105 109  
106   -function quoteMySqlIdent(value) {
107   - return '`' + String(value).replaceAll('`', '``') + '`'
  110 +// COPY = 一次性副本库名。取值顺序 env > marker > `<source>__test`(串行缺省)。
  111 +const LANE_MARKER_SCHEMA = readLaneDbMarker()
  112 +const COPY_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || LANE_MARKER_SCHEMA || `${SOURCE_SCHEMA}__test`
  113 +// marker 是不可见的兜底路径(env 前缀在命令行上自明),生效时必须留痕便于排查指错库。
  114 +if (!process.env.ERP_TEST_DB_SCHEMA && LANE_MARKER_SCHEMA) {
  115 + console.log(`[setup-test-db] lane DB marker 生效: ${LANE_MARKER_SCHEMA}`)
108 116 }
109 117  
110   -const DB_SCHEMA_SQL = quoteMySqlIdent(DB_SCHEMA)
111   -
112   -console.log(`[setup-test-db] 即将 DROP + CREATE ${DB_SCHEMA_SQL} on ${DB_HOST}:${DB_PORT}`)
113   -
114   -const sql =
115   - `DROP DATABASE IF EXISTS ${DB_SCHEMA_SQL}; ` +
116   - `CREATE DATABASE ${DB_SCHEMA_SQL} CHARACTER SET utf8 COLLATE utf8_general_ci;`
117   -
118   -const mysqlArgs = [
119   - `--host=${DB_HOST}`,
120   - `--port=${DB_PORT}`,
121   - `--user=${DB_USER}`,
122   - `--password=${DB_PASSWORD}`,
123   - '-e',
124   - sql,
125   -]
126   -const res = spawnSync('mysql', mysqlArgs, { stdio: 'inherit' })
127   -if (res.error) {
128   - console.error(`[setup-test-db] FATAL: 无法执行 mysql(请确认其在 PATH 中): ${res.error.message}`)
  118 +// **安全红线**:副本名绝不能等于源库名——否则 DROP <copy> 就是 DROP 源库。任何一跳把 env/marker
  119 +// 误设成源库名、或源库名恰好等于串行缺省副本名,都在此拦下(本脚本是唯一会 DROP 的地方)。
  120 +if (COPY_SCHEMA === SOURCE_SCHEMA) {
  121 + console.error(`[setup-test-db] 拒绝:副本库名与源库名相同(${SOURCE_SCHEMA})——绝不能 DROP 源库。请把 ERP_TEST_DB_SCHEMA / .tmp/erp-lane-db 设成不同于 database.schema 的名字。`)
129 122 process.exit(1)
130 123 }
131   -if (res.status !== 0) {
132   - console.error(`[setup-test-db] FAIL: mysql exit=${res.status}`)
133   - process.exit(res.status === null ? 1 : res.status)
  124 +
  125 +function quoteMySqlIdent(value) {
  126 + return '`' + String(value).replaceAll('`', '``') + '`'
  127 +}
  128 +
  129 +const COPY_SQL = quoteMySqlIdent(COPY_SCHEMA)
  130 +
  131 +console.log(`[setup-test-db] 复制源库 ${SOURCE_SCHEMA} → 一次性副本 ${COPY_SCHEMA} on ${DB_HOST}:${DB_PORT}(源库只读,绝不 drop)`)
  132 +
  133 +const CONN_ARGS = [`--host=${DB_HOST}`, `--port=${DB_PORT}`, `--user=${DB_USER}`, `--password=${DB_PASSWORD}`]
  134 +
  135 +function fatal(res, label) {
  136 + if (res.error) {
  137 + console.error(`[setup-test-db] FATAL: 无法执行 ${label}(请确认其在 PATH 中): ${res.error.message}`)
  138 + process.exit(1)
  139 + }
  140 + if (res.status !== 0) {
  141 + if (res.stderr) process.stderr.write(res.stderr)
  142 + console.error(`[setup-test-db] FAIL (${label}): exit=${res.status}`)
  143 + process.exit(res.status === null ? 1 : res.status)
  144 + }
134 145 }
135 146  
136   -console.log('[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts')
  147 +// ① 重建空的副本库(只 DROP 副本;源库从不出现在 DROP 语句里)。
  148 +const createSql =
  149 + `DROP DATABASE IF EXISTS ${COPY_SQL}; ` +
  150 + `CREATE DATABASE ${COPY_SQL} CHARACTER SET utf8 COLLATE utf8_general_ci;`
  151 +fatal(spawnSync('mysql', [...CONN_ARGS, '-e', createSql], { stdio: 'inherit' }), 'mysql create-copy')
  152 +
  153 +// ② mysqldump 源库(只读、一致快照)→ 灌进副本。argv 传参(不走 shell,密码不被 shell 暴露/需转义);
  154 +// 副本数据量通常是演示种子级别,dump 走内存 buffer 即可(大库可改为落临时文件流式导入)。
  155 +const dump = spawnSync(
  156 + 'mysqldump',
  157 + [...CONN_ARGS, '--single-transaction', '--no-tablespaces', '--routines', '--triggers', '--events', '--set-gtid-purged=OFF', SOURCE_SCHEMA],
  158 + { maxBuffer: 1024 * 1024 * 1024 },
  159 +)
  160 +fatal(dump, 'mysqldump source')
  161 +
  162 +const load = spawnSync('mysql', [...CONN_ARGS, COPY_SCHEMA], { input: dump.stdout })
  163 +fatal(load, 'mysql load-copy')
  164 +
  165 +console.log(`[setup-test-db] done — 副本 ${COPY_SCHEMA} 已就绪(含源库 schema+数据+flyway 历史);起后端时 Flyway 仅 apply 本轮新迁移`)
... ...
skills/plan/skeleton-gen/templates/scripts-test-template.mjs
1 1 #!/usr/bin/env node
2 2 // scripts/test.mjs —— 合并到默认分支(main / master)前的测试闸门。
3   -// 顺序:detect → setup-db → build → lint → unit+integration → e2e
4   -// (不在尾部 reset:下次跑的 setup-db 会 DROP+CREATE,重复清库无意义)
  3 +// 顺序:detect → setup-db(复制源库→副本)→ build → lint → unit+integration → e2e → 绿则晋升新迁移到源库
  4 +// → finally 删副本(无论红绿;源库绝不被 drop)
5 5 // 由 coding.mjs 的 test-gate stage(通过子会话)调用。
6 6 //
7 7 // 跨平台:所有命令经 child_process.spawnSync(cmd, { shell:true }) 执行,
... ... @@ -14,12 +14,33 @@ import { dirname, join } from &#39;node:path&#39;
14 14 import { fileURLToPath } from 'node:url'
15 15  
16 16 const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
  17 +const CONFIG_FILE = join(PROJECT_ROOT, 'config-vars.yaml')
17 18  
18   -// 测试 DB lane 化:本闸门自身不连库,schema 经 ERP_TEST_DB_SCHEMA env 贯通到全部子进程——
19   -// setup-test-db.mjs 直读该 env;gradle test fork 的 JVM 与 e2e globalSetup 起的 bootRun 默认继承 env,
20   -// 由后端 application*.yml 的 ${ERP_TEST_DB_SCHEMA:<缺省 schema>} 占位消费(锁定约定见 docs/04 §一)。
21   -// env 缺省时读 lane 兜底 marker(.tmp/erp-lane-db,并行波次执行器建 lane worktree 后写入)回填 process.env,
22   -// 保证提示词链丢前缀时整个闸门仍确定性指向 lane 库;主根无 marker,串行行为一字不变。
  19 +// 从 config-vars.yaml 只取 database.schema(源库名)——best-effort,缺文件/缺值返回空串。
  20 +function readSourceSchema() {
  21 + if (!existsSync(CONFIG_FILE)) return ''
  22 + let inDatabase = false
  23 + for (const raw of readFileSync(CONFIG_FILE, 'utf8').split('\n')) {
  24 + const line = raw.replace(/\r$/, '')
  25 + if (line.trim() === '' || line.trim()[0] === '#') continue
  26 + const indent = line.length - line.replace(/^\s+/, '').length
  27 + if (indent === 0) { inDatabase = /^database\s*:/.test(line.trim()); continue }
  28 + if (!inDatabase) continue
  29 + const m = line.match(/^\s+schema\s*:\s*(.+?)\s*$/)
  30 + if (m) {
  31 + let v = m[1].trim()
  32 + if (v[0] === '"' || v[0] === "'") { const e = v.indexOf(v[0], 1); if (e !== -1) v = v.slice(1, e) }
  33 + else { const h = v.indexOf(' #'); if (h !== -1) v = v.slice(0, h).trim() }
  34 + return v
  35 + }
  36 + }
  37 + return ''
  38 +}
  39 +
  40 +// 测试 DB = 一次性**副本库**(源库永不被本闸门 drop)。副本名经 ERP_TEST_DB_SCHEMA env 贯通到全部子进程——
  41 +// setup/drop/promote 脚本直读该 env;gradle test fork 的 JVM 与 e2e globalSetup 起的 bootRun 默认继承 env,
  42 +// 由后端 application*.yml 的 ${ERP_TEST_DB_SCHEMA:<source>__test} 占位消费(锁定约定见 docs/04 §一)。
  43 +// env 缺省时读 lane 兜底 marker(.tmp/erp-lane-db,并行波次执行器建 lane worktree 后写入)回填 process.env。
23 44 if (!process.env.ERP_TEST_DB_SCHEMA) {
24 45 const markerFile = join(PROJECT_ROOT, '.tmp', 'erp-lane-db')
25 46 if (existsSync(markerFile)) {
... ... @@ -30,19 +51,27 @@ if (!process.env.ERP_TEST_DB_SCHEMA) {
30 51 }
31 52 }
32 53 }
  54 +// 串行缺省:仍无 env → 显式派生 `<source>__test` 并导出到所有子进程。这一步是安全关键——
  55 +// 即便后端 application*.yml 占位缺省万一回退到源库,此处显式 env 也会把它覆写到副本,测试绝不触碰源库。
  56 +if (!process.env.ERP_TEST_DB_SCHEMA) {
  57 + const src = readSourceSchema()
  58 + if (src !== '') {
  59 + process.env.ERP_TEST_DB_SCHEMA = `${src}__test`
  60 + console.log(`[test.mjs] 串行缺省副本库: ERP_TEST_DB_SCHEMA=${src}__test`)
  61 + }
  62 +}
33 63  
34   -// 在指定子目录下跑一条 shell 命令;非零退出码即终止整个闸门并透传该码。
  64 +// 闸门失败类错误:携带退出码,供顶层在跑完 finally(删副本)后透传。
  65 +class GateError extends Error {
  66 + constructor(message, code) { super(message); this.code = code }
  67 +}
  68 +
  69 +// 在指定子目录下跑一条 shell 命令;非零退出码即抛 GateError(由顶层 finally 兜底删副本后透传码)。
35 70 function run(label, command, cwd = PROJECT_ROOT) {
36 71 console.log(`[test.mjs] ${label}: ${command}`)
37 72 const res = spawnSync(command, { cwd, shell: true, stdio: 'inherit' })
38   - if (res.error) {
39   - console.error(`[test.mjs] FATAL: 无法执行 (${label}): ${res.error.message}`)
40   - process.exit(1)
41   - }
42   - if (res.status !== 0) {
43   - console.error(`[test.mjs] FAIL (${label}) exit=${res.status}`)
44   - process.exit(res.status === null ? 1 : res.status)
45   - }
  73 + if (res.error) throw new GateError(`无法执行 (${label}): ${res.error.message}`, 1)
  74 + if (res.status !== 0) throw new GateError(`FAIL (${label}) exit=${res.status}`, res.status === null ? 1 : res.status)
46 75 }
47 76  
48 77 // Stack detection (runtime, mode-agnostic)
... ... @@ -55,29 +84,50 @@ if (!hasBackend &amp;&amp; !hasFrontend) {
55 84  
56 85 const backendDir = join(PROJECT_ROOT, 'backend')
57 86 const frontendDir = join(PROJECT_ROOT, 'frontend')
  87 +const nodeScript = (name) => `node ${JSON.stringify(join('scripts', name))}`
  88 +
  89 +let failure = null
  90 +try {
  91 + console.log('[test.mjs] 1/5 setup test db(复制源库 → 一次性副本)')
  92 + run('setup-test-db', nodeScript('setup-test-db.mjs'))
58 93  
59   -console.log('[test.mjs] 1/5 setup test db')
60   -run('setup-test-db', `node ${JSON.stringify(join('scripts', 'setup-test-db.mjs'))}`)
  94 + console.log('[test.mjs] 2/5 build')
  95 + if (hasBackend) run('backend build', '{{backend_build}}', backendDir)
  96 + else console.log('[test.mjs] skip backend build')
  97 + if (hasFrontend) run('frontend build', '{{frontend_build}}', frontendDir)
  98 + else console.log('[test.mjs] skip frontend build')
61 99  
62   -console.log('[test.mjs] 2/5 build')
63   -if (hasBackend) run('backend build', '{{backend_build}}', backendDir)
64   -else console.log('[test.mjs] skip backend build')
65   -if (hasFrontend) run('frontend build', '{{frontend_build}}', frontendDir)
66   -else console.log('[test.mjs] skip frontend build')
  100 + console.log('[test.mjs] 3/5 lint')
  101 + if (hasBackend) run('backend lint', '{{backend_lint}}', backendDir)
  102 + else console.log('[test.mjs] skip backend lint')
  103 + if (hasFrontend) run('frontend lint', '{{frontend_lint}}', frontendDir)
  104 + else console.log('[test.mjs] skip frontend lint')
67 105  
68   -console.log('[test.mjs] 3/5 lint')
69   -if (hasBackend) run('backend lint', '{{backend_lint}}', backendDir)
70   -else console.log('[test.mjs] skip backend lint')
71   -if (hasFrontend) run('frontend lint', '{{frontend_lint}}', frontendDir)
72   -else console.log('[test.mjs] skip frontend lint')
  106 + console.log('[test.mjs] 4/5 unit + integration')
  107 + if (hasBackend) run('backend test', '{{backend_test}}', backendDir)
  108 + else console.log('[test.mjs] skip backend test')
  109 + if (hasFrontend) run('frontend test', '{{frontend_test}}', frontendDir)
  110 + else console.log('[test.mjs] skip frontend test')
73 111  
74   -console.log('[test.mjs] 4/5 unit + integration')
75   -if (hasBackend) run('backend test', '{{backend_test}}', backendDir)
76   -else console.log('[test.mjs] skip backend test')
77   -if (hasFrontend) run('frontend test', '{{frontend_test}}', frontendDir)
78   -else console.log('[test.mjs] skip frontend test')
  112 + console.log('[test.mjs] 5/5 E2E')
  113 + run('e2e', '{{e2e_cmd}}')
79 114  
80   -console.log('[test.mjs] 5/5 E2E')
81   -run('e2e', '{{e2e_cmd}}')
  115 + // 绿 → 把副本上已验证的本轮新迁移晋升到源库(幂等;跨 lane 串行由编排层 test-gate 起栈互斥保证)。
  116 + console.log('[test.mjs] promote:晋升本轮新迁移到源库')
  117 + run('promote-to-source', nodeScript('promote-to-source.mjs'))
  118 +
  119 + console.log('[test.mjs] GREEN')
  120 +} catch (e) {
  121 + failure = e instanceof GateError ? e : new GateError(String(e?.message || e), 1)
  122 + console.error(`[test.mjs] ${failure.message}`)
  123 +} finally {
  124 + // 做完测试直接删副本(无论红绿;源库绝不被删)。
  125 + try {
  126 + run('drop-test-db', nodeScript('drop-test-db.mjs'))
  127 + } catch (e) {
  128 + console.error(`[test.mjs] 警告:删副本失败:${e?.message || e}`)
  129 + if (!failure) failure = e instanceof GateError ? e : new GateError(String(e?.message || e), 1)
  130 + }
  131 +}
82 132  
83   -console.log('[test.mjs] GREEN')
  133 +if (failure) process.exit(failure.code ?? 1)
... ...
workflows/coding.mjs
... ... @@ -315,8 +315,9 @@ function dateFromArtifactPath(artifactPath) {
315 315  
316 316 // 所有子代理共享的"非交互静默"硬约束。
317 317 // c:模块执行上下文(末位参数,调用方必传)。lane 模式(c.dbSchema 非空)追加 lane 测试库硬约束——
318   -// env 前缀是 JS 拼好的**成品字符串**(附录补 6c):tdd 等主会话还会把命令转述给嵌套子会话(提示词
319   -// 链两跳),任何一跳自行拼接都可能丢/错前缀,而 setup-test-db 是 DROP+CREATE——成品照抄是唯一可靠形态。
  318 +// env 前缀(ERP_TEST_DB_SCHEMA)指定本 lane 的一次性副本库名,是 JS 拼好的**成品字符串**(附录补 6c):
  319 +// tdd 等主会话还会把命令转述给嵌套子会话(提示词链两跳),任何一跳自行拼接都可能丢/错前缀,导致 lane 间
  320 +// 用错副本库互相踩——成品照抄是唯一可靠形态(源库有 COPY≠SOURCE 硬守卫兜底、绝不会被误 drop,但 lane 隔离仍靠前缀正确)。
320 321 function featureStageContract(phase, c) {
321 322 const fe = isFrontend(phase)
322 323 return [
... ... @@ -329,7 +330,7 @@ function featureStageContract(phase, c) {
329 330 '- 仅当缺失的是**无法自洽决策的硬事实**(如某表结构 / 业务主键语义完全缺失且无任何旁证,任何默认都可能污染源码或伪造业务语义)时,才以 `status:halt` 结束并把阻塞点写清;上层会再经仲裁评估能否继续,halt 是最后手段而非首选。',
330 331 '- 输出纪律:本次若做过任何自主默认 / 解读,成功返回(status:ok)**必须**带 `decisions[]`(逐条 `{question,choice,rationale,confidence}`,与上面登记要求一致);完全没有自主决策时才可省略——别照抄"输出"段里不含 decisions 的最简示例而漏登记。',
331 332 c.dbSchema
332   - ? `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane。**所有**测试 / 建库 / 种子命令(gradle test、pnpm test/e2e、\`node scripts/setup-test-db.mjs\`、\`node scripts/seed-demo-data.mjs- ? `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane。**所有**测试 / 建库 / 种子命令(gradle test、pnpm test/e2e、\`node scripts/setup-test-db.mjs\`、\`node scripts/seed-demo-data、playwright 等,**含派发给子会话 / 嵌套子会话的命令**)必须前置环境变量 - ? `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane。**所有**测试 / 建库 / 种子命令(gradle test、pnpm test/e2e、\`node scripts/setup-test-db.mjs\`、\`node scripts/seed-demo-dataERP_TEST_DB_SCHEMA=${c.dbSchema}- ? `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane。**所有**测试 / 建库 / 种子命令(gradle test、pnpm test/e2e、\`node scripts/setup-test-db.mjs\`、\`node scripts/seed-demo-data——成品字符串照抄使用,**绝不**自行拼接 / 改名 / 省略前缀。`
  333 + ? `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane,测试跑在**一次性副本库**(从源库复制而来、跑完删除,源库绝不被 drop)。**所有**测试 / 建副本 / 种子 / 晋升命令(gradle test、pnpm test/e2e、\`node scripts/setup-test-db.mjs\`、\`node scripts/seed-demo-data.mjs\`、\`node scripts/drop-test-db.mjs\`、\`node scripts/promote-to-source.mjs+ ? `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane,测试跑在**一次性副本库**(从源库复制而来、跑完删除,源库绝不被 drop)。**所有**测试 / 建副本 / 种子 / 晋升命令(gradle test、pnpm test/e2e、\`node scripts/setup-test-db.mjs\`、\`node scripts/seed-demo-data.mjs\`、\`node scripts/drop-test-db.mjs\`、\`node scripts/promote-to-source、playwright 等,**含派发给子会话 / 嵌套子会话的命令**)必须前置环境变量 + ? `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane,测试跑在**一次性副本库**(从源库复制而来、跑完删除,源库绝不被 drop)。**所有**测试 / 建副本 / 种子 / 晋升命令(gradle test、pnpm test/e2e、\`node scripts/setup-test-db.mjs\`、\`node scripts/seed-demo-data.mjs\`、\`node scripts/drop-test-db.mjs\`、\`node scripts/promote-to-sourceERP_TEST_DB_SCHEMA=${c.dbSchema}+ ? `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane,测试跑在**一次性副本库**(从源库复制而来、跑完删除,源库绝不被 drop)。**所有**测试 / 建副本 / 种子 / 晋升命令(gradle test、pnpm test/e2e、\`node scripts/setup-test-db.mjs\`、\`node scripts/seed-demo-data.mjs\`、\`node scripts/drop-test-db.mjs\`、\`node scripts/promote-to-source——成品字符串照抄使用,**绝不**自行拼接 / 改名 / 省略前缀。`
333 334 : '',
334 335 c.dbSchema
335 336 ? '- **lane 内禁全量测试入口(硬约束)**:lane 内 tdd/verify/fix 阶段**只允许目标化测试命令**(如 gradle 按类/按模块过滤、vitest 过滤模式);**禁止**运行含 e2e / 冷起全栈的全量入口(`node scripts/test.mjs`、`pnpm e2e:ci` 等)——它们按固定端口起栈,会击杀兄弟 lane 正在验证的进程;全量回归只发生在 test-gate(已由编排层起栈互斥保护)。'
... ... @@ -576,9 +577,9 @@ function tddPrompt(id, phase, planPath, c, feStage = &#39;all&#39;) {
576 577 ? `\`<n>\` 取**你的专属版本段 [${c.vBase}, ${c.vBase + 99}]** 内现有最大版本号 +1(段内尚无文件则取 ${c.vBase});**绝不**使用段外版本号——并行版本段下版本号只保唯一、不保连续,段间空洞与乱序合并安全(骨架已约定 Flyway out-of-order),**不要**"补洞"或改用段外小号`
577 578 : '\`<n>\` = 现有 \`sql/migrations/V*.sql\` 最大版本号 + 1'},只含 DDL),**同步**把新 CREATE / ALTER 反向更新到 \`docs/03-数据库设计文档.md\` 对应表小节(docs/03 是 schema 的 SSoT),migration + docs/03 改动同一 commit。`,
578 579 // 数据源占位约定(附录补 6a):lane 测试库 env 能打穿 Spring 进程的前提——application*.yml 写死
579   - // schema 时 ERP_TEST_DB_SCHEMA 传进去无人消费(脚本支持 ≠ 测试进程支持)。占位 env 缺省回退字面量,
580   - // 串行行为一字不变;gradle test fork 与 bootRun 默认继承 env,无需 SPRING_DATASOURCE_URL 拼装。
581   - fe ? '' : '- **数据源 schema 占位约定**:生成 / 修改后端 `application*.yml`(含测试 profile)时,JDBC URL 的 schema 段必须写成 Spring 占位 `${ERP_TEST_DB_SCHEMA:<db.schema 字面量>}`(env 缺省回退原 schema,行为不变);**绝不**把 schema 名写死成纯字面量。',
  580 + // schema 时 ERP_TEST_DB_SCHEMA 传进去无人消费(脚本支持 ≠ 测试进程支持)。**关键安全点**:占位缺省值
  581 + // 必须是副本库 `<schema>__test`,绝不能是源库 `<schema>` 本身——否则 env 万一缺省,测试就直接跑在源库上。
  582 + fe ? '' : '- **数据源 schema 占位约定**:生成 / 修改后端 `application*.yml`(含测试 profile)时,JDBC URL 的 schema 段必须写成 Spring 占位 `${ERP_TEST_DB_SCHEMA:<db.schema 字面量>__test}`(**缺省值是副本库 `<schema>__test`,绝不是源库**——测试永远连一次性副本、绝不连源库);**绝不**把 schema 名写死成纯字面量、**绝不**把缺省值写成源库名。',
582 583 // feStage 任务子集(前端 jsdom-only 重叠;设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md)。
583 584 // 'all'(缺省,含全部后端)与现行一字不差;'jsdom'/'e2e' 仅前端 phase1/phase2 传入。
584 585 fe && feStage === 'jsdom'
... ... @@ -592,7 +593,7 @@ function tddPrompt(id, phase, planPath, c, feStage = &#39;all&#39;) {
592 593 ? '- **测试落位(硬约定,对齐 docs/04 § 2.1)**:jsdom 单测用 vitest/jest 一律写到 `frontend/tests/`,目录**镜像** `frontend/src/` 相对路径(如 `src/pages/home/HomePage.tsx` → `tests/pages/home/HomePage.test.tsx`);e2e 类型在 `frontend/e2e/` 写 Playwright(headless)。**绝不**把 `*.test.*` / `*.spec.*` / `__tests__/` / `__mocks__/` / `__smoke__/` 落在 `frontend/src/` 内(交付源码与测试物理分离,同后端 src/main↔src/test)。实现时:色值用 `var(--color-*)`(不硬编码 hex),业务校验按 spec 在 form-level 复刻。'
593 594 : '',
594 595 fe
595   - ? '- **e2e 基线约束**:e2e 跑在「空库重建 + Flyway schema + 演示种子」基线上(骨架 globalSetup 已注入 `sql/seed`,无需测试自行建库/起栈)。e2e 断言**优先**定位**演示种子已知主键行**(1000–9999)或**测试自建数据**;**禁止**「全表恰好 N 行」式依赖全局计数的脆弱断言(演示种子行数会随后续模块种子增长,全局计数断言必然 flaky)。'
  596 + ? '- **e2e 基线约束**:e2e 跑在「源库副本 + 本轮新迁移 + 演示种子」基线上(骨架 globalSetup 已复制源库为副本并注入 `sql/seed`,无需测试自行建库/起栈)。e2e 断言**优先**定位**演示种子已知主键行**(1000–9999)或**测试自建数据**;**禁止**「全表恰好 N 行」式依赖全局计数的脆弱断言(副本含源库真实数据 + 演示种子行数会随后续模块种子增长,全局计数断言必然 flaky)。'
596 597 : '',
597 598 fe
598 599 ? `- **占位替换(保证中途可构建 + 阶段末尾行为门可达本 FE 路由)**:前端骨架阶段已在 router 里为本 FE 路由声明 lazy import 但指向占位组件 \`FeStub\`。本 FE 实现完成后,**必须**把 router 中本 FE 路由的 import 从 \`FeStub\` 改为本 FE 真组件(用 Grep 在 \`${c.root}/frontend/\` router 定位本 FE 路由 path 的 import 行;仍在 \`frontend/\` 路径内,不破坏护栏)。改完确保 router 该路由 lazy import 指向真组件、可构建可达。`
... ... @@ -744,8 +745,8 @@ function gatePrompt(module, phase, attempt = 1, c) {
744 745 '',
745 746 '## 命令',
746 747 fe
747   - ? `- 前端:命令从 \`${c.root}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.e2e_command\` 拼接(缺失则 \`pnpm test:ci && pnpm e2e:ci\`),跑 vitest + playwright(含全 FE 回归)。`
748   - : `- 后端:跑 \`${c.root}/scripts/test.mjs\`(跨平台 Node 测试入口;含本模块新增 + 已合并模块回归)。`,
  748 + ? `- 前端:命令从 \`${c.root}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.e2e_command\` 拼接(缺失则 \`pnpm test:ci && pnpm e2e:ci\`),跑 vitest + playwright(含全 FE 回归)。**收尾(无论红绿)**:测试跑完后执行 \`node ${c.root}/scripts/drop-test-db.mjs\` 删测试副本(前端阶段不走 test.mjs,副本清理落在本门;源库不动)。`
  749 + : `- 后端:跑 \`${c.root}/scripts/test.mjs\`(跨平台 Node 测试入口;含本模块新增 + 已合并模块回归;其内部自带 复制副本→测试→绿后晋升→删副本 全时序)。`,
749 750 '- 子会话只返回结构化 JSON:`{command, exit_code, passed, failed, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行含 FAIL 摘要)。',
750 751 // 补 9:testGate 的 e2e(Playwright globalSetup 按固定端口冷起后端)与 Seed 同属起栈类 stage——
751 752 // 整段由编排层包进全局起栈互斥(stackLock,Phase D 定义);lane 测试库硬约束已由 featureStageContract 注入。
... ... @@ -782,15 +783,15 @@ function seedStageContract(c) {
782 783 '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。',
783 784 '- 你的职责 = **为本模块生成演示种子(demo seed)并冷起栈真跑验证**——**不是**实现功能、**不是**改源码、**不是**改 schema。',
784 785 '- 缺值查找顺序:`config-vars.yaml` → `docs/03-数据库设计文档.md` → `docs/01-需求清单/` 各 REQ 卡(业务语义)→ 既有 `sql/seed/*`(跨模块语义引用前序模块种子的已知主键)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。',
785   - `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下——\`node ${c.root}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)、起后端服务(gradle bootRun 等,Flyway 在此建 schema)、\`node ${c.root}/scripts/seed-demo-data.mjs\`(注入种子)、mysql **只读** COUNT/查询;唯一允许**写入**的路径是 \`${c.root}/sql/seed/\`(种子文件,随 git 提交)+ \`${c.root}/.tmp/seed-gen/<module_id>/\`(一次性 runner,跑完即弃)+ 证据报告 \`${c.root}/docs/superpowers/module-reports/<module_id>-seed-verify.md\`。`,
786   - `- **越界硬停**:**绝不**编辑 \`backend/\` / \`frontend/\` / \`scripts/\` 下的任何源码文件(只许**运行** scripts/setup-test-db.mjs 与 scripts/seed-demo-data.mjs,不许改它们)。区分「运行 backend 服务 / 运行脚本」(允许)与「写 backend 实现 / 改脚本」(越界)。命中越界即以 \`status:halt\` 写清阻塞点结束。`,
  786 + `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下——\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)、起后端服务(gradle bootRun 等,Flyway 在副本上 apply 本轮新迁移)、\`node ${c.root}/scripts/seed-demo-data.mjs\`(注入种子到副本)、\`node ${c.root}/scripts/drop-test-db.mjs\`(收尾删副本)、mysql **只读** COUNT/查询;唯一允许**写入**的路径是 \`${c.root}/sql/seed/\`(种子文件,随 git 提交)+ \`${c.root}/.tmp/seed-gen/<module_id>/\`(一次性 runner,跑完即弃)+ 证据报告 \`${c.root}/docs/superpowers/module-reports/<module_id>-seed-verify.md\`。`,
  787 + `- **越界硬停**:**绝不**编辑 \`backend/\` / \`frontend/\` / \`scripts/\` 下的任何源码文件(只许**运行** scripts/setup-test-db.mjs / scripts/seed-demo-data.mjs / scripts/drop-test-db.mjs,不许改它们)。区分「运行 backend 服务 / 运行脚本」(允许)与「写 backend 实现 / 改脚本」(越界)。命中越界即以 \`status:halt\` 写清阻塞点结束。`,
787 788 '- **确定性红线(关键)**:种子值一律**显式主键**(1000–9999 区间)+ **固定历史日期**(写死字面量,如 `2024-03-15`),**绝不**依赖时间戳 / `NOW()` / 随机数 / 自增主键的隐式取值。',
788 789 '- **区间隔离红线**:演示数据值**绝不含 `_S<数字>` 样式编码串**(如 `CUST_NAME_S001`)——该样式预留给行为门 sentinel;数值主键固定落 1000–9999(1–999=初始数据 / ≥100000=sentinel)。',
789 790 // lane 注入(附录补 10):Seed 是不套 featureStageContract 的"第三类 stage",必须单独注入同一条
790   - // 硬约束——Seed 全链恰是 DROP+CREATE / 起栈 / 灌种子的大户,漏注入会在并行 lane 下直接 DROP 共享库。
791   - // 与 marker 兜底(补 7,lane 根 .tmp/erp-lane-db)自洽:lane 内一切脚本确定性指向 lane 库。
  791 + // 硬约束——Seed 全链恰是 复制副本 / 起栈 / 灌种子 的大户,漏注入会让 lane 用错副本库互相踩(源库有
  792 + // COPY≠SOURCE 守卫兜底、绝不被误 drop)。与 marker 兜底(补 7,lane 根 .tmp/erp-lane-db)自洽:lane 内一切脚本确定性指向本 lane 副本。
792 793 ...(c.dbSchema ? [
793   - `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane。Seed 全链——\`node scripts/setup-test-db.mjs\` / 起后端(gradle bootRun 等)/ \`node scripts/seed-demo-data.mjs\` / mysql COUNT 对账——的**全部**命令(含派发给子会话的)必须前置环境变量 \`ERP_TEST_DB_SCHEMA=${c.dbSchema}\`:成品字符串照抄使用,**绝不**自行拼接 / 改名 / 省略(bootRun 经 application*.yml 的 \`\${ERP_TEST_DB_SCHEMA:...}\` 占位吃到同一 lane 库;COUNT 对账必须连 lane 库查询)。`,
  794 + `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane,跑在一次性副本库上。Seed 全链——\`node scripts/setup-test-db.mjs\` / 起后端(gradle bootRun 等)/ \`node scripts/seed-demo-data.mjs\` / \`node scripts/drop-test-db.mjs\` / mysql COUNT 对账——的**全部**命令(含派发给子会话的)必须前置环境变量 \`ERP_TEST_DB_SCHEMA=${c.dbSchema}\`:成品字符串照抄使用,**绝不**自行拼接 / 改名 / 省略(bootRun 经 application*.yml 的 \`\${ERP_TEST_DB_SCHEMA:...}\` 占位吃到同一 lane 副本;COUNT 对账必须连 lane 副本查询)。`,
794 795 ] : []),
795 796 '- 红线:**绝不**伪造验证通过;**绝不**留 `TBD` / `TODO` / `【人工填写:】`;自主默认必须可被现有证据支撑且记入 `decisions[]`。',
796 797 '- 证据报告 / 注释 / 提示**使用中文**;SQL / 标识符 / 表名可用英文(受控 `[A-Za-z0-9_]` 格式)。',
... ... @@ -809,8 +810,8 @@ function seedGenPrompt(module, c) {
809 810 seedStageContract(c),
810 811 '',
811 812 '## 目标',
812   - `为本模块 \`${id}\` 生成**演示假数据(demo seed)**并冷起栈真跑验证:生成 → \`node ${c.root}/scripts/setup-test-db.mjs\` → 起后端(Flyway 建 schema)→ \`node ${c.root}/scripts/seed-demo-data.mjs\` → mysql 只读 COUNT 对账。`,
813   - '种子产物随 git 提交(不保证「存活」,保证「随时可复现」——三处 DROP+CREATE 各在自己时序里固定重注入)。',
  813 + `为本模块 \`${id}\` 生成**演示假数据(demo seed)**并冷起栈真跑验证:生成 → \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)→ 起后端(Flyway apply 本轮新迁移到副本)→ \`node ${c.root}/scripts/seed-demo-data.mjs\`(灌进副本)→ mysql 只读 COUNT 对账 → \`node ${c.root}/scripts/drop-test-db.mjs\`(收尾删副本)。`,
  814 + '种子产物随 git 提交(不保证「存活」,保证「随时可复现」——每次都从源库副本重注入,副本跑完即删;源库绝不被 drop)。',
814 815 '',
815 816 '## 输入',
816 817 `- \`${c.root}/docs/03-数据库设计文档.md\`:本模块各表结构(列 / 类型 / enum 值域 / 语义引用关系 / NOT NULL / UNIQUE 约束)。`,
... ... @@ -850,11 +851,11 @@ function seedGenPrompt(module, c) {
850 851 '## 运行验证(写一次性 runner,仿行为门冷起栈纪律的简化版)',
851 852 `- **入口清目录**:先用确定性、跨平台方式重建 \`${tmpDir}/\`(\`fs.rmSync(tmpDir,{recursive:true,force:true})\` 后 \`fs.mkdirSync(tmpDir,{recursive:true})\`),仅限该受控路径,绝不删其它路径。`,
852 853 `- 在 \`${tmpDir}/\` 写一次性 runner \`run.mjs\`,依序:`,
853   - ` 1) \`node ${c.root}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。`,
854   - ` 2) **起后端**:从 \`${c.root}/config-vars.yaml\` 取端口;起栈前先探测端口占用并按 \`${tmpDir}/*.pid\` / 既知端口回收上一次残留 pid;spawn 到后台进程树 + 轮询健康端点(\`/actuator/health\` 或登录端点 200)就绪(Flyway 在此 apply 建 schema)。`,
855   - ` 3) \`node ${c.root}/scripts/seed-demo-data.mjs\`(注入种子;幂等账本 \`_demo_seed_history\` 自动跳过已应用文件)。`,
  854 + ` 1) \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本;源库绝不被 drop)。`,
  855 + ` 2) **起后端**:从 \`${c.root}/config-vars.yaml\` 取端口;起栈前先探测端口占用并按 \`${tmpDir}/*.pid\` / 既知端口回收上一次残留 pid;spawn 到后台进程树 + 轮询健康端点(\`/actuator/health\` 或登录端点 200)就绪(Flyway 在此把本轮新迁移 apply 到副本,历史里已有的自动跳过)。`,
  856 + ` 3) \`node ${c.root}/scripts/seed-demo-data.mjs\`(注入种子到副本;幂等账本 \`_demo_seed_history\` 自动跳过已应用文件)。`,
856 857 ' 4) **mysql 只读 COUNT 对账**:对本模块种子涉及的**每张表**,跑 `SELECT COUNT(*) ... WHERE <主键列> BETWEEN 1000 AND 9999`(**只数演示种子区间**——后端启动可能把 admin_init 等初始数据 bootstrap 进共表,其键落 1–999,不计入 expect),与「全部 `sql/seed/*.sql` 文件头 `-- expect: <table>=<rows>` 之和」逐表比对(同一张表可能被多个种子文件插入,必须求和后再比)。',
857   - ' - `finally` **硬要求 kill 本 stage 起的全部子进程**(绝不让 gradle bootRun 挂死会话)。',
  858 + ` - \`finally\` **硬要求 kill 本 stage 起的全部子进程**(绝不让 gradle bootRun 挂死会话)**并跑 \`node ${c.root}/scripts/drop-test-db.mjs\` 删副本**(无论成败;源库不动)。`,
858 859 '- **失败归类(reason 里必须分清)**:',
859 860 ' - **环境类**(端口占用 / 起栈超时 / setup-test-db 失败 / 健康端点不就绪)→ reason 标 `env-error` + 端口/pid。',
860 861 ' - **数据类**(撞主键/唯一键 / 引用错序或悬空 / enum 越界 / 类型截断 / COUNT 不符)→ reason 标 `data-error` + 具体表与根因(这是种子本身的 bug,必须修种子文件后重验)。',
... ... @@ -960,8 +961,8 @@ function behaviorGateContract(c) {
960 961 '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。',
961 962 '- 你是**跨栈只读验证门**:用真实运行(起后端 + 起前端 headless + Playwright 枚举)证明「每个 FE 的每个按钮/点击真的生效、每段文字显示正确内容」,**不是**实现功能、**不是**改源码。',
962 963 '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md § 零` → `docs/05-API接口契约.md` → `docs/03-数据库设计文档.md` → `prototype/`(前端布局/交互权威)→ `frontend/`(router 配置 / package.json)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。',
963   - `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下命令——\`node ${c.root}/scripts/setup-test-db.mjs\`、起后端服务(gradle bootRun 等)、\`node ${c.root}/scripts/seed-demo-data.mjs\`(只运行注入演示种子,不修改脚本)、起前端 headless(vite / playwright)、跑 Playwright;唯一允许**写入**的路径是 \`${c.root}/.tmp/behavior-gate/frontend-phase/r<behaviorRound>/\`(种子 SQL/runner,跑完即弃)+ 证据报告 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r<behaviorRound>-a<attempt>.md\` + 其 assets(截图归档到 \`${c.root}/docs/superpowers/module-reports/assets/...\`)。`,
964   - `- **越界硬停**:**绝不**编辑 \`frontend/\` / \`backend/\` / \`sql/\` 下的任何源码文件,也**绝不**编辑 \`${c.root}/scripts/\` 下的脚本——只许**运行** scripts/setup-test-db.mjs。区分「运行 backend 服务」(允许)与「写 backend 实现」(越界)。命中越界即以 \`status:red\` + \`envError\` 或写清阻塞点结束。`,
  964 + `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下命令——\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)、起后端服务(gradle bootRun 等)、\`node ${c.root}/scripts/seed-demo-data.mjs\`(只运行注入演示种子,不修改脚本)、\`node ${c.root}/scripts/drop-test-db.mjs\`(teardown 删副本)、起前端 headless(vite / playwright)、跑 Playwright;唯一允许**写入**的路径是 \`${c.root}/.tmp/behavior-gate/frontend-phase/r<behaviorRound>/\`(种子 SQL/runner,跑完即弃)+ 证据报告 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r<behaviorRound>-a<attempt>.md\` + 其 assets(截图归档到 \`${c.root}/docs/superpowers/module-reports/assets/...\`)。`,
  965 + `- **越界硬停**:**绝不**编辑 \`frontend/\` / \`backend/\` / \`sql/\` 下的任何源码文件,也**绝不**编辑 \`${c.root}/scripts/\` 下的脚本——只许**运行** scripts/setup-test-db.mjs / scripts/seed-demo-data.mjs / scripts/drop-test-db.mjs。区分「运行 backend 服务」(允许)与「写 backend 实现」(越界)。命中越界即以 \`status:red\` + \`envError\` 或写清阻塞点结束。`,
965 966 '- **全量终态前提(关键)**:本门跑在**全部 FE 已实现并通过静态 review 之后**——`frontend/` 不应再有未实现路由 / FeStub 占位。某路由仍渲染 `data-fe-stub` 占位 → 这是硬缺陷(tdd 漏做占位替换),归 `interactionFailures[kind="no-observable-effect"]`,locator 指向 router 文件该路由 import 行,detail 写明「路由仍指向 FeStub 占位」。**断言作用域 = 全部 FE spec 的 `## 行为验收作用域` 小节并集**;白名单外控件记证据不入断言集。',
966 967 '- 红线:**绝不**伪造断言通过;**绝不**留 `TBD` / `TODO`;自主默认必须可被现有证据支撑且记入 `decisions[]`。',
967 968 '- 证据报告**使用中文**;spec / sentinel 标识符 / SQL 可用英文(`[A-Za-z0-9_]`,受控格式,不取任意文本)。',
... ... @@ -1012,14 +1013,14 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1012 1013 '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**每路由的预期控件与文字来源**;每路由标注所需登录角色。',
1013 1014 '- 带参动态路由用**种子已知主键**实例化(可用**演示种子已知主键**(1000–9999)或 **sentinel 主键**(≥100000));无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。',
1014 1015 '',
1015   - '## step2 起栈五段严格时序(schema 由 Flyway 在后端启动时才建)',
1016   - `1) \`node ${c.root}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`,
1017   - '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。',
1018   - `3) **注入演示种子**:\`node ${c.root}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过已应用文件,把 \`sql/seed/*.sql\` 演示数据注入空库)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / 引用序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`,
  1016 + '## step2 起栈五段严格时序(副本从源库复制而来,Flyway 在后端启动时把本轮新迁移 apply 到副本)',
  1017 + `1) \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本;源库绝不被 drop)。建副本前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`,
  1018 + '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此把本轮新迁移 apply 到副本,历史里已有的自动跳过);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。',
  1019 + `3) **注入演示种子**:\`node ${c.root}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过源库已灌过的文件,把新 \`sql/seed/*.sql\` 演示数据灌进副本)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / 引用序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`,
1019 1020 '4) **此时才跑 sentinel 种子**:按 `docs/03-数据库设计文档.md` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)。失败 → `envError.kind="seed-error"` + 结构化根因,**不**混进交互 RED。',
1020 1021 ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——数值主键**一律 ≥100000**(固定区间,不再动态扫描既有键:初始数据 1–999 / 演示种子 1000–9999 已由区间约定隔离,sentinel 落 ≥100000 天然不冲突);字符串列**仍逐字段唯一编码**(`_S<NNN>` 样式,如 `CUST_NAME_S001`,抓绑错字段——演示数据已被禁用该样式,故 sentinel 独占)+ 行序号保 UNIQUE;enum 列从 docs/03 值域取并标注。断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。',
1021 1022 '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。',
1022   - '- `finally` **硬要求 kill 本门起的全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。',
  1023 + `- \`finally\` **硬要求 kill 本门起的全部子进程**,**并跑 \`node ${c.root}/scripts/drop-test-db.mjs\` 删副本**(无论成败;源库不动);端口 + pid 写入 \`envError.ports\` / \`envError.pids\`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。`,
1023 1024 '',
1024 1025 '## step2.5 鉴权 bootstrap(确定性前置)',
1025 1026 '- 用 config-vars `admin_init` 或种子已知凭据,经 `docs/05` 登录端点**真实登录**拿 JWT,注入 Playwright `storageState`;`authState` 记角色覆盖(覆盖 / 未覆盖角色集)。',
... ... @@ -1135,9 +1136,13 @@ function frontendSkeletonPrompt(feItems, c) {
1135 1136 '4. **共享布局/导航**:导航链接**全部指向已在 router 声明的路由 path**(不指向任何不存在的 path),保证任意时刻无悬空链接。',
1136 1137 '5. **e2e 基线脚手架(全部落 `frontend/` 内)**:',
1137 1138 ' - **Playwright 配置**(按 docs/04 § 零 `frontend.e2e_runner` 约定,如 `frontend/playwright.config.*`):声明 `globalSetup` / `globalTeardown` 入口 + 共享 `storageState`。',
1138   - ` - **globalSetup**(如 \`frontend/e2e/global-setup.*\`):冷起后端 + 轮询健康端点就绪(Flyway 建 schema)→ 执行 \`node ${c.root}/scripts/seed-demo-data.mjs\`(注入演示种子)→ 用 \`config-vars.yaml\` 的 \`admin_init\` 凭据经 \`docs/05-API接口契约.md\` 登录端点取 JWT,写 \`storageState\`(admin 登录态供 e2e 复用)。`,
1139   - ' - **globalTeardown**(如 `frontend/e2e/global-teardown.*`):kill globalSetup 起的后端进程树。',
1140   - ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 空库重建 + Flyway schema + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐。',
  1139 + ` - **globalSetup**(如 \`frontend/e2e/global-setup.*\`):\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)→ 冷起后端 + 轮询健康端点就绪(Flyway 在此把本轮新迁移 apply 到副本)→ 执行 \`node ${c.root}/scripts/seed-demo-data.mjs\`(灌演示种子进副本)→ 用 \`config-vars.yaml\` 的 \`admin_init\` 凭据经 \`docs/05-API接口契约.md\` 登录端点取 JWT,写 \`storageState\`(admin 登录态供 e2e 复用)。`,
  1140 + // globalTeardown 只 kill 进程、**不删副本**:test.mjs 的晋升(promote-to-source)在 e2e 之后跑、
  1141 + // 靠读副本的 flyway_schema_history 算 delta——teardown 若删副本,promote 会静默判"无迁移可晋升",
  1142 + // 晋升链路断且无声。副本清理归各闸门的 finally(test.mjs / seed runner / 行为门 runner)在 promote 后做;
  1143 + // tdd 内循环单独跑 e2e 时副本留存到下次 globalSetup 重新复制,无害。
  1144 + ' - **globalTeardown**(如 `frontend/e2e/global-teardown.*`):kill globalSetup 起的后端进程树(**不**删副本——副本清理由外层闸门在晋升后负责)。',
  1145 + ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐。',
1141 1146 '6. **单测基线(测试隔离接线点)**:vitest 配置(按 docs/04 § 零 `frontend.unit_test_runner` 约定,如 `frontend/vitest.config.*` 或 package.json 内配置段)`include` **限定** `tests/**/*.test.*`——单测一律落 `frontend/tests/**`(镜像 `src/` 结构,smoke 类归 `tests/__smoke__/` 且文件名同样以 `.test.*` 结尾,见 docs/04 § 2.1),`frontend/src/` 内的测试残留不被执行(约定漂移立即可见)。不存在才创建,已存在则只补齐 include 限定。**legacy 守卫**:若 `frontend/src/` 内已存在测试文件(旧约定 colocation 残留),**绝不**收窄 include(否则旧单测静默停跑、回归覆盖丢失)——保持现有 include 不动,登记 `decisions[]` 提示人工迁移(src/ 内测试迁至 tests/ 镜像路径后方可收窄)。',
1142 1147 '- **lazy 硬护栏**:router 表里**任何** FE 路由都不得用顶部静态 `import`;必须 `() => import(...)`。自检:Grep 路由文件,确认每个 FE 路由的 `component` 都是动态 import 形态。',
1143 1148 '- **路径硬护栏**:所有产出文件必须以 `frontend/` 开头;命中 `backend/` / `sql/` / `scripts/` → 越界硬停。',
... ... @@ -1609,9 +1614,9 @@ function readDbSchemaPromptM() {
1609 1614  
1610 1615 // laneDbSupportPromptM(Task 8 Step 3 / 附录补 6b:两点联查替换单 grep):存量项目 lane 测试库
1611 1616 // 支持探测。只 grep scripts/ 不够——schema 引用若只活在 Node 脚本里,gradle test / bootRun 的
1612   -// datasource 仍写死共享库,单点探测会假阳性放行(lane 建了库、两条 lane 的 gradle 测试照样互相清库)。
1613   -// 两点都满足才算支持;任一不满足 → Phase D 波次执行器把本波次降级 maxWidth=1(log 写明原因 + 建议
1614   -// 重跑 skeleton 升级脚本),**不 halt**——与其并行却在共享库上互相清库,不如明示串行。主根专属、只读。
  1617 +// datasource 仍写死库名,单点探测会假阳性放行(脚本建了副本,但 gradle test / bootRun 照样连那个写死的库——
  1618 +// 两条 lane 互相踩数据、甚至直接跑在源库上污染它)。两点都满足才算支持;任一不满足 → Phase D 波次执行器把本波次
  1619 +// 降级 maxWidth=1(log 写明原因 + 建议重跑 skeleton 升级脚本),**不 halt**——与其并行却让测试跑在同一个写死的库上互相踩,不如明示串行。主根专属、只读。
1615 1620 function laneDbSupportPromptM() {
1616 1621 return [
1617 1622 '# 探测目标项目是否支持 lane 测试库(ERP_TEST_DB_SCHEMA 两点联查,只读)',
... ... @@ -1652,13 +1657,14 @@ function pruneWorktreesPromptM() {
1652 1657 ].join('\n')
1653 1658 }
1654 1659  
1655   -// writeLaneDbMarkerPromptM(附录补 7:lane schema 的 fail-closed 兜底 marker)。
  1660 +// writeLaneDbMarkerPromptM(附录补 7:lane 副本库名的 fail-closed 兜底 marker)。
1656 1661 // lane 注入是纯 prompt-only,且 tdd 主会话会把命令转述给二级子会话(提示词链两跳)——任何一跳丢掉
1657   -// ERP_TEST_DB_SCHEMA 前缀,setup-test-db 就对共享 schema DROP+CREATE(兄弟 lane 随机红测、烧
1658   -// testGate/仲裁预算甚至假 halt)。最具毁灭性的操作需要确定性兜底:三个脚本模板按
1659   -// 「env → 本仓库根 .tmp/erp-lane-db → db.schema」顺序取值,lane 副本天然 lane 作用域;主根无此
1660   -// 文件,串行行为一字不变。`.tmp/` 已被 gitignore 忽略,绝不会被 recoverDirtyWorktree 判 in-scope
1661   -// 自动 commit。明令**禁止**改 lane 副本 config-vars.yaml 的 database.schema 兜底(该文件随 git
  1662 +// ERP_TEST_DB_SCHEMA 前缀,若无兜底则各脚本回退到串行缺省副本名 `<source>__test`(多条 lane 撞同一副本名、
  1663 +// 互相 DROP 重建对方副本,随机红测/烧 testGate/仲裁预算甚至假 halt;源库有 COPY≠SOURCE 守卫、绝不被误 drop)。
  1664 +// lane 隔离需要确定性兜底:setup / drop / promote / seed / test 脚本模板均按「env → 本仓库根 .tmp/erp-lane-db →
  1665 +// `<db.schema>__test`」顺序取副本库名(缺省是副本 `<source>__test`,**绝非源库本身**),lane 副本天然 lane 作用域;
  1666 +// 主根无此文件,串行缺省副本 `<source>__test`。`.tmp/` 已被 gitignore 忽略,绝不会被 recoverDirtyWorktree 判
  1667 +// in-scope 自动 commit。明令**禁止**改 lane 副本 config-vars.yaml 的 database.schema 兜底(该文件随 git
1662 1668 // 提交,lane 树一脏会被自动 commit 并随 milestone 合回默认分支,把真 schema 名搞坏)。幂等(覆盖写)。
1663 1669 function writeLaneDbMarkerPromptM(lanePath, schema) {
1664 1670 return [
... ...