Commit f6f13dfc821612d3c7770c98c35d3781e91ed53b

Authored by zichun
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 接入)。
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(&#39;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(&#39;parseDocsTables: top-level ## headers like &quot;## 一ã€å…¨å±€çº¦å®š&quot; 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(&#39;parseDDL: columns, types, indexes, foreign keys (backtick-quoted)&#39;, () =&gt;
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(&#39;parseDDL: multiple tables&#39;, () =&gt; {
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(&#39;diffSchema: extra column in DDL reported as columnMismatch&#39;, () =&gt; {
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(&#39;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(&#39;parseDDL: `KEY decimal (c)` ä¸åº”被解æžä¸ºåˆ—(fix #2/#20)&#39;, () =&gt;
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(&#39;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(&#39;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(&#39;parseDDL: 独立 CREATE INDEX 多列归一化(C1)&#39;, () =&gt; {
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(&#39;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(&#39;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(&#39;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(&#39;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(&#39;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 &quot;${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs&quot; \
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 &quot;${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs&quot; \
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 烧时间)。',
... ...