Commit 56c4d89124d3fdebb7c3b72b684b365531237f80
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 与源库真实自增数据增长存在长期碰撞风险。
Showing
19 changed files
with
1093 additions
and
216 deletions
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('seed-demo-data: well-formed <NN>__<module_id>.sql passes filename check', |
| 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('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 = '127.0.0.1', port = '1', user = 'root', password = ' |
| 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('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 'node:url' |
| 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('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 "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ |
| 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 ?? '' |
| 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) <= 0 || Number(DB_PORT) > 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 ?? '' |
| 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) <= 0 || Number(DB_PORT) > 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 'node:path' |
| 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 && !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 = 'all') { |
| 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 = 'all') { |
| 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 [ | ... | ... |
-
mentioned in commit f2216a4c