Commit f6f13dfc821612d3c7770c98c35d3781e91ed53b
1 parent
12b0d6d4
schema: 标准列扩展(主表7/从表8列) + 去外键改用纯语义判断(validate-ddl 降4维)
标准列:每表 7 列(新增 iOrder/sMemo;sId/sBrandsId/sSubsidiaryId 改 varchar(50) NOT NULL),从表额外 +sParentId 共 8 列。docs-03 模板 + A3 渲染规则 + A4 DDL 默认值翻译(CURRENT_TIMESTAMP / DEFAULT '1111111111' / iOrder app-assigned)。 去外键:A3 不再推导 FK 约束(改语义引用关系,无 ON DELETE/ON UPDATE,应用层维护一致性);A4 DDL 不生成 ALTER ADD FOREIGN KEY;validate-ddl 移除外键维度 5→4 维(表/列/类型/索引),单测 45 pass/0 fail;coding.mjs 种子/sentinel「FK 有序」→「按语义引用有序」(保留先被引用方后引用方/不可悬空)。 注:db-design-gen 的 docs/06 读取清单/步骤B 两条 bullet 与本区域改动同处一个 diff hunk,随本提交落入(实属 docs/06 feature 的 A3 接入)。
Showing
9 changed files
with
114 additions
and
362 deletions
README.md
| ... | ... | @@ -77,7 +77,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 |
| 77 | 77 | ``` |
| 78 | 78 | Plan 阶段**两段式**执行,中间有一个人工审阅断点(docs/03 数据库 schema): |
| 79 | 79 | |
| 80 | - - **第一段(首次运行)**:执行 **A0 → A1 → A2 → A3**(创建骨架 / 锁技术栈 / 填需求 / 生成 REQ 卡片 / 生成项目骨架 / 从 REQ 正向设计 `docs/03-数据库设计文档.md` 并回填 REQ 依赖表)后**停下**,等你审阅 docs/03 的表 / 字段 / 索引 / 外键(人工关口:数据库 schema —— A4 会基于它翻译 DDL 并 apply 到 MySQL)。A1 的 REQ 卡片由 CC 据 index.md 填 6 个占位、字段表按模板原样复制,**不再单独停下审阅** | |
| 80 | + - **第一段(首次运行)**:执行 **A0 → A1 → A2 → A3**(创建骨架 / 锁技术栈 / 填需求 / 生成 REQ 卡片 / 生成项目骨架 / 从 REQ 正向设计 `docs/03-数据库设计文档.md` 并回填 REQ 依赖表)后**停下**,等你审阅 docs/03 的表 / 字段 / 索引 / 语义引用关系(人工关口:数据库 schema —— A4 会基于它翻译 DDL 并 apply 到 MySQL)。A1 的 REQ 卡片由 CC 据 index.md 填 6 个占位、字段表按模板原样复制,**不再单独停下审阅** | |
| 81 | 81 | - **第二段(docs/03 审阅完重新运行)**:执行 **A4 → A5**(解析 docs/03 → 生成 V1 migration → 自动 `DROP+CREATE` 本地 schema 并 apply → 生成下游文档 → **docs/05 + docs/02 评审闸** → prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三),通过 **Plan 终结硬闸** 后再次**停下**(前端布局/交互以 `prototype/` 为权威,不另设 UI 规范文档) |
| 82 | 82 | |
| 83 | 83 | Plan 完成后不会自动进入编码,需手动 /erp-workflow:coding-start。 |
| ... | ... | @@ -100,7 +100,7 @@ erp-workflow-plugin/ |
| 100 | 100 | ├── workflows/ |
| 101 | 101 | │ └── coding.mjs # 阶段 B:整个编码阶段编排为单个静默 Workflow |
| 102 | 102 | ├── lib/ # 跨平台 Node 助手(ESM,node:test 单测) |
| 103 | -│ ├── validate-ddl.mjs # docs/03 ↔ DDL 5 维校验(替代 validate.sh) | |
| 103 | +│ ├── validate-ddl.mjs # docs/03 ↔ DDL 4 维校验(替代 validate.sh) | |
| 104 | 104 | │ ├── yaml-config.mjs # config-vars.yaml 极简 YAML 读取(2 层 map + 标量) |
| 105 | 105 | │ ├── apply-ddl.mjs # 解析 config-vars.yaml database: 段 + mysql2 apply |
| 106 | 106 | │ └── *.test.mjs # 各助手的 node:test 单测 |
| ... | ... | @@ -135,8 +135,8 @@ erp-workflow-plugin/ |
| 135 | 135 | | A0 | `project-init` | • **依赖检查**:检测 git / mysql / node 是否在 PATH,缺失则按 OS 自动安装,装不上再停下提示用户<br>• 空目录初始化:用 Read/Write/Glob 工具拷模板创建 CLAUDE.md / docs/01/index.md / docs/08<br>• `git init` | `plan-start` | |
| 136 | 136 | | A1 | `scope-lock` | • 引导填项目概述 / 技术栈 / 需求索引<br>• 按 `docs/01-需求清单/<module>/{_module.md, <req_id>.md}` 子目录结构生成 REQ 卡片(req_id = `<模块代码>-<子模块代码>-<功能名>`,如 `USR-UserInfo-Login`;CC 据 index.md 填 `{{req_id/title/goal/rules/constraints/acceptance}}` 6 个占位,模板其余内容含输入/输出示例字段表原样复制)<br>• **A1 终结校验**:REQ 6 个占位均填真实数据、无 `{{` 残留、`config-vars.yaml` **全部配置**(包名 / 端口 / 初始账号 + DB 凭据 / 密钥占位)已锁、各 stack 的 build/lint/unit/e2e 命令写入 docs/04 § 零;缺失则在此(Plan 期)用 `AskUserQuestion` 问清(敏感凭据由用户自填,不进会话)<br>• 据模板直接 `Write` 生成 `_module.md` / `<req_id>.md`<br>• 终结校验通过后**自动**调用 `Skill(skeleton-gen)` 进入 A2(不停下) | A0 | |
| 137 | 137 | | A2 | `skeleton-gen` | • 生成架构文档:docs/04 § 一+<br>• 生成跨平台工具脚本:`scripts/*.mjs`(**无 chmod**;凭据 / 配置统一在 A1 产出的 config-vars.yaml)<br>• 据 `gitignore-append-template` 用 Read/Write 并入项目 .gitignore | `plan-start` | |
| 138 | -| A3 | `db-design-gen` | • 套用固定 ERP 约定(列前缀 `i/s/t`、`iIncrement` 主键、`sBrandsId`/`sSubsidiaryId` 租户列)从 docs/01 REQ 卡片正向设计 `docs/03-数据库设计文档.md`(schema SSoT)<br>• 回填 REQ 卡片依赖表(`TBD(A3 自动补)` → 实际表名)<br>• **停下**等人工审阅 docs/03,审阅完毕用 `/plan-start` 续进 A4 | A2 | | |
| 139 | -| A4 | `db-init` | • LLM 解析 docs/03 → `sql/migrations/V1__initial_schema.sql`(DDL only)<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 校验 DDL ↔ docs/03(5 维:表/列名/列类型/索引/FK),fail-closed<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs config-vars.yaml V1.sql`(读取 config-vars.yaml database: 段 + mysql2 apply) | A3 | | |
| 138 | +| 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)<br>• 回填 REQ 卡片依赖表(`TBD(A3 自动补)` → 实际表名)<br>• **停下**等人工审阅 docs/03,审阅完毕用 `/plan-start` 续进 A4 | A2 | | |
| 139 | +| A4 | `db-init` | • LLM 解析 docs/03 → `sql/migrations/V1__initial_schema.sql`(DDL only)<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 校验 DDL ↔ docs/03(4 维:表/列名/列类型/索引),fail-closed<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs config-vars.yaml V1.sql`(读取 config-vars.yaml database: 段 + mysql2 apply) | A3 | | |
| 140 | 140 | | A5 | `downstream-gen` | • 一次性生成 docs/02 / docs/05<br>• 回填 REQ 卡片依赖接口(`TBD(A5 自动补)` → 实际 endpoint)<br>• 追加模块清单到 docs/08 § 二<br>• **docs/05 + docs/02 评审闸**:用 `AskUserQuestion` 让用户确认 API 端点/字段无误 + 构建顺序可接受,未确认不勾 A5<br>• **prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三**(原 A6 已并入;无 prototype 则问「无前端」→ § 三 留空)<br>• 最终占位符 + 结构残留扫描 | A4 | |
| 141 | 141 | |
| 142 | 142 | ### Coding 阶段(1 个 Workflow,非 skill) | ... | ... |
lib/validate-ddl.mjs
| 1 | -// lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验 | |
| 1 | +// lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 4 维校验 | |
| 2 | 2 | // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 |
| 3 | 3 | // |
| 4 | 4 | // 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath> |
| ... | ... | @@ -6,14 +6,13 @@ |
| 6 | 6 | // 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' |
| 7 | 7 | // |
| 8 | 8 | // 数据结构(解析结果):Map<tableName, { |
| 9 | -// columns: Map<colName, type>, indexes: Set<string>, foreignKeys: Set<string> }> | |
| 9 | +// columns: Map<colName, type>, indexes: Set<string> }> | |
| 10 | 10 | |
| 11 | 11 | // ── 解析 docs/03 markdown 表定义 ───────────────────────────────── |
| 12 | 12 | // 约定:每张表一节,节标题形如 ## `表名` 或 ## `表名` — 业务含义 |
| 13 | -// 节内分 ### 字段(markdown 表格,首列列名、次列类型)、### 索引、### 外键(项目符号列表)。 | |
| 14 | -// 索引/外键的 bullet 形态见 db-design-gen/templates/docs-03-table-template.md: | |
| 13 | +// 节内分 ### 字段(markdown 表格,首列列名、次列类型)、### 索引(项目符号列表)。 | |
| 14 | +// 索引的 bullet 形态见 db-design-gen/templates/docs-03-table-template.md: | |
| 15 | 15 | // ### 索引 → - `name` (type): cols |
| 16 | -// ### 外键 → - `name`: from_col → to_table.to_col (on_delete) | |
| 17 | 16 | // 跳过表头行(列/字段/类型等标签)与分隔行(---)。 |
| 18 | 17 | // 形如「## 一、全局约定」这类非反引号标题不视为表。 |
| 19 | 18 | export function parseDocsTables(text) { |
| ... | ... | @@ -21,14 +20,14 @@ export function parseDocsTables(text) { |
| 21 | 20 | const lines = String(text).split('\n') |
| 22 | 21 | // 反引号包裹的表名:## `name` 或 ## `name` — purpose |
| 23 | 22 | const headerRe = /^##\s+`([^`]+)`/ |
| 24 | - let current = null // { columns, indexes, foreignKeys } | |
| 25 | - let mode = 'col' // 当前子区块:'col'(字段表格)/ 'idx'(索引)/ 'fk'(外键) | |
| 23 | + let current = null // { columns, indexes } | |
| 24 | + let mode = 'col' // 当前子区块:'col'(字段表格)/ 'idx'(索引) | |
| 26 | 25 | |
| 27 | 26 | for (const raw of lines) { |
| 28 | 27 | const line = raw.replace(/\r$/, '') |
| 29 | 28 | const h2 = line.match(headerRe) |
| 30 | 29 | if (h2) { |
| 31 | - current = { columns: new Map(), indexes: new Set(), foreignKeys: new Set() } | |
| 30 | + current = { columns: new Map(), indexes: new Set() } | |
| 32 | 31 | mode = 'col' |
| 33 | 32 | tables.set(h2[1].trim(), current) |
| 34 | 33 | continue |
| ... | ... | @@ -39,15 +38,14 @@ export function parseDocsTables(text) { |
| 39 | 38 | continue |
| 40 | 39 | } |
| 41 | 40 | if (!current) continue |
| 42 | - // ### 子区块切换(### 索引 / ### 外键 / 其它如 ### 字段、### 业务注记 → col) | |
| 41 | + // ### 子区块切换(### 索引 / 其它如 ### 字段、### 业务注记、### 引用关系 → col) | |
| 43 | 42 | const h3 = line.match(/^###\s+(.+)$/) |
| 44 | 43 | if (h3) { |
| 45 | 44 | const title = h3[1].trim() |
| 46 | - mode = /索引|index/i.test(title) ? 'idx' : /外键|foreign/i.test(title) ? 'fk' : 'col' | |
| 45 | + mode = /索引|index/i.test(title) ? 'idx' : 'col' | |
| 47 | 46 | continue |
| 48 | 47 | } |
| 49 | 48 | if (mode === 'idx') { parseIndexBullet(line, current.indexes); continue } |
| 50 | - if (mode === 'fk') { parseForeignKeyBullet(line, current.foreignKeys); continue } | |
| 51 | 49 | // mode === 'col':markdown 表格行(以 | 开头) |
| 52 | 50 | if (!/^\s*\|/.test(line)) continue |
| 53 | 51 | const cells = splitMarkdownRow(line) |
| ... | ... | @@ -89,42 +87,6 @@ function parseIndexBullet(line, indexes) { |
| 89 | 87 | indexes.add(`${name}:${kind}:${cols}`) |
| 90 | 88 | } |
| 91 | 89 | |
| 92 | -// 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete) | |
| 93 | -// 归一化为 parseDDL 同形的 `${fromCols}->${toTable}(${toCols})`(注意 docs 用 unicode → / DDL 用 ->)。 | |
| 94 | -function parseForeignKeyBullet(line, foreignKeys) { | |
| 95 | - // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 | |
| 96 | - // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 | |
| 97 | - // 目标表名用 [^`\s.]+(接受反引号包裹的中文表名,H3;以 `.` 与目标列分隔),与 docs headerRe 的非 ASCII 容许度对齐。 | |
| 98 | - const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([^`\s.]+)`?\s*\.\s*(.+)$/) | |
| 99 | - if (!head) return | |
| 100 | - const fromRaw = head[1] | |
| 101 | - const toTable = head[2] | |
| 102 | - let toRaw = head[3] | |
| 103 | - if (!fromRaw || !toTable || !toRaw) return | |
| 104 | - | |
| 105 | - const fromCols = fromRaw.replace(/`/g, '').replace(/\s+/g, '') | |
| 106 | - | |
| 107 | - // 2) 目标列:剥掉一对外层圆括号(如果有),按逗号切分,去反引号 / 空白;遇到第一个非 | |
| 108 | - // `[A-Za-z0-9_]` 列分隔符以外的字符(如 `(CASCADE)`、` on delete ...`)就停止收集。 | |
| 109 | - toRaw = toRaw.trim() | |
| 110 | - // 在分列前先尝试抓取尾部的 on-delete 标记:(CASCADE) / (RESTRICT) / (SET NULL) / (NO ACTION) / | |
| 111 | - // (SET DEFAULT);docs 模板规约把 action 写在一对独立括号里,紧跟在目标列之后。 | |
| 112 | - const onDeleteMatch = toRaw.match(/\((CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION)\)\s*$/i) | |
| 113 | - const onDelete = onDeleteMatch ? onDeleteMatch[1].toUpperCase().replace(/\s+/g, ' ') : 'RESTRICT' | |
| 114 | - // 剥外层括号:(idA, idB) → idA, idB | |
| 115 | - const paren = toRaw.match(/^\(([^)]*)\)/) | |
| 116 | - let toBody = paren ? paren[1] : toRaw | |
| 117 | - // 截断到第一个 `(`(如 `(CASCADE)`)或行尾。 | |
| 118 | - toBody = toBody.split('(')[0] | |
| 119 | - const toCols = toBody | |
| 120 | - .split(',') | |
| 121 | - .map(s => s.replace(/`/g, '').trim()) | |
| 122 | - .filter(s => /^[A-Za-z0-9_]+$/.test(s)) | |
| 123 | - .join(',') | |
| 124 | - if (!fromCols || !toTable || !toCols) return | |
| 125 | - foreignKeys.add(`${fromCols}->${toTable}(${toCols}):${onDelete}`) | |
| 126 | -} | |
| 127 | - | |
| 128 | 90 | // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── |
| 129 | 91 | // 标识符 token:反引号包裹(任意非反引号字符,支持中文)或裸 ASCII 标识符(含 `$`)。 |
| 130 | 92 | // docs 侧表名/索引名以 `[^`]+` 接受中文,DDL 侧此前仅 `[A-Za-z0-9_]+` → 中文名假阳性(H3)。 |
| ... | ... | @@ -170,15 +132,12 @@ function blankStringLiterals(s) { |
| 170 | 132 | return out |
| 171 | 133 | } |
| 172 | 134 | |
| 173 | -// 表体内联索引 / 外键的匹配器(与 IDENT 同语法,支持反引号包裹的非 ASCII 名,H3 全路径一致)。 | |
| 135 | +// 表体内联索引的匹配器(与 IDENT 同语法,支持反引号包裹的非 ASCII 名,H3 全路径一致)。 | |
| 174 | 136 | const INLINE_KEY_RE = new RegExp( |
| 175 | 137 | '^(?:UNIQUE\\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\\s+KEY|SPATIAL\\s+KEY)\\s+(' + IDENT + ')\\s*\\(', 'i') |
| 176 | -const INLINE_FK_RE = new RegExp( | |
| 177 | - 'FOREIGN\\s+KEY\\s*\\(([^)]*)\\)\\s*REFERENCES\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(([^)]*)\\)' + | |
| 178 | - '(?:\\s+ON\\s+DELETE\\s+(CASCADE|RESTRICT|SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION))?', 'i') | |
| 179 | 138 | |
| 180 | -// 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。 | |
| 181 | -// 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX / ALTER TABLE ADD FK,C1)。 | |
| 139 | +// 提取每个 CREATE TABLE 的:列名→类型、索引名集合。 | |
| 140 | +// 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX,C1)。 | |
| 182 | 141 | export function parseDDL(text) { |
| 183 | 142 | const tables = new Map() |
| 184 | 143 | // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 |
| ... | ... | @@ -194,19 +153,18 @@ export function parseDDL(text) { |
| 194 | 153 | const bodyStart = createRe.lastIndex - 1 // 指向 '(' |
| 195 | 154 | const body = extractBalancedParens(src, bodyStart) |
| 196 | 155 | if (body == null) continue |
| 197 | - // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "FOREIGN KEY …" / "KEY …" 文本被 | |
| 156 | + // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "KEY …" 文本被 | |
| 198 | 157 | // 内联检测误当真实约束(REGEX-3);反引号标识符整段保留,列名/类型解析不读字面量内容,故不受影响。 |
| 199 | 158 | tables.set(tableName, parseTableBody(blankStringLiterals(body))) |
| 200 | 159 | // 继续从 body 之后扫描 |
| 201 | 160 | createRe.lastIndex = bodyStart + body.length + 2 |
| 202 | 161 | } |
| 203 | 162 | |
| 204 | - // 第二遍:db-init A.1/A.2 强制 DDL 形态为 CREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK, | |
| 205 | - // 索引 / 外键写在表体之外。把这些独立语句并回对应表,否则含索引 / 外键的 schema 首轮校验必报假阳性(C1)。 | |
| 206 | - // 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" / "ALTER TABLE …" 文本被误当语句(REGEX-3)。 | |
| 163 | + // 第二遍:db-init A.1/A.2 强制 DDL 形态为 CREATE TABLE → CREATE INDEX, | |
| 164 | + // 索引写在表体之外。把这些独立语句并回对应表,否则含索引的 schema 首轮校验必报假阳性(C1)。 | |
| 165 | + // 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" 文本被误当语句(REGEX-3)。 | |
| 207 | 166 | const scanSrc = blankStringLiterals(src) |
| 208 | 167 | mergeStandaloneIndexes(scanSrc, tables) |
| 209 | - mergeStandaloneForeignKeys(scanSrc, tables) | |
| 210 | 168 | return tables |
| 211 | 169 | } |
| 212 | 170 | |
| ... | ... | @@ -229,57 +187,16 @@ function mergeStandaloneIndexes(src, tables) { |
| 229 | 187 | } |
| 230 | 188 | } |
| 231 | 189 | |
| 232 | -// 独立 `ALTER TABLE <table> ADD [CONSTRAINT n] FOREIGN KEY (cols) REFERENCES [<db>.]<ref> (refcols) [ON DELETE x]` | |
| 233 | -// → 并入 table.foreignKeys,归一化与 parseTableBody 内联 FK 同形(C1)。 | |
| 234 | -// 先框定每条 ALTER 语句(到 `;` 或结尾),再在其体内抓所有 ADD…FOREIGN KEY 子句, | |
| 235 | -// 支持一条 ALTER 内逗号分隔的多个 ADD(REGEX-4)。src 已抹掉字符串字面量,故 `;` 边界与匹配都安全。 | |
| 236 | -function mergeStandaloneForeignKeys(src, tables) { | |
| 237 | - const stmtRe = new RegExp( | |
| 238 | - 'ALTER\\s+TABLE\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')([\\s\\S]*?)(?:;|$)', 'gi') | |
| 239 | - const clauseRe = new RegExp( | |
| 240 | - 'ADD\\s+(?:CONSTRAINT\\s+' + IDENT + '\\s+)?FOREIGN\\s+KEY\\s*\\(([^)]*)\\)\\s*REFERENCES\\s+' + | |
| 241 | - '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(([^)]*)\\)' + | |
| 242 | - '(?:\\s+ON\\s+DELETE\\s+(CASCADE|RESTRICT|SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION))?', 'gi') | |
| 243 | - let s | |
| 244 | - while ((s = stmtRe.exec(src)) !== null) { | |
| 245 | - const t = tables.get(stripTicks(s[1])) | |
| 246 | - if (!t) continue | |
| 247 | - const body = s[2] | |
| 248 | - clauseRe.lastIndex = 0 | |
| 249 | - let c | |
| 250 | - while ((c = clauseRe.exec(body)) !== null) { | |
| 251 | - const fromCols = c[1].replace(/`/g, '').replace(/\s+/g, '') | |
| 252 | - const refTable = stripTicks(c[2]) | |
| 253 | - const toCols = c[3].replace(/`/g, '').replace(/\s+/g, '') | |
| 254 | - const onDelete = (c[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') | |
| 255 | - if (!fromCols || !refTable || !toCols) continue | |
| 256 | - t.foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) | |
| 257 | - } | |
| 258 | - } | |
| 259 | -} | |
| 260 | - | |
| 261 | 190 | function parseTableBody(body) { |
| 262 | 191 | const columns = new Map() |
| 263 | 192 | const indexes = new Set() |
| 264 | - const foreignKeys = new Set() | |
| 265 | 193 | for (const itemRaw of splitTopLevelCommas(body)) { |
| 266 | 194 | const item = itemRaw.trim() |
| 267 | 195 | if (!item) continue |
| 268 | 196 | const upper = item.toUpperCase() |
| 269 | 197 | |
| 270 | - // 外键约束(可带前缀 CONSTRAINT <name>) | |
| 198 | + // 外键约束(可带前缀 CONSTRAINT <name>)→ 已去掉外键维度,直接跳过(不进 indexes/约束)。 | |
| 271 | 199 | if (/\bFOREIGN\s+KEY\b/i.test(item)) { |
| 272 | - // REFERENCES 支持 schema 限定与反引号包裹的非 ASCII 目标表(IDENT,H3 全路径一致;取末段为表名)。 | |
| 273 | - const fk = item.match(INLINE_FK_RE) | |
| 274 | - if (fk) { | |
| 275 | - const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') | |
| 276 | - const refTable = stripTicks(fk[2]) | |
| 277 | - const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') | |
| 278 | - const onDelete = (fk[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') | |
| 279 | - foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) | |
| 280 | - } else { | |
| 281 | - foreignKeys.add(item) | |
| 282 | - } | |
| 283 | 200 | continue |
| 284 | 201 | } |
| 285 | 202 | |
| ... | ... | @@ -325,7 +242,7 @@ function parseTableBody(body) { |
| 325 | 242 | const type = extractType(col[3]) |
| 326 | 243 | columns.set(name, type) |
| 327 | 244 | } |
| 328 | - return { columns, indexes, foreignKeys } | |
| 245 | + return { columns, indexes } | |
| 329 | 246 | } |
| 330 | 247 | |
| 331 | 248 | // 从列定义剩余部分提取类型(含括号内长度),到下一个属性关键字前停止。 |
| ... | ... | @@ -350,7 +267,6 @@ export function diffSchema(docsTables, ddlTables) { |
| 350 | 267 | columnMismatches: [], // { table, column, side: 'docs'|'ddl' } |
| 351 | 268 | typeMismatches: [], // { table, column, docsType, ddlType } |
| 352 | 269 | indexMismatches: [], // { table, index, side: 'docs'|'ddl' } |
| 353 | - foreignKeyMismatches: [],// { table, foreignKey, side: 'docs'|'ddl' } | |
| 354 | 270 | hasDiff: false, |
| 355 | 271 | } |
| 356 | 272 | |
| ... | ... | @@ -361,7 +277,7 @@ export function diffSchema(docsTables, ddlTables) { |
| 361 | 277 | diff.missingTables.sort() |
| 362 | 278 | diff.extraTables.sort() |
| 363 | 279 | |
| 364 | - // 仅对共有表做列/类型/索引/外键比对 | |
| 280 | + // 仅对共有表做列/类型/索引比对 | |
| 365 | 281 | for (const t of [...docNames].filter(n => ddlNames.has(n)).sort()) { |
| 366 | 282 | const d = docsTables.get(t) |
| 367 | 283 | const s = ddlTables.get(t) |
| ... | ... | @@ -388,16 +304,11 @@ export function diffSchema(docsTables, ddlTables) { |
| 388 | 304 | symDiff(dIdx, sIdx, |
| 389 | 305 | ix => diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }), |
| 390 | 306 | ix => diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' })) |
| 391 | - | |
| 392 | - // 维度 5:外键 | |
| 393 | - symDiff(d.foreignKeys || new Set(), s.foreignKeys || new Set(), | |
| 394 | - fk => diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'docs' }), | |
| 395 | - fk => diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'ddl' })) | |
| 396 | 307 | } |
| 397 | 308 | |
| 398 | 309 | diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 || |
| 399 | 310 | diff.columnMismatches.length > 0 || diff.typeMismatches.length > 0 || |
| 400 | - diff.indexMismatches.length > 0 || diff.foreignKeyMismatches.length > 0 | |
| 311 | + diff.indexMismatches.length > 0 | |
| 401 | 312 | return diff |
| 402 | 313 | } |
| 403 | 314 | |
| ... | ... | @@ -568,12 +479,6 @@ export function formatDiff(diff) { |
| 568 | 479 | out.push(` - ${m.table} 索引 ${m.index} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) |
| 569 | 480 | } |
| 570 | 481 | } |
| 571 | - if (diff.foreignKeyMismatches.length) { | |
| 572 | - out.push('=== 维度5 外键 ===') | |
| 573 | - for (const m of diff.foreignKeyMismatches) { | |
| 574 | - out.push(` - ${m.table} 外键 ${m.foreignKey} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) | |
| 575 | - } | |
| 576 | - } | |
| 577 | 482 | return out.join('\n') |
| 578 | 483 | } |
| 579 | 484 | |
| ... | ... | @@ -597,6 +502,6 @@ if (isCliEntry) { |
| 597 | 502 | console.error(formatDiff(diff)) |
| 598 | 503 | process.exit(1) |
| 599 | 504 | } |
| 600 | - console.log('validate-ddl: ✓ docs/03 与 DDL 在 5 维(表/列/类型/索引/外键)一致') | |
| 505 | + console.log('validate-ddl: ✓ docs/03 与 DDL 在 4 维(表/列/类型/索引)一致') | |
| 601 | 506 | process.exit(0) |
| 602 | 507 | } | ... | ... |
lib/validate-ddl.test.mjs
| 1 | -// lib/validate-ddl.test.mjs — 啿µ‹ï¼šdocs/03 è¡¨æ ¼ ↔ DDL 5 ç»´ diff | |
| 1 | +// lib/validate-ddl.test.mjs — 啿µ‹ï¼šdocs/03 è¡¨æ ¼ ↔ DDL 4 ç»´ diff | |
| 2 | 2 | import { test } from 'node:test' |
| 3 | 3 | import assert from 'node:assert/strict' |
| 4 | 4 | import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' |
| ... | ... | @@ -61,8 +61,8 @@ test('parseDocsTables: real docs/03 format — ## `t` — purpose + ### å—æ®µ + |
| 61 | 61 | assert.equal(order.columns.has('---'), false) |
| 62 | 62 | }) |
| 63 | 63 | |
| 64 | -// å…¨é“¾è·¯ï¼šæ¨¡æ¿æ ¼å¼ docs/03(### å—æ®µ + ### 索引 + ### 外键 bullet)→ parseDocsTables å¿…é¡» | |
| 65 | -// 把索引/外键解æžè¿› Set(回归 C2:æ¤å‰ parseDocsTables 从ä¸å†™ indexes/foreignKeys)。 | |
| 64 | +// å…¨é“¾è·¯ï¼šæ¨¡æ¿æ ¼å¼ docs/03(### å—æ®µ + ### 索引 bullet)→ parseDocsTables å¿…é¡» | |
| 65 | +// 把索引解æžè¿› Set(回归 C2:æ¤å‰ parseDocsTables 从ä¸å†™ indexes)。 | |
| 66 | 66 | const DOCS_FULL = [ |
| 67 | 67 | '## `t_order` — 订å•主表', |
| 68 | 68 | '', |
| ... | ... | @@ -76,51 +76,30 @@ const DOCS_FULL = [ |
| 76 | 76 | '- `pk` (PRIMARY): iId', |
| 77 | 77 | '- `idx_user` (index): sUserId', |
| 78 | 78 | '', |
| 79 | - '### 外键', | |
| 80 | - '- `fk_user`: sUserId → t_user.sId (CASCADE)', | |
| 81 | - '', | |
| 82 | 79 | ].join('\n') |
| 83 | 80 | const DDL_FULL = [ |
| 84 | 81 | 'CREATE TABLE `t_order` (', |
| 85 | 82 | ' `iId` bigint NOT NULL AUTO_INCREMENT,', |
| 86 | 83 | ' `sUserId` varchar(100) NOT NULL,', |
| 87 | 84 | ' PRIMARY KEY (`iId`),', |
| 88 | - ' KEY `idx_user` (`sUserId`),', | |
| 89 | - ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`) ON DELETE CASCADE', | |
| 85 | + ' KEY `idx_user` (`sUserId`)', | |
| 90 | 86 | ') ENGINE=InnoDB;', |
| 91 | 87 | ].join('\n') |
| 92 | 88 | |
| 93 | -test('parseDocsTables: parses ### 索引 / ### 外键 bullets into sets (C2 regression)', () => { | |
| 89 | +test('parseDocsTables: parses ### 索引 bullets into sets (C2 regression)', () => { | |
| 94 | 90 | const t = parseDocsTables(DOCS_FULL).get('t_order') |
| 95 | 91 | assert.ok(t) |
| 96 | 92 | assert.ok(t.indexes.has('PRIMARY'), 'PRIMARY index normalized') |
| 97 | 93 | assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), |
| 98 | 94 | 'named index normalized to name:kind:cols — got: ' + [...t.indexes]) |
| 99 | - assert.ok(t.foreignKeys.has('sUserId->t_user(sId):CASCADE'), | |
| 100 | - 'FK normalized to parseDDL form with on-delete — got: ' + [...t.foreignKeys]) | |
| 101 | 95 | }) |
| 102 | 96 | |
| 103 | -test('full chain: matching docs/03 (with indexes+FK) ↔ DDL yields no diff (C2 regression)', () => { | |
| 97 | +test('full chain: matching docs/03 (with indexes) ↔ DDL yields no diff (C2 regression)', () => { | |
| 104 | 98 | const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(DDL_FULL)) |
| 105 | 99 | assert.deepEqual(d.indexMismatches, [], 'index dimension clean') |
| 106 | - assert.deepEqual(d.foreignKeyMismatches, [], 'FK dimension clean') | |
| 107 | 100 | assert.equal(d.hasDiff, false, 'no spurious diff on a faithful schema') |
| 108 | 101 | }) |
| 109 | 102 | |
| 110 | -test('full chain: a real FK present in docs but missing from DDL is caught', () => { | |
| 111 | - const ddlNoFk = [ | |
| 112 | - 'CREATE TABLE `t_order` (', | |
| 113 | - ' `iId` bigint NOT NULL AUTO_INCREMENT,', | |
| 114 | - ' `sUserId` varchar(100) NOT NULL,', | |
| 115 | - ' PRIMARY KEY (`iId`),', | |
| 116 | - ' KEY `idx_user` (`sUserId`)', | |
| 117 | - ') ENGINE=InnoDB;', | |
| 118 | - ].join('\n') | |
| 119 | - const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(ddlNoFk)) | |
| 120 | - assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'sUserId->t_user(sId):CASCADE')) | |
| 121 | - assert.equal(d.hasDiff, true) | |
| 122 | -}) | |
| 123 | - | |
| 124 | 103 | test('parseDDL: CREATE TABLE inside a comment is NOT counted as a table (L4)', () => { |
| 125 | 104 | const ddl = [ |
| 126 | 105 | '-- CREATE TABLE ghost_line ( x int );', |
| ... | ... | @@ -148,7 +127,7 @@ test('parseDocsTables: top-level ## headers like "## 一ã€å…¨å±€çº¦å®š" are NOT |
| 148 | 127 | }) |
| 149 | 128 | |
| 150 | 129 | // ── parseDDL ───────────────────────────────────────────────────── |
| 151 | -test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => { | |
| 130 | +test('parseDDL: columns, types, indexes (backtick-quoted); FOREIGN KEY 项被跳过', () => { | |
| 152 | 131 | const ddl = [ |
| 153 | 132 | 'CREATE TABLE `t_order` (', |
| 154 | 133 | ' `iIncrement` int NOT NULL AUTO_INCREMENT,', |
| ... | ... | @@ -169,8 +148,9 @@ test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => |
| 169 | 148 | assert.ok(t.indexes.has('uk_sid:UNIQUE:sId'), 'unique index normalized — got: ' + [...t.indexes]) |
| 170 | 149 | assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), 'named index normalized — got: ' + [...t.indexes]) |
| 171 | 150 | assert.ok([...t.indexes].some(i => i.toUpperCase().includes('PRIMARY'))) |
| 172 | - // foreign key collected | |
| 173 | - assert.ok([...t.foreignKeys].some(fk => fk.includes('sUserId') && fk.includes('t_user'))) | |
| 151 | + // FOREIGN KEY 项ä¸å†è¢« track,也ä¸åº”æ··å…¥ indexes | |
| 152 | + assert.equal([...t.indexes].some(ix => /fk_user|t_user|FOREIGN/i.test(ix)), false, | |
| 153 | + 'FK 项ä¸åº”è½è¿› indexes — got: ' + [...t.indexes]) | |
| 174 | 154 | }) |
| 175 | 155 | |
| 176 | 156 | test('parseDDL: unquoted identifiers and inline PRIMARY KEY', () => { |
| ... | ... | @@ -187,7 +167,7 @@ test('parseDDL: multiple tables', () => { |
| 187 | 167 | assert.deepEqual([...tables.keys()].sort(), ['a', 'b']) |
| 188 | 168 | }) |
| 189 | 169 | |
| 190 | -// ── diffSchema 5 dimensions ────────────────────────────────────── | |
| 170 | +// ── diffSchema 4 dimensions ────────────────────────────────────── | |
| 191 | 171 | test('diffSchema: missing table (in docs, not in DDL) reported', () => { |
| 192 | 172 | const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') |
| 193 | 173 | const ddl = parseDDL('CREATE TABLE other ( z int );') |
| ... | ... | @@ -211,19 +191,12 @@ test('diffSchema: extra column in DDL reported as columnMismatch', () => { |
| 211 | 191 | }) |
| 212 | 192 | |
| 213 | 193 | test('diffSchema: index dimension diff reported', () => { |
| 214 | - const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c:INDEX:c']), foreignKeys: new Set() }]]) | |
| 194 | + const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c:INDEX:c']) }]]) | |
| 215 | 195 | const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes |
| 216 | 196 | const d = diffSchema(docs, ddl) |
| 217 | 197 | assert.ok(d.indexMismatches.some(m => m.table === 't' && m.index === 'idx_c:INDEX:c')) |
| 218 | 198 | }) |
| 219 | 199 | |
| 220 | -test('diffSchema: foreign-key dimension diff reported', () => { | |
| 221 | - const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(), foreignKeys: new Set(['c->other']) }]]) | |
| 222 | - const ddl = parseDDL('CREATE TABLE t ( c int );') // no FKs | |
| 223 | - const d = diffSchema(docs, ddl) | |
| 224 | - assert.ok(d.foreignKeyMismatches.some(m => m.table === 't' && m.foreignKey === 'c->other')) | |
| 225 | -}) | |
| 226 | - | |
| 227 | 200 | test('diffSchema: hasDiff is false when everything matches, true otherwise', () => { |
| 228 | 201 | const ok = diffSchema(parseDocsTables(DOCS), parseDDL(DDL)) |
| 229 | 202 | assert.equal(ok.hasDiff, false) |
| ... | ... | @@ -268,47 +241,6 @@ test('parseDDL: CREATE TABLE db.t 与 `db`.`t` 都应解æžï¼ˆå–末段为表å |
| 268 | 241 | assert.deepEqual([...tables2.keys()], ['t_user']) |
| 269 | 242 | }) |
| 270 | 243 | |
| 271 | -// ── å¤åˆå¤–é”® docs↔DDL 对称(回归)──────────────────────────────── | |
| 272 | -test('parseDocsTables: å¤åˆå¤–é”® - colA, colB → other.idA, idB åº”å¹³é“ºæˆ colA,colB->other(idA,idB)', () => { | |
| 273 | - const docs = [ | |
| 274 | - '## `t_link`', | |
| 275 | - '### å—æ®µ', | |
| 276 | - '| 列 | 类型 |', | |
| 277 | - '|---|---|', | |
| 278 | - '| `colA` | int |', | |
| 279 | - '| `colB` | int |', | |
| 280 | - '### 外键', | |
| 281 | - '- `fk_x`: colA, colB → other.idA, idB (CASCADE)', | |
| 282 | - ].join('\n') | |
| 283 | - const t = parseDocsTables(docs).get('t_link') | |
| 284 | - assert.ok(t) | |
| 285 | - assert.ok(t.foreignKeys.has('colA,colB->other(idA,idB):CASCADE'), | |
| 286 | - 'docs-side composite FK should normalize the same way as parseDDL — got: ' + [...t.foreignKeys]) | |
| 287 | -}) | |
| 288 | - | |
| 289 | -test('full chain: å¤åˆå¤–é”® docs ↔ DDL 一致时ä¸åº”误报åŒå‘ mismatch', () => { | |
| 290 | - const docs = [ | |
| 291 | - '## `t_link`', | |
| 292 | - '### å—æ®µ', | |
| 293 | - '| 列 | 类型 |', | |
| 294 | - '|---|---|', | |
| 295 | - '| `colA` | int |', | |
| 296 | - '| `colB` | int |', | |
| 297 | - '### 外键', | |
| 298 | - '- `fk_x`: colA, colB → other.(idA, idB)', | |
| 299 | - ].join('\n') | |
| 300 | - const ddl = [ | |
| 301 | - 'CREATE TABLE `t_link` (', | |
| 302 | - ' `colA` int NOT NULL,', | |
| 303 | - ' `colB` int NOT NULL,', | |
| 304 | - ' CONSTRAINT `fk_x` FOREIGN KEY (`colA`, `colB`) REFERENCES `other` (`idA`, `idB`)', | |
| 305 | - ') ENGINE=InnoDB;', | |
| 306 | - ].join('\n') | |
| 307 | - const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | |
| 308 | - assert.deepEqual(d.foreignKeyMismatches, [], | |
| 309 | - 'å¤åˆ FK 一致时ä¸åº”误报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) | |
| 310 | -}) | |
| 311 | - | |
| 312 | 244 | // ── æœªåŠ å¼•å·çš„ä¿ç•™å—列å(回归)───────────────────────────────── |
| 313 | 245 | test('parseDDL: æœªåŠ å¼•å·çš„ä¿ç•™å—列å `key varchar(...)` ä¸åº”被误判为索引也ä¸åº”åˆ¶é€ å¹½çµåˆ—(fix #2)', () => { |
| 314 | 246 | // 列å key æœªåŠ å引å·ï¼Œä¸”åŽé¢è·Ÿçš„æ˜¯ `varchar(`ï¼ˆä¸€ä¸ªç±»åž‹è€Œéž `key <name> (`)。 |
| ... | ... | @@ -352,20 +284,6 @@ test('parseDDL: `KEY decimal (c)` ä¸åº”被解æžä¸ºåˆ—(fix #2/#20)', () => |
| 352 | 284 | assert.deepEqual([...t.columns.keys()], ['c']) |
| 353 | 285 | }) |
| 354 | 286 | |
| 355 | -// ── #3 REFERENCES schema-qualified table ───────────────────────── | |
| 356 | -test('parseDDL: FK REFERENCES mydb.users(id) 归一化为 uid->users(id)(fix #3)', () => { | |
| 357 | - const ddl = [ | |
| 358 | - 'CREATE TABLE t (', | |
| 359 | - ' uid int NOT NULL,', | |
| 360 | - ' FOREIGN KEY (uid) REFERENCES mydb.users(id)', | |
| 361 | - ');', | |
| 362 | - ].join('\n') | |
| 363 | - const t = parseDDL(ddl).get('t') | |
| 364 | - assert.ok(t) | |
| 365 | - assert.ok(t.foreignKeys.has('uid->users(id):RESTRICT'), | |
| 366 | - 'FK 表ååº”å–æœ«æ®µ users 并附默认 on-delete — got: ' + [...t.foreignKeys]) | |
| 367 | -}) | |
| 368 | - | |
| 369 | 287 | // ── #4 extractType ä¿ç•™ unsigned/signed 修饰 ───────────────────── |
| 370 | 288 | test('extractType: `int unsigned` vs `int unsigned` 匹é…,`int` vs `int unsigned` 报 mismatch(fix #4)', () => { |
| 371 | 289 | 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 |
| 380 | 298 | '一侧带 unsigned 一侧ä¸å¸¦åº”报 mismatch — got: ' + JSON.stringify(bad.typeMismatches)) |
| 381 | 299 | }) |
| 382 | 300 | |
| 383 | -// ── #9 散文 bullet ä¸åº”被当 FK / 索引 ──────────────────────────── | |
| 384 | -test('parseDocsTables: ### 外键 下的散文 bullet (å« `>`) ä¸åº”被当外键(fix #9)', () => { | |
| 385 | - const docs = '## `t`\n### 外键\n- note: a > users.id\n' | |
| 386 | - const t = parseDocsTables(docs).get('t') | |
| 387 | - assert.ok(t) | |
| 388 | - assert.equal(t.foreignKeys.size, 0, 'bare `>` ä¸å†ä½œä¸ºå¤–é”®ç®å¤´ — got: ' + [...t.foreignKeys]) | |
| 389 | -}) | |
| 390 | - | |
| 301 | +// ── #9 散文 bullet ä¸åº”被当索引 ────────────────────────────────── | |
| 391 | 302 | test('parseDocsTables: ### 索引 下纯散文 bullet ä¸åº”被当索引(fix #9)', () => { |
| 392 | 303 | const docs = '## `t`\n### 索引\n- This bullet is not an index entry\n' |
| 393 | 304 | const t = parseDocsTables(docs).get('t') |
| ... | ... | @@ -438,37 +349,16 @@ test('diffSchema: åŒå索引 UNIQUE vs éž UNIQUE 应报 mismatch(fix #10) |
| 438 | 349 | assert.ok(d.indexMismatches.length > 0, 'UNIQUE vs INDEX 应报 — got: ' + JSON.stringify(d.indexMismatches)) |
| 439 | 350 | }) |
| 440 | 351 | |
| 441 | -// ── #11 ON DELETE actions differentiated ───────────────────────── | |
| 442 | -test('diffSchema: FK ON DELETE CASCADE vs ç¼ºçœ RESTRICT 应报 mismatch(fix #11)', () => { | |
| 443 | - const docs = parseDocsTables([ | |
| 444 | - '## `t`', | |
| 445 | - '### å—æ®µ', | |
| 446 | - '| 列 | 类型 |', | |
| 447 | - '|---|---|', | |
| 448 | - '| `uid` | int |', | |
| 449 | - '### 外键', | |
| 450 | - '- `fk_uid`: uid → users.id (CASCADE)', | |
| 451 | - ].join('\n')) | |
| 452 | - const ddl = parseDDL([ | |
| 453 | - 'CREATE TABLE `t` (', | |
| 454 | - ' `uid` int,', | |
| 455 | - ' FOREIGN KEY (`uid`) REFERENCES `users`(`id`)', | |
| 456 | - ') ENGINE=InnoDB;', | |
| 457 | - ].join('\n')) | |
| 458 | - const d = diffSchema(docs, ddl) | |
| 459 | - assert.ok(d.foreignKeyMismatches.length > 0, 'CASCADE vs RESTRICT 应报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) | |
| 460 | -}) | |
| 461 | - | |
| 462 | 352 | // ── #16 CREATE TEMPORARY TABLE 也应被识别 ───────────────────────── |
| 463 | 353 | test('parseDDL: CREATE TEMPORARY TABLE 也应被解æžï¼ˆfix #16)', () => { |
| 464 | 354 | const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') |
| 465 | 355 | assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()]) |
| 466 | 356 | }) |
| 467 | 357 | |
| 468 | -// ── C1: 独立è¯å¥å½¢æ€çš„索引 / 外键(db-init A.1 强制的 DDL å½¢æ€ï¼‰â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€ | |
| 469 | -// db-init A.1/A.2 强制 DDL å½¢æ€ä¸ºï¼šCREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK | |
| 470 | -// (索引 / 外键写在表体之外的独立è¯å¥ï¼‰ã€‚parseDDL 必须把这些独立è¯å¥å¹¶å›žå¯¹åº”表的 | |
| 471 | -// indexes / foreignKeys 集åˆï¼Œå¦åˆ™ä»»ä½•å«ç´¢å¼• / 外键的 schema é¦–è½®æ ¡éªŒå¿…æŠ¥å‡é˜³æ€§ã€‚ | |
| 358 | +// ── C1: 独立è¯å¥å½¢æ€çš„索引(db-init A.1 强制的 DDL å½¢æ€ï¼‰â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€ | |
| 359 | +// db-init A.1/A.2 强制 DDL å½¢æ€ä¸ºï¼šCREATE TABLE → CREATE INDEX(索引写在表体之外的 | |
| 360 | +// 独立è¯å¥ï¼‰ã€‚parseDDL 必须把这些独立è¯å¥å¹¶å›žå¯¹åº”表的 indexes 集åˆï¼Œå¦åˆ™ä»»ä½•å«ç´¢å¼•çš„ | |
| 361 | +// schema é¦–è½®æ ¡éªŒå¿…æŠ¥å‡é˜³æ€§ã€‚ | |
| 472 | 362 | test('parseDDL: 独立 CREATE INDEX 并入对应表的 indexes(C1)', () => { |
| 473 | 363 | const ddl = [ |
| 474 | 364 | 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', |
| ... | ... | @@ -498,27 +388,7 @@ test('parseDDL: 独立 CREATE INDEX 多列归一化(C1)', () => { |
| 498 | 388 | assert.ok(t.indexes.has('idx_tenant:INDEX:sBrandsId,sSubsidiaryId'), 'got: ' + [...t.indexes]) |
| 499 | 389 | }) |
| 500 | 390 | |
| 501 | -test('parseDDL: 独立 ALTER TABLE ADD CONSTRAINT FOREIGN KEY 并入对应表的 foreignKeys(C1)', () => { | |
| 502 | - const ddl = [ | |
| 503 | - 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL );', | |
| 504 | - 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_cust` FOREIGN KEY (`iCustomerId`) REFERENCES `t_customer` (`iIncrement`) ON DELETE RESTRICT;', | |
| 505 | - ].join('\n') | |
| 506 | - const t = parseDDL(ddl).get('t_order') | |
| 507 | - assert.ok(t) | |
| 508 | - assert.ok(t.foreignKeys.has('iCustomerId->t_customer(iIncrement):RESTRICT'), | |
| 509 | - '独立 ALTER ADD FK 应并入表外键集 — got: ' + [...t.foreignKeys]) | |
| 510 | -}) | |
| 511 | - | |
| 512 | -test('parseDDL: 独立 ALTER TABLE ADD FOREIGN KEYï¼ˆæ— CONSTRAINT å)默认 RESTRICT(C1)', () => { | |
| 513 | - const ddl = [ | |
| 514 | - 'CREATE TABLE `t` ( `uid` int );', | |
| 515 | - 'ALTER TABLE `t` ADD FOREIGN KEY (`uid`) REFERENCES `users` (`id`);', | |
| 516 | - ].join('\n') | |
| 517 | - const t = parseDDL(ddl).get('t') | |
| 518 | - assert.ok(t.foreignKeys.has('uid->users(id):RESTRICT'), 'got: ' + [...t.foreignKeys]) | |
| 519 | -}) | |
| 520 | - | |
| 521 | -test('full chain: A.1 å½¢æ€ DDL(CREATE TABLE → CREATE INDEX → ALTER ADD FK)↔ docs/03 ä¸åº”有 diff(C1 头å·å›žå½’)', () => { | |
| 391 | +test('full chain: A.1 å½¢æ€ DDL(CREATE TABLE → CREATE INDEX)↔ docs/03 ä¸åº”有 diff(C1 头å·å›žå½’)', () => { | |
| 522 | 392 | const docs = [ |
| 523 | 393 | '## `t_customer` — 客户表', |
| 524 | 394 | '### å—æ®µ', |
| ... | ... | @@ -534,38 +404,18 @@ test('full chain: A.1 å½¢æ€ DDL(CREATE TABLE → CREATE INDEX → ALTER ADD F |
| 534 | 404 | '| `iCustomerId` | int |', |
| 535 | 405 | '### 索引', |
| 536 | 406 | '- `idx_cust` (INDEX): iCustomerId', |
| 537 | - '### 外键', | |
| 538 | - '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', | |
| 539 | 407 | '', |
| 540 | 408 | ].join('\n') |
| 541 | 409 | const ddl = [ |
| 542 | 410 | 'CREATE TABLE `t_customer` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );', |
| 543 | 411 | 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', |
| 544 | 412 | 'CREATE INDEX `idx_cust` ON `t_order` (`iCustomerId`);', |
| 545 | - 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_cust` FOREIGN KEY (`iCustomerId`) REFERENCES `t_customer` (`iIncrement`) ON DELETE RESTRICT;', | |
| 546 | 413 | ].join('\n') |
| 547 | 414 | const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) |
| 548 | 415 | assert.deepEqual(d.indexMismatches, [], '索引维度应干净 — got: ' + JSON.stringify(d.indexMismatches)) |
| 549 | - assert.deepEqual(d.foreignKeyMismatches, [], '外键维度应干净 — got: ' + JSON.stringify(d.foreignKeyMismatches)) | |
| 550 | 416 | assert.equal(d.hasDiff, false, 'A.1 å½¢æ€çš„å¿ å®ž schema ä¸åº”报 diff') |
| 551 | 417 | }) |
| 552 | 418 | |
| 553 | -test('full chain: 独立 ALTER ADD FK 在 docs 有而 DDL 缺时ä»è¢«æ•获(C1 ä¸æŽ©ç›–çœŸå®žç¼ºå¤±ï¼‰', () => { | |
| 554 | - const docs = [ | |
| 555 | - '## `t_order`', | |
| 556 | - '### å—æ®µ', | |
| 557 | - '| 列 | 类型 |', | |
| 558 | - '|---|---|', | |
| 559 | - '| `iCustomerId` | int |', | |
| 560 | - '### 外键', | |
| 561 | - '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', | |
| 562 | - ].join('\n') | |
| 563 | - const ddl = 'CREATE TABLE `t_order` ( `iCustomerId` int NOT NULL );' // FK 真的缺失 | |
| 564 | - const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | |
| 565 | - assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'iCustomerId->t_customer(iIncrement):RESTRICT'), | |
| 566 | - '真实缺失的 FK ä»åº”报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) | |
| 567 | -}) | |
| 568 | - | |
| 569 | 419 | // ── H3: å引å·åŒ…è£¹çš„éž ASCII 表å(docs ä¾§ [^`]+ 接å—,DDL 侧需对é½ï¼‰â”€â”€â”€â”€â”€â”€ |
| 570 | 420 | test('parseDDL: å引å·åŒ…è£¹çš„ä¸æ–‡è¡¨å应被解æžï¼ˆH3 æ ‡è¯†ç¬¦è¯æ³•对é½ï¼‰', () => { |
| 571 | 421 | const t = parseDDL('CREATE TABLE `订å•表` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );') |
| ... | ... | @@ -580,15 +430,6 @@ test('full chain: docs 与 DDL åŒä¸ºä¸æ–‡è¡¨åæ—¶ä¸åº”误报 missingTablesï¼ |
| 580 | 430 | assert.deepEqual(d.extraTables, []) |
| 581 | 431 | }) |
| 582 | 432 | |
| 583 | -test('parseDDL: å引å·åŒ…裹的 FK ç›®æ ‡è¡¨ä¸ºä¸æ–‡æ—¶å½’一化ä¿ç•™ä¸æ–‡ï¼ˆH3)', () => { | |
| 584 | - const ddl = [ | |
| 585 | - 'CREATE TABLE `t` ( `uid` int );', | |
| 586 | - 'ALTER TABLE `t` ADD FOREIGN KEY (`uid`) REFERENCES `用户表` (`id`);', | |
| 587 | - ].join('\n') | |
| 588 | - const t = parseDDL(ddl).get('t') | |
| 589 | - assert.ok(t.foreignKeys.has('uid->用户表(id):RESTRICT'), 'got: ' + [...t.foreignKeys]) | |
| 590 | -}) | |
| 591 | - | |
| 592 | 433 | // ── DDL-9: 索引列归一化两侧对é½ï¼ˆå‰ç¼€é•¿åº¦ / æŽ’åºæ–¹å‘)──────────────────── |
| 593 | 434 | test('full chain: å‰ç¼€é•¿åº¦ç´¢å¼•列 sName(20) docs↔DDL 一致时ä¸åº”误报(DDL-9)', () => { |
| 594 | 435 | const docs = [ |
| ... | ... | @@ -641,12 +482,12 @@ test('full chain: 索引列 `sName(20) DESC` 应完全归一化为裸列åï¼Œä¸ |
| 641 | 482 | } |
| 642 | 483 | }) |
| 643 | 484 | |
| 644 | -// REGEX-1 / EFFICACY-4 / PROSE-1:inline KEY å + inline FK ç›®æ ‡è¡¨ä¸ºä¸æ–‡æ—¶ä¹Ÿåº”与 docs 对é½ã€‚ | |
| 645 | -test('full chain: inline 䏿–‡ç´¢å¼•å + inline 䏿–‡ FK ç›®æ ‡è¡¨åº”ä¸Ž docs 对é½ï¼ˆREGEX-1 / H3 一致)', () => { | |
| 485 | +// REGEX-1 / EFFICACY-4 / PROSE-1:inline KEY åä¸ºä¸æ–‡æ—¶ä¹Ÿåº”与 docs 对é½ï¼› | |
| 486 | +// åŒæ—¶è¡¨ä½“内的 inline FOREIGN KEY 项应被跳过ã€ä¸æ±¡æŸ“索引集。 | |
| 487 | +test('full chain: inline 䏿–‡ç´¢å¼•å应与 docs 对é½ï¼Œinline FK 项被跳过(REGEX-1 / H3 一致)', () => { | |
| 646 | 488 | const docs = [ |
| 647 | 489 | '## `订å•`', '### å—æ®µ', '| 列 | 类型 |', '|---|---|', '| `user_id` | int |', |
| 648 | 490 | '### 索引', '- `䏿–‡ç´¢å¼•` (INDEX): user_id', |
| 649 | - '### 外键', '- `fk_u`: user_id → 用户.id (RESTRICT)', | |
| 650 | 491 | ].join('\n') |
| 651 | 492 | const ddl = [ |
| 652 | 493 | 'CREATE TABLE `订å•` (', ' `user_id` int,', |
| ... | ... | @@ -654,12 +495,15 @@ test('full chain: inline 䏿–‡ç´¢å¼•å + inline 䏿–‡ FK ç›®æ ‡è¡¨åº”ä¸Ž docs |
| 654 | 495 | ' CONSTRAINT `fk_u` FOREIGN KEY (`user_id`) REFERENCES `用户` (`id`)', |
| 655 | 496 | ') ENGINE=InnoDB;', |
| 656 | 497 | ].join('\n') |
| 498 | + const t = parseDDL(ddl).get('订å•') | |
| 499 | + assert.ok(t) | |
| 500 | + assert.equal([...t.indexes].some(ix => /fk_u|用户|FOREIGN/i.test(ix)), false, | |
| 501 | + 'inline FK 项ä¸åº”污染索引集 — got: ' + [...t.indexes]) | |
| 657 | 502 | const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) |
| 658 | 503 | assert.deepEqual(d.indexMismatches, [], 'inline 䏿–‡ç´¢å¼•ååº”å¯¹é½ â€” got: ' + JSON.stringify(d.indexMismatches)) |
| 659 | - assert.deepEqual(d.foreignKeyMismatches, [], 'inline 䏿–‡ FK ç›®æ ‡è¡¨åº”å¯¹é½ â€” got: ' + JSON.stringify(d.foreignKeyMismatches)) | |
| 660 | 504 | }) |
| 661 | 505 | |
| 662 | -// REGEX-3:å—符串å—é¢é‡é‡Œçš„ CREATE INDEX / ALTER ADD FK ä¸åº”被独立è¯å¥æ‰«æè¯¯å½“真实定义。 | |
| 506 | +// REGEX-3:å—符串å—é¢é‡é‡Œçš„ CREATE INDEX ä¸åº”被独立è¯å¥æ‰«æè¯¯å½“真实定义。 | |
| 663 | 507 | test('parseDDL: å—符串å—é¢é‡ä¸çš„ CREATE INDEX 文本ä¸åº”注入幽çµç´¢å¼•(REGEX-3)', () => { |
| 664 | 508 | const ddl = "CREATE TABLE `t_order` ( `iId` int NOT NULL, `note` varchar(200) DEFAULT 'CREATE INDEX `ghost` ON `t_order` (`iId`)', PRIMARY KEY (`iId`) );" |
| 665 | 509 | const t = parseDDL(ddl).get('t_order') |
| ... | ... | @@ -667,26 +511,7 @@ test('parseDDL: å—符串å—é¢é‡ä¸çš„ CREATE INDEX 文本ä¸åº”注入幽çµç´ |
| 667 | 511 | assert.equal([...t.indexes].some(ix => ix.includes('ghost')), false, 'å—é¢é‡å†…çš„ CREATE INDEX ä¸åº”æˆä¸ºçœŸå®žç´¢å¼• — got: ' + [...t.indexes]) |
| 668 | 512 | }) |
| 669 | 513 | |
| 670 | -test('parseDDL: å—符串å—é¢é‡ä¸çš„ ALTER ADD FK 文本ä¸åº”注入幽çµå¤–键(REGEX-3)', () => { | |
| 671 | - const ddl = "CREATE TABLE `t` ( `c` int, `doc` varchar(300) DEFAULT 'see ALTER TABLE `t` ADD FOREIGN KEY (`c`) REFERENCES `x` (`id`)' );" | |
| 672 | - const t = parseDDL(ddl).get('t') | |
| 673 | - assert.ok(t) | |
| 674 | - assert.equal(t.foreignKeys.size, 0, 'å—é¢é‡å†…çš„ ALTER ADD FK ä¸åº”æˆä¸ºçœŸå®žå¤–é”® — got: ' + [...t.foreignKeys]) | |
| 675 | -}) | |
| 676 | - | |
| 677 | -// REGEX-4ï¼šä¸€æ¡ ALTER TABLE 内多个逗å·åˆ†éš” ADD FK 都应被æ•获;CREATE INDEX çš„ USING åå¥åº”容å¿ã€‚ | |
| 678 | -test('parseDDL: 啿¡ ALTER 内多个 ADD FOREIGN KEY 都应被æ•获(REGEX-4 multi-ADD)', () => { | |
| 679 | - const ddl = [ | |
| 680 | - 'CREATE TABLE `t_order` ( `a` int, `b` int );', | |
| 681 | - 'CREATE TABLE `t_a` ( `id` int );', | |
| 682 | - 'CREATE TABLE `t_b` ( `id` int );', | |
| 683 | - '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`);', | |
| 684 | - ].join('\n') | |
| 685 | - const t = parseDDL(ddl).get('t_order') | |
| 686 | - assert.ok(t.foreignKeys.has('a->t_a(id):CASCADE'), '第一个 FK — got: ' + [...t.foreignKeys]) | |
| 687 | - assert.ok(t.foreignKeys.has('b->t_b(id):RESTRICT'), 'åŒæ¡ ALTER 的第二个 FK — got: ' + [...t.foreignKeys]) | |
| 688 | -}) | |
| 689 | - | |
| 514 | +// REGEX-4:CREATE INDEX çš„ USING åå¥åº”容å¿ã€‚ | |
| 690 | 515 | test('parseDDL: CREATE INDEX ... USING BTREE ON ... 应被解æžï¼ˆREGEX-4 USING)', () => { |
| 691 | 516 | const ddl = ['CREATE TABLE `t` ( `c` int );', 'CREATE INDEX `idx_c` USING BTREE ON `t` (`c`);'].join('\n') |
| 692 | 517 | const t = parseDDL(ddl).get('t') | ... | ... |
skills/plan/db-design-gen/SKILL.md
| ... | ... | @@ -18,26 +18,29 @@ allowed-tools: Read Write Edit Grep Glob |
| 18 | 18 | 读: |
| 19 | 19 | |
| 20 | 20 | - `docs/04-技术规范.md` |
| 21 | +- `docs/06-实现策略.md`(A2 人工填写的实现策略;若含影响数据模型的关键决策 / 对默认约定的偏离,步骤 B 据此调整) | |
| 21 | 22 | - `docs/01-需求清单/index.md` 模块索引 |
| 22 | 23 | - `docs/01-需求清单/*/*.md` 所有 REQ 卡片(跳过文件名为 `_module.md` 的模块头;卡片文件名 == req_id) |
| 23 | 24 | |
| 24 | 25 | ### B. 推导 schema |
| 25 | 26 | |
| 26 | -基于步骤 A 读到的 REQ + 命名规范,**正向推导**业务实体 → 表 + 字段 + 索引 + 外键。要求: | |
| 27 | +基于步骤 A 读到的 REQ + 命名规范,**正向推导**业务实体 → 表 + 字段 + 索引 + 语义引用关系。要求: | |
| 27 | 28 | |
| 28 | 29 | 1. 严格套用 `docs/04` 命名规范 + 匈牙利列前缀(`i`=int / `s`=varchar / `t`=datetime) |
| 29 | 30 | 2. **主键**:标准列 `iIncrement` int 主键。REQ 明确要求不同主键(复合主键 / UUID / 业务主键)时按 REQ,并在该表业务注记里注明偏离原因 |
| 30 | -3. **外键**:依据 REQ 中的引用关系(如「订单引用客户」),明确列出 `ON DELETE` / `ON UPDATE` 策略;不能确定时默认 `RESTRICT` | |
| 31 | -4. **索引**:根据 REQ 的查询模式推导业务索引;外键列默认建索引;租户隔离列 `sBrandsId` / `sSubsidiaryId`(标准列)按业务查询模式建组合索引。 | |
| 31 | +3. **语义引用关系**:依据 REQ 中的引用关系(如「订单引用客户」),列出 `from→to`(如 `sCustomerId → 客户表.sId`);仅语义、不建 FK 约束、不写 `ON DELETE` / `ON UPDATE`,应用层维护一致性 | |
| 32 | +4. **索引**:根据 REQ 的查询模式推导业务索引;语义引用列默认建索引;租户隔离列 `sBrandsId` / `sSubsidiaryId`(标准列)按业务查询模式建组合索引。 | |
| 32 | 33 | - 索引 bullet 的 `(类别)` 槽位**统一用 ASCII**:唯一索引写 `UNIQUE`、普通/组合索引写 `INDEX`(与 DDL 侧 `UNIQUE KEY` / `KEY` 对齐,validate-ddl 据此比对 UNIQUE\|INDEX 类别);主键不在 `### 索引` 重复列(由标准列 `iIncrement` 治理)。 |
| 33 | 34 | 5. **业务注记**:对每张表用一两句话说明业务用途、关键约束、与其他表的关系 |
| 34 | 35 | |
| 36 | +> 若 `docs/06-实现策略.md` 载有影响数据模型的关键决策 / 对默认约定的偏离(如软删除标志、乐观锁版本列、特殊主键策略、多租户隔离方式等),**优先遵循**,并在对应表「业务注记」注明依据。 | |
| 37 | + | |
| 35 | 38 | 如果某 REQ 表述模糊以致无法推断关键 schema 细节(如:枚举值范围 / 字段长度上限 / 必填性),先按合理默认推导并在该字段「业务含义」列加 `【人工填写:需用户审阅】` 标注,待步骤 E 用户审阅时调整;**不打断本次推导**。 |
| 36 | 39 | |
| 37 | 40 | ### C. 渲染 docs/03 |
| 38 | 41 | |
| 39 | -1. 读取 `${CLAUDE_SKILL_DIR}/templates/docs-03-header-template.md`,填充 `schema_name`(从 `config-vars.yaml` 读 `database.schema`,无则填 `【人工填写:database.schema】`)、`er_overview`(纯文本 ER 概览)。「项目标准列约定」是固定 5 列,无占位、原样保留。 | |
| 40 | -2. 渲染「表清单」:对每张表读取并填充 `${CLAUDE_SKILL_DIR}/templates/docs-03-table-template.md`——标准列 5 行已内置原样输出,只需填业务字段(`{{#each columns}}`)/ 索引 / 外键 / 业务注记。 | |
| 42 | +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`),无占位、原样保留。 | |
| 43 | +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 只记录业务语义。 | |
| 41 | 44 | 3. 写入 `docs/03-数据库设计文档.md`。 |
| 42 | 45 | |
| 43 | 46 | 勾选:` - [ ] docs/03-数据库设计文档.md 已生成` |
| ... | ... | @@ -59,7 +62,7 @@ allowed-tools: Read Write Edit Grep Glob |
| 59 | 62 | ``` |
| 60 | 63 | [db-design-gen] ✅ A3 DB 设计完成 |
| 61 | 64 | 产出:docs/03-数据库设计文档.md + REQ 卡片依赖表/模块头涉及表已回填 |
| 62 | - ⏸ 请审阅 docs/03(业务实体覆盖、字段类型/默认值、索引、外键策略、`【人工填写:需用户审阅】` 标注)。 | |
| 65 | + ⏸ 请审阅 docs/03(业务实体覆盖、字段类型/默认值、索引、语义引用关系、`【人工填写:需用户审阅】` 标注)。 | |
| 63 | 66 | 审阅完成后运行:/erp-workflow:plan-start |
| 64 | 67 | ``` |
| 65 | 68 | ... | ... |
skills/plan/db-design-gen/templates/docs-03-header-template.md
| ... | ... | @@ -6,15 +6,25 @@ |
| 6 | 6 | |
| 7 | 7 | ## 项目标准列约定 |
| 8 | 8 | |
| 9 | -下文每张业务表的字段清单都自动包含以下 5 个标准列(匈牙利前缀 `i` int / `s` varchar / `t` datetime)。渲染时由 `docs-03-table-template.md` 模板内置原样输出。 | |
| 10 | - | |
| 11 | -| 列名 | 类型 | 可空 | 主键 | 说明 | | |
| 12 | -|---|---|---|---|---| | |
| 13 | -| `iIncrement` | int | 否 | 是 | 整数主键 ID(自增方式由实现决定:DB `AUTO_INCREMENT` 或应用 / 触发器分配) | | |
| 14 | -| `sId` | varchar(100) | 是 | — | 业务 ID(对外暴露的字符串标识,如 UUID / 人类可读编号) | | |
| 15 | -| `sBrandsId` | varchar(100) | 是 | — | 品牌 ID(多租户隔离) | | |
| 16 | -| `sSubsidiaryId` | varchar(100) | 是 | — | 子公司 ID(组织层级隔离) | | |
| 17 | -| `tCreateDate` | datetime | 否 | — | 记录创建时间 | | |
| 9 | +下文每张业务表的字段清单都自动包含以下 7 个标准列(匈牙利前缀 `i` int / `s` varchar / `t` datetime);**从表(本文档「表清单」里除第一张主表之外的所有表)额外再加 1 个标准列 `sParentId`,共 8 个标准列**。渲染时由 `docs-03-table-template.md` 模板内置原样输出。 | |
| 10 | + | |
| 11 | +主表 = 「表清单」中的**第一张表**;从表 = 其余各表。 | |
| 12 | + | |
| 13 | +| 列名 | 类型 | 可空 | 主键 | 默认 | 说明 | | |
| 14 | +|---|---|---|---|---|---| | |
| 15 | +| `iIncrement` | int | 否 | 是 | — | 整数主键 ID(标准列);DDL 译为 `PRIMARY KEY` + `AUTO_INCREMENT` | | |
| 16 | +| `sId` | varchar(50) | 否 | — | — | 业务 ID(标准列,对外暴露的字符串标识,如 UUID / 人类可读编号) | | |
| 17 | +| `sBrandsId` | varchar(50) | 否 | — | `1111111111` | 品牌 ID(多租户隔离,标准列);DDL 译为 `DEFAULT '1111111111'` | | |
| 18 | +| `sSubsidiaryId` | varchar(50) | 否 | — | `1111111111` | 子公司 ID(组织层级隔离,标准列);DDL 译为 `DEFAULT '1111111111'` | | |
| 19 | +| `tCreateDate` | datetime | 否 | — | 当前时间 | 记录创建时间(标准列);DDL 译为 `DEFAULT CURRENT_TIMESTAMP` | | |
| 20 | +| `iOrder` | int | 否 | — | 数据行条数+1 | 排序号;**非 SQL 默认**——应用在 insert 时按 count+1 赋值,DDL 仅写 `int NOT NULL`(不写 DEFAULT 表达式,在该列 COMMENT / 表业务注记里注明 app-assigned) | | |
| 21 | +| `sMemo` | LONGTEXT | 是 | — | — | 备注(标准列) | | |
| 22 | + | |
| 23 | +**从表专属标准列**(从「表清单」第二张表起,即除第一张主表外的所有表都加,插入位置紧随 `sId` 之后): | |
| 24 | + | |
| 25 | +| 列名 | 类型 | 可空 | 主键 | 默认 | 说明 | | |
| 26 | +|---|---|---|---|---|---| | |
| 27 | +| `sParentId` | varchar(50) | 否 | — | — | 业务父级 ID(标准列);仅从表有,紧随 `sId` 之后 | | |
| 18 | 28 | |
| 19 | 29 | 字典 / 辅助表如有豁免,在该表业务注记里注明豁免原因。 |
| 20 | 30 | ... | ... |
skills/plan/db-design-gen/templates/docs-03-table-template.md
| ... | ... | @@ -5,10 +5,12 @@ |
| 5 | 5 | | 字段 | 类型 | Nullable | 默认 | 业务含义 | |
| 6 | 6 | |---|---|---|---|---| |
| 7 | 7 | | `iIncrement` | int | 否 | — | 整数主键 ID(标准列) | |
| 8 | -| `sId` | varchar(100) | 是 | — | 业务 ID(标准列) | | |
| 9 | -| `sBrandsId` | varchar(100) | 是 | `1111111111` | 品牌 ID,多租户隔离(标准列) | | |
| 10 | -| `sSubsidiaryId` | varchar(100) | 是 | `1111111111` | 子公司 ID,组织层级隔离(标准列) | | |
| 8 | +| `sId` | varchar(50) | 否 | — | 业务 ID(标准列) | | |
| 9 | +| `sBrandsId` | varchar(50) | 否 | `1111111111` | 品牌 ID,多租户隔离(标准列) | | |
| 10 | +| `sSubsidiaryId` | varchar(50) | 否 | `1111111111` | 子公司 ID,组织层级隔离(标准列) | | |
| 11 | 11 | | `tCreateDate` | datetime | 否 | 当前时间 | 创建时间(标准列) | |
| 12 | +| `iOrder` | int | 否 | 数据行条数+1 | 排序号(标准列) | | |
| 13 | +| `sMemo` | LONGTEXT | 是 | — | 备注(标准列) | | |
| 12 | 14 | {{#each columns}} |
| 13 | 15 | | {{name}} | {{type}} | {{nullable}} | {{default}} | {{business_meaning}} | |
| 14 | 16 | {{/each}} |
| ... | ... | @@ -18,9 +20,9 @@ |
| 18 | 20 | - `{{name}}` ({{type}}): {{columns}} |
| 19 | 21 | {{/each}} |
| 20 | 22 | |
| 21 | -### 外键 | |
| 22 | -{{#each foreign_keys}} | |
| 23 | -- `{{name}}`: {{from_col}} → {{to_table}}.{{to_col}} ({{on_delete}}) | |
| 23 | +### 引用关系(语义,无 FK 约束) | |
| 24 | +{{#each references}} | |
| 25 | +- {{from_col}} → {{to_table}}.{{to_col}}(语义引用,应用维护一致性) | |
| 24 | 26 | {{/each}} |
| 25 | 27 | |
| 26 | 28 | ### 业务注记 | ... | ... |
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 做 5 维校验(表/列/类型/索引/外键)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 一致性 → 调 scripts/setup-test-db.mjs DROP+CREATE 空库 → 用 lib/apply-ddl.mjs apply V1。 | |
| 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 | --- |
| ... | ... | @@ -17,11 +17,18 @@ allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm in |
| 17 | 17 | |
| 18 | 18 | #### A.1 读 docs/03 并翻译为 DDL |
| 19 | 19 | |
| 20 | -读取 `docs/03-数据库设计文档.md`,对每张表生成一段 `CREATE TABLE`(字段顺序/可空/默认/列注释严格对齐 docs/03 行序),随后按顺序追加 `CREATE INDEX` 与统一追加的 `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY`。**严禁臆造或省略** docs/03 中的任何表/字段/索引/外键/约束。字符集 `utf8mb4` + `utf8mb4_unicode_ci`、引擎 `InnoDB`,除非 docs/03 业务注记另有说明。 | |
| 20 | +读取 `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 业务注记另有说明。 | |
| 21 | + | |
| 22 | +> **标准列默认值的 DDL 翻译规则**(docs/03「默认」列是人话,翻进 DDL 时按下表落成 SQL,仍以 docs/03 行内容为准,不臆造): | |
| 23 | +> - `iIncrement`(整数主键)→ `PRIMARY KEY` + `AUTO_INCREMENT`。 | |
| 24 | +> - `tCreateDate`「当前时间」→ `DEFAULT CURRENT_TIMESTAMP`。 | |
| 25 | +> - `sBrandsId` / `sSubsidiaryId`「1111111111」→ `DEFAULT '1111111111'`。 | |
| 26 | +> - `iOrder`「数据行条数+1」→ **不可作为 SQL DEFAULT**(MySQL 无法 default 成 count+1),DDL 只写 `int NOT NULL`,count+1 由应用在 insert 时算好赋值(在该列 `COMMENT` 或表业务注记里注明 app-assigned)。 | |
| 27 | +> - `sId` / `sParentId`(从表才有,紧随 `sId`)/ `sMemo` → 无 `DEFAULT`(`sMemo` 可空 `LONGTEXT`,其余 `NOT NULL`)。 | |
| 21 | 28 | |
| 22 | 29 | #### A.2 落盘 V1 文件 |
| 23 | 30 | |
| 24 | -用 `Write` 写 `sql/migrations/V1__initial_schema.sql`(`Write` 自动创建父目录)。文件开头是以下 6 行注释,其后接 A.1 的 DDL 主体(`CREATE TABLE` → `CREATE INDEX` → `ALTER TABLE ... ADD FOREIGN KEY`): | |
| 31 | +用 `Write` 写 `sql/migrations/V1__initial_schema.sql`(`Write` 自动创建父目录)。文件开头是以下 6 行注释,其后接 A.1 的 DDL 主体(`CREATE TABLE` → `CREATE INDEX`): | |
| 25 | 32 | |
| 26 | 33 | ```sql |
| 27 | 34 | -- Flyway migration V1 — initial schema for <project_name> -- 从 CLAUDE.md § 🎯 项目概述 读 |
| ... | ... | @@ -32,11 +39,11 @@ allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm in |
| 32 | 39 | -- Do not hand-edit this file after it is committed; write a new migration instead. |
| 33 | 40 | ``` |
| 34 | 41 | |
| 35 | -#### A.3 校验 V1 ↔ docs/03 5 维一致性 + 自主修正 | |
| 42 | +#### A.3 校验 V1 ↔ docs/03 4 维一致性 + 自主修正 | |
| 36 | 43 | |
| 37 | -调 `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 做跨平台、纯 Node 的 5 维校验(表集合 / 列名 / 列类型 / 索引 / 外键)。**注意参数顺序:docs/03 在前,V1.sql 在后。** | |
| 44 | +调 `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 做跨平台、纯 Node 的 4 维校验(表集合 / 列名 / 列类型 / 索引)。**注意参数顺序:docs/03 在前,V1.sql 在后。** | |
| 38 | 45 | |
| 39 | -> **机检边界(勿误解)**:5 维 = 表集合 / 列名 / 列类型 / 索引(名 + UNIQUE\|INDEX 类别 + 列)/ 外键(列 → 表(列) + ON DELETE);表体内联与独立 `CREATE INDEX` / `ALTER TABLE ... ADD FOREIGN KEY` 两种形态都识别。**A.1 要求的「字段顺序 / 可空 / 默认 / 列注释对齐」不在机检范围内**——这几项靠 A.1 翻译时忠实对齐 docs/03(docs/03 已在 A3 人工审阅过),validate-ddl 不会代为兜底,勿因校验通过就认定它们也一致。 | |
| 46 | +> **机检边界(勿误解)**:4 维 = 表集合 / 列名 / 列类型 / 索引(名 + UNIQUE\|INDEX 类别 + 列);表体内联与独立 `CREATE INDEX` 两种形态都识别。**A.1 要求的「字段顺序 / 可空 / 默认 / 列注释对齐」不在机检范围内**——这几项靠 A.1 翻译时忠实对齐 docs/03(docs/03 已在 A3 人工审阅过),validate-ddl 不会代为兜底,勿因校验通过就认定它们也一致。 | |
| 40 | 47 | |
| 41 | 48 | ```bash |
| 42 | 49 | node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ |
| ... | ... | @@ -46,7 +53,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ |
| 46 | 53 | |
| 47 | 54 | 退出码与处理: |
| 48 | 55 | - `0` → 通过,进入步骤 B |
| 49 | -- `1` → 存在差异(5 维 diff 明细打印到 stderr)。进入**自主修正循环**(最多 3 轮,docs/03 是 SSoT 不动): | |
| 56 | +- `1` → 存在差异(4 维 diff 明细打印到 stderr)。进入**自主修正循环**(最多 3 轮,docs/03 是 SSoT 不动): | |
| 50 | 57 | 1. 解析 stderr 差异清单,修正 V1.sql |
| 51 | 58 | 2. 重跑 `validate-ddl.mjs` |
| 52 | 59 | 3. 退出 0 → 进入 B;退出 1 且本轮 < 3 → 回步骤 1;本轮 ≥ 3 仍失败 → 停下,打印最终残留差异 + 已尝试的 3 轮修正摘要,让用户介入 |
| ... | ... | @@ -54,7 +61,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ |
| 54 | 61 | |
| 55 | 62 | 完成后(V1 写入并通过 `validate-ddl.mjs` 校验),勾选: |
| 56 | 63 | - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成` |
| 57 | -- ` - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs)` | |
| 64 | +- ` - [ ] DDL ↔ docs/03 4 维一致(validate-ddl.mjs)` | |
| 58 | 65 | |
| 59 | 66 | ### B. 自动导入 MySQL |
| 60 | 67 | |
| ... | ... | @@ -95,14 +102,14 @@ node scripts/setup-test-db.mjs |
| 95 | 102 | |
| 96 | 103 | ### C. 勾选 docs/08 进度 + 进入 A5 |
| 97 | 104 | |
| 98 | -1. 勾选 A4 顶层(5 维一致已由 A.3 的 `validate-ddl.mjs` 校验过,apply 不改 V1,无需复校): | |
| 105 | +1. 勾选 A4 顶层(4 维一致已由 A.3 的 `validate-ddl.mjs` 校验过,apply 不改 V1,无需复校): | |
| 99 | 106 | - `- [ ] A4 DB 初始化 — db-init` |
| 100 | 107 | |
| 101 | 108 | 2. 立即调用 `Skill(downstream-gen)` 进入 A5,不等用户手动输入。 |
| 102 | 109 | |
| 103 | 110 | ## 参考 |
| 104 | 111 | |
| 105 | -- `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs`(A.3 docs/03 ↔ V1.sql 5 维一致性校验,跨平台纯 Node) | |
| 112 | +- `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs`(A.3 docs/03 ↔ V1.sql 4 维一致性校验,跨平台纯 Node) | |
| 106 | 113 | - `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`(B.2 读取 config-vars.yaml 的 database: 段 + mysql2 灌入 DDL) |
| 107 | 114 | - `${CLAUDE_PLUGIN_ROOT}/lib/yaml-config.mjs`(apply-ddl 依赖的极简 YAML 读取) |
| 108 | 115 | - `docs/03-数据库设计文档.md`(DDL 翻译输入,SSoT) | ... | ... |
skills/plan/project-init/templates/docs-08-initial-template.md
| ... | ... | @@ -27,7 +27,7 @@ |
| 27 | 27 | |
| 28 | 28 | - [ ] A4 DB 初始化 — db-init |
| 29 | 29 | - [ ] sql/migrations/V1__initial_schema.sql 已生成 |
| 30 | - - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs) | |
| 30 | + - [ ] DDL ↔ docs/03 4 维一致(validate-ddl.mjs) | |
| 31 | 31 | - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行 |
| 32 | 32 | |
| 33 | 33 | - [ ] A5 下游文档生成 — downstream-gen | ... | ... |
workflows/coding.mjs
| ... | ... | @@ -590,7 +590,7 @@ function seedStageContract() { |
| 590 | 590 | '## 硬约束(非交互演示种子子代理)', |
| 591 | 591 | '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', |
| 592 | 592 | '- 你的职责 = **为本模块生成演示种子(demo seed)并冷起栈真跑验证**——**不是**实现功能、**不是**改源码、**不是**改 schema。', |
| 593 | - '- 缺值查找顺序:`config-vars.yaml` → `docs/03-数据库设计文档.md` → `docs/01-需求清单/` 各 REQ 卡(业务语义)→ 既有 `sql/seed/*`(跨模块 FK 引用前序模块种子的已知主键)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。', | |
| 593 | + '- 缺值查找顺序:`config-vars.yaml` → `docs/03-数据库设计文档.md` → `docs/01-需求清单/` 各 REQ 卡(业务语义)→ 既有 `sql/seed/*`(跨模块语义引用前序模块种子的已知主键)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。', | |
| 594 | 594 | `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下——\`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/<module_id>/\`(一次性 runner,跑完即弃)+ 证据报告 \`${ROOT}/docs/superpowers/module-reports/<module_id>-seed-verify.md\`。`, |
| 595 | 595 | `- **越界硬停**:**绝不**编辑 \`backend/\` / \`frontend/\` / \`scripts/\` 下的任何源码文件(只许**运行** scripts/setup-test-db.mjs 与 scripts/seed-demo-data.mjs,不许改它们)。区分「运行 backend 服务 / 运行脚本」(允许)与「写 backend 实现 / 改脚本」(越界)。命中越界即以 \`status:halt\` 写清阻塞点结束。`, |
| 596 | 596 | '- **确定性红线(关键)**:种子值一律**显式主键**(1000–9999 区间)+ **固定历史日期**(写死字面量,如 `2024-03-15`),**绝不**依赖时间戳 / `NOW()` / 随机数 / 自增主键的隐式取值。', |
| ... | ... | @@ -616,9 +616,9 @@ function seedGenPrompt(module) { |
| 616 | 616 | '种子产物随 git 提交(不保证「存活」,保证「随时可复现」——三处 DROP+CREATE 各在自己时序里固定重注入)。', |
| 617 | 617 | '', |
| 618 | 618 | '## 输入', |
| 619 | - `- \`${ROOT}/docs/03-数据库设计文档.md\`:本模块各表结构(列 / 类型 / enum 值域 / FK / NOT NULL / UNIQUE 约束)。`, | |
| 619 | + `- \`${ROOT}/docs/03-数据库设计文档.md\`:本模块各表结构(列 / 类型 / enum 值域 / 语义引用关系 / NOT NULL / UNIQUE 约束)。`, | |
| 620 | 620 | `- \`${ROOT}/docs/01-需求清单/<module>/\` 本模块 REQ 卡:业务语义(让假数据有真实感、符合业务取值)。`, |
| 621 | - `- 既有 \`${ROOT}/sql/seed/*.sql\`:跨模块 FK 引用前序模块种子的**已知确定性主键**(你的 FK 列必须引用这些已存在的主键,不可悬空)。`, | |
| 621 | + `- 既有 \`${ROOT}/sql/seed/*.sql\`:跨模块语义引用前序模块种子的**已知确定性主键**(你的语义引用列必须指向这些已存在的主键,不可悬空)。`, | |
| 622 | 622 | `- \`${ROOT}/config-vars.yaml\`:database 段凭据(seed-demo-data.mjs / setup-test-db.mjs 自行读取,你只需确保起栈参数一致)。`, |
| 623 | 623 | '', |
| 624 | 624 | '## 幂等(resume 安全)', |
| ... | ... | @@ -626,12 +626,12 @@ function seedGenPrompt(module) { |
| 626 | 626 | `- **不存在** → 新建 \`sql/seed/<NN>__${id}.sql\`,其中 \`NN\` = 既有 \`sql/seed/*.sql\` 文件名最大序号 + 1(两位补零,如既有最大为 \`03\` → 本文件用 \`04\`;无任何既有文件 → \`01\`)。`, |
| 627 | 627 | '', |
| 628 | 628 | '## 生成规则', |
| 629 | - '- **FK 有序**:同一文件内 INSERT 先父后子;跨模块 FK 列引用既有 `sql/seed/*` 中前序模块种子的已知主键。', | |
| 629 | + '- **按语义引用有序(先被引用方后引用方)**:同一文件内 INSERT 先被引用方后引用方;跨模块语义引用列指向既有 `sql/seed/*` 中前序模块种子的已知主键。', | |
| 630 | 630 | '- **显式主键**:本模块种子行主键固定落 **1000–9999** 区间(避开 1–999 初始数据 / ≥100000 sentinel);同表内主键唯一、确定性。', |
| 631 | 631 | '- **真实感中文业务数据**:依 REQ 卡业务语义取值(人名 / 机构 / 金额 / 状态等),不要 `测试1`/`aaa` 占位;但**绝不含 `_S<数字>` 样式编码**(预留 sentinel)。', |
| 632 | 632 | '- **enum 取值域**:enum 列只从 `docs/03` 声明的值域取值(越界即数据类失败)。', |
| 633 | 633 | '- **固定历史日期**:日期/时间列写死固定历史字面量(如 `2024-03-15 10:00:00`),绝不 `NOW()` / 时间戳。', |
| 634 | - '- **行数**:主业务列表表(页面会分页展示的)给 **15–30 行**(够触发分页 + 行级操作);字典/配置类小表按需少量(够 FK 引用 + 下拉非空)。', | |
| 634 | + '- **行数**:主业务列表表(页面会分页展示的)给 **15–30 行**(够触发分页 + 行级操作);字典/配置类小表按需少量(够语义引用 + 下拉非空)。', | |
| 635 | 635 | `- **头部注释(机器可读,验证对账依赖)**:文件头第一行 \`-- demo-seed: ${id}\`;随后**每张被本文件 INSERT 的表各一行** \`-- expect: <table>=<rows>\`(rows = 本文件向该表插入的行数)。`, |
| 636 | 636 | `- **本模块无可种表**(纯计算/无表模块)→ **不建文件**,直接 \`status:ok\` + summary 说明「模块 ${id} 无可种表,跳过」(跳过下面的验证与 commit)。`, |
| 637 | 637 | '', |
| ... | ... | @@ -645,7 +645,7 @@ function seedGenPrompt(module) { |
| 645 | 645 | ' - `finally` **硬要求 kill 本 stage 起的全部子进程**(绝不让 gradle bootRun 挂死会话)。', |
| 646 | 646 | '- **失败归类(reason 里必须分清)**:', |
| 647 | 647 | ' - **环境类**(端口占用 / 起栈超时 / setup-test-db 失败 / 健康端点不就绪)→ reason 标 `env-error` + 端口/pid。', |
| 648 | - ' - **数据类**(撞主键/唯一键 / FK 错序或悬空 / enum 越界 / 类型截断 / COUNT 不符)→ reason 标 `data-error` + 具体表与根因(这是种子本身的 bug,必须修种子文件后重验)。', | |
| 648 | + ' - **数据类**(撞主键/唯一键 / 引用错序或悬空 / enum 越界 / 类型截断 / COUNT 不符)→ reason 标 `data-error` + 具体表与根因(这是种子本身的 bug,必须修种子文件后重验)。', | |
| 649 | 649 | '', |
| 650 | 650 | '## 证据落盘', |
| 651 | 651 | `- 写 \`${evidence}\`(中文):逐表「期望行数 / 实际行数 / 结论(match/mismatch)」表格 + 本模块种子文件路径 + 起栈端口 + 关键决策。`, |
| ... | ... | @@ -734,8 +734,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt) { |
| 734 | 734 | '## step2 起栈五段严格时序(schema 由 Flyway 在后端启动时才建)', |
| 735 | 735 | `1) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`, |
| 736 | 736 | '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。', |
| 737 | - `3) **注入演示种子**:\`node ${ROOT}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过已应用文件,把 \`sql/seed/*.sql\` 演示数据注入空库)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`, | |
| 738 | - '4) **此时才跑 sentinel 种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** sentinel 种子(先父后子;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)。失败 → `envError.kind="seed-error"` + 结构化根因,**不**混进交互 RED。', | |
| 737 | + `3) **注入演示种子**:\`node ${ROOT}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过已应用文件,把 \`sql/seed/*.sql\` 演示数据注入空库)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / 引用序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`, | |
| 738 | + '4) **此时才跑 sentinel 种子**:按 `docs/03-数据库设计文档.md` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)。失败 → `envError.kind="seed-error"` + 结构化根因,**不**混进交互 RED。', | |
| 739 | 739 | ' - **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_]` 格式。', |
| 740 | 740 | '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。', |
| 741 | 741 | '- `finally` **硬要求 kill 本门起的全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。', | ... | ... |