diff --git a/README.md b/README.md index 3f83abf..de9a0ef 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 ``` Plan 阶段**两段式**执行,中间有一个人工审阅断点(docs/03 数据库 schema): - - **第一段(首次运行)**:执行 **A0 → A1 → A2 → A3**(创建骨架 / 锁技术栈 / 填需求 / 生成 REQ 卡片 / 生成项目骨架 / 从 REQ 正向设计 `docs/03-数据库设计文档.md` 并回填 REQ 依赖表)后**停下**,等你审阅 docs/03 的表 / 字段 / 索引 / 外键(人工关口:数据库 schema —— A4 会基于它翻译 DDL 并 apply 到 MySQL)。A1 的 REQ 卡片由 CC 据 index.md 填 6 个占位、字段表按模板原样复制,**不再单独停下审阅** + - **第一段(首次运行)**:执行 **A0 → A1 → A2 → A3**(创建骨架 / 锁技术栈 / 填需求 / 生成 REQ 卡片 / 生成项目骨架 / 从 REQ 正向设计 `docs/03-数据库设计文档.md` 并回填 REQ 依赖表)后**停下**,等你审阅 docs/03 的表 / 字段 / 索引 / 语义引用关系(人工关口:数据库 schema —— A4 会基于它翻译 DDL 并 apply 到 MySQL)。A1 的 REQ 卡片由 CC 据 index.md 填 6 个占位、字段表按模板原样复制,**不再单独停下审阅** - **第二段(docs/03 审阅完重新运行)**:执行 **A4 → A5**(解析 docs/03 → 生成 V1 migration → 自动 `DROP+CREATE` 本地 schema 并 apply → 生成下游文档 → **docs/05 + docs/02 评审闸** → prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三),通过 **Plan 终结硬闸** 后再次**停下**(前端布局/交互以 `prototype/` 为权威,不另设 UI 规范文档) Plan 完成后不会自动进入编码,需手动 /erp-workflow:coding-start。 @@ -100,7 +100,7 @@ erp-workflow-plugin/ ├── workflows/ │ └── coding.mjs # 阶段 B:整个编码阶段编排为单个静默 Workflow ├── lib/ # 跨平台 Node 助手(ESM,node:test 单测) -│ ├── validate-ddl.mjs # docs/03 ↔ DDL 5 维校验(替代 validate.sh) +│ ├── validate-ddl.mjs # docs/03 ↔ DDL 4 维校验(替代 validate.sh) │ ├── yaml-config.mjs # config-vars.yaml 极简 YAML 读取(2 层 map + 标量) │ ├── apply-ddl.mjs # 解析 config-vars.yaml database: 段 + mysql2 apply │ └── *.test.mjs # 各助手的 node:test 单测 @@ -135,8 +135,8 @@ erp-workflow-plugin/ | A0 | `project-init` | • **依赖检查**:检测 git / mysql / node 是否在 PATH,缺失则按 OS 自动安装,装不上再停下提示用户
• 空目录初始化:用 Read/Write/Glob 工具拷模板创建 CLAUDE.md / docs/01/index.md / docs/08
• `git init` | `plan-start` | | A1 | `scope-lock` | • 引导填项目概述 / 技术栈 / 需求索引
• 按 `docs/01-需求清单//{_module.md, .md}` 子目录结构生成 REQ 卡片(req_id = `<模块代码>-<子模块代码>-<功能名>`,如 `USR-UserInfo-Login`;CC 据 index.md 填 `{{req_id/title/goal/rules/constraints/acceptance}}` 6 个占位,模板其余内容含输入/输出示例字段表原样复制)
• **A1 终结校验**:REQ 6 个占位均填真实数据、无 `{{` 残留、`config-vars.yaml` **全部配置**(包名 / 端口 / 初始账号 + DB 凭据 / 密钥占位)已锁、各 stack 的 build/lint/unit/e2e 命令写入 docs/04 § 零;缺失则在此(Plan 期)用 `AskUserQuestion` 问清(敏感凭据由用户自填,不进会话)
• 据模板直接 `Write` 生成 `_module.md` / `.md`
• 终结校验通过后**自动**调用 `Skill(skeleton-gen)` 进入 A2(不停下) | A0 | | A2 | `skeleton-gen` | • 生成架构文档:docs/04 § 一+
• 生成跨平台工具脚本:`scripts/*.mjs`(**无 chmod**;凭据 / 配置统一在 A1 产出的 config-vars.yaml)
• 据 `gitignore-append-template` 用 Read/Write 并入项目 .gitignore | `plan-start` | -| A3 | `db-design-gen` | • 套用固定 ERP 约定(列前缀 `i/s/t`、`iIncrement` 主键、`sBrandsId`/`sSubsidiaryId` 租户列)从 docs/01 REQ 卡片正向设计 `docs/03-数据库设计文档.md`(schema SSoT)
• 回填 REQ 卡片依赖表(`TBD(A3 自动补)` → 实际表名)
• **停下**等人工审阅 docs/03,审阅完毕用 `/plan-start` 续进 A4 | A2 | -| A4 | `db-init` | • LLM 解析 docs/03 → `sql/migrations/V1__initial_schema.sql`(DDL only)
• `node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 校验 DDL ↔ docs/03(5 维:表/列名/列类型/索引/FK),fail-closed
• `node ${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs config-vars.yaml V1.sql`(读取 config-vars.yaml database: 段 + mysql2 apply) | A3 | +| A3 | `db-design-gen` | • 套用固定 ERP 约定(列前缀 `i/s/t`、`iIncrement` 主键、`sBrandsId`/`sSubsidiaryId` 租户列)+ 每表自动补标准列(主表 7 列:`iIncrement`/`sId`/`sBrandsId`/`sSubsidiaryId`/`tCreateDate`/`iOrder`/`sMemo`,从表 8 列额外加 `sParentId` 紧随 `sId`;其中 `sId`/`sBrandsId`/`sSubsidiaryId` 为 varchar(50) NOT NULL)从 docs/01 REQ 卡片正向设计 `docs/03-数据库设计文档.md`(schema SSoT)
• 回填 REQ 卡片依赖表(`TBD(A3 自动补)` → 实际表名)
• **停下**等人工审阅 docs/03,审阅完毕用 `/plan-start` 续进 A4 | A2 | +| A4 | `db-init` | • LLM 解析 docs/03 → `sql/migrations/V1__initial_schema.sql`(DDL only)
• `node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 校验 DDL ↔ docs/03(4 维:表/列名/列类型/索引),fail-closed
• `node ${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs config-vars.yaml V1.sql`(读取 config-vars.yaml database: 段 + mysql2 apply) | A3 | | A5 | `downstream-gen` | • 一次性生成 docs/02 / docs/05
• 回填 REQ 卡片依赖接口(`TBD(A5 自动补)` → 实际 endpoint)
• 追加模块清单到 docs/08 § 二
• **docs/05 + docs/02 评审闸**:用 `AskUserQuestion` 让用户确认 API 端点/字段无误 + 构建顺序可接受,未确认不勾 A5
• **prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三**(原 A6 已并入;无 prototype 则问「无前端」→ § 三 留空)
• 最终占位符 + 结构残留扫描 | A4 | ### Coding 阶段(1 个 Workflow,非 skill) diff --git a/lib/validate-ddl.mjs b/lib/validate-ddl.mjs index 51dd473..20178a9 100644 --- a/lib/validate-ddl.mjs +++ b/lib/validate-ddl.mjs @@ -1,4 +1,4 @@ -// lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验 +// lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 4 维校验 // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 // // 用法(CLI):node lib/validate-ddl.mjs @@ -6,14 +6,13 @@ // 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' // // 数据结构(解析结果):Map, indexes: Set, foreignKeys: Set }> +// columns: Map, indexes: Set }> // ── 解析 docs/03 markdown 表定义 ───────────────────────────────── // 约定:每张表一节,节标题形如 ## `表名` 或 ## `表名` — 业务含义 -// 节内分 ### 字段(markdown 表格,首列列名、次列类型)、### 索引、### 外键(项目符号列表)。 -// 索引/外键的 bullet 形态见 db-design-gen/templates/docs-03-table-template.md: +// 节内分 ### 字段(markdown 表格,首列列名、次列类型)、### 索引(项目符号列表)。 +// 索引的 bullet 形态见 db-design-gen/templates/docs-03-table-template.md: // ### 索引 → - `name` (type): cols -// ### 外键 → - `name`: from_col → to_table.to_col (on_delete) // 跳过表头行(列/字段/类型等标签)与分隔行(---)。 // 形如「## 一、全局约定」这类非反引号标题不视为表。 export function parseDocsTables(text) { @@ -21,14 +20,14 @@ export function parseDocsTables(text) { const lines = String(text).split('\n') // 反引号包裹的表名:## `name` 或 ## `name` — purpose const headerRe = /^##\s+`([^`]+)`/ - let current = null // { columns, indexes, foreignKeys } - let mode = 'col' // 当前子区块:'col'(字段表格)/ 'idx'(索引)/ 'fk'(外键) + let current = null // { columns, indexes } + let mode = 'col' // 当前子区块:'col'(字段表格)/ 'idx'(索引) for (const raw of lines) { const line = raw.replace(/\r$/, '') const h2 = line.match(headerRe) if (h2) { - current = { columns: new Map(), indexes: new Set(), foreignKeys: new Set() } + current = { columns: new Map(), indexes: new Set() } mode = 'col' tables.set(h2[1].trim(), current) continue @@ -39,15 +38,14 @@ export function parseDocsTables(text) { continue } if (!current) continue - // ### 子区块切换(### 索引 / ### 外键 / 其它如 ### 字段、### 业务注记 → col) + // ### 子区块切换(### 索引 / 其它如 ### 字段、### 业务注记、### 引用关系 → col) const h3 = line.match(/^###\s+(.+)$/) if (h3) { const title = h3[1].trim() - mode = /索引|index/i.test(title) ? 'idx' : /外键|foreign/i.test(title) ? 'fk' : 'col' + mode = /索引|index/i.test(title) ? 'idx' : 'col' continue } if (mode === 'idx') { parseIndexBullet(line, current.indexes); continue } - if (mode === 'fk') { parseForeignKeyBullet(line, current.foreignKeys); continue } // mode === 'col':markdown 表格行(以 | 开头) if (!/^\s*\|/.test(line)) continue const cells = splitMarkdownRow(line) @@ -89,42 +87,6 @@ function parseIndexBullet(line, indexes) { indexes.add(`${name}:${kind}:${cols}`) } -// 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete) -// 归一化为 parseDDL 同形的 `${fromCols}->${toTable}(${toCols})`(注意 docs 用 unicode → / DDL 用 ->)。 -function parseForeignKeyBullet(line, foreignKeys) { - // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 - // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 - // 目标表名用 [^`\s.]+(接受反引号包裹的中文表名,H3;以 `.` 与目标列分隔),与 docs headerRe 的非 ASCII 容许度对齐。 - const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([^`\s.]+)`?\s*\.\s*(.+)$/) - if (!head) return - const fromRaw = head[1] - const toTable = head[2] - let toRaw = head[3] - if (!fromRaw || !toTable || !toRaw) return - - const fromCols = fromRaw.replace(/`/g, '').replace(/\s+/g, '') - - // 2) 目标列:剥掉一对外层圆括号(如果有),按逗号切分,去反引号 / 空白;遇到第一个非 - // `[A-Za-z0-9_]` 列分隔符以外的字符(如 `(CASCADE)`、` on delete ...`)就停止收集。 - toRaw = toRaw.trim() - // 在分列前先尝试抓取尾部的 on-delete 标记:(CASCADE) / (RESTRICT) / (SET NULL) / (NO ACTION) / - // (SET DEFAULT);docs 模板规约把 action 写在一对独立括号里,紧跟在目标列之后。 - const onDeleteMatch = toRaw.match(/\((CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION)\)\s*$/i) - const onDelete = onDeleteMatch ? onDeleteMatch[1].toUpperCase().replace(/\s+/g, ' ') : 'RESTRICT' - // 剥外层括号:(idA, idB) → idA, idB - const paren = toRaw.match(/^\(([^)]*)\)/) - let toBody = paren ? paren[1] : toRaw - // 截断到第一个 `(`(如 `(CASCADE)`)或行尾。 - toBody = toBody.split('(')[0] - const toCols = toBody - .split(',') - .map(s => s.replace(/`/g, '').trim()) - .filter(s => /^[A-Za-z0-9_]+$/.test(s)) - .join(',') - if (!fromCols || !toTable || !toCols) return - foreignKeys.add(`${fromCols}->${toTable}(${toCols}):${onDelete}`) -} - // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── // 标识符 token:反引号包裹(任意非反引号字符,支持中文)或裸 ASCII 标识符(含 `$`)。 // docs 侧表名/索引名以 `[^`]+` 接受中文,DDL 侧此前仅 `[A-Za-z0-9_]+` → 中文名假阳性(H3)。 @@ -170,15 +132,12 @@ function blankStringLiterals(s) { return out } -// 表体内联索引 / 外键的匹配器(与 IDENT 同语法,支持反引号包裹的非 ASCII 名,H3 全路径一致)。 +// 表体内联索引的匹配器(与 IDENT 同语法,支持反引号包裹的非 ASCII 名,H3 全路径一致)。 const INLINE_KEY_RE = new RegExp( '^(?:UNIQUE\\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\\s+KEY|SPATIAL\\s+KEY)\\s+(' + IDENT + ')\\s*\\(', 'i') -const INLINE_FK_RE = new RegExp( - 'FOREIGN\\s+KEY\\s*\\(([^)]*)\\)\\s*REFERENCES\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(([^)]*)\\)' + - '(?:\\s+ON\\s+DELETE\\s+(CASCADE|RESTRICT|SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION))?', 'i') -// 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。 -// 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX / ALTER TABLE ADD FK,C1)。 +// 提取每个 CREATE TABLE 的:列名→类型、索引名集合。 +// 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX,C1)。 export function parseDDL(text) { const tables = new Map() // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 @@ -194,19 +153,18 @@ export function parseDDL(text) { const bodyStart = createRe.lastIndex - 1 // 指向 '(' const body = extractBalancedParens(src, bodyStart) if (body == null) continue - // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "FOREIGN KEY …" / "KEY …" 文本被 + // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "KEY …" 文本被 // 内联检测误当真实约束(REGEX-3);反引号标识符整段保留,列名/类型解析不读字面量内容,故不受影响。 tables.set(tableName, parseTableBody(blankStringLiterals(body))) // 继续从 body 之后扫描 createRe.lastIndex = bodyStart + body.length + 2 } - // 第二遍:db-init A.1/A.2 强制 DDL 形态为 CREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK, - // 索引 / 外键写在表体之外。把这些独立语句并回对应表,否则含索引 / 外键的 schema 首轮校验必报假阳性(C1)。 - // 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" / "ALTER TABLE …" 文本被误当语句(REGEX-3)。 + // 第二遍:db-init A.1/A.2 强制 DDL 形态为 CREATE TABLE → CREATE INDEX, + // 索引写在表体之外。把这些独立语句并回对应表,否则含索引的 schema 首轮校验必报假阳性(C1)。 + // 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" 文本被误当语句(REGEX-3)。 const scanSrc = blankStringLiterals(src) mergeStandaloneIndexes(scanSrc, tables) - mergeStandaloneForeignKeys(scanSrc, tables) return tables } @@ -229,57 +187,16 @@ function mergeStandaloneIndexes(src, tables) { } } -// 独立 `ALTER TABLE ADD [CONSTRAINT n] FOREIGN KEY (cols) REFERENCES [.] (refcols) [ON DELETE x]` -// → 并入 table.foreignKeys,归一化与 parseTableBody 内联 FK 同形(C1)。 -// 先框定每条 ALTER 语句(到 `;` 或结尾),再在其体内抓所有 ADD…FOREIGN KEY 子句, -// 支持一条 ALTER 内逗号分隔的多个 ADD(REGEX-4)。src 已抹掉字符串字面量,故 `;` 边界与匹配都安全。 -function mergeStandaloneForeignKeys(src, tables) { - const stmtRe = new RegExp( - 'ALTER\\s+TABLE\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')([\\s\\S]*?)(?:;|$)', 'gi') - const clauseRe = new RegExp( - 'ADD\\s+(?:CONSTRAINT\\s+' + IDENT + '\\s+)?FOREIGN\\s+KEY\\s*\\(([^)]*)\\)\\s*REFERENCES\\s+' + - '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(([^)]*)\\)' + - '(?:\\s+ON\\s+DELETE\\s+(CASCADE|RESTRICT|SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION))?', 'gi') - let s - while ((s = stmtRe.exec(src)) !== null) { - const t = tables.get(stripTicks(s[1])) - if (!t) continue - const body = s[2] - clauseRe.lastIndex = 0 - let c - while ((c = clauseRe.exec(body)) !== null) { - const fromCols = c[1].replace(/`/g, '').replace(/\s+/g, '') - const refTable = stripTicks(c[2]) - const toCols = c[3].replace(/`/g, '').replace(/\s+/g, '') - const onDelete = (c[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') - if (!fromCols || !refTable || !toCols) continue - t.foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) - } - } -} - function parseTableBody(body) { const columns = new Map() const indexes = new Set() - const foreignKeys = new Set() for (const itemRaw of splitTopLevelCommas(body)) { const item = itemRaw.trim() if (!item) continue const upper = item.toUpperCase() - // 外键约束(可带前缀 CONSTRAINT ) + // 外键约束(可带前缀 CONSTRAINT )→ 已去掉外键维度,直接跳过(不进 indexes/约束)。 if (/\bFOREIGN\s+KEY\b/i.test(item)) { - // REFERENCES 支持 schema 限定与反引号包裹的非 ASCII 目标表(IDENT,H3 全路径一致;取末段为表名)。 - const fk = item.match(INLINE_FK_RE) - if (fk) { - const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') - const refTable = stripTicks(fk[2]) - const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') - const onDelete = (fk[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') - foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) - } else { - foreignKeys.add(item) - } continue } @@ -325,7 +242,7 @@ function parseTableBody(body) { const type = extractType(col[3]) columns.set(name, type) } - return { columns, indexes, foreignKeys } + return { columns, indexes } } // 从列定义剩余部分提取类型(含括号内长度),到下一个属性关键字前停止。 @@ -350,7 +267,6 @@ export function diffSchema(docsTables, ddlTables) { columnMismatches: [], // { table, column, side: 'docs'|'ddl' } typeMismatches: [], // { table, column, docsType, ddlType } indexMismatches: [], // { table, index, side: 'docs'|'ddl' } - foreignKeyMismatches: [],// { table, foreignKey, side: 'docs'|'ddl' } hasDiff: false, } @@ -361,7 +277,7 @@ export function diffSchema(docsTables, ddlTables) { diff.missingTables.sort() diff.extraTables.sort() - // 仅对共有表做列/类型/索引/外键比对 + // 仅对共有表做列/类型/索引比对 for (const t of [...docNames].filter(n => ddlNames.has(n)).sort()) { const d = docsTables.get(t) const s = ddlTables.get(t) @@ -388,16 +304,11 @@ export function diffSchema(docsTables, ddlTables) { symDiff(dIdx, sIdx, ix => diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }), ix => diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' })) - - // 维度 5:外键 - symDiff(d.foreignKeys || new Set(), s.foreignKeys || new Set(), - fk => diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'docs' }), - fk => diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'ddl' })) } diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 || diff.columnMismatches.length > 0 || diff.typeMismatches.length > 0 || - diff.indexMismatches.length > 0 || diff.foreignKeyMismatches.length > 0 + diff.indexMismatches.length > 0 return diff } @@ -568,12 +479,6 @@ export function formatDiff(diff) { out.push(` - ${m.table} 索引 ${m.index} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) } } - if (diff.foreignKeyMismatches.length) { - out.push('=== 维度5 外键 ===') - for (const m of diff.foreignKeyMismatches) { - out.push(` - ${m.table} 外键 ${m.foreignKey} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) - } - } return out.join('\n') } @@ -597,6 +502,6 @@ if (isCliEntry) { console.error(formatDiff(diff)) process.exit(1) } - console.log('validate-ddl: ✓ docs/03 与 DDL 在 5 维(表/列/类型/索引/外键)一致') + console.log('validate-ddl: ✓ docs/03 与 DDL 在 4 维(表/列/类型/索引)一致') process.exit(0) } diff --git a/lib/validate-ddl.test.mjs b/lib/validate-ddl.test.mjs index 8090923..3c608a4 100644 --- a/lib/validate-ddl.test.mjs +++ b/lib/validate-ddl.test.mjs @@ -1,4 +1,4 @@ -// lib/validate-ddl.test.mjs — 单测:docs/03 表格 ↔ DDL 5 维 diff +// lib/validate-ddl.test.mjs — 单测:docs/03 表格 ↔ DDL 4 维 diff import { test } from 'node:test' import assert from 'node:assert/strict' import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' @@ -61,8 +61,8 @@ test('parseDocsTables: real docs/03 format — ## `t` — purpose + ### 字段 + assert.equal(order.columns.has('---'), false) }) -// 全链路:模板格式 docs/03(### 字段 + ### 索引 + ### 外键 bullet)→ parseDocsTables 必须 -// 把索引/外键解析进 Set(回归 C2:此前 parseDocsTables 从不写 indexes/foreignKeys)。 +// 全链路:模板格式 docs/03(### 字段 + ### 索引 bullet)→ parseDocsTables 必须 +// 把索引解析进 Set(回归 C2:此前 parseDocsTables 从不写 indexes)。 const DOCS_FULL = [ '## `t_order` — 订单主表', '', @@ -76,51 +76,30 @@ const DOCS_FULL = [ '- `pk` (PRIMARY): iId', '- `idx_user` (index): sUserId', '', - '### 外键', - '- `fk_user`: sUserId → t_user.sId (CASCADE)', - '', ].join('\n') const DDL_FULL = [ 'CREATE TABLE `t_order` (', ' `iId` bigint NOT NULL AUTO_INCREMENT,', ' `sUserId` varchar(100) NOT NULL,', ' PRIMARY KEY (`iId`),', - ' KEY `idx_user` (`sUserId`),', - ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`) ON DELETE CASCADE', + ' KEY `idx_user` (`sUserId`)', ') ENGINE=InnoDB;', ].join('\n') -test('parseDocsTables: parses ### 索引 / ### 外键 bullets into sets (C2 regression)', () => { +test('parseDocsTables: parses ### 索引 bullets into sets (C2 regression)', () => { const t = parseDocsTables(DOCS_FULL).get('t_order') assert.ok(t) assert.ok(t.indexes.has('PRIMARY'), 'PRIMARY index normalized') assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), 'named index normalized to name:kind:cols — got: ' + [...t.indexes]) - assert.ok(t.foreignKeys.has('sUserId->t_user(sId):CASCADE'), - 'FK normalized to parseDDL form with on-delete — got: ' + [...t.foreignKeys]) }) -test('full chain: matching docs/03 (with indexes+FK) ↔ DDL yields no diff (C2 regression)', () => { +test('full chain: matching docs/03 (with indexes) ↔ DDL yields no diff (C2 regression)', () => { const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(DDL_FULL)) assert.deepEqual(d.indexMismatches, [], 'index dimension clean') - assert.deepEqual(d.foreignKeyMismatches, [], 'FK dimension clean') assert.equal(d.hasDiff, false, 'no spurious diff on a faithful schema') }) -test('full chain: a real FK present in docs but missing from DDL is caught', () => { - const ddlNoFk = [ - 'CREATE TABLE `t_order` (', - ' `iId` bigint NOT NULL AUTO_INCREMENT,', - ' `sUserId` varchar(100) NOT NULL,', - ' PRIMARY KEY (`iId`),', - ' KEY `idx_user` (`sUserId`)', - ') ENGINE=InnoDB;', - ].join('\n') - const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(ddlNoFk)) - assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'sUserId->t_user(sId):CASCADE')) - assert.equal(d.hasDiff, true) -}) - test('parseDDL: CREATE TABLE inside a comment is NOT counted as a table (L4)', () => { const ddl = [ '-- CREATE TABLE ghost_line ( x int );', @@ -148,7 +127,7 @@ test('parseDocsTables: top-level ## headers like "## 一、全局约定" are NOT }) // ── parseDDL ───────────────────────────────────────────────────── -test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => { +test('parseDDL: columns, types, indexes (backtick-quoted); FOREIGN KEY 项被跳过', () => { const ddl = [ 'CREATE TABLE `t_order` (', ' `iIncrement` int NOT NULL AUTO_INCREMENT,', @@ -169,8 +148,9 @@ test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => assert.ok(t.indexes.has('uk_sid:UNIQUE:sId'), 'unique index normalized — got: ' + [...t.indexes]) assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), 'named index normalized — got: ' + [...t.indexes]) assert.ok([...t.indexes].some(i => i.toUpperCase().includes('PRIMARY'))) - // foreign key collected - assert.ok([...t.foreignKeys].some(fk => fk.includes('sUserId') && fk.includes('t_user'))) + // FOREIGN KEY 项不再被 track,也不应混入 indexes + assert.equal([...t.indexes].some(ix => /fk_user|t_user|FOREIGN/i.test(ix)), false, + 'FK 项不应落进 indexes — got: ' + [...t.indexes]) }) test('parseDDL: unquoted identifiers and inline PRIMARY KEY', () => { @@ -187,7 +167,7 @@ test('parseDDL: multiple tables', () => { assert.deepEqual([...tables.keys()].sort(), ['a', 'b']) }) -// ── diffSchema 5 dimensions ────────────────────────────────────── +// ── diffSchema 4 dimensions ────────────────────────────────────── test('diffSchema: missing table (in docs, not in DDL) reported', () => { const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') const ddl = parseDDL('CREATE TABLE other ( z int );') @@ -211,19 +191,12 @@ test('diffSchema: extra column in DDL reported as columnMismatch', () => { }) test('diffSchema: index dimension diff reported', () => { - const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c:INDEX:c']), foreignKeys: new Set() }]]) + const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c:INDEX:c']) }]]) const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes const d = diffSchema(docs, ddl) assert.ok(d.indexMismatches.some(m => m.table === 't' && m.index === 'idx_c:INDEX:c')) }) -test('diffSchema: foreign-key dimension diff reported', () => { - const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(), foreignKeys: new Set(['c->other']) }]]) - const ddl = parseDDL('CREATE TABLE t ( c int );') // no FKs - const d = diffSchema(docs, ddl) - assert.ok(d.foreignKeyMismatches.some(m => m.table === 't' && m.foreignKey === 'c->other')) -}) - test('diffSchema: hasDiff is false when everything matches, true otherwise', () => { const ok = diffSchema(parseDocsTables(DOCS), parseDDL(DDL)) assert.equal(ok.hasDiff, false) @@ -268,47 +241,6 @@ test('parseDDL: CREATE TABLE db.t 与 `db`.`t` 都应解析(取末段为表名 assert.deepEqual([...tables2.keys()], ['t_user']) }) -// ── 复合外键 docs↔DDL 对称(回归)──────────────────────────────── -test('parseDocsTables: 复合外键 - colA, colB → other.idA, idB 应平铺成 colA,colB->other(idA,idB)', () => { - const docs = [ - '## `t_link`', - '### 字段', - '| 列 | 类型 |', - '|---|---|', - '| `colA` | int |', - '| `colB` | int |', - '### 外键', - '- `fk_x`: colA, colB → other.idA, idB (CASCADE)', - ].join('\n') - const t = parseDocsTables(docs).get('t_link') - assert.ok(t) - assert.ok(t.foreignKeys.has('colA,colB->other(idA,idB):CASCADE'), - 'docs-side composite FK should normalize the same way as parseDDL — got: ' + [...t.foreignKeys]) -}) - -test('full chain: 复合外键 docs ↔ DDL 一致时不应误报双向 mismatch', () => { - const docs = [ - '## `t_link`', - '### 字段', - '| 列 | 类型 |', - '|---|---|', - '| `colA` | int |', - '| `colB` | int |', - '### 外键', - '- `fk_x`: colA, colB → other.(idA, idB)', - ].join('\n') - const ddl = [ - 'CREATE TABLE `t_link` (', - ' `colA` int NOT NULL,', - ' `colB` int NOT NULL,', - ' CONSTRAINT `fk_x` FOREIGN KEY (`colA`, `colB`) REFERENCES `other` (`idA`, `idB`)', - ') ENGINE=InnoDB;', - ].join('\n') - const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) - assert.deepEqual(d.foreignKeyMismatches, [], - '复合 FK 一致时不应误报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) -}) - // ── 未加引号的保留字列名(回归)───────────────────────────────── test('parseDDL: 未加引号的保留字列名 `key varchar(...)` 不应被误判为索引也不应制造幽灵列(fix #2)', () => { // 列名 key 未加反引号,且后面跟的是 `varchar(`(一个类型而非 `key (`)。 @@ -352,20 +284,6 @@ test('parseDDL: `KEY decimal (c)` 不应被解析为列(fix #2/#20)', () => assert.deepEqual([...t.columns.keys()], ['c']) }) -// ── #3 REFERENCES schema-qualified table ───────────────────────── -test('parseDDL: FK REFERENCES mydb.users(id) 归一化为 uid->users(id)(fix #3)', () => { - const ddl = [ - 'CREATE TABLE t (', - ' uid int NOT NULL,', - ' FOREIGN KEY (uid) REFERENCES mydb.users(id)', - ');', - ].join('\n') - const t = parseDDL(ddl).get('t') - assert.ok(t) - assert.ok(t.foreignKeys.has('uid->users(id):RESTRICT'), - 'FK 表名应取末段 users 并附默认 on-delete — got: ' + [...t.foreignKeys]) -}) - // ── #4 extractType 保留 unsigned/signed 修饰 ───────────────────── test('extractType: `int unsigned` vs `int unsigned` 匹配,`int` vs `int unsigned` 报 mismatch(fix #4)', () => { const docsOk = parseDocsTables('## `t`\n| 列 | 类型 |\n|---|---|\n| id | int unsigned |\n') @@ -380,14 +298,7 @@ test('extractType: `int unsigned` vs `int unsigned` 匹配,`int` vs `int unsig '一侧带 unsigned 一侧不带应报 mismatch — got: ' + JSON.stringify(bad.typeMismatches)) }) -// ── #9 散文 bullet 不应被当 FK / 索引 ──────────────────────────── -test('parseDocsTables: ### 外键 下的散文 bullet (含 `>`) 不应被当外键(fix #9)', () => { - const docs = '## `t`\n### 外键\n- note: a > users.id\n' - const t = parseDocsTables(docs).get('t') - assert.ok(t) - assert.equal(t.foreignKeys.size, 0, 'bare `>` 不再作为外键箭头 — got: ' + [...t.foreignKeys]) -}) - +// ── #9 散文 bullet 不应被当索引 ────────────────────────────────── test('parseDocsTables: ### 索引 下纯散文 bullet 不应被当索引(fix #9)', () => { const docs = '## `t`\n### 索引\n- This bullet is not an index entry\n' const t = parseDocsTables(docs).get('t') @@ -438,37 +349,16 @@ test('diffSchema: 同名索引 UNIQUE vs 非 UNIQUE 应报 mismatch(fix #10) assert.ok(d.indexMismatches.length > 0, 'UNIQUE vs INDEX 应报 — got: ' + JSON.stringify(d.indexMismatches)) }) -// ── #11 ON DELETE actions differentiated ───────────────────────── -test('diffSchema: FK ON DELETE CASCADE vs 缺省 RESTRICT 应报 mismatch(fix #11)', () => { - const docs = parseDocsTables([ - '## `t`', - '### 字段', - '| 列 | 类型 |', - '|---|---|', - '| `uid` | int |', - '### 外键', - '- `fk_uid`: uid → users.id (CASCADE)', - ].join('\n')) - const ddl = parseDDL([ - 'CREATE TABLE `t` (', - ' `uid` int,', - ' FOREIGN KEY (`uid`) REFERENCES `users`(`id`)', - ') ENGINE=InnoDB;', - ].join('\n')) - const d = diffSchema(docs, ddl) - assert.ok(d.foreignKeyMismatches.length > 0, 'CASCADE vs RESTRICT 应报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) -}) - // ── #16 CREATE TEMPORARY TABLE 也应被识别 ───────────────────────── test('parseDDL: CREATE TEMPORARY TABLE 也应被解析(fix #16)', () => { const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()]) }) -// ── C1: 独立语句形态的索引 / 外键(db-init A.1 强制的 DDL 形态)────────── -// db-init A.1/A.2 强制 DDL 形态为:CREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK -// (索引 / 外键写在表体之外的独立语句)。parseDDL 必须把这些独立语句并回对应表的 -// indexes / foreignKeys 集合,否则任何含索引 / 外键的 schema 首轮校验必报假阳性。 +// ── C1: 独立语句形态的索引(db-init A.1 强制的 DDL 形态)────────── +// db-init A.1/A.2 强制 DDL 形态为:CREATE TABLE → CREATE INDEX(索引写在表体之外的 +// 独立语句)。parseDDL 必须把这些独立语句并回对应表的 indexes 集合,否则任何含索引的 +// schema 首轮校验必报假阳性。 test('parseDDL: 独立 CREATE INDEX 并入对应表的 indexes(C1)', () => { const ddl = [ 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', @@ -498,27 +388,7 @@ test('parseDDL: 独立 CREATE INDEX 多列归一化(C1)', () => { assert.ok(t.indexes.has('idx_tenant:INDEX:sBrandsId,sSubsidiaryId'), 'got: ' + [...t.indexes]) }) -test('parseDDL: 独立 ALTER TABLE ADD CONSTRAINT FOREIGN KEY 并入对应表的 foreignKeys(C1)', () => { - const ddl = [ - 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL );', - 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_cust` FOREIGN KEY (`iCustomerId`) REFERENCES `t_customer` (`iIncrement`) ON DELETE RESTRICT;', - ].join('\n') - const t = parseDDL(ddl).get('t_order') - assert.ok(t) - assert.ok(t.foreignKeys.has('iCustomerId->t_customer(iIncrement):RESTRICT'), - '独立 ALTER ADD FK 应并入表外键集 — got: ' + [...t.foreignKeys]) -}) - -test('parseDDL: 独立 ALTER TABLE ADD FOREIGN KEY(无 CONSTRAINT 名)默认 RESTRICT(C1)', () => { - const ddl = [ - 'CREATE TABLE `t` ( `uid` int );', - 'ALTER TABLE `t` ADD FOREIGN KEY (`uid`) REFERENCES `users` (`id`);', - ].join('\n') - const t = parseDDL(ddl).get('t') - assert.ok(t.foreignKeys.has('uid->users(id):RESTRICT'), 'got: ' + [...t.foreignKeys]) -}) - -test('full chain: A.1 形态 DDL(CREATE TABLE → CREATE INDEX → ALTER ADD FK)↔ docs/03 不应有 diff(C1 头号回归)', () => { +test('full chain: A.1 形态 DDL(CREATE TABLE → CREATE INDEX)↔ docs/03 不应有 diff(C1 头号回归)', () => { const docs = [ '## `t_customer` — 客户表', '### 字段', @@ -534,38 +404,18 @@ test('full chain: A.1 形态 DDL(CREATE TABLE → CREATE INDEX → ALTER ADD F '| `iCustomerId` | int |', '### 索引', '- `idx_cust` (INDEX): iCustomerId', - '### 外键', - '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', '', ].join('\n') const ddl = [ 'CREATE TABLE `t_customer` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );', 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', 'CREATE INDEX `idx_cust` ON `t_order` (`iCustomerId`);', - 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_cust` FOREIGN KEY (`iCustomerId`) REFERENCES `t_customer` (`iIncrement`) ON DELETE RESTRICT;', ].join('\n') const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) assert.deepEqual(d.indexMismatches, [], '索引维度应干净 — got: ' + JSON.stringify(d.indexMismatches)) - assert.deepEqual(d.foreignKeyMismatches, [], '外键维度应干净 — got: ' + JSON.stringify(d.foreignKeyMismatches)) assert.equal(d.hasDiff, false, 'A.1 形态的忠实 schema 不应报 diff') }) -test('full chain: 独立 ALTER ADD FK 在 docs 有而 DDL 缺时仍被捕获(C1 不掩盖真实缺失)', () => { - const docs = [ - '## `t_order`', - '### 字段', - '| 列 | 类型 |', - '|---|---|', - '| `iCustomerId` | int |', - '### 外键', - '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', - ].join('\n') - const ddl = 'CREATE TABLE `t_order` ( `iCustomerId` int NOT NULL );' // FK 真的缺失 - const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) - assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'iCustomerId->t_customer(iIncrement):RESTRICT'), - '真实缺失的 FK 仍应报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) -}) - // ── H3: 反引号包裹的非 ASCII 表名(docs 侧 [^`]+ 接受,DDL 侧需对齐)────── test('parseDDL: 反引号包裹的中文表名应被解析(H3 标识符语法对齐)', () => { const t = parseDDL('CREATE TABLE `订单表` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );') @@ -580,15 +430,6 @@ test('full chain: docs 与 DDL 同为中文表名时不应误报 missingTables assert.deepEqual(d.extraTables, []) }) -test('parseDDL: 反引号包裹的 FK 目标表为中文时归一化保留中文(H3)', () => { - const ddl = [ - 'CREATE TABLE `t` ( `uid` int );', - 'ALTER TABLE `t` ADD FOREIGN KEY (`uid`) REFERENCES `用户表` (`id`);', - ].join('\n') - const t = parseDDL(ddl).get('t') - assert.ok(t.foreignKeys.has('uid->用户表(id):RESTRICT'), 'got: ' + [...t.foreignKeys]) -}) - // ── DDL-9: 索引列归一化两侧对齐(前缀长度 / 排序方向)──────────────────── test('full chain: 前缀长度索引列 sName(20) docs↔DDL 一致时不应误报(DDL-9)', () => { const docs = [ @@ -641,12 +482,12 @@ test('full chain: 索引列 `sName(20) DESC` 应完全归一化为裸列名, } }) -// REGEX-1 / EFFICACY-4 / PROSE-1:inline KEY 名 + inline FK 目标表为中文时也应与 docs 对齐。 -test('full chain: inline 中文索引名 + inline 中文 FK 目标表应与 docs 对齐(REGEX-1 / H3 一致)', () => { +// REGEX-1 / EFFICACY-4 / PROSE-1:inline KEY 名为中文时也应与 docs 对齐; +// 同时表体内的 inline FOREIGN KEY 项应被跳过、不污染索引集。 +test('full chain: inline 中文索引名应与 docs 对齐,inline FK 项被跳过(REGEX-1 / H3 一致)', () => { const docs = [ '## `订单`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `user_id` | int |', '### 索引', '- `中文索引` (INDEX): user_id', - '### 外键', '- `fk_u`: user_id → 用户.id (RESTRICT)', ].join('\n') const ddl = [ 'CREATE TABLE `订单` (', ' `user_id` int,', @@ -654,12 +495,15 @@ test('full chain: inline 中文索引名 + inline 中文 FK 目标表应与 docs ' CONSTRAINT `fk_u` FOREIGN KEY (`user_id`) REFERENCES `用户` (`id`)', ') ENGINE=InnoDB;', ].join('\n') + const t = parseDDL(ddl).get('订单') + assert.ok(t) + assert.equal([...t.indexes].some(ix => /fk_u|用户|FOREIGN/i.test(ix)), false, + 'inline FK 项不应污染索引集 — got: ' + [...t.indexes]) const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) assert.deepEqual(d.indexMismatches, [], 'inline 中文索引名应对齐 — got: ' + JSON.stringify(d.indexMismatches)) - assert.deepEqual(d.foreignKeyMismatches, [], 'inline 中文 FK 目标表应对齐 — got: ' + JSON.stringify(d.foreignKeyMismatches)) }) -// REGEX-3:字符串字面量里的 CREATE INDEX / ALTER ADD FK 不应被独立语句扫描误当真实定义。 +// REGEX-3:字符串字面量里的 CREATE INDEX 不应被独立语句扫描误当真实定义。 test('parseDDL: 字符串字面量中的 CREATE INDEX 文本不应注入幽灵索引(REGEX-3)', () => { const ddl = "CREATE TABLE `t_order` ( `iId` int NOT NULL, `note` varchar(200) DEFAULT 'CREATE INDEX `ghost` ON `t_order` (`iId`)', PRIMARY KEY (`iId`) );" const t = parseDDL(ddl).get('t_order') @@ -667,26 +511,7 @@ test('parseDDL: 字符串字面量中的 CREATE INDEX 文本不应注入幽灵 assert.equal([...t.indexes].some(ix => ix.includes('ghost')), false, '字面量内的 CREATE INDEX 不应成为真实索引 — got: ' + [...t.indexes]) }) -test('parseDDL: 字符串字面量中的 ALTER ADD FK 文本不应注入幽灵外键(REGEX-3)', () => { - const ddl = "CREATE TABLE `t` ( `c` int, `doc` varchar(300) DEFAULT 'see ALTER TABLE `t` ADD FOREIGN KEY (`c`) REFERENCES `x` (`id`)' );" - const t = parseDDL(ddl).get('t') - assert.ok(t) - assert.equal(t.foreignKeys.size, 0, '字面量内的 ALTER ADD FK 不应成为真实外键 — got: ' + [...t.foreignKeys]) -}) - -// REGEX-4:一条 ALTER TABLE 内多个逗号分隔 ADD FK 都应被捕获;CREATE INDEX 的 USING 子句应容忍。 -test('parseDDL: 单条 ALTER 内多个 ADD FOREIGN KEY 都应被捕获(REGEX-4 multi-ADD)', () => { - const ddl = [ - 'CREATE TABLE `t_order` ( `a` int, `b` int );', - 'CREATE TABLE `t_a` ( `id` int );', - 'CREATE TABLE `t_b` ( `id` int );', - 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_a` FOREIGN KEY (`a`) REFERENCES `t_a` (`id`) ON DELETE CASCADE, ADD CONSTRAINT `fk_b` FOREIGN KEY (`b`) REFERENCES `t_b` (`id`);', - ].join('\n') - const t = parseDDL(ddl).get('t_order') - assert.ok(t.foreignKeys.has('a->t_a(id):CASCADE'), '第一个 FK — got: ' + [...t.foreignKeys]) - assert.ok(t.foreignKeys.has('b->t_b(id):RESTRICT'), '同条 ALTER 的第二个 FK — got: ' + [...t.foreignKeys]) -}) - +// REGEX-4:CREATE INDEX 的 USING 子句应容忍。 test('parseDDL: CREATE INDEX ... USING BTREE ON ... 应被解析(REGEX-4 USING)', () => { const ddl = ['CREATE TABLE `t` ( `c` int );', 'CREATE INDEX `idx_c` USING BTREE ON `t` (`c`);'].join('\n') const t = parseDDL(ddl).get('t') diff --git a/skills/plan/db-design-gen/SKILL.md b/skills/plan/db-design-gen/SKILL.md index ab1d319..8e7f20b 100644 --- a/skills/plan/db-design-gen/SKILL.md +++ b/skills/plan/db-design-gen/SKILL.md @@ -18,26 +18,29 @@ allowed-tools: Read Write Edit Grep Glob 读: - `docs/04-技术规范.md` +- `docs/06-实现策略.md`(A2 人工填写的实现策略;若含影响数据模型的关键决策 / 对默认约定的偏离,步骤 B 据此调整) - `docs/01-需求清单/index.md` 模块索引 - `docs/01-需求清单/*/*.md` 所有 REQ 卡片(跳过文件名为 `_module.md` 的模块头;卡片文件名 == req_id) ### B. 推导 schema -基于步骤 A 读到的 REQ + 命名规范,**正向推导**业务实体 → 表 + 字段 + 索引 + 外键。要求: +基于步骤 A 读到的 REQ + 命名规范,**正向推导**业务实体 → 表 + 字段 + 索引 + 语义引用关系。要求: 1. 严格套用 `docs/04` 命名规范 + 匈牙利列前缀(`i`=int / `s`=varchar / `t`=datetime) 2. **主键**:标准列 `iIncrement` int 主键。REQ 明确要求不同主键(复合主键 / UUID / 业务主键)时按 REQ,并在该表业务注记里注明偏离原因 -3. **外键**:依据 REQ 中的引用关系(如「订单引用客户」),明确列出 `ON DELETE` / `ON UPDATE` 策略;不能确定时默认 `RESTRICT` -4. **索引**:根据 REQ 的查询模式推导业务索引;外键列默认建索引;租户隔离列 `sBrandsId` / `sSubsidiaryId`(标准列)按业务查询模式建组合索引。 +3. **语义引用关系**:依据 REQ 中的引用关系(如「订单引用客户」),列出 `from→to`(如 `sCustomerId → 客户表.sId`);仅语义、不建 FK 约束、不写 `ON DELETE` / `ON UPDATE`,应用层维护一致性 +4. **索引**:根据 REQ 的查询模式推导业务索引;语义引用列默认建索引;租户隔离列 `sBrandsId` / `sSubsidiaryId`(标准列)按业务查询模式建组合索引。 - 索引 bullet 的 `(类别)` 槽位**统一用 ASCII**:唯一索引写 `UNIQUE`、普通/组合索引写 `INDEX`(与 DDL 侧 `UNIQUE KEY` / `KEY` 对齐,validate-ddl 据此比对 UNIQUE\|INDEX 类别);主键不在 `### 索引` 重复列(由标准列 `iIncrement` 治理)。 5. **业务注记**:对每张表用一两句话说明业务用途、关键约束、与其他表的关系 +> 若 `docs/06-实现策略.md` 载有影响数据模型的关键决策 / 对默认约定的偏离(如软删除标志、乐观锁版本列、特殊主键策略、多租户隔离方式等),**优先遵循**,并在对应表「业务注记」注明依据。 + 如果某 REQ 表述模糊以致无法推断关键 schema 细节(如:枚举值范围 / 字段长度上限 / 必填性),先按合理默认推导并在该字段「业务含义」列加 `【人工填写:需用户审阅】` 标注,待步骤 E 用户审阅时调整;**不打断本次推导**。 ### C. 渲染 docs/03 -1. 读取 `${CLAUDE_SKILL_DIR}/templates/docs-03-header-template.md`,填充 `schema_name`(从 `config-vars.yaml` 读 `database.schema`,无则填 `【人工填写:database.schema】`)、`er_overview`(纯文本 ER 概览)。「项目标准列约定」是固定 5 列,无占位、原样保留。 -2. 渲染「表清单」:对每张表读取并填充 `${CLAUDE_SKILL_DIR}/templates/docs-03-table-template.md`——标准列 5 行已内置原样输出,只需填业务字段(`{{#each columns}}`)/ 索引 / 外键 / 业务注记。 +1. 读取 `${CLAUDE_SKILL_DIR}/templates/docs-03-header-template.md`,填充 `schema_name`(从 `config-vars.yaml` 读 `database.schema`,无则填 `【人工填写:database.schema】`)、`er_overview`(纯文本 ER 概览)。「项目标准列约定」是固定 7 列(主表)/ 8 列(从表,额外含 `sParentId`),无占位、原样保留。 +2. 渲染「表清单」:对每张表读取并填充 `${CLAUDE_SKILL_DIR}/templates/docs-03-table-template.md`——标准列已内置原样输出,只需填业务字段(`{{#each columns}}`)/ 索引 / 引用关系 / 业务注记。标准列按表位区分:**主表**(docs/03 表清单第一张表)输出 7 行标准列(`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `iOrder` / `sMemo`);**从表**(第二张起的其余表)输出 8 行,额外加入 `sParentId`(varchar(50) NOT NULL,业务父级ID)且**位置紧随 `sId` 之后**。其中 `sId` / `sBrandsId` / `sSubsidiaryId` / `sParentId` 均为 varchar(50) NOT NULL;`sBrandsId` / `sSubsidiaryId` 默认 `1111111111`;`tCreateDate` 默认当前时间;`iOrder`(int NOT NULL,排序号)默认为数据行条数+1,由应用在 insert 时算 count+1 赋值(非 SQL 默认);`sMemo`(LONGTEXT,可空)备注。这些列的『当前时间 → CURRENT_TIMESTAMP』『1111111111 → DEFAULT '1111111111'』『iOrder 不写 SQL 默认』等 DDL 默认值翻译由下游 A.1 db-init 处理,docs/03 只记录业务语义。 3. 写入 `docs/03-数据库设计文档.md`。 勾选:` - [ ] docs/03-数据库设计文档.md 已生成` @@ -59,7 +62,7 @@ allowed-tools: Read Write Edit Grep Glob ``` [db-design-gen] ✅ A3 DB 设计完成 产出:docs/03-数据库设计文档.md + REQ 卡片依赖表/模块头涉及表已回填 - ⏸ 请审阅 docs/03(业务实体覆盖、字段类型/默认值、索引、外键策略、`【人工填写:需用户审阅】` 标注)。 + ⏸ 请审阅 docs/03(业务实体覆盖、字段类型/默认值、索引、语义引用关系、`【人工填写:需用户审阅】` 标注)。 审阅完成后运行:/erp-workflow:plan-start ``` diff --git a/skills/plan/db-design-gen/templates/docs-03-header-template.md b/skills/plan/db-design-gen/templates/docs-03-header-template.md index c8ad6a1..117fe73 100644 --- a/skills/plan/db-design-gen/templates/docs-03-header-template.md +++ b/skills/plan/db-design-gen/templates/docs-03-header-template.md @@ -6,15 +6,25 @@ ## 项目标准列约定 -下文每张业务表的字段清单都自动包含以下 5 个标准列(匈牙利前缀 `i` int / `s` varchar / `t` datetime)。渲染时由 `docs-03-table-template.md` 模板内置原样输出。 - -| 列名 | 类型 | 可空 | 主键 | 说明 | -|---|---|---|---|---| -| `iIncrement` | int | 否 | 是 | 整数主键 ID(自增方式由实现决定:DB `AUTO_INCREMENT` 或应用 / 触发器分配) | -| `sId` | varchar(100) | 是 | — | 业务 ID(对外暴露的字符串标识,如 UUID / 人类可读编号) | -| `sBrandsId` | varchar(100) | 是 | — | 品牌 ID(多租户隔离) | -| `sSubsidiaryId` | varchar(100) | 是 | — | 子公司 ID(组织层级隔离) | -| `tCreateDate` | datetime | 否 | — | 记录创建时间 | +下文每张业务表的字段清单都自动包含以下 7 个标准列(匈牙利前缀 `i` int / `s` varchar / `t` datetime);**从表(本文档「表清单」里除第一张主表之外的所有表)额外再加 1 个标准列 `sParentId`,共 8 个标准列**。渲染时由 `docs-03-table-template.md` 模板内置原样输出。 + +主表 = 「表清单」中的**第一张表**;从表 = 其余各表。 + +| 列名 | 类型 | 可空 | 主键 | 默认 | 说明 | +|---|---|---|---|---|---| +| `iIncrement` | int | 否 | 是 | — | 整数主键 ID(标准列);DDL 译为 `PRIMARY KEY` + `AUTO_INCREMENT` | +| `sId` | varchar(50) | 否 | — | — | 业务 ID(标准列,对外暴露的字符串标识,如 UUID / 人类可读编号) | +| `sBrandsId` | varchar(50) | 否 | — | `1111111111` | 品牌 ID(多租户隔离,标准列);DDL 译为 `DEFAULT '1111111111'` | +| `sSubsidiaryId` | varchar(50) | 否 | — | `1111111111` | 子公司 ID(组织层级隔离,标准列);DDL 译为 `DEFAULT '1111111111'` | +| `tCreateDate` | datetime | 否 | — | 当前时间 | 记录创建时间(标准列);DDL 译为 `DEFAULT CURRENT_TIMESTAMP` | +| `iOrder` | int | 否 | — | 数据行条数+1 | 排序号;**非 SQL 默认**——应用在 insert 时按 count+1 赋值,DDL 仅写 `int NOT NULL`(不写 DEFAULT 表达式,在该列 COMMENT / 表业务注记里注明 app-assigned) | +| `sMemo` | LONGTEXT | 是 | — | — | 备注(标准列) | + +**从表专属标准列**(从「表清单」第二张表起,即除第一张主表外的所有表都加,插入位置紧随 `sId` 之后): + +| 列名 | 类型 | 可空 | 主键 | 默认 | 说明 | +|---|---|---|---|---|---| +| `sParentId` | varchar(50) | 否 | — | — | 业务父级 ID(标准列);仅从表有,紧随 `sId` 之后 | 字典 / 辅助表如有豁免,在该表业务注记里注明豁免原因。 diff --git a/skills/plan/db-design-gen/templates/docs-03-table-template.md b/skills/plan/db-design-gen/templates/docs-03-table-template.md index ab9517e..3b43ec0 100644 --- a/skills/plan/db-design-gen/templates/docs-03-table-template.md +++ b/skills/plan/db-design-gen/templates/docs-03-table-template.md @@ -5,10 +5,12 @@ | 字段 | 类型 | Nullable | 默认 | 业务含义 | |---|---|---|---|---| | `iIncrement` | int | 否 | — | 整数主键 ID(标准列) | -| `sId` | varchar(100) | 是 | — | 业务 ID(标准列) | -| `sBrandsId` | varchar(100) | 是 | `1111111111` | 品牌 ID,多租户隔离(标准列) | -| `sSubsidiaryId` | varchar(100) | 是 | `1111111111` | 子公司 ID,组织层级隔离(标准列) | +| `sId` | varchar(50) | 否 | — | 业务 ID(标准列) | +| `sBrandsId` | varchar(50) | 否 | `1111111111` | 品牌 ID,多租户隔离(标准列) | +| `sSubsidiaryId` | varchar(50) | 否 | `1111111111` | 子公司 ID,组织层级隔离(标准列) | | `tCreateDate` | datetime | 否 | 当前时间 | 创建时间(标准列) | +| `iOrder` | int | 否 | 数据行条数+1 | 排序号(标准列) | +| `sMemo` | LONGTEXT | 是 | — | 备注(标准列) | {{#each columns}} | {{name}} | {{type}} | {{nullable}} | {{default}} | {{business_meaning}} | {{/each}} @@ -18,9 +20,9 @@ - `{{name}}` ({{type}}): {{columns}} {{/each}} -### 外键 -{{#each foreign_keys}} -- `{{name}}`: {{from_col}} → {{to_table}}.{{to_col}} ({{on_delete}}) +### 引用关系(语义,无 FK 约束) +{{#each references}} +- {{from_col}} → {{to_table}}.{{to_col}}(语义引用,应用维护一致性) {{/each}} ### 业务注记 diff --git a/skills/plan/db-init/SKILL.md b/skills/plan/db-init/SKILL.md index 830f03c..12fbe48 100644 --- a/skills/plan/db-init/SKILL.md +++ b/skills/plan/db-init/SKILL.md @@ -1,6 +1,6 @@ --- name: db-init -description: A4 DB 初始化——LLM 解析 docs/03-数据库设计文档.md → 生成 sql/migrations/V1__initial_schema.sql(DDL only,Flyway 初始 migration)→ 用 lib/validate-ddl.mjs 做 5 维校验(表/列/类型/索引/外键)DDL ↔ docs/03 一致性 → 调 scripts/setup-test-db.mjs DROP+CREATE 空库 → 用 lib/apply-ddl.mjs apply V1。 +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。 user-invocable: false allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm install mysql2) --- @@ -17,11 +17,18 @@ allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm in #### A.1 读 docs/03 并翻译为 DDL -读取 `docs/03-数据库设计文档.md`,对每张表生成一段 `CREATE TABLE`(字段顺序/可空/默认/列注释严格对齐 docs/03 行序),随后按顺序追加 `CREATE INDEX` 与统一追加的 `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY`。**严禁臆造或省略** docs/03 中的任何表/字段/索引/外键/约束。字符集 `utf8mb4` + `utf8mb4_unicode_ci`、引擎 `InnoDB`,除非 docs/03 业务注记另有说明。 +读取 `docs/03-数据库设计文档.md`,对每张表生成一段 `CREATE TABLE`(字段顺序/可空/默认/列注释严格对齐 docs/03 行序),随后按顺序追加 `CREATE INDEX`(含语义引用列上的索引)。**不生成任何 `ALTER TABLE ... ADD FOREIGN KEY` 或内联 `FOREIGN KEY` 约束**——表间关系靠语义判断(列命名约定 + 应用层一致性)维护,docs/03 的引用关系仅作语义记录。**严禁臆造或省略** docs/03 中的任何表/字段/索引。字符集 `utf8mb4` + `utf8mb4_unicode_ci`、引擎 `InnoDB`,除非 docs/03 业务注记另有说明。 + +> **标准列默认值的 DDL 翻译规则**(docs/03「默认」列是人话,翻进 DDL 时按下表落成 SQL,仍以 docs/03 行内容为准,不臆造): +> - `iIncrement`(整数主键)→ `PRIMARY KEY` + `AUTO_INCREMENT`。 +> - `tCreateDate`「当前时间」→ `DEFAULT CURRENT_TIMESTAMP`。 +> - `sBrandsId` / `sSubsidiaryId`「1111111111」→ `DEFAULT '1111111111'`。 +> - `iOrder`「数据行条数+1」→ **不可作为 SQL DEFAULT**(MySQL 无法 default 成 count+1),DDL 只写 `int NOT NULL`,count+1 由应用在 insert 时算好赋值(在该列 `COMMENT` 或表业务注记里注明 app-assigned)。 +> - `sId` / `sParentId`(从表才有,紧随 `sId`)/ `sMemo` → 无 `DEFAULT`(`sMemo` 可空 `LONGTEXT`,其余 `NOT NULL`)。 #### A.2 落盘 V1 文件 -用 `Write` 写 `sql/migrations/V1__initial_schema.sql`(`Write` 自动创建父目录)。文件开头是以下 6 行注释,其后接 A.1 的 DDL 主体(`CREATE TABLE` → `CREATE INDEX` → `ALTER TABLE ... ADD FOREIGN KEY`): +用 `Write` 写 `sql/migrations/V1__initial_schema.sql`(`Write` 自动创建父目录)。文件开头是以下 6 行注释,其后接 A.1 的 DDL 主体(`CREATE TABLE` → `CREATE INDEX`): ```sql -- Flyway migration V1 — initial schema for -- 从 CLAUDE.md § 🎯 项目概述 读 @@ -32,11 +39,11 @@ allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm in -- Do not hand-edit this file after it is committed; write a new migration instead. ``` -#### A.3 校验 V1 ↔ docs/03 5 维一致性 + 自主修正 +#### A.3 校验 V1 ↔ docs/03 4 维一致性 + 自主修正 -调 `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 做跨平台、纯 Node 的 5 维校验(表集合 / 列名 / 列类型 / 索引 / 外键)。**注意参数顺序:docs/03 在前,V1.sql 在后。** +调 `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 做跨平台、纯 Node 的 4 维校验(表集合 / 列名 / 列类型 / 索引)。**注意参数顺序:docs/03 在前,V1.sql 在后。** -> **机检边界(勿误解)**:5 维 = 表集合 / 列名 / 列类型 / 索引(名 + UNIQUE\|INDEX 类别 + 列)/ 外键(列 → 表(列) + ON DELETE);表体内联与独立 `CREATE INDEX` / `ALTER TABLE ... ADD FOREIGN KEY` 两种形态都识别。**A.1 要求的「字段顺序 / 可空 / 默认 / 列注释对齐」不在机检范围内**——这几项靠 A.1 翻译时忠实对齐 docs/03(docs/03 已在 A3 人工审阅过),validate-ddl 不会代为兜底,勿因校验通过就认定它们也一致。 +> **机检边界(勿误解)**:4 维 = 表集合 / 列名 / 列类型 / 索引(名 + UNIQUE\|INDEX 类别 + 列);表体内联与独立 `CREATE INDEX` 两种形态都识别。**A.1 要求的「字段顺序 / 可空 / 默认 / 列注释对齐」不在机检范围内**——这几项靠 A.1 翻译时忠实对齐 docs/03(docs/03 已在 A3 人工审阅过),validate-ddl 不会代为兜底,勿因校验通过就认定它们也一致。 ```bash node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ @@ -46,7 +53,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ 退出码与处理: - `0` → 通过,进入步骤 B -- `1` → 存在差异(5 维 diff 明细打印到 stderr)。进入**自主修正循环**(最多 3 轮,docs/03 是 SSoT 不动): +- `1` → 存在差异(4 维 diff 明细打印到 stderr)。进入**自主修正循环**(最多 3 轮,docs/03 是 SSoT 不动): 1. 解析 stderr 差异清单,修正 V1.sql 2. 重跑 `validate-ddl.mjs` 3. 退出 0 → 进入 B;退出 1 且本轮 < 3 → 回步骤 1;本轮 ≥ 3 仍失败 → 停下,打印最终残留差异 + 已尝试的 3 轮修正摘要,让用户介入 @@ -54,7 +61,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ 完成后(V1 写入并通过 `validate-ddl.mjs` 校验),勾选: - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成` -- ` - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs)` +- ` - [ ] DDL ↔ docs/03 4 维一致(validate-ddl.mjs)` ### B. 自动导入 MySQL @@ -95,14 +102,14 @@ node scripts/setup-test-db.mjs ### C. 勾选 docs/08 进度 + 进入 A5 -1. 勾选 A4 顶层(5 维一致已由 A.3 的 `validate-ddl.mjs` 校验过,apply 不改 V1,无需复校): +1. 勾选 A4 顶层(4 维一致已由 A.3 的 `validate-ddl.mjs` 校验过,apply 不改 V1,无需复校): - `- [ ] A4 DB 初始化 — db-init` 2. 立即调用 `Skill(downstream-gen)` 进入 A5,不等用户手动输入。 ## 参考 -- `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs`(A.3 docs/03 ↔ V1.sql 5 维一致性校验,跨平台纯 Node) +- `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs`(A.3 docs/03 ↔ V1.sql 4 维一致性校验,跨平台纯 Node) - `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`(B.2 读取 config-vars.yaml 的 database: 段 + mysql2 灌入 DDL) - `${CLAUDE_PLUGIN_ROOT}/lib/yaml-config.mjs`(apply-ddl 依赖的极简 YAML 读取) - `docs/03-数据库设计文档.md`(DDL 翻译输入,SSoT) diff --git a/skills/plan/project-init/templates/docs-08-initial-template.md b/skills/plan/project-init/templates/docs-08-initial-template.md index d057613..cedce9b 100644 --- a/skills/plan/project-init/templates/docs-08-initial-template.md +++ b/skills/plan/project-init/templates/docs-08-initial-template.md @@ -27,7 +27,7 @@ - [ ] A4 DB 初始化 — db-init - [ ] sql/migrations/V1__initial_schema.sql 已生成 - - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs) + - [ ] DDL ↔ docs/03 4 维一致(validate-ddl.mjs) - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行 - [ ] A5 下游文档生成 — downstream-gen diff --git a/workflows/coding.mjs b/workflows/coding.mjs index 0887715..d7a2beb 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -590,7 +590,7 @@ function seedStageContract() { '## 硬约束(非交互演示种子子代理)', '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', '- 你的职责 = **为本模块生成演示种子(demo seed)并冷起栈真跑验证**——**不是**实现功能、**不是**改源码、**不是**改 schema。', - '- 缺值查找顺序:`config-vars.yaml` → `docs/03-数据库设计文档.md` → `docs/01-需求清单/` 各 REQ 卡(业务语义)→ 既有 `sql/seed/*`(跨模块 FK 引用前序模块种子的已知主键)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。', + '- 缺值查找顺序:`config-vars.yaml` → `docs/03-数据库设计文档.md` → `docs/01-需求清单/` 各 REQ 卡(业务语义)→ 既有 `sql/seed/*`(跨模块语义引用前序模块种子的已知主键)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。', `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下——\`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)、起后端服务(gradle bootRun 等,Flyway 在此建 schema)、\`node ${ROOT}/scripts/seed-demo-data.mjs\`(注入种子)、mysql **只读** COUNT/查询;唯一允许**写入**的路径是 \`${ROOT}/sql/seed/\`(种子文件,随 git 提交)+ \`${ROOT}/.tmp/seed-gen//\`(一次性 runner,跑完即弃)+ 证据报告 \`${ROOT}/docs/superpowers/module-reports/-seed-verify.md\`。`, `- **越界硬停**:**绝不**编辑 \`backend/\` / \`frontend/\` / \`scripts/\` 下的任何源码文件(只许**运行** scripts/setup-test-db.mjs 与 scripts/seed-demo-data.mjs,不许改它们)。区分「运行 backend 服务 / 运行脚本」(允许)与「写 backend 实现 / 改脚本」(越界)。命中越界即以 \`status:halt\` 写清阻塞点结束。`, '- **确定性红线(关键)**:种子值一律**显式主键**(1000–9999 区间)+ **固定历史日期**(写死字面量,如 `2024-03-15`),**绝不**依赖时间戳 / `NOW()` / 随机数 / 自增主键的隐式取值。', @@ -616,9 +616,9 @@ function seedGenPrompt(module) { '种子产物随 git 提交(不保证「存活」,保证「随时可复现」——三处 DROP+CREATE 各在自己时序里固定重注入)。', '', '## 输入', - `- \`${ROOT}/docs/03-数据库设计文档.md\`:本模块各表结构(列 / 类型 / enum 值域 / FK / NOT NULL / UNIQUE 约束)。`, + `- \`${ROOT}/docs/03-数据库设计文档.md\`:本模块各表结构(列 / 类型 / enum 值域 / 语义引用关系 / NOT NULL / UNIQUE 约束)。`, `- \`${ROOT}/docs/01-需求清单//\` 本模块 REQ 卡:业务语义(让假数据有真实感、符合业务取值)。`, - `- 既有 \`${ROOT}/sql/seed/*.sql\`:跨模块 FK 引用前序模块种子的**已知确定性主键**(你的 FK 列必须引用这些已存在的主键,不可悬空)。`, + `- 既有 \`${ROOT}/sql/seed/*.sql\`:跨模块语义引用前序模块种子的**已知确定性主键**(你的语义引用列必须指向这些已存在的主键,不可悬空)。`, `- \`${ROOT}/config-vars.yaml\`:database 段凭据(seed-demo-data.mjs / setup-test-db.mjs 自行读取,你只需确保起栈参数一致)。`, '', '## 幂等(resume 安全)', @@ -626,12 +626,12 @@ function seedGenPrompt(module) { `- **不存在** → 新建 \`sql/seed/__${id}.sql\`,其中 \`NN\` = 既有 \`sql/seed/*.sql\` 文件名最大序号 + 1(两位补零,如既有最大为 \`03\` → 本文件用 \`04\`;无任何既有文件 → \`01\`)。`, '', '## 生成规则', - '- **FK 有序**:同一文件内 INSERT 先父后子;跨模块 FK 列引用既有 `sql/seed/*` 中前序模块种子的已知主键。', + '- **按语义引用有序(先被引用方后引用方)**:同一文件内 INSERT 先被引用方后引用方;跨模块语义引用列指向既有 `sql/seed/*` 中前序模块种子的已知主键。', '- **显式主键**:本模块种子行主键固定落 **1000–9999** 区间(避开 1–999 初始数据 / ≥100000 sentinel);同表内主键唯一、确定性。', '- **真实感中文业务数据**:依 REQ 卡业务语义取值(人名 / 机构 / 金额 / 状态等),不要 `测试1`/`aaa` 占位;但**绝不含 `_S<数字>` 样式编码**(预留 sentinel)。', '- **enum 取值域**:enum 列只从 `docs/03` 声明的值域取值(越界即数据类失败)。', '- **固定历史日期**:日期/时间列写死固定历史字面量(如 `2024-03-15 10:00:00`),绝不 `NOW()` / 时间戳。', - '- **行数**:主业务列表表(页面会分页展示的)给 **15–30 行**(够触发分页 + 行级操作);字典/配置类小表按需少量(够 FK 引用 + 下拉非空)。', + '- **行数**:主业务列表表(页面会分页展示的)给 **15–30 行**(够触发分页 + 行级操作);字典/配置类小表按需少量(够语义引用 + 下拉非空)。', `- **头部注释(机器可读,验证对账依赖)**:文件头第一行 \`-- demo-seed: ${id}\`;随后**每张被本文件 INSERT 的表各一行** \`-- expect:
=\`(rows = 本文件向该表插入的行数)。`, `- **本模块无可种表**(纯计算/无表模块)→ **不建文件**,直接 \`status:ok\` + summary 说明「模块 ${id} 无可种表,跳过」(跳过下面的验证与 commit)。`, '', @@ -645,7 +645,7 @@ function seedGenPrompt(module) { ' - `finally` **硬要求 kill 本 stage 起的全部子进程**(绝不让 gradle bootRun 挂死会话)。', '- **失败归类(reason 里必须分清)**:', ' - **环境类**(端口占用 / 起栈超时 / setup-test-db 失败 / 健康端点不就绪)→ reason 标 `env-error` + 端口/pid。', - ' - **数据类**(撞主键/唯一键 / FK 错序或悬空 / enum 越界 / 类型截断 / COUNT 不符)→ reason 标 `data-error` + 具体表与根因(这是种子本身的 bug,必须修种子文件后重验)。', + ' - **数据类**(撞主键/唯一键 / 引用错序或悬空 / enum 越界 / 类型截断 / COUNT 不符)→ reason 标 `data-error` + 具体表与根因(这是种子本身的 bug,必须修种子文件后重验)。', '', '## 证据落盘', `- 写 \`${evidence}\`(中文):逐表「期望行数 / 实际行数 / 结论(match/mismatch)」表格 + 本模块种子文件路径 + 起栈端口 + 关键决策。`, @@ -734,8 +734,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt) { '## step2 起栈五段严格时序(schema 由 Flyway 在后端启动时才建)', `1) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`, '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。', - `3) **注入演示种子**:\`node ${ROOT}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过已应用文件,把 \`sql/seed/*.sql\` 演示数据注入空库)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`, - '4) **此时才跑 sentinel 种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** sentinel 种子(先父后子;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)。失败 → `envError.kind="seed-error"` + 结构化根因,**不**混进交互 RED。', + `3) **注入演示种子**:\`node ${ROOT}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过已应用文件,把 \`sql/seed/*.sql\` 演示数据注入空库)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / 引用序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`, + '4) **此时才跑 sentinel 种子**:按 `docs/03-数据库设计文档.md` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)。失败 → `envError.kind="seed-error"` + 结构化根因,**不**混进交互 RED。', ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——数值主键**一律 ≥100000**(固定区间,不再动态扫描既有键:初始数据 1–999 / 演示种子 1000–9999 已由区间约定隔离,sentinel 落 ≥100000 天然不冲突);字符串列**仍逐字段唯一编码**(`_S` 样式,如 `CUST_NAME_S001`,抓绑错字段——演示数据已被禁用该样式,故 sentinel 独占)+ 行序号保 UNIQUE;enum 列从 docs/03 值域取并标注。断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。', '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。', '- `finally` **硬要求 kill 本门起的全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。',