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