Commit f6f13dfc821612d3c7770c98c35d3781e91ed53b
1 parent
12b0d6d4
schema: 标准列扩展(主表7/从表8列) + 去外键改用纯语义判断(validate-ddl 降4维)
标准列:每表 7 列(新增 iOrder/sMemo;sId/sBrandsId/sSubsidiaryId 改 varchar(50) NOT NULL),从表额外 +sParentId 共 8 列。docs-03 模板 + A3 渲染规则 + A4 DDL 默认值翻译(CURRENT_TIMESTAMP / DEFAULT '1111111111' / iOrder app-assigned)。 去外键:A3 不再推导 FK 约束(改语义引用关系,无 ON DELETE/ON UPDATE,应用层维护一致性);A4 DDL 不生成 ALTER ADD FOREIGN KEY;validate-ddl 移除外键维度 5→4 维(表/列/类型/索引),单测 45 pass/0 fail;coding.mjs 种子/sentinel「FK 有序」→「按语义引用有序」(保留先被引用方后引用方/不可悬空)。 注:db-design-gen 的 docs/06 读取清单/步骤B 两条 bullet 与本区域改动同处一个 diff hunk,随本提交落入(实属 docs/06 feature 的 A3 接入)。
Showing
9 changed files
with
114 additions
and
362 deletions
README.md
| @@ -77,7 +77,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 | @@ -77,7 +77,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 | ||
| 77 | ``` | 77 | ``` |
| 78 | Plan 阶段**两段式**执行,中间有一个人工审阅断点(docs/03 数据库 schema): | 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 | - **第二段(docs/03 审阅完重新运行)**:执行 **A4 → A5**(解析 docs/03 → 生成 V1 migration → 自动 `DROP+CREATE` 本地 schema 并 apply → 生成下游文档 → **docs/05 + docs/02 评审闸** → prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三),通过 **Plan 终结硬闸** 后再次**停下**(前端布局/交互以 `prototype/` 为权威,不另设 UI 规范文档) | 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 | Plan 完成后不会自动进入编码,需手动 /erp-workflow:coding-start。 | 83 | Plan 完成后不会自动进入编码,需手动 /erp-workflow:coding-start。 |
| @@ -100,7 +100,7 @@ erp-workflow-plugin/ | @@ -100,7 +100,7 @@ erp-workflow-plugin/ | ||
| 100 | ├── workflows/ | 100 | ├── workflows/ |
| 101 | │ └── coding.mjs # 阶段 B:整个编码阶段编排为单个静默 Workflow | 101 | │ └── coding.mjs # 阶段 B:整个编码阶段编排为单个静默 Workflow |
| 102 | ├── lib/ # 跨平台 Node 助手(ESM,node:test 单测) | 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 | │ ├── yaml-config.mjs # config-vars.yaml 极简 YAML 读取(2 层 map + 标量) | 104 | │ ├── yaml-config.mjs # config-vars.yaml 极简 YAML 读取(2 层 map + 标量) |
| 105 | │ ├── apply-ddl.mjs # 解析 config-vars.yaml database: 段 + mysql2 apply | 105 | │ ├── apply-ddl.mjs # 解析 config-vars.yaml database: 段 + mysql2 apply |
| 106 | │ └── *.test.mjs # 各助手的 node:test 单测 | 106 | │ └── *.test.mjs # 各助手的 node:test 单测 |
| @@ -135,8 +135,8 @@ erp-workflow-plugin/ | @@ -135,8 +135,8 @@ erp-workflow-plugin/ | ||
| 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` | | 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 | | 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 | | 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 | | A2 | `skeleton-gen` | • 生成架构文档:docs/04 § 一+<br>• 生成跨平台工具脚本:`scripts/*.mjs`(**无 chmod**;凭据 / 配置统一在 A1 产出的 config-vars.yaml)<br>• 据 `gitignore-append-template` 用 Read/Write 并入项目 .gitignore | `plan-start` | | 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 | | 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 | | 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 | ### Coding 阶段(1 个 Workflow,非 skill) | 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 | // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 | 2 | // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 |
| 3 | // | 3 | // |
| 4 | // 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath> | 4 | // 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath> |
| @@ -6,14 +6,13 @@ | @@ -6,14 +6,13 @@ | ||
| 6 | // 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' | 6 | // 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' |
| 7 | // | 7 | // |
| 8 | // 数据结构(解析结果):Map<tableName, { | 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 | // ── 解析 docs/03 markdown 表定义 ───────────────────────────────── | 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 | // ### 索引 → - `name` (type): cols | 15 | // ### 索引 → - `name` (type): cols |
| 16 | -// ### 外键 → - `name`: from_col → to_table.to_col (on_delete) | ||
| 17 | // 跳过表头行(列/字段/类型等标签)与分隔行(---)。 | 16 | // 跳过表头行(列/字段/类型等标签)与分隔行(---)。 |
| 18 | // 形如「## 一、全局约定」这类非反引号标题不视为表。 | 17 | // 形如「## 一、全局约定」这类非反引号标题不视为表。 |
| 19 | export function parseDocsTables(text) { | 18 | export function parseDocsTables(text) { |
| @@ -21,14 +20,14 @@ export function parseDocsTables(text) { | @@ -21,14 +20,14 @@ export function parseDocsTables(text) { | ||
| 21 | const lines = String(text).split('\n') | 20 | const lines = String(text).split('\n') |
| 22 | // 反引号包裹的表名:## `name` 或 ## `name` — purpose | 21 | // 反引号包裹的表名:## `name` 或 ## `name` — purpose |
| 23 | const headerRe = /^##\s+`([^`]+)`/ | 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 | for (const raw of lines) { | 26 | for (const raw of lines) { |
| 28 | const line = raw.replace(/\r$/, '') | 27 | const line = raw.replace(/\r$/, '') |
| 29 | const h2 = line.match(headerRe) | 28 | const h2 = line.match(headerRe) |
| 30 | if (h2) { | 29 | if (h2) { |
| 31 | - current = { columns: new Map(), indexes: new Set(), foreignKeys: new Set() } | 30 | + current = { columns: new Map(), indexes: new Set() } |
| 32 | mode = 'col' | 31 | mode = 'col' |
| 33 | tables.set(h2[1].trim(), current) | 32 | tables.set(h2[1].trim(), current) |
| 34 | continue | 33 | continue |
| @@ -39,15 +38,14 @@ export function parseDocsTables(text) { | @@ -39,15 +38,14 @@ export function parseDocsTables(text) { | ||
| 39 | continue | 38 | continue |
| 40 | } | 39 | } |
| 41 | if (!current) continue | 40 | if (!current) continue |
| 42 | - // ### 子区块切换(### 索引 / ### 外键 / 其它如 ### 字段、### 业务注记 → col) | 41 | + // ### 子区块切换(### 索引 / 其它如 ### 字段、### 业务注记、### 引用关系 → col) |
| 43 | const h3 = line.match(/^###\s+(.+)$/) | 42 | const h3 = line.match(/^###\s+(.+)$/) |
| 44 | if (h3) { | 43 | if (h3) { |
| 45 | const title = h3[1].trim() | 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 | continue | 46 | continue |
| 48 | } | 47 | } |
| 49 | if (mode === 'idx') { parseIndexBullet(line, current.indexes); continue } | 48 | if (mode === 'idx') { parseIndexBullet(line, current.indexes); continue } |
| 50 | - if (mode === 'fk') { parseForeignKeyBullet(line, current.foreignKeys); continue } | ||
| 51 | // mode === 'col':markdown 表格行(以 | 开头) | 49 | // mode === 'col':markdown 表格行(以 | 开头) |
| 52 | if (!/^\s*\|/.test(line)) continue | 50 | if (!/^\s*\|/.test(line)) continue |
| 53 | const cells = splitMarkdownRow(line) | 51 | const cells = splitMarkdownRow(line) |
| @@ -89,42 +87,6 @@ function parseIndexBullet(line, indexes) { | @@ -89,42 +87,6 @@ function parseIndexBullet(line, indexes) { | ||
| 89 | indexes.add(`${name}:${kind}:${cols}`) | 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 | // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── | 90 | // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── |
| 129 | // 标识符 token:反引号包裹(任意非反引号字符,支持中文)或裸 ASCII 标识符(含 `$`)。 | 91 | // 标识符 token:反引号包裹(任意非反引号字符,支持中文)或裸 ASCII 标识符(含 `$`)。 |
| 130 | // docs 侧表名/索引名以 `[^`]+` 接受中文,DDL 侧此前仅 `[A-Za-z0-9_]+` → 中文名假阳性(H3)。 | 92 | // docs 侧表名/索引名以 `[^`]+` 接受中文,DDL 侧此前仅 `[A-Za-z0-9_]+` → 中文名假阳性(H3)。 |
| @@ -170,15 +132,12 @@ function blankStringLiterals(s) { | @@ -170,15 +132,12 @@ function blankStringLiterals(s) { | ||
| 170 | return out | 132 | return out |
| 171 | } | 133 | } |
| 172 | 134 | ||
| 173 | -// 表体内联索引 / 外键的匹配器(与 IDENT 同语法,支持反引号包裹的非 ASCII 名,H3 全路径一致)。 | 135 | +// 表体内联索引的匹配器(与 IDENT 同语法,支持反引号包裹的非 ASCII 名,H3 全路径一致)。 |
| 174 | const INLINE_KEY_RE = new RegExp( | 136 | const INLINE_KEY_RE = new RegExp( |
| 175 | '^(?:UNIQUE\\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\\s+KEY|SPATIAL\\s+KEY)\\s+(' + IDENT + ')\\s*\\(', 'i') | 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 | export function parseDDL(text) { | 141 | export function parseDDL(text) { |
| 183 | const tables = new Map() | 142 | const tables = new Map() |
| 184 | // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 | 143 | // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 |
| @@ -194,19 +153,18 @@ export function parseDDL(text) { | @@ -194,19 +153,18 @@ export function parseDDL(text) { | ||
| 194 | const bodyStart = createRe.lastIndex - 1 // 指向 '(' | 153 | const bodyStart = createRe.lastIndex - 1 // 指向 '(' |
| 195 | const body = extractBalancedParens(src, bodyStart) | 154 | const body = extractBalancedParens(src, bodyStart) |
| 196 | if (body == null) continue | 155 | if (body == null) continue |
| 197 | - // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "FOREIGN KEY …" / "KEY …" 文本被 | 156 | + // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "KEY …" 文本被 |
| 198 | // 内联检测误当真实约束(REGEX-3);反引号标识符整段保留,列名/类型解析不读字面量内容,故不受影响。 | 157 | // 内联检测误当真实约束(REGEX-3);反引号标识符整段保留,列名/类型解析不读字面量内容,故不受影响。 |
| 199 | tables.set(tableName, parseTableBody(blankStringLiterals(body))) | 158 | tables.set(tableName, parseTableBody(blankStringLiterals(body))) |
| 200 | // 继续从 body 之后扫描 | 159 | // 继续从 body 之后扫描 |
| 201 | createRe.lastIndex = bodyStart + body.length + 2 | 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 | const scanSrc = blankStringLiterals(src) | 166 | const scanSrc = blankStringLiterals(src) |
| 208 | mergeStandaloneIndexes(scanSrc, tables) | 167 | mergeStandaloneIndexes(scanSrc, tables) |
| 209 | - mergeStandaloneForeignKeys(scanSrc, tables) | ||
| 210 | return tables | 168 | return tables |
| 211 | } | 169 | } |
| 212 | 170 | ||
| @@ -229,57 +187,16 @@ function mergeStandaloneIndexes(src, tables) { | @@ -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 | function parseTableBody(body) { | 190 | function parseTableBody(body) { |
| 262 | const columns = new Map() | 191 | const columns = new Map() |
| 263 | const indexes = new Set() | 192 | const indexes = new Set() |
| 264 | - const foreignKeys = new Set() | ||
| 265 | for (const itemRaw of splitTopLevelCommas(body)) { | 193 | for (const itemRaw of splitTopLevelCommas(body)) { |
| 266 | const item = itemRaw.trim() | 194 | const item = itemRaw.trim() |
| 267 | if (!item) continue | 195 | if (!item) continue |
| 268 | const upper = item.toUpperCase() | 196 | const upper = item.toUpperCase() |
| 269 | 197 | ||
| 270 | - // 外键约束(可带前缀 CONSTRAINT <name>) | 198 | + // 外键约束(可带前缀 CONSTRAINT <name>)→ 已去掉外键维度,直接跳过(不进 indexes/约束)。 |
| 271 | if (/\bFOREIGN\s+KEY\b/i.test(item)) { | 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 | continue | 200 | continue |
| 284 | } | 201 | } |
| 285 | 202 | ||
| @@ -325,7 +242,7 @@ function parseTableBody(body) { | @@ -325,7 +242,7 @@ function parseTableBody(body) { | ||
| 325 | const type = extractType(col[3]) | 242 | const type = extractType(col[3]) |
| 326 | columns.set(name, type) | 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,7 +267,6 @@ export function diffSchema(docsTables, ddlTables) { | ||
| 350 | columnMismatches: [], // { table, column, side: 'docs'|'ddl' } | 267 | columnMismatches: [], // { table, column, side: 'docs'|'ddl' } |
| 351 | typeMismatches: [], // { table, column, docsType, ddlType } | 268 | typeMismatches: [], // { table, column, docsType, ddlType } |
| 352 | indexMismatches: [], // { table, index, side: 'docs'|'ddl' } | 269 | indexMismatches: [], // { table, index, side: 'docs'|'ddl' } |
| 353 | - foreignKeyMismatches: [],// { table, foreignKey, side: 'docs'|'ddl' } | ||
| 354 | hasDiff: false, | 270 | hasDiff: false, |
| 355 | } | 271 | } |
| 356 | 272 | ||
| @@ -361,7 +277,7 @@ export function diffSchema(docsTables, ddlTables) { | @@ -361,7 +277,7 @@ export function diffSchema(docsTables, ddlTables) { | ||
| 361 | diff.missingTables.sort() | 277 | diff.missingTables.sort() |
| 362 | diff.extraTables.sort() | 278 | diff.extraTables.sort() |
| 363 | 279 | ||
| 364 | - // 仅对共有表做列/类型/索引/外键比对 | 280 | + // 仅对共有表做列/类型/索引比对 |
| 365 | for (const t of [...docNames].filter(n => ddlNames.has(n)).sort()) { | 281 | for (const t of [...docNames].filter(n => ddlNames.has(n)).sort()) { |
| 366 | const d = docsTables.get(t) | 282 | const d = docsTables.get(t) |
| 367 | const s = ddlTables.get(t) | 283 | const s = ddlTables.get(t) |
| @@ -388,16 +304,11 @@ export function diffSchema(docsTables, ddlTables) { | @@ -388,16 +304,11 @@ export function diffSchema(docsTables, ddlTables) { | ||
| 388 | symDiff(dIdx, sIdx, | 304 | symDiff(dIdx, sIdx, |
| 389 | ix => diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }), | 305 | ix => diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }), |
| 390 | ix => diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' })) | 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 | diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 || | 309 | diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 || |
| 399 | diff.columnMismatches.length > 0 || diff.typeMismatches.length > 0 || | 310 | diff.columnMismatches.length > 0 || diff.typeMismatches.length > 0 || |
| 400 | - diff.indexMismatches.length > 0 || diff.foreignKeyMismatches.length > 0 | 311 | + diff.indexMismatches.length > 0 |
| 401 | return diff | 312 | return diff |
| 402 | } | 313 | } |
| 403 | 314 | ||
| @@ -568,12 +479,6 @@ export function formatDiff(diff) { | @@ -568,12 +479,6 @@ export function formatDiff(diff) { | ||
| 568 | out.push(` - ${m.table} 索引 ${m.index} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) | 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 | return out.join('\n') | 482 | return out.join('\n') |
| 578 | } | 483 | } |
| 579 | 484 | ||
| @@ -597,6 +502,6 @@ if (isCliEntry) { | @@ -597,6 +502,6 @@ if (isCliEntry) { | ||
| 597 | console.error(formatDiff(diff)) | 502 | console.error(formatDiff(diff)) |
| 598 | process.exit(1) | 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 | process.exit(0) | 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 | import { test } from 'node:test' | 2 | import { test } from 'node:test' |
| 3 | import assert from 'node:assert/strict' | 3 | import assert from 'node:assert/strict' |
| 4 | import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' | 4 | import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' |
| @@ -61,8 +61,8 @@ test('parseDocsTables: real docs/03 format — ## `t` — purpose + ### å—æ®µ + | @@ -61,8 +61,8 @@ test('parseDocsTables: real docs/03 format — ## `t` — purpose + ### å—æ®µ + | ||
| 61 | assert.equal(order.columns.has('---'), false) | 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 | const DOCS_FULL = [ | 66 | const DOCS_FULL = [ |
| 67 | '## `t_order` — 订å•主表', | 67 | '## `t_order` — 订å•主表', |
| 68 | '', | 68 | '', |
| @@ -76,51 +76,30 @@ const DOCS_FULL = [ | @@ -76,51 +76,30 @@ const DOCS_FULL = [ | ||
| 76 | '- `pk` (PRIMARY): iId', | 76 | '- `pk` (PRIMARY): iId', |
| 77 | '- `idx_user` (index): sUserId', | 77 | '- `idx_user` (index): sUserId', |
| 78 | '', | 78 | '', |
| 79 | - '### 外键', | ||
| 80 | - '- `fk_user`: sUserId → t_user.sId (CASCADE)', | ||
| 81 | - '', | ||
| 82 | ].join('\n') | 79 | ].join('\n') |
| 83 | const DDL_FULL = [ | 80 | const DDL_FULL = [ |
| 84 | 'CREATE TABLE `t_order` (', | 81 | 'CREATE TABLE `t_order` (', |
| 85 | ' `iId` bigint NOT NULL AUTO_INCREMENT,', | 82 | ' `iId` bigint NOT NULL AUTO_INCREMENT,', |
| 86 | ' `sUserId` varchar(100) NOT NULL,', | 83 | ' `sUserId` varchar(100) NOT NULL,', |
| 87 | ' PRIMARY KEY (`iId`),', | 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 | ') ENGINE=InnoDB;', | 86 | ') ENGINE=InnoDB;', |
| 91 | ].join('\n') | 87 | ].join('\n') |
| 92 | 88 | ||
| 93 | -test('parseDocsTables: parses ### 索引 / ### 外键 bullets into sets (C2 regression)', () => { | 89 | +test('parseDocsTables: parses ### 索引 bullets into sets (C2 regression)', () => { |
| 94 | const t = parseDocsTables(DOCS_FULL).get('t_order') | 90 | const t = parseDocsTables(DOCS_FULL).get('t_order') |
| 95 | assert.ok(t) | 91 | assert.ok(t) |
| 96 | assert.ok(t.indexes.has('PRIMARY'), 'PRIMARY index normalized') | 92 | assert.ok(t.indexes.has('PRIMARY'), 'PRIMARY index normalized') |
| 97 | assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), | 93 | assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), |
| 98 | 'named index normalized to name:kind:cols — got: ' + [...t.indexes]) | 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 | const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(DDL_FULL)) | 98 | const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(DDL_FULL)) |
| 105 | assert.deepEqual(d.indexMismatches, [], 'index dimension clean') | 99 | assert.deepEqual(d.indexMismatches, [], 'index dimension clean') |
| 106 | - assert.deepEqual(d.foreignKeyMismatches, [], 'FK dimension clean') | ||
| 107 | assert.equal(d.hasDiff, false, 'no spurious diff on a faithful schema') | 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 | test('parseDDL: CREATE TABLE inside a comment is NOT counted as a table (L4)', () => { | 103 | test('parseDDL: CREATE TABLE inside a comment is NOT counted as a table (L4)', () => { |
| 125 | const ddl = [ | 104 | const ddl = [ |
| 126 | '-- CREATE TABLE ghost_line ( x int );', | 105 | '-- CREATE TABLE ghost_line ( x int );', |
| @@ -148,7 +127,7 @@ test('parseDocsTables: top-level ## headers like "## 一ã€å…¨å±€çº¦å®š" are NOT | @@ -148,7 +127,7 @@ test('parseDocsTables: top-level ## headers like "## 一ã€å…¨å±€çº¦å®š" are NOT | ||
| 148 | }) | 127 | }) |
| 149 | 128 | ||
| 150 | // ── parseDDL ───────────────────────────────────────────────────── | 129 | // ── parseDDL ───────────────────────────────────────────────────── |
| 151 | -test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => { | 130 | +test('parseDDL: columns, types, indexes (backtick-quoted); FOREIGN KEY 项被跳过', () => { |
| 152 | const ddl = [ | 131 | const ddl = [ |
| 153 | 'CREATE TABLE `t_order` (', | 132 | 'CREATE TABLE `t_order` (', |
| 154 | ' `iIncrement` int NOT NULL AUTO_INCREMENT,', | 133 | ' `iIncrement` int NOT NULL AUTO_INCREMENT,', |
| @@ -169,8 +148,9 @@ test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => | @@ -169,8 +148,9 @@ test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => | ||
| 169 | assert.ok(t.indexes.has('uk_sid:UNIQUE:sId'), 'unique index normalized — got: ' + [...t.indexes]) | 148 | assert.ok(t.indexes.has('uk_sid:UNIQUE:sId'), 'unique index normalized — got: ' + [...t.indexes]) |
| 170 | assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), 'named index normalized — got: ' + [...t.indexes]) | 149 | assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), 'named index normalized — got: ' + [...t.indexes]) |
| 171 | assert.ok([...t.indexes].some(i => i.toUpperCase().includes('PRIMARY'))) | 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 | test('parseDDL: unquoted identifiers and inline PRIMARY KEY', () => { | 156 | test('parseDDL: unquoted identifiers and inline PRIMARY KEY', () => { |
| @@ -187,7 +167,7 @@ test('parseDDL: multiple tables', () => { | @@ -187,7 +167,7 @@ test('parseDDL: multiple tables', () => { | ||
| 187 | assert.deepEqual([...tables.keys()].sort(), ['a', 'b']) | 167 | assert.deepEqual([...tables.keys()].sort(), ['a', 'b']) |
| 188 | }) | 168 | }) |
| 189 | 169 | ||
| 190 | -// ── diffSchema 5 dimensions ────────────────────────────────────── | 170 | +// ── diffSchema 4 dimensions ────────────────────────────────────── |
| 191 | test('diffSchema: missing table (in docs, not in DDL) reported', () => { | 171 | test('diffSchema: missing table (in docs, not in DDL) reported', () => { |
| 192 | const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') | 172 | const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') |
| 193 | const ddl = parseDDL('CREATE TABLE other ( z int );') | 173 | const ddl = parseDDL('CREATE TABLE other ( z int );') |
| @@ -211,19 +191,12 @@ test('diffSchema: extra column in DDL reported as columnMismatch', () => { | @@ -211,19 +191,12 @@ test('diffSchema: extra column in DDL reported as columnMismatch', () => { | ||
| 211 | }) | 191 | }) |
| 212 | 192 | ||
| 213 | test('diffSchema: index dimension diff reported', () => { | 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 | const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes | 195 | const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes |
| 216 | const d = diffSchema(docs, ddl) | 196 | const d = diffSchema(docs, ddl) |
| 217 | assert.ok(d.indexMismatches.some(m => m.table === 't' && m.index === 'idx_c:INDEX:c')) | 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 | test('diffSchema: hasDiff is false when everything matches, true otherwise', () => { | 200 | test('diffSchema: hasDiff is false when everything matches, true otherwise', () => { |
| 228 | const ok = diffSchema(parseDocsTables(DOCS), parseDDL(DDL)) | 201 | const ok = diffSchema(parseDocsTables(DOCS), parseDDL(DDL)) |
| 229 | assert.equal(ok.hasDiff, false) | 202 | assert.equal(ok.hasDiff, false) |
| @@ -268,47 +241,6 @@ test('parseDDL: CREATE TABLE db.t 与 `db`.`t` 都应解æžï¼ˆå–末段为表å | @@ -268,47 +241,6 @@ test('parseDDL: CREATE TABLE db.t 与 `db`.`t` 都应解æžï¼ˆå–末段为表å | ||
| 268 | assert.deepEqual([...tables2.keys()], ['t_user']) | 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 | test('parseDDL: æœªåŠ å¼•å·çš„ä¿ç•™å—列å `key varchar(...)` ä¸åº”被误判为索引也ä¸åº”åˆ¶é€ å¹½çµåˆ—(fix #2)', () => { | 245 | test('parseDDL: æœªåŠ å¼•å·çš„ä¿ç•™å—列å `key varchar(...)` ä¸åº”被误判为索引也ä¸åº”åˆ¶é€ å¹½çµåˆ—(fix #2)', () => { |
| 314 | // 列å key æœªåŠ å引å·ï¼Œä¸”åŽé¢è·Ÿçš„æ˜¯ `varchar(`ï¼ˆä¸€ä¸ªç±»åž‹è€Œéž `key <name> (`)。 | 246 | // 列å key æœªåŠ å引å·ï¼Œä¸”åŽé¢è·Ÿçš„æ˜¯ `varchar(`ï¼ˆä¸€ä¸ªç±»åž‹è€Œéž `key <name> (`)。 |
| @@ -352,20 +284,6 @@ test('parseDDL: `KEY decimal (c)` ä¸åº”被解æžä¸ºåˆ—(fix #2/#20)', () => | @@ -352,20 +284,6 @@ test('parseDDL: `KEY decimal (c)` ä¸åº”被解æžä¸ºåˆ—(fix #2/#20)', () => | ||
| 352 | assert.deepEqual([...t.columns.keys()], ['c']) | 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 | // ── #4 extractType ä¿ç•™ unsigned/signed 修饰 ───────────────────── | 287 | // ── #4 extractType ä¿ç•™ unsigned/signed 修饰 ───────────────────── |
| 370 | test('extractType: `int unsigned` vs `int unsigned` 匹é…,`int` vs `int unsigned` 报 mismatch(fix #4)', () => { | 288 | test('extractType: `int unsigned` vs `int unsigned` 匹é…,`int` vs `int unsigned` 报 mismatch(fix #4)', () => { |
| 371 | const docsOk = parseDocsTables('## `t`\n| 列 | 类型 |\n|---|---|\n| id | int unsigned |\n') | 289 | const docsOk = parseDocsTables('## `t`\n| 列 | 类型 |\n|---|---|\n| id | int unsigned |\n') |
| @@ -380,14 +298,7 @@ test('extractType: `int unsigned` vs `int unsigned` 匹é…,`int` vs `int unsig | @@ -380,14 +298,7 @@ test('extractType: `int unsigned` vs `int unsigned` 匹é…,`int` vs `int unsig | ||
| 380 | '一侧带 unsigned 一侧ä¸å¸¦åº”报 mismatch — got: ' + JSON.stringify(bad.typeMismatches)) | 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 | test('parseDocsTables: ### 索引 下纯散文 bullet ä¸åº”被当索引(fix #9)', () => { | 302 | test('parseDocsTables: ### 索引 下纯散文 bullet ä¸åº”被当索引(fix #9)', () => { |
| 392 | const docs = '## `t`\n### 索引\n- This bullet is not an index entry\n' | 303 | const docs = '## `t`\n### 索引\n- This bullet is not an index entry\n' |
| 393 | const t = parseDocsTables(docs).get('t') | 304 | const t = parseDocsTables(docs).get('t') |
| @@ -438,37 +349,16 @@ test('diffSchema: åŒå索引 UNIQUE vs éž UNIQUE 应报 mismatch(fix #10) | @@ -438,37 +349,16 @@ test('diffSchema: åŒå索引 UNIQUE vs éž UNIQUE 应报 mismatch(fix #10) | ||
| 438 | assert.ok(d.indexMismatches.length > 0, 'UNIQUE vs INDEX 应报 — got: ' + JSON.stringify(d.indexMismatches)) | 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 | // ── #16 CREATE TEMPORARY TABLE 也应被识别 ───────────────────────── | 352 | // ── #16 CREATE TEMPORARY TABLE 也应被识别 ───────────────────────── |
| 463 | test('parseDDL: CREATE TEMPORARY TABLE 也应被解æžï¼ˆfix #16)', () => { | 353 | test('parseDDL: CREATE TEMPORARY TABLE 也应被解æžï¼ˆfix #16)', () => { |
| 464 | const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') | 354 | const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') |
| 465 | assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()]) | 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 | test('parseDDL: 独立 CREATE INDEX 并入对应表的 indexes(C1)', () => { | 362 | test('parseDDL: 独立 CREATE INDEX 并入对应表的 indexes(C1)', () => { |
| 473 | const ddl = [ | 363 | const ddl = [ |
| 474 | 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', | 364 | 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', |
| @@ -498,27 +388,7 @@ test('parseDDL: 独立 CREATE INDEX 多列归一化(C1)', () => { | @@ -498,27 +388,7 @@ test('parseDDL: 独立 CREATE INDEX 多列归一化(C1)', () => { | ||
| 498 | assert.ok(t.indexes.has('idx_tenant:INDEX:sBrandsId,sSubsidiaryId'), 'got: ' + [...t.indexes]) | 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 | const docs = [ | 392 | const docs = [ |
| 523 | '## `t_customer` — 客户表', | 393 | '## `t_customer` — 客户表', |
| 524 | '### å—æ®µ', | 394 | '### å—æ®µ', |
| @@ -534,38 +404,18 @@ test('full chain: A.1 å½¢æ€ DDL(CREATE TABLE → CREATE INDEX → ALTER ADD F | @@ -534,38 +404,18 @@ test('full chain: A.1 å½¢æ€ DDL(CREATE TABLE → CREATE INDEX → ALTER ADD F | ||
| 534 | '| `iCustomerId` | int |', | 404 | '| `iCustomerId` | int |', |
| 535 | '### 索引', | 405 | '### 索引', |
| 536 | '- `idx_cust` (INDEX): iCustomerId', | 406 | '- `idx_cust` (INDEX): iCustomerId', |
| 537 | - '### 外键', | ||
| 538 | - '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', | ||
| 539 | '', | 407 | '', |
| 540 | ].join('\n') | 408 | ].join('\n') |
| 541 | const ddl = [ | 409 | const ddl = [ |
| 542 | 'CREATE TABLE `t_customer` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );', | 410 | 'CREATE TABLE `t_customer` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );', |
| 543 | 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', | 411 | 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', |
| 544 | 'CREATE INDEX `idx_cust` ON `t_order` (`iCustomerId`);', | 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 | ].join('\n') | 413 | ].join('\n') |
| 547 | const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | 414 | const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) |
| 548 | assert.deepEqual(d.indexMismatches, [], '索引维度应干净 — got: ' + JSON.stringify(d.indexMismatches)) | 415 | assert.deepEqual(d.indexMismatches, [], '索引维度应干净 — got: ' + JSON.stringify(d.indexMismatches)) |
| 549 | - assert.deepEqual(d.foreignKeyMismatches, [], '外键维度应干净 — got: ' + JSON.stringify(d.foreignKeyMismatches)) | ||
| 550 | assert.equal(d.hasDiff, false, 'A.1 å½¢æ€çš„å¿ å®ž schema ä¸åº”报 diff') | 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 | // ── H3: å引å·åŒ…è£¹çš„éž ASCII 表å(docs ä¾§ [^`]+ 接å—,DDL 侧需对é½ï¼‰â”€â”€â”€â”€â”€â”€ | 419 | // ── H3: å引å·åŒ…è£¹çš„éž ASCII 表å(docs ä¾§ [^`]+ 接å—,DDL 侧需对é½ï¼‰â”€â”€â”€â”€â”€â”€ |
| 570 | test('parseDDL: å引å·åŒ…è£¹çš„ä¸æ–‡è¡¨å应被解æžï¼ˆH3 æ ‡è¯†ç¬¦è¯æ³•对é½ï¼‰', () => { | 420 | test('parseDDL: å引å·åŒ…è£¹çš„ä¸æ–‡è¡¨å应被解æžï¼ˆH3 æ ‡è¯†ç¬¦è¯æ³•对é½ï¼‰', () => { |
| 571 | const t = parseDDL('CREATE TABLE `订å•表` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );') | 421 | const t = parseDDL('CREATE TABLE `订å•表` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );') |
| @@ -580,15 +430,6 @@ test('full chain: docs 与 DDL åŒä¸ºä¸æ–‡è¡¨åæ—¶ä¸åº”误报 missingTablesï¼ | @@ -580,15 +430,6 @@ test('full chain: docs 与 DDL åŒä¸ºä¸æ–‡è¡¨åæ—¶ä¸åº”误报 missingTablesï¼ | ||
| 580 | assert.deepEqual(d.extraTables, []) | 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 | // ── DDL-9: 索引列归一化两侧对é½ï¼ˆå‰ç¼€é•¿åº¦ / æŽ’åºæ–¹å‘)──────────────────── | 433 | // ── DDL-9: 索引列归一化两侧对é½ï¼ˆå‰ç¼€é•¿åº¦ / æŽ’åºæ–¹å‘)──────────────────── |
| 593 | test('full chain: å‰ç¼€é•¿åº¦ç´¢å¼•列 sName(20) docs↔DDL 一致时ä¸åº”误报(DDL-9)', () => { | 434 | test('full chain: å‰ç¼€é•¿åº¦ç´¢å¼•列 sName(20) docs↔DDL 一致时ä¸åº”误报(DDL-9)', () => { |
| 594 | const docs = [ | 435 | const docs = [ |
| @@ -641,12 +482,12 @@ test('full chain: 索引列 `sName(20) DESC` 应完全归一化为裸列åï¼Œä¸ | @@ -641,12 +482,12 @@ test('full chain: 索引列 `sName(20) DESC` 应完全归一化为裸列åï¼Œä¸ | ||
| 641 | } | 482 | } |
| 642 | }) | 483 | }) |
| 643 | 484 | ||
| 644 | -// REGEX-1 / EFFICACY-4 / PROSE-1:inline KEY å + inline FK ç›®æ ‡è¡¨ä¸ºä¸æ–‡æ—¶ä¹Ÿåº”与 docs 对é½ã€‚ | ||
| 645 | -test('full chain: inline 䏿–‡ç´¢å¼•å + inline 䏿–‡ FK ç›®æ ‡è¡¨åº”ä¸Ž docs 对é½ï¼ˆREGEX-1 / H3 一致)', () => { | 485 | +// REGEX-1 / EFFICACY-4 / PROSE-1:inline KEY åä¸ºä¸æ–‡æ—¶ä¹Ÿåº”与 docs 对é½ï¼› |
| 486 | +// åŒæ—¶è¡¨ä½“内的 inline FOREIGN KEY 项应被跳过ã€ä¸æ±¡æŸ“索引集。 | ||
| 487 | +test('full chain: inline 䏿–‡ç´¢å¼•å应与 docs 对é½ï¼Œinline FK 项被跳过(REGEX-1 / H3 一致)', () => { | ||
| 646 | const docs = [ | 488 | const docs = [ |
| 647 | '## `订å•`', '### å—æ®µ', '| 列 | 类型 |', '|---|---|', '| `user_id` | int |', | 489 | '## `订å•`', '### å—æ®µ', '| 列 | 类型 |', '|---|---|', '| `user_id` | int |', |
| 648 | '### 索引', '- `䏿–‡ç´¢å¼•` (INDEX): user_id', | 490 | '### 索引', '- `䏿–‡ç´¢å¼•` (INDEX): user_id', |
| 649 | - '### 外键', '- `fk_u`: user_id → 用户.id (RESTRICT)', | ||
| 650 | ].join('\n') | 491 | ].join('\n') |
| 651 | const ddl = [ | 492 | const ddl = [ |
| 652 | 'CREATE TABLE `订å•` (', ' `user_id` int,', | 493 | 'CREATE TABLE `订å•` (', ' `user_id` int,', |
| @@ -654,12 +495,15 @@ test('full chain: inline 䏿–‡ç´¢å¼•å + inline 䏿–‡ FK ç›®æ ‡è¡¨åº”ä¸Ž docs | @@ -654,12 +495,15 @@ test('full chain: inline 䏿–‡ç´¢å¼•å + inline 䏿–‡ FK ç›®æ ‡è¡¨åº”ä¸Ž docs | ||
| 654 | ' CONSTRAINT `fk_u` FOREIGN KEY (`user_id`) REFERENCES `用户` (`id`)', | 495 | ' CONSTRAINT `fk_u` FOREIGN KEY (`user_id`) REFERENCES `用户` (`id`)', |
| 655 | ') ENGINE=InnoDB;', | 496 | ') ENGINE=InnoDB;', |
| 656 | ].join('\n') | 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 | const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | 502 | const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) |
| 658 | assert.deepEqual(d.indexMismatches, [], 'inline 䏿–‡ç´¢å¼•ååº”å¯¹é½ â€” got: ' + JSON.stringify(d.indexMismatches)) | 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 | test('parseDDL: å—符串å—é¢é‡ä¸çš„ CREATE INDEX 文本ä¸åº”注入幽çµç´¢å¼•(REGEX-3)', () => { | 507 | test('parseDDL: å—符串å—é¢é‡ä¸çš„ CREATE INDEX 文本ä¸åº”注入幽çµç´¢å¼•(REGEX-3)', () => { |
| 664 | const ddl = "CREATE TABLE `t_order` ( `iId` int NOT NULL, `note` varchar(200) DEFAULT 'CREATE INDEX `ghost` ON `t_order` (`iId`)', PRIMARY KEY (`iId`) );" | 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 | const t = parseDDL(ddl).get('t_order') | 509 | const t = parseDDL(ddl).get('t_order') |
| @@ -667,26 +511,7 @@ test('parseDDL: å—符串å—é¢é‡ä¸çš„ CREATE INDEX 文本ä¸åº”注入幽çµç´ | @@ -667,26 +511,7 @@ test('parseDDL: å—符串å—é¢é‡ä¸çš„ CREATE INDEX 文本ä¸åº”注入幽çµç´ | ||
| 667 | assert.equal([...t.indexes].some(ix => ix.includes('ghost')), false, 'å—é¢é‡å†…çš„ CREATE INDEX ä¸åº”æˆä¸ºçœŸå®žç´¢å¼• — got: ' + [...t.indexes]) | 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 | test('parseDDL: CREATE INDEX ... USING BTREE ON ... 应被解æžï¼ˆREGEX-4 USING)', () => { | 515 | test('parseDDL: CREATE INDEX ... USING BTREE ON ... 应被解æžï¼ˆREGEX-4 USING)', () => { |
| 691 | const ddl = ['CREATE TABLE `t` ( `c` int );', 'CREATE INDEX `idx_c` USING BTREE ON `t` (`c`);'].join('\n') | 516 | const ddl = ['CREATE TABLE `t` ( `c` int );', 'CREATE INDEX `idx_c` USING BTREE ON `t` (`c`);'].join('\n') |
| 692 | const t = parseDDL(ddl).get('t') | 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,26 +18,29 @@ allowed-tools: Read Write Edit Grep Glob | ||
| 18 | 读: | 18 | 读: |
| 19 | 19 | ||
| 20 | - `docs/04-技术规范.md` | 20 | - `docs/04-技术规范.md` |
| 21 | +- `docs/06-实现策略.md`(A2 人工填写的实现策略;若含影响数据模型的关键决策 / 对默认约定的偏离,步骤 B 据此调整) | ||
| 21 | - `docs/01-需求清单/index.md` 模块索引 | 22 | - `docs/01-需求清单/index.md` 模块索引 |
| 22 | - `docs/01-需求清单/*/*.md` 所有 REQ 卡片(跳过文件名为 `_module.md` 的模块头;卡片文件名 == req_id) | 23 | - `docs/01-需求清单/*/*.md` 所有 REQ 卡片(跳过文件名为 `_module.md` 的模块头;卡片文件名 == req_id) |
| 23 | 24 | ||
| 24 | ### B. 推导 schema | 25 | ### B. 推导 schema |
| 25 | 26 | ||
| 26 | -基于步骤 A 读到的 REQ + 命名规范,**正向推导**业务实体 → 表 + 字段 + 索引 + 外键。要求: | 27 | +基于步骤 A 读到的 REQ + 命名规范,**正向推导**业务实体 → 表 + 字段 + 索引 + 语义引用关系。要求: |
| 27 | 28 | ||
| 28 | 1. 严格套用 `docs/04` 命名规范 + 匈牙利列前缀(`i`=int / `s`=varchar / `t`=datetime) | 29 | 1. 严格套用 `docs/04` 命名规范 + 匈牙利列前缀(`i`=int / `s`=varchar / `t`=datetime) |
| 29 | 2. **主键**:标准列 `iIncrement` int 主键。REQ 明确要求不同主键(复合主键 / UUID / 业务主键)时按 REQ,并在该表业务注记里注明偏离原因 | 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 | - 索引 bullet 的 `(类别)` 槽位**统一用 ASCII**:唯一索引写 `UNIQUE`、普通/组合索引写 `INDEX`(与 DDL 侧 `UNIQUE KEY` / `KEY` 对齐,validate-ddl 据此比对 UNIQUE\|INDEX 类别);主键不在 `### 索引` 重复列(由标准列 `iIncrement` 治理)。 | 33 | - 索引 bullet 的 `(类别)` 槽位**统一用 ASCII**:唯一索引写 `UNIQUE`、普通/组合索引写 `INDEX`(与 DDL 侧 `UNIQUE KEY` / `KEY` 对齐,validate-ddl 据此比对 UNIQUE\|INDEX 类别);主键不在 `### 索引` 重复列(由标准列 `iIncrement` 治理)。 |
| 33 | 5. **业务注记**:对每张表用一两句话说明业务用途、关键约束、与其他表的关系 | 34 | 5. **业务注记**:对每张表用一两句话说明业务用途、关键约束、与其他表的关系 |
| 34 | 35 | ||
| 36 | +> 若 `docs/06-实现策略.md` 载有影响数据模型的关键决策 / 对默认约定的偏离(如软删除标志、乐观锁版本列、特殊主键策略、多租户隔离方式等),**优先遵循**,并在对应表「业务注记」注明依据。 | ||
| 37 | + | ||
| 35 | 如果某 REQ 表述模糊以致无法推断关键 schema 细节(如:枚举值范围 / 字段长度上限 / 必填性),先按合理默认推导并在该字段「业务含义」列加 `【人工填写:需用户审阅】` 标注,待步骤 E 用户审阅时调整;**不打断本次推导**。 | 38 | 如果某 REQ 表述模糊以致无法推断关键 schema 细节(如:枚举值范围 / 字段长度上限 / 必填性),先按合理默认推导并在该字段「业务含义」列加 `【人工填写:需用户审阅】` 标注,待步骤 E 用户审阅时调整;**不打断本次推导**。 |
| 36 | 39 | ||
| 37 | ### C. 渲染 docs/03 | 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 | 3. 写入 `docs/03-数据库设计文档.md`。 | 44 | 3. 写入 `docs/03-数据库设计文档.md`。 |
| 42 | 45 | ||
| 43 | 勾选:` - [ ] docs/03-数据库设计文档.md 已生成` | 46 | 勾选:` - [ ] docs/03-数据库设计文档.md 已生成` |
| @@ -59,7 +62,7 @@ allowed-tools: Read Write Edit Grep Glob | @@ -59,7 +62,7 @@ allowed-tools: Read Write Edit Grep Glob | ||
| 59 | ``` | 62 | ``` |
| 60 | [db-design-gen] ✅ A3 DB 设计完成 | 63 | [db-design-gen] ✅ A3 DB 设计完成 |
| 61 | 产出:docs/03-数据库设计文档.md + REQ 卡片依赖表/模块头涉及表已回填 | 64 | 产出:docs/03-数据库设计文档.md + REQ 卡片依赖表/模块头涉及表已回填 |
| 62 | - ⏸ 请审阅 docs/03(业务实体覆盖、字段类型/默认值、索引、外键策略、`【人工填写:需用户审阅】` 标注)。 | 65 | + ⏸ 请审阅 docs/03(业务实体覆盖、字段类型/默认值、索引、语义引用关系、`【人工填写:需用户审阅】` 标注)。 |
| 63 | 审阅完成后运行:/erp-workflow:plan-start | 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,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,10 +5,12 @@ | ||
| 5 | | 字段 | 类型 | Nullable | 默认 | 业务含义 | | 5 | | 字段 | 类型 | Nullable | 默认 | 业务含义 | |
| 6 | |---|---|---|---|---| | 6 | |---|---|---|---|---| |
| 7 | | `iIncrement` | int | 否 | — | 整数主键 ID(标准列) | | 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 | | `tCreateDate` | datetime | 否 | 当前时间 | 创建时间(标准列) | | 11 | | `tCreateDate` | datetime | 否 | 当前时间 | 创建时间(标准列) | |
| 12 | +| `iOrder` | int | 否 | 数据行条数+1 | 排序号(标准列) | | ||
| 13 | +| `sMemo` | LONGTEXT | 是 | — | 备注(标准列) | | ||
| 12 | {{#each columns}} | 14 | {{#each columns}} |
| 13 | | {{name}} | {{type}} | {{nullable}} | {{default}} | {{business_meaning}} | | 15 | | {{name}} | {{type}} | {{nullable}} | {{default}} | {{business_meaning}} | |
| 14 | {{/each}} | 16 | {{/each}} |
| @@ -18,9 +20,9 @@ | @@ -18,9 +20,9 @@ | ||
| 18 | - `{{name}}` ({{type}}): {{columns}} | 20 | - `{{name}}` ({{type}}): {{columns}} |
| 19 | {{/each}} | 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 | {{/each}} | 26 | {{/each}} |
| 25 | 27 | ||
| 26 | ### 业务注记 | 28 | ### 业务注记 |
skills/plan/db-init/SKILL.md
| 1 | --- | 1 | --- |
| 2 | name: db-init | 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 | user-invocable: false | 4 | user-invocable: false |
| 5 | allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm install mysql2) | 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,11 +17,18 @@ allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm in | ||
| 17 | 17 | ||
| 18 | #### A.1 读 docs/03 并翻译为 DDL | 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 | #### A.2 落盘 V1 文件 | 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 | ```sql | 33 | ```sql |
| 27 | -- Flyway migration V1 — initial schema for <project_name> -- 从 CLAUDE.md § 🎯 项目概述 读 | 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,11 +39,11 @@ allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm in | ||
| 32 | -- Do not hand-edit this file after it is committed; write a new migration instead. | 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 | ```bash | 48 | ```bash |
| 42 | node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | 49 | node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ |
| @@ -46,7 +53,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | @@ -46,7 +53,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | ||
| 46 | 53 | ||
| 47 | 退出码与处理: | 54 | 退出码与处理: |
| 48 | - `0` → 通过,进入步骤 B | 55 | - `0` → 通过,进入步骤 B |
| 49 | -- `1` → 存在差异(5 维 diff 明细打印到 stderr)。进入**自主修正循环**(最多 3 轮,docs/03 是 SSoT 不动): | 56 | +- `1` → 存在差异(4 维 diff 明细打印到 stderr)。进入**自主修正循环**(最多 3 轮,docs/03 是 SSoT 不动): |
| 50 | 1. 解析 stderr 差异清单,修正 V1.sql | 57 | 1. 解析 stderr 差异清单,修正 V1.sql |
| 51 | 2. 重跑 `validate-ddl.mjs` | 58 | 2. 重跑 `validate-ddl.mjs` |
| 52 | 3. 退出 0 → 进入 B;退出 1 且本轮 < 3 → 回步骤 1;本轮 ≥ 3 仍失败 → 停下,打印最终残留差异 + 已尝试的 3 轮修正摘要,让用户介入 | 59 | 3. 退出 0 → 进入 B;退出 1 且本轮 < 3 → 回步骤 1;本轮 ≥ 3 仍失败 → 停下,打印最终残留差异 + 已尝试的 3 轮修正摘要,让用户介入 |
| @@ -54,7 +61,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | @@ -54,7 +61,7 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | ||
| 54 | 61 | ||
| 55 | 完成后(V1 写入并通过 `validate-ddl.mjs` 校验),勾选: | 62 | 完成后(V1 写入并通过 `validate-ddl.mjs` 校验),勾选: |
| 56 | - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成` | 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 | ### B. 自动导入 MySQL | 66 | ### B. 自动导入 MySQL |
| 60 | 67 | ||
| @@ -95,14 +102,14 @@ node scripts/setup-test-db.mjs | @@ -95,14 +102,14 @@ node scripts/setup-test-db.mjs | ||
| 95 | 102 | ||
| 96 | ### C. 勾选 docs/08 进度 + 进入 A5 | 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 | - `- [ ] A4 DB 初始化 — db-init` | 106 | - `- [ ] A4 DB 初始化 — db-init` |
| 100 | 107 | ||
| 101 | 2. 立即调用 `Skill(downstream-gen)` 进入 A5,不等用户手动输入。 | 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 | - `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`(B.2 读取 config-vars.yaml 的 database: 段 + mysql2 灌入 DDL) | 113 | - `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`(B.2 读取 config-vars.yaml 的 database: 段 + mysql2 灌入 DDL) |
| 107 | - `${CLAUDE_PLUGIN_ROOT}/lib/yaml-config.mjs`(apply-ddl 依赖的极简 YAML 读取) | 114 | - `${CLAUDE_PLUGIN_ROOT}/lib/yaml-config.mjs`(apply-ddl 依赖的极简 YAML 读取) |
| 108 | - `docs/03-数据库设计文档.md`(DDL 翻译输入,SSoT) | 115 | - `docs/03-数据库设计文档.md`(DDL 翻译输入,SSoT) |
skills/plan/project-init/templates/docs-08-initial-template.md
| @@ -27,7 +27,7 @@ | @@ -27,7 +27,7 @@ | ||
| 27 | 27 | ||
| 28 | - [ ] A4 DB 初始化 — db-init | 28 | - [ ] A4 DB 初始化 — db-init |
| 29 | - [ ] sql/migrations/V1__initial_schema.sql 已生成 | 29 | - [ ] sql/migrations/V1__initial_schema.sql 已生成 |
| 30 | - - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs) | 30 | + - [ ] DDL ↔ docs/03 4 维一致(validate-ddl.mjs) |
| 31 | - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行 | 31 | - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行 |
| 32 | 32 | ||
| 33 | - [ ] A5 下游文档生成 — downstream-gen | 33 | - [ ] A5 下游文档生成 — downstream-gen |
workflows/coding.mjs
| @@ -590,7 +590,7 @@ function seedStageContract() { | @@ -590,7 +590,7 @@ function seedStageContract() { | ||
| 590 | '## 硬约束(非交互演示种子子代理)', | 590 | '## 硬约束(非交互演示种子子代理)', |
| 591 | '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', | 591 | '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', |
| 592 | '- 你的职责 = **为本模块生成演示种子(demo seed)并冷起栈真跑验证**——**不是**实现功能、**不是**改源码、**不是**改 schema。', | 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 | `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下——\`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\`。`, | 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 | `- **越界硬停**:**绝不**编辑 \`backend/\` / \`frontend/\` / \`scripts/\` 下的任何源码文件(只许**运行** scripts/setup-test-db.mjs 与 scripts/seed-demo-data.mjs,不许改它们)。区分「运行 backend 服务 / 运行脚本」(允许)与「写 backend 实现 / 改脚本」(越界)。命中越界即以 \`status:halt\` 写清阻塞点结束。`, | 595 | `- **越界硬停**:**绝不**编辑 \`backend/\` / \`frontend/\` / \`scripts/\` 下的任何源码文件(只许**运行** scripts/setup-test-db.mjs 与 scripts/seed-demo-data.mjs,不许改它们)。区分「运行 backend 服务 / 运行脚本」(允许)与「写 backend 实现 / 改脚本」(越界)。命中越界即以 \`status:halt\` 写清阻塞点结束。`, |
| 596 | '- **确定性红线(关键)**:种子值一律**显式主键**(1000–9999 区间)+ **固定历史日期**(写死字面量,如 `2024-03-15`),**绝不**依赖时间戳 / `NOW()` / 随机数 / 自增主键的隐式取值。', | 596 | '- **确定性红线(关键)**:种子值一律**显式主键**(1000–9999 区间)+ **固定历史日期**(写死字面量,如 `2024-03-15`),**绝不**依赖时间戳 / `NOW()` / 随机数 / 自增主键的隐式取值。', |
| @@ -616,9 +616,9 @@ function seedGenPrompt(module) { | @@ -616,9 +616,9 @@ function seedGenPrompt(module) { | ||
| 616 | '种子产物随 git 提交(不保证「存活」,保证「随时可复现」——三处 DROP+CREATE 各在自己时序里固定重注入)。', | 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 | `- \`${ROOT}/docs/01-需求清单/<module>/\` 本模块 REQ 卡:业务语义(让假数据有真实感、符合业务取值)。`, | 620 | `- \`${ROOT}/docs/01-需求清单/<module>/\` 本模块 REQ 卡:业务语义(让假数据有真实感、符合业务取值)。`, |
| 621 | - `- 既有 \`${ROOT}/sql/seed/*.sql\`:跨模块 FK 引用前序模块种子的**已知确定性主键**(你的 FK 列必须引用这些已存在的主键,不可悬空)。`, | 621 | + `- 既有 \`${ROOT}/sql/seed/*.sql\`:跨模块语义引用前序模块种子的**已知确定性主键**(你的语义引用列必须指向这些已存在的主键,不可悬空)。`, |
| 622 | `- \`${ROOT}/config-vars.yaml\`:database 段凭据(seed-demo-data.mjs / setup-test-db.mjs 自行读取,你只需确保起栈参数一致)。`, | 622 | `- \`${ROOT}/config-vars.yaml\`:database 段凭据(seed-demo-data.mjs / setup-test-db.mjs 自行读取,你只需确保起栈参数一致)。`, |
| 623 | '', | 623 | '', |
| 624 | '## 幂等(resume 安全)', | 624 | '## 幂等(resume 安全)', |
| @@ -626,12 +626,12 @@ function seedGenPrompt(module) { | @@ -626,12 +626,12 @@ function seedGenPrompt(module) { | ||
| 626 | `- **不存在** → 新建 \`sql/seed/<NN>__${id}.sql\`,其中 \`NN\` = 既有 \`sql/seed/*.sql\` 文件名最大序号 + 1(两位补零,如既有最大为 \`03\` → 本文件用 \`04\`;无任何既有文件 → \`01\`)。`, | 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 | '- **显式主键**:本模块种子行主键固定落 **1000–9999** 区间(避开 1–999 初始数据 / ≥100000 sentinel);同表内主键唯一、确定性。', | 630 | '- **显式主键**:本模块种子行主键固定落 **1000–9999** 区间(避开 1–999 初始数据 / ≥100000 sentinel);同表内主键唯一、确定性。', |
| 631 | '- **真实感中文业务数据**:依 REQ 卡业务语义取值(人名 / 机构 / 金额 / 状态等),不要 `测试1`/`aaa` 占位;但**绝不含 `_S<数字>` 样式编码**(预留 sentinel)。', | 631 | '- **真实感中文业务数据**:依 REQ 卡业务语义取值(人名 / 机构 / 金额 / 状态等),不要 `测试1`/`aaa` 占位;但**绝不含 `_S<数字>` 样式编码**(预留 sentinel)。', |
| 632 | '- **enum 取值域**:enum 列只从 `docs/03` 声明的值域取值(越界即数据类失败)。', | 632 | '- **enum 取值域**:enum 列只从 `docs/03` 声明的值域取值(越界即数据类失败)。', |
| 633 | '- **固定历史日期**:日期/时间列写死固定历史字面量(如 `2024-03-15 10:00:00`),绝不 `NOW()` / 时间戳。', | 633 | '- **固定历史日期**:日期/时间列写死固定历史字面量(如 `2024-03-15 10:00:00`),绝不 `NOW()` / 时间戳。', |
| 634 | - '- **行数**:主业务列表表(页面会分页展示的)给 **15–30 行**(够触发分页 + 行级操作);字典/配置类小表按需少量(够 FK 引用 + 下拉非空)。', | 634 | + '- **行数**:主业务列表表(页面会分页展示的)给 **15–30 行**(够触发分页 + 行级操作);字典/配置类小表按需少量(够语义引用 + 下拉非空)。', |
| 635 | `- **头部注释(机器可读,验证对账依赖)**:文件头第一行 \`-- demo-seed: ${id}\`;随后**每张被本文件 INSERT 的表各一行** \`-- expect: <table>=<rows>\`(rows = 本文件向该表插入的行数)。`, | 635 | `- **头部注释(机器可读,验证对账依赖)**:文件头第一行 \`-- demo-seed: ${id}\`;随后**每张被本文件 INSERT 的表各一行** \`-- expect: <table>=<rows>\`(rows = 本文件向该表插入的行数)。`, |
| 636 | `- **本模块无可种表**(纯计算/无表模块)→ **不建文件**,直接 \`status:ok\` + summary 说明「模块 ${id} 无可种表,跳过」(跳过下面的验证与 commit)。`, | 636 | `- **本模块无可种表**(纯计算/无表模块)→ **不建文件**,直接 \`status:ok\` + summary 说明「模块 ${id} 无可种表,跳过」(跳过下面的验证与 commit)。`, |
| 637 | '', | 637 | '', |
| @@ -645,7 +645,7 @@ function seedGenPrompt(module) { | @@ -645,7 +645,7 @@ function seedGenPrompt(module) { | ||
| 645 | ' - `finally` **硬要求 kill 本 stage 起的全部子进程**(绝不让 gradle bootRun 挂死会话)。', | 645 | ' - `finally` **硬要求 kill 本 stage 起的全部子进程**(绝不让 gradle bootRun 挂死会话)。', |
| 646 | '- **失败归类(reason 里必须分清)**:', | 646 | '- **失败归类(reason 里必须分清)**:', |
| 647 | ' - **环境类**(端口占用 / 起栈超时 / setup-test-db 失败 / 健康端点不就绪)→ reason 标 `env-error` + 端口/pid。', | 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 | `- 写 \`${evidence}\`(中文):逐表「期望行数 / 实际行数 / 结论(match/mismatch)」表格 + 本模块种子文件路径 + 起栈端口 + 关键决策。`, | 651 | `- 写 \`${evidence}\`(中文):逐表「期望行数 / 实际行数 / 结论(match/mismatch)」表格 + 本模块种子文件路径 + 起栈端口 + 关键决策。`, |
| @@ -734,8 +734,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt) { | @@ -734,8 +734,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt) { | ||
| 734 | '## step2 起栈五段严格时序(schema 由 Flyway 在后端启动时才建)', | 734 | '## step2 起栈五段严格时序(schema 由 Flyway 在后端启动时才建)', |
| 735 | `1) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`, | 735 | `1) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`, |
| 736 | '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。', | 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 | ' - **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_]` 格式。', | 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 | '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。', | 740 | '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。', |
| 741 | '- `finally` **硬要求 kill 本门起的全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。', | 741 | '- `finally` **硬要求 kill 本门起的全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。', |