Commit e69a73c7757f14704a6a9f20a21fee2981ac8445

Authored by zichun
1 parent ce708e4b

db-init: parse standalone CREATE INDEX / ALTER ADD FK + harden connect path

validate-ddl now recognizes the db-init A.1 DDL form (CREATE TABLE →
CREATE INDEX → ALTER TABLE ADD FK), so schemas with out-of-body indexes/
FKs no longer report false-positive index/FK diffs. Adds a second parse
pass (mergeStandaloneIndexes / mergeStandaloneForeignKeys), string-literal
blanking so DEFAULT/COMMENT text isn't mistaken for real DDL, backtick
non-ASCII identifier support across all matchers (H3), and shared index-
column normalization (strip backtick / prefix-length / ASC|DESC) on both
docs and DDL sides. Chinese 主键/唯一 index labels map to PRIMARY/UNIQUE.

apply-ddl resolves mysql2 from the target project dir (not the plugin),
rejects 【人工填写】 credential placeholders before connecting, and exits 2
on missing config/DDL paths. setup-test-db template validates schema as an
ASCII identifier before interpolating it into DROP/CREATE. db-init gains a
B.1 toolchain precheck (mysql2 module + mysql client) before any DROP.
lib/apply-ddl.mjs
1 import { parseYamlConfig } from './yaml-config.mjs' 1 import { parseYamlConfig } from './yaml-config.mjs'
  2 +import { createRequire } from 'node:module'
  3 +import { pathToFileURL } from 'node:url'
  4 +import { dirname, resolve as resolvePath, join } from 'node:path'
  5 +
  6 +/**
  7 + * Resolve the `mysql2/promise` driver from the TARGET PROJECT directory.
  8 + *
  9 + * ESM resolves a bare specifier relative to the importing module — and this helper
  10 + * lives inside the PLUGIN, not the project being scaffolded. A plain
  11 + * `import('mysql2/promise')` here would therefore look in the plugin's own
  12 + * node_modules and never see a target-project `npm i mysql2`. We instead build a
  13 + * `require` rooted at the target project so the documented "install mysql2 in your
  14 + * project" actually takes effect. Throws (MODULE_NOT_FOUND) when it is absent.
  15 + *
  16 + * @param {string} [baseDir] target project root (defaults to cwd)
  17 + * @returns {string} absolute path to the resolved mysql2/promise entry
  18 + */
  19 +export function resolveMysql2Path(baseDir = process.cwd()) {
  20 + const require = createRequire(pathToFileURL(join(baseDir, 'package.json')).href)
  21 + return require.resolve('mysql2/promise')
  22 +}
2 23
3 /** 24 /**
4 * Apply a DDL file to a MySQL database using mysql2/promise. 25 * Apply a DDL file to a MySQL database using mysql2/promise.
@@ -16,7 +37,9 @@ export async function applyDDL({ configPath, ddlPath }) { @@ -16,7 +37,9 @@ export async function applyDDL({ configPath, ddlPath }) {
16 37
17 let mysql 38 let mysql
18 try { 39 try {
19 - ;({ default: mysql } = await import('mysql2/promise')) 40 + // 从目标项目(config-vars.yaml 所在目录)解析 mysql2,而非插件自身目录(见 resolveMysql2Path)。
  41 + const resolved = resolveMysql2Path(dirname(resolvePath(configPath)))
  42 + ;({ default: mysql } = await import(pathToFileURL(resolved).href))
20 } catch { 43 } catch {
21 throw new MysqlUnavailableError() 44 throw new MysqlUnavailableError()
22 } 45 }
@@ -60,6 +83,13 @@ export function resolveDbConfig(config, cfgPath = 'config-vars.yaml') { @@ -60,6 +83,13 @@ export function resolveDbConfig(config, cfgPath = 'config-vars.yaml') {
60 if (!Number.isInteger(port) || port <= 0 || port > 65535) { 83 if (!Number.isInteger(port) || port <= 0 || port > 65535) {
61 throw new Error(`apply-ddl: 端口非法 — ${cfgPath} 的 database.port 必须是 1..65535 的整数`) 84 throw new Error(`apply-ddl: 端口非法 — ${cfgPath} 的 database.port 必须是 1..65535 的整数`)
62 } 85 }
  86 + // DDL-7:lib 自保护——拒绝把未填的「【人工填写】」凭据占位直连 MySQL。
  87 + // db-init 步骤 B 已用 LLM 文本检查把关,这里在真正建连的那一层再加一道防御(占位文本绝不应连库)。
  88 + for (const [k, v] of [['host', host], ['user', user], ['password', password], ['schema', database]]) {
  89 + if (typeof v === 'string' && v.includes('【人工填写')) {
  90 + throw new Error(`apply-ddl: ${cfgPath} 的 database.${k} 仍是「【人工填写】」占位 — 请先填真实凭据`)
  91 + }
  92 + }
63 return { host, port, user, password, database } 93 return { host, port, user, password, database }
64 } 94 }
65 95
@@ -72,13 +102,16 @@ export class MysqlUnavailableError extends Error { @@ -72,13 +102,16 @@ export class MysqlUnavailableError extends Error {
72 } 102 }
73 103
74 // CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) 104 // CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配)
75 -const { pathToFileURL } = await import('node:url')  
76 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { 105 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
77 const [configPath, ddlPath] = process.argv.slice(2) 106 const [configPath, ddlPath] = process.argv.slice(2)
78 if (!configPath || !ddlPath) { 107 if (!configPath || !ddlPath) {
79 console.error('usage: node lib/apply-ddl.mjs <configPath> <ddlPath>') 108 console.error('usage: node lib/apply-ddl.mjs <configPath> <ddlPath>')
80 process.exit(2) 109 process.exit(2)
81 } 110 }
  111 + // DDL-6:缺文件属用法/路径错 → 退出码 2(与 db-init C.2 文档「2 = 用法错(路径找不到)」及 validate-ddl 对齐)。
  112 + const { existsSync } = await import('node:fs')
  113 + if (!existsSync(configPath)) { console.error(`apply-ddl: 配置文件不存在: ${configPath}`); process.exit(2) }
  114 + if (!existsSync(ddlPath)) { console.error(`apply-ddl: DDL 文件不存在: ${ddlPath}`); process.exit(2) }
82 try { 115 try {
83 await applyDDL({ configPath, ddlPath }) 116 await applyDDL({ configPath, ddlPath })
84 console.log(`apply-ddl: applied ${ddlPath} using ${configPath}`) 117 console.log(`apply-ddl: applied ${ddlPath} using ${configPath}`)
lib/apply-ddl.test.mjs
1 import { test } from 'node:test' 1 import { test } from 'node:test'
2 import assert from 'node:assert/strict' 2 import assert from 'node:assert/strict'
3 -import { resolveDbConfig } from './apply-ddl.mjs' 3 +import { spawnSync } from 'node:child_process'
  4 +import { fileURLToPath } from 'node:url'
  5 +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
  6 +import { tmpdir } from 'node:os'
  7 +import { join } from 'node:path'
  8 +import { resolveDbConfig, resolveMysql2Path } from './apply-ddl.mjs'
  9 +
  10 +const APPLY = fileURLToPath(new URL('./apply-ddl.mjs', import.meta.url))
4 11
5 // ── resolveDbConfig(直接读 config-vars.yaml 解析后的 database: 段)───────── 12 // ── resolveDbConfig(直接读 config-vars.yaml 解析后的 database: 段)─────────
6 test('resolveDbConfig maps the database section to mysql2 settings', () => { 13 test('resolveDbConfig maps the database section to mysql2 settings', () => {
@@ -31,3 +38,52 @@ test(&#39;resolveDbConfig rejects invalid ports&#39;, () =&gt; { @@ -31,3 +38,52 @@ test(&#39;resolveDbConfig rejects invalid ports&#39;, () =&gt; {
31 assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: 'abc' } }), /database\.port/) 38 assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: 'abc' } }), /database\.port/)
32 assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: '70000' } }), /database\.port/) 39 assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: '70000' } }), /database\.port/)
33 }) 40 })
  41 +
  42 +// ── DDL-7:lib 自保护——未填的 【人工填写】 凭据占位不得直连 MySQL ──────────
  43 +test('resolveDbConfig rejects unfilled 【人工填写】 placeholders in credentials (DDL-7)', () => {
  44 + const base = { host: 'h', port: '3306', user: 'u', password: 'p', schema: 's' }
  45 + assert.throws(() => resolveDbConfig({ database: { ...base, schema: '【人工填写:schema 名】' } }), /人工填写/)
  46 + assert.throws(() => resolveDbConfig({ database: { ...base, host: '【人工填写:MySQL host】' } }), /人工填写/)
  47 + assert.throws(() => resolveDbConfig({ database: { ...base, user: '【人工填写:账号】' } }), /人工填写/)
  48 + assert.throws(() => resolveDbConfig({ database: { ...base, password: '【人工填写:密码】' } }), /人工填写/)
  49 +})
  50 +
  51 +test('resolveDbConfig still accepts a fully-filled real config (DDL-7 no false positive)', () => {
  52 + const c = resolveDbConfig({ database: { host: '127.0.0.1', port: '3306', user: 'root', password: 'p@ss', schema: 'erp_dev' } })
  53 + assert.equal(c.database, 'erp_dev')
  54 +})
  55 +
  56 +// ── DDL-6:缺文件应退出码 2(用法/路径错),与 db-init C.2 文档及 validate-ddl 对齐 ──
  57 +test('apply-ddl CLI exits 2 when the config file does not exist (DDL-6)', () => {
  58 + const r = spawnSync('node', [APPLY, '/no/such/config-vars.yaml', '/no/such/V1.sql'], { encoding: 'utf8' })
  59 + assert.equal(r.status, 2, 'missing config file should be exit 2, not 1 — stderr: ' + r.stderr)
  60 +})
  61 +
  62 +test('apply-ddl CLI exits 2 when the DDL file does not exist (DDL-6)', () => {
  63 + // config exists (this very test file stands in as an existing path), DDL does not
  64 + const r = spawnSync('node', [APPLY, APPLY, '/no/such/V1.sql'], { encoding: 'utf8' })
  65 + assert.equal(r.status, 2, 'missing DDL file should be exit 2 — stderr: ' + r.stderr)
  66 +})
  67 +
  68 +test('apply-ddl CLI exits 2 on missing arguments (existing behavior preserved)', () => {
  69 + const r = spawnSync('node', [APPLY], { encoding: 'utf8' })
  70 + assert.equal(r.status, 2)
  71 +})
  72 +
  73 +// ── H2:mysql2 必须从「目标项目」解析,而非插件自身目录 ────────────────────
  74 +// ESM 裸说明符按 importer 解析;apply-ddl.mjs 住在插件目录,若直接 import('mysql2/promise')
  75 +// 则永远看不到目标项目 `npm i mysql2` 的安装。resolveMysql2Path 用 createRequire(目标根) 修正。
  76 +test('resolveMysql2Path resolves mysql2/promise from the target project dir (H2)', () => {
  77 + const dir = mkdtempSync(join(tmpdir(), 'erp-m2-'))
  78 + mkdirSync(join(dir, 'node_modules', 'mysql2'), { recursive: true })
  79 + writeFileSync(join(dir, 'node_modules', 'mysql2', 'package.json'),
  80 + JSON.stringify({ name: 'mysql2', version: '0.0.0', exports: { './promise': './promise.js' } }))
  81 + writeFileSync(join(dir, 'node_modules', 'mysql2', 'promise.js'), 'export default {}')
  82 + const p = resolveMysql2Path(dir)
  83 + assert.match(p, /node_modules[\\/]mysql2[\\/]promise\.js$/, 'got: ' + p)
  84 +})
  85 +
  86 +test('resolveMysql2Path throws when mysql2 is absent in the target project (H2)', () => {
  87 + const dir = mkdtempSync(join(tmpdir(), 'erp-m2-empty-'))
  88 + assert.throws(() => resolveMysql2Path(dir))
  89 +})
lib/setup-test-db-template.test.mjs 0 → 100644
  1 +// lib/setup-test-db-template.test.mjs — 校验生成模板 scripts/setup-test-db.mjs 的 schema 守卫。
  2 +// 跑的是真实模板产物:复制到临时 scripts/ 下、写一个 ../config-vars.yaml、再 node 执行。
  3 +// 所有用例的 host/port 故意指向 127.0.0.1:1(必拒连),即便守卫缺失也绝不触碰真实库。
  4 +import { test } from 'node:test'
  5 +import assert from 'node:assert/strict'
  6 +import { spawnSync } from 'node:child_process'
  7 +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs'
  8 +import { tmpdir } from 'node:os'
  9 +import { join } from 'node:path'
  10 +import { fileURLToPath } from 'node:url'
  11 +
  12 +const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs', import.meta.url))
  13 +
  14 +function runWithSchema(schemaLine) {
  15 + const dir = mkdtempSync(join(tmpdir(), 'erp-stdb-'))
  16 + mkdirSync(join(dir, 'scripts'))
  17 + copyFileSync(TEMPLATE, join(dir, 'scripts', 'setup-test-db.mjs'))
  18 + writeFileSync(
  19 + join(dir, 'config-vars.yaml'),
  20 + ['database:', ' host: 127.0.0.1', ' port: 1', ' user: root', ' password: x', ' ' + schemaLine, ''].join('\n'),
  21 + )
  22 + return spawnSync('node', [join(dir, 'scripts', 'setup-test-db.mjs')], { encoding: 'utf8' })
  23 +}
  24 +
  25 +// ROBUST-3:空 schema 不应进到 DROP DATABASE `` —— 守卫应先拦下。
  26 +test('setup-test-db: empty schema fails closed with a schema message (ROBUST-3)', () => {
  27 + const r = runWithSchema('schema:')
  28 + assert.equal(r.status, 1)
  29 + assert.match(r.stderr, /schema/, '应是 schema 守卫报错而非连库失败 — stderr: ' + r.stderr)
  30 +})
  31 +
  32 +// ROBUST-3:未填的 【人工填写】 占位不应被当库名。
  33 +test('setup-test-db: 【人工填写】 placeholder schema fails closed (ROBUST-3)', () => {
  34 + const r = runWithSchema('schema: 【人工填写:schema 名】')
  35 + assert.equal(r.status, 1)
  36 + assert.match(r.stderr, /schema/, 'stderr: ' + r.stderr)
  37 +})
  38 +
  39 +// DDL-8:含反引号的 schema(标识符注入)应被拒,而不是拼进 DROP/CREATE 语句。
  40 +test('setup-test-db: schema with a backtick is rejected (DDL-8 injection guard)', () => {
  41 + const r = runWithSchema('schema: ev`il')
  42 + assert.equal(r.status, 1)
  43 + assert.match(r.stderr, /schema/, 'stderr: ' + r.stderr)
  44 +})
  45 +
  46 +// 正例:合法标识符 schema 应通过守卫并继续到连库阶段(此处连 127.0.0.1:1 必失败,
  47 +// 但 stderr 应是连库/mysql 错误,而非 schema 守卫错误)——证明守卫不误伤合法名。
  48 +test('setup-test-db: a valid identifier schema passes the guard (no false positive)', () => {
  49 + const r = runWithSchema('schema: erp_dev')
  50 + // 连不上 127.0.0.1:1 → 非零退出;关键是错误不来自 schema 守卫。
  51 + assert.doesNotMatch(r.stderr, /database\.schema 非法|schema 非法或未填/, 'stderr: ' + r.stderr)
  52 +})
lib/validate-ddl.mjs
@@ -76,17 +76,16 @@ function parseIndexBullet(line, indexes) { @@ -76,17 +76,16 @@ function parseIndexBullet(line, indexes) {
76 if (!name) return 76 if (!name) return
77 // 散文 bullet 守门:没有括号也没有冒号列段 → 不是索引项 77 // 散文 bullet 守门:没有括号也没有冒号列段 → 不是索引项
78 if (!type && !colsRaw) return 78 if (!type && !colsRaw) return
79 - if (/^primary$/i.test(type) || /^primary$/i.test(name)) { 79 + // PRIMARY:英文 primary 或恰为中文「主键」(M3:type 槽位可能写中文)。锚定匹配——
  80 + // 「主键索引」「主键候选」等含「主键」但非主键的标签不得被剔除为 PRIMARY(EFFICACY-1)。
  81 + if (/^primary$/i.test(type) || /^primary$/i.test(name) || /^主键$/.test(type)) {
80 indexes.add('PRIMARY') 82 indexes.add('PRIMARY')
81 return 83 return
82 } 84 }
83 - // 列与 UNIQUE/INDEX 类别一并参与等价比较(fix #10)  
84 - const cols = colsRaw  
85 - .split(',')  
86 - .map(c => c.replace(/`/g, '').trim())  
87 - .filter(c => /^[A-Za-z0-9_]+$/.test(c))  
88 - .join(',')  
89 - const kind = /^unique$/i.test(type) ? 'UNIQUE' : 'INDEX' 85 + // 列与 UNIQUE/INDEX 类别一并参与等价比较(fix #10);列归一化两侧共用(DDL-9)。
  86 + // UNIQUE 识别英文 unique 或恰为中文「唯一」(M3,锚定),否则视为普通 INDEX。
  87 + const cols = normalizeIndexCols(colsRaw)
  88 + const kind = (/^unique$/i.test(type) || /^唯一$/.test(type)) ? 'UNIQUE' : 'INDEX'
90 indexes.add(`${name}:${kind}:${cols}`) 89 indexes.add(`${name}:${kind}:${cols}`)
91 } 90 }
92 91
@@ -95,7 +94,8 @@ function parseIndexBullet(line, indexes) { @@ -95,7 +94,8 @@ function parseIndexBullet(line, indexes) {
95 function parseForeignKeyBullet(line, foreignKeys) { 94 function parseForeignKeyBullet(line, foreignKeys) {
96 // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 95 // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是
97 // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 96 // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。
98 - const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([A-Za-z0-9_]+)`?\s*\.\s*(.+)$/) 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 99 if (!head) return
100 const fromRaw = head[1] 100 const fromRaw = head[1]
101 const toTable = head[2] 101 const toTable = head[2]
@@ -126,28 +126,138 @@ function parseForeignKeyBullet(line, foreignKeys) { @@ -126,28 +126,138 @@ function parseForeignKeyBullet(line, foreignKeys) {
126 } 126 }
127 127
128 // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── 128 // ── 解析 CREATE TABLE DDL ────────────────────────────────────────
  129 +// 标识符 token:反引号包裹(任意非反引号字符,支持中文)或裸 ASCII 标识符(含 `$`)。
  130 +// docs 侧表名/索引名以 `[^`]+` 接受中文,DDL 侧此前仅 `[A-Za-z0-9_]+` → 中文名假阳性(H3)。
  131 +const IDENT = '(?:`[^`]+`|[A-Za-z0-9_$]+)'
  132 +
  133 +// 索引列归一化(两侧共用,消除 DDL-9 假阳性):去反引号 / 去前缀长度 `(N)` / 去 ASC|DESC 排序方向。
  134 +// 例:`sName`(20) → sName;a DESC → a。空 token 丢弃。
  135 +function normalizeIndexCols(raw) {
  136 + return String(raw)
  137 + .split(',')
  138 + .map(c => c.replace(/`/g, '').trim())
  139 + .map(c => c.replace(/\s+(?:asc|desc)\s*$/i, '').trim()) // 先去排序方向:`col(N) DESC` → `col(N)`
  140 + .map(c => c.replace(/\(\s*\d+\s*\)?\s*$/, '').trim()) // 再去前缀长度:`col(N)` → `col`(闭括号可缺,容忍被截断的 `(N`)
  141 + .filter(Boolean)
  142 + .join(',')
  143 +}
  144 +
  145 +// 把 '...' / "..." 字符串字面量内部抹成等长空格(保留首尾引号与总长度),反引号标识符整段保留。
  146 +// 用于独立 CREATE INDEX / ALTER ADD FK 扫描前预处理:DEFAULT / COMMENT 字面量里出现的 "CREATE INDEX …"
  147 +// "ALTER TABLE …" 文本不应被当成真实 DDL 语句(REGEX-3)。长度不变 → 偏移可直接用于平衡括号提取。
  148 +function blankStringLiterals(s) {
  149 + let out = ''
  150 + let i = 0
  151 + while (i < s.length) {
  152 + const ch = s[i]
  153 + if (ch === "'" || ch === '"') {
  154 + const end = advanceLiteral(s, i) // end 指向闭引号之后
  155 + out += ch // 开引号
  156 + for (let k = i + 1; k < end - 1; k++) out += ' '
  157 + if (end - 1 > i) out += s[end - 1] // 闭引号(字面量已终止时)
  158 + i = end
  159 + continue
  160 + }
  161 + if (ch === '`') { // 反引号标识符整段保留——它们正是要匹配的标识符
  162 + const end = advanceLiteral(s, i)
  163 + out += s.slice(i, end)
  164 + i = end
  165 + continue
  166 + }
  167 + out += ch
  168 + i++
  169 + }
  170 + return out
  171 +}
  172 +
  173 +// 表体内联索引 / 外键的匹配器(与 IDENT 同语法,支持反引号包裹的非 ASCII 名,H3 全路径一致)。
  174 +const INLINE_KEY_RE = new RegExp(
  175 + '^(?: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 +
129 // 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。 180 // 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。
  181 +// 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX / ALTER TABLE ADD FK,C1)。
130 export function parseDDL(text) { 182 export function parseDDL(text) {
131 const tables = new Map() 183 const tables = new Map()
132 // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 184 // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。
133 const src = stripSqlComments(String(text)) 185 const src = stripSqlComments(String(text))
134 - // 抓取 CREATE TABLE <name> ( <body> ) ;name 可带反引号;body 到匹配的右括号。 186 + // 抓取 CREATE TABLE <name> ( <body> ) ;name 反引号可含中文(H3);body 到匹配的右括号。
135 // 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。 187 // 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。
136 - const createRe = /CREATE\s+(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`?[A-Za-z0-9_]+`?\s*\.\s*)?`?([A-Za-z0-9_]+)`?\s*\(/gi 188 + const createRe = new RegExp(
  189 + 'CREATE\\s+(?:(?:GLOBAL|LOCAL)\\s+)?(?:TEMPORARY\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?' +
  190 + '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(', 'gi')
137 let m 191 let m
138 while ((m = createRe.exec(src)) !== null) { 192 while ((m = createRe.exec(src)) !== null) {
139 - const tableName = m[1] 193 + const tableName = stripTicks(m[1])
140 const bodyStart = createRe.lastIndex - 1 // 指向 '(' 194 const bodyStart = createRe.lastIndex - 1 // 指向 '('
141 const body = extractBalancedParens(src, bodyStart) 195 const body = extractBalancedParens(src, bodyStart)
142 if (body == null) continue 196 if (body == null) continue
143 - const parsed = parseTableBody(body)  
144 - tables.set(tableName, parsed) 197 + // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "FOREIGN KEY …" / "KEY …" 文本被
  198 + // 内联检测误当真实约束(REGEX-3);反引号标识符整段保留,列名/类型解析不读字面量内容,故不受影响。
  199 + tables.set(tableName, parseTableBody(blankStringLiterals(body)))
145 // 继续从 body 之后扫描 200 // 继续从 body 之后扫描
146 createRe.lastIndex = bodyStart + body.length + 2 201 createRe.lastIndex = bodyStart + body.length + 2
147 } 202 }
  203 +
  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)。
  207 + const scanSrc = blankStringLiterals(src)
  208 + mergeStandaloneIndexes(scanSrc, tables)
  209 + mergeStandaloneForeignKeys(scanSrc, tables)
148 return tables 210 return tables
149 } 211 }
150 212
  213 +// 独立 `CREATE [UNIQUE] INDEX <name> [USING BTREE|HASH] ON [<db>.]<table> (<cols>)` → 并入 table.indexes(C1)。
  214 +// USING 子句可出现在 ON 之前(合法 MySQL),需容忍(REGEX-4)。
  215 +function mergeStandaloneIndexes(src, tables) {
  216 + const re = new RegExp(
  217 + 'CREATE\\s+(UNIQUE\\s+)?INDEX\\s+(' + IDENT + ')(?:\\s+USING\\s+(?:BTREE|HASH))?\\s+ON\\s+' +
  218 + '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(', 'gi')
  219 + let m
  220 + while ((m = re.exec(src)) !== null) {
  221 + const kind = m[1] ? 'UNIQUE' : 'INDEX'
  222 + const idxName = stripTicks(m[2])
  223 + const tbl = stripTicks(m[3])
  224 + const colsBody = extractBalancedParens(src, re.lastIndex - 1) // 指向 '(',平衡括号容纳前缀长度 (N)
  225 + if (colsBody == null) continue
  226 + const t = tables.get(tbl)
  227 + if (!t) continue // 索引指向未声明的表 → 维度1(表集合)会另行报缺,这里不凭空造表
  228 + t.indexes.add(`${idxName}:${kind}:${normalizeIndexCols(colsBody)}`)
  229 + }
  230 +}
  231 +
  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 +
151 function parseTableBody(body) { 261 function parseTableBody(body) {
152 const columns = new Map() 262 const columns = new Map()
153 const indexes = new Set() 263 const indexes = new Set()
@@ -159,11 +269,11 @@ function parseTableBody(body) { @@ -159,11 +269,11 @@ function parseTableBody(body) {
159 269
160 // 外键约束(可带前缀 CONSTRAINT <name>) 270 // 外键约束(可带前缀 CONSTRAINT <name>)
161 if (/\bFOREIGN\s+KEY\b/i.test(item)) { 271 if (/\bFOREIGN\s+KEY\b/i.test(item)) {
162 - // REFERENCES 支持 schema 限定 `db`.`t` / db.t(取末段为表名,与 CREATE TABLE 一致)。  
163 - const fk = item.match(/FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+(?:`?[A-Za-z0-9_]+`?\s*\.\s*)?`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)(?:\s+ON\s+DELETE\s+(CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION))?/i) 272 + // REFERENCES 支持 schema 限定与反引号包裹的非 ASCII 目标表(IDENT,H3 全路径一致;取末段为表名)。
  273 + const fk = item.match(INLINE_FK_RE)
164 if (fk) { 274 if (fk) {
165 const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') 275 const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '')
166 - const refTable = fk[2] 276 + const refTable = stripTicks(fk[2])
167 const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') 277 const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '')
168 const onDelete = (fk[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') 278 const onDelete = (fk[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ')
169 foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) 279 foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`)
@@ -183,17 +293,17 @@ function parseTableBody(body) { @@ -183,17 +293,17 @@ function parseTableBody(body) {
183 // `key varchar(10)`),更可能是未加反引号的保留字列名 + 类型,回退到普通列解析避免漏列; 293 // `key varchar(10)`),更可能是未加反引号的保留字列名 + 类型,回退到普通列解析避免漏列;
184 // 但下游列正则会显式排斥以 KEY/INDEX/UNIQUE/FULLTEXT/SPATIAL 开头的整项,避免 fix #2 的幽灵列。 294 // 但下游列正则会显式排斥以 KEY/INDEX/UNIQUE/FULLTEXT/SPATIAL 开头的整项,避免 fix #2 的幽灵列。
185 if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) { 295 if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) {
186 - const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i) 296 + // 名支持反引号包裹的非 ASCII(IDENT,H3);列体用平衡括号提取,避免前缀长度 `(N)` 处的 `)` 提前截断丢列(DDL-9)。
  297 + const nameMatch = item.match(INLINE_KEY_RE)
187 const SQL_TYPE_RE = /^(?:int|integer|bigint|smallint|tinyint|mediumint|varchar|char|text|blob|date|datetime|timestamp|time|year|decimal|numeric|float|double|real|bit|enum|set|json|binary|varbinary|longtext|longblob|mediumtext|mediumblob|tinytext|tinyblob)$/i 298 const SQL_TYPE_RE = /^(?:int|integer|bigint|smallint|tinyint|mediumint|varchar|char|text|blob|date|datetime|timestamp|time|year|decimal|numeric|float|double|real|bit|enum|set|json|binary|varbinary|longtext|longblob|mediumtext|mediumblob|tinytext|tinyblob)$/i
188 - if (nameMatch && !SQL_TYPE_RE.test(nameMatch[1])) {  
189 - const kind = /^UNIQUE/i.test(item) ? 'UNIQUE' : 'INDEX'  
190 - const cols = nameMatch[2]  
191 - .split(',')  
192 - .map(c => c.replace(/`/g, '').trim())  
193 - .filter(Boolean)  
194 - .join(',')  
195 - indexes.add(`${nameMatch[1]}:${kind}:${cols}`)  
196 - continue 299 + if (nameMatch) {
  300 + const idxName = stripTicks(nameMatch[1]) // 未加反引号的保留字(如 `key varchar`)启发式仍由 SQL_TYPE_RE 兜住
  301 + if (!SQL_TYPE_RE.test(idxName)) {
  302 + const kind = /^UNIQUE/i.test(item) ? 'UNIQUE' : 'INDEX'
  303 + const colsBody = extractBalancedParens(item, nameMatch[0].length - 1) // nameMatch[0] 以 '(' 结尾
  304 + indexes.add(`${idxName}:${kind}:${normalizeIndexCols(colsBody || '')}`) // 列归一化两侧共用(DDL-9)
  305 + continue
  306 + }
197 } 307 }
198 } 308 }
199 // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 309 // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记
lib/validate-ddl.test.mjs
@@ -464,3 +464,246 @@ test(&#39;parseDDL: CREATE TEMPORARY TABLE 也应被解析(fix #16)&#39;, () =&gt; { @@ -464,3 +464,246 @@ test(&#39;parseDDL: CREATE TEMPORARY TABLE 也应被解析(fix #16)&#39;, () =&gt; {
464 const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') 464 const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );')
465 assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()]) 465 assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()])
466 }) 466 })
  467 +
  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 首轮校验必报假阳性。
  472 +test('parseDDL: 独立 CREATE INDEX 并入对应表的 indexes(C1)', () => {
  473 + const ddl = [
  474 + 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );',
  475 + 'CREATE INDEX `idx_cust` ON `t_order` (`iCustomerId`);',
  476 + ].join('\n')
  477 + const t = parseDDL(ddl).get('t_order')
  478 + assert.ok(t)
  479 + assert.ok(t.indexes.has('idx_cust:INDEX:iCustomerId'),
  480 + '独立 CREATE INDEX 应并入表索引集 — got: ' + [...t.indexes])
  481 +})
  482 +
  483 +test('parseDDL: 独立 CREATE UNIQUE INDEX 归一化为 UNIQUE(C1)', () => {
  484 + const ddl = [
  485 + 'CREATE TABLE `t` ( `c` int NOT NULL );',
  486 + 'CREATE UNIQUE INDEX `uk_c` ON `t` (`c`);',
  487 + ].join('\n')
  488 + const t = parseDDL(ddl).get('t')
  489 + assert.ok(t.indexes.has('uk_c:UNIQUE:c'), 'got: ' + [...t.indexes])
  490 +})
  491 +
  492 +test('parseDDL: 独立 CREATE INDEX 多列归一化(C1)', () => {
  493 + const ddl = [
  494 + 'CREATE TABLE `t` ( `sBrandsId` varchar(100), `sSubsidiaryId` varchar(100) );',
  495 + 'CREATE INDEX `idx_tenant` ON `t` (`sBrandsId`, `sSubsidiaryId`);',
  496 + ].join('\n')
  497 + const t = parseDDL(ddl).get('t')
  498 + assert.ok(t.indexes.has('idx_tenant:INDEX:sBrandsId,sSubsidiaryId'), 'got: ' + [...t.indexes])
  499 +})
  500 +
  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 头号回归)', () => {
  522 + const docs = [
  523 + '## `t_customer` — 客户表',
  524 + '### 字段',
  525 + '| 字段 | 类型 |',
  526 + '|---|---|',
  527 + '| `iIncrement` | int |',
  528 + '',
  529 + '## `t_order` — 订单表',
  530 + '### 字段',
  531 + '| 字段 | 类型 |',
  532 + '|---|---|',
  533 + '| `iId` | int |',
  534 + '| `iCustomerId` | int |',
  535 + '### 索引',
  536 + '- `idx_cust` (INDEX): iCustomerId',
  537 + '### 外键',
  538 + '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)',
  539 + '',
  540 + ].join('\n')
  541 + const ddl = [
  542 + '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`) );',
  544 + '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')
  547 + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl))
  548 + 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')
  551 +})
  552 +
  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 侧需对齐)──────
  570 +test('parseDDL: 反引号包裹的中文表名应被解析(H3 标识符语法对齐)', () => {
  571 + const t = parseDDL('CREATE TABLE `订单表` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );')
  572 + assert.ok(t.get('订单表'), '中文表名应入 Map — got: ' + [...t.keys()])
  573 +})
  574 +
  575 +test('full chain: docs 与 DDL 同为中文表名时不应误报 missingTables(H3)', () => {
  576 + const docs = '## `订单表`\n| 列 | 类型 |\n|---|---|\n| `iIncrement` | int |\n'
  577 + const ddl = 'CREATE TABLE `订单表` ( `iIncrement` int NOT NULL );'
  578 + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl))
  579 + assert.deepEqual(d.missingTables, [], 'got: ' + JSON.stringify(d.missingTables))
  580 + assert.deepEqual(d.extraTables, [])
  581 +})
  582 +
  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: 索引列归一化两侧对齐(前缀长度 / 排序方向)────────────────────
  593 +test('full chain: 前缀长度索引列 sName(20) docs↔DDL 一致时不应误报(DDL-9)', () => {
  594 + const docs = [
  595 + '## `t`',
  596 + '### 字段',
  597 + '| 列 | 类型 |',
  598 + '|---|---|',
  599 + '| `sName` | varchar(100) |',
  600 + '### 索引',
  601 + '- `idx_name` (INDEX): sName(20)',
  602 + ].join('\n')
  603 + const ddl = [
  604 + 'CREATE TABLE `t` (',
  605 + ' `sName` varchar(100),',
  606 + ' KEY `idx_name` (`sName`(20))',
  607 + ') ENGINE=InnoDB;',
  608 + ].join('\n')
  609 + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl))
  610 + assert.deepEqual(d.indexMismatches, [],
  611 + '前缀长度索引两侧应归一化为同一列名 — got: ' + JSON.stringify(d.indexMismatches))
  612 +})
  613 +
  614 +// ── M3: 索引 type 槽位的中文同义词应映射到与 DDL 一致的 kind ────────────
  615 +test('parseDocsTables: 索引 type 写「唯一」中文标签应归一化为 UNIQUE(M3)', () => {
  616 + const docs = '## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `uk_c` (唯一): c\n'
  617 + const t = parseDocsTables(docs).get('t')
  618 + assert.ok(t.indexes.has('uk_c:UNIQUE:c'),
  619 + '中文「唯一」应映射为 UNIQUE 而非 INDEX — got: ' + [...t.indexes])
  620 +})
  621 +
  622 +// ── 实现复审回归(REGEX/EFFICACY 系列)────────────────────────────────
  623 +
  624 +// REGEX-2:前缀长度 + 排序方向同时出现 `col(N) DESC` 时必须完全归一化到裸列名 `col`,
  625 +// 否则 docs 写 `sName(20) DESC`、DDL 写裸 `sName`(或反之)会假阳性。两侧故意不对称以暴露归一化顺序 bug。
  626 +test('full chain: 索引列 `sName(20) DESC` 应完全归一化为裸列名,与裸 `sName` 对齐(REGEX-2 前缀长度+方向)', () => {
  627 + const docs = [
  628 + '## `t`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `sName` | varchar(100) |',
  629 + '### 索引', '- `idx_name` (INDEX): sName(20) DESC',
  630 + ].join('\n')
  631 + const ddlInline = [
  632 + 'CREATE TABLE `t` (', ' `sName` varchar(100),', ' KEY `idx_name` (`sName`)', ') ENGINE=InnoDB;',
  633 + ].join('\n')
  634 + const ddlStandalone = [
  635 + 'CREATE TABLE `t` ( `sName` varchar(100) );',
  636 + 'CREATE INDEX `idx_name` ON `t` (`sName`);',
  637 + ].join('\n')
  638 + for (const ddl of [ddlInline, ddlStandalone]) {
  639 + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl))
  640 + assert.deepEqual(d.indexMismatches, [], '`sName(20) DESC` 应归一化为 sName — got: ' + JSON.stringify(d.indexMismatches))
  641 + }
  642 +})
  643 +
  644 +// REGEX-1 / EFFICACY-4 / PROSE-1:inline KEY 名 + inline FK 目标表为中文时也应与 docs 对齐。
  645 +test('full chain: inline 中文索引名 + inline 中文 FK 目标表应与 docs 对齐(REGEX-1 / H3 一致)', () => {
  646 + const docs = [
  647 + '## `订单`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `user_id` | int |',
  648 + '### 索引', '- `中文索引` (INDEX): user_id',
  649 + '### 外键', '- `fk_u`: user_id → 用户.id (RESTRICT)',
  650 + ].join('\n')
  651 + const ddl = [
  652 + 'CREATE TABLE `订单` (', ' `user_id` int,',
  653 + ' KEY `中文索引` (`user_id`),',
  654 + ' CONSTRAINT `fk_u` FOREIGN KEY (`user_id`) REFERENCES `用户` (`id`)',
  655 + ') ENGINE=InnoDB;',
  656 + ].join('\n')
  657 + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl))
  658 + assert.deepEqual(d.indexMismatches, [], 'inline 中文索引名应对齐 — got: ' + JSON.stringify(d.indexMismatches))
  659 + assert.deepEqual(d.foreignKeyMismatches, [], 'inline 中文 FK 目标表应对齐 — got: ' + JSON.stringify(d.foreignKeyMismatches))
  660 +})
  661 +
  662 +// REGEX-3:字符串字面量里的 CREATE INDEX / ALTER ADD FK 不应被独立语句扫描误当真实定义。
  663 +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`) );"
  665 + const t = parseDDL(ddl).get('t_order')
  666 + assert.ok(t)
  667 + assert.equal([...t.indexes].some(ix => ix.includes('ghost')), false, '字面量内的 CREATE INDEX 不应成为真实索引 — got: ' + [...t.indexes])
  668 +})
  669 +
  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 +
  690 +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')
  692 + const t = parseDDL(ddl).get('t')
  693 + assert.ok(t.indexes.has('idx_c:INDEX:c'), 'got: ' + [...t.indexes])
  694 +})
  695 +
  696 +// EFFICACY-1:中文 type 映射应锚定——「主键索引」(含「主键」但不是主键) 不得被当 PRIMARY 剔除。
  697 +test('parseDocsTables: 「主键索引」标签不应被误当 PRIMARY(EFFICACY-1 锚定)', () => {
  698 + const docs = '## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `idx_c` (主键索引): c\n'
  699 + const t = parseDocsTables(docs).get('t')
  700 + assert.ok(t.indexes.has('idx_c:INDEX:c'), '「主键索引」应作普通 INDEX 保留 — got: ' + [...t.indexes])
  701 + assert.equal(t.indexes.has('PRIMARY'), false, '不应被剔除为 PRIMARY')
  702 +})
  703 +
  704 +test('parseDocsTables: 恰为「主键」/「唯一」仍正确映射(EFFICACY-1 锚定不误伤正例)', () => {
  705 + const pk = parseDocsTables('## `t`\n### 索引\n- `pk` (主键): c\n').get('t')
  706 + assert.ok(pk.indexes.has('PRIMARY'), '「主键」应映射 PRIMARY')
  707 + const uk = parseDocsTables('## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `uk` (唯一): c\n').get('t')
  708 + assert.ok(uk.indexes.has('uk:UNIQUE:c'), '「唯一」应映射 UNIQUE — got: ' + [...uk.indexes])
  709 +})
skills/plan/db-design-gen/SKILL.md
@@ -28,7 +28,8 @@ allowed-tools: Read Write Edit Grep Glob @@ -28,7 +28,8 @@ allowed-tools: Read Write Edit Grep Glob
28 1. 严格套用 `docs/04` 命名规范 + 匈牙利列前缀(`i`=int / `s`=varchar / `t`=datetime) 28 1. 严格套用 `docs/04` 命名规范 + 匈牙利列前缀(`i`=int / `s`=varchar / `t`=datetime)
29 2. **主键**:标准列 `iIncrement` int 主键。REQ 明确要求不同主键(复合主键 / UUID / 业务主键)时按 REQ,并在该表业务注记里注明偏离原因 29 2. **主键**:标准列 `iIncrement` int 主键。REQ 明确要求不同主键(复合主键 / UUID / 业务主键)时按 REQ,并在该表业务注记里注明偏离原因
30 3. **外键**:依据 REQ 中的引用关系(如「订单引用客户」),明确列出 `ON DELETE` / `ON UPDATE` 策略;不能确定时默认 `RESTRICT` 30 3. **外键**:依据 REQ 中的引用关系(如「订单引用客户」),明确列出 `ON DELETE` / `ON UPDATE` 策略;不能确定时默认 `RESTRICT`
31 -4. **索引**:根据 REQ 的查询模式推导业务索引;外键列默认建索引;租户隔离列 `sBrandsId` / `sSubsidiaryId`(标准列)按业务查询模式建组合索引 31 +4. **索引**:根据 REQ 的查询模式推导业务索引;外键列默认建索引;租户隔离列 `sBrandsId` / `sSubsidiaryId`(标准列)按业务查询模式建组合索引。
  32 + - 索引 bullet 的 `(类别)` 槽位**统一用 ASCII**:唯一索引写 `UNIQUE`、普通/组合索引写 `INDEX`(与 DDL 侧 `UNIQUE KEY` / `KEY` 对齐,validate-ddl 据此比对 UNIQUE\|INDEX 类别);主键不在 `### 索引` 重复列(由标准列 `iIncrement` 治理)。
32 5. **业务注记**:对每张表用一两句话说明业务用途、关键约束、与其他表的关系 33 5. **业务注记**:对每张表用一两句话说明业务用途、关键约束、与其他表的关系
33 34
34 如果某 REQ 表述模糊以致无法推断关键 schema 细节(如:枚举值范围 / 字段长度上限 / 必填性),先按合理默认推导并在该字段「业务含义」列加 `【人工填写:需用户审阅】` 标注,待步骤 E 用户审阅时调整;**不打断本次推导**。 35 如果某 REQ 表述模糊以致无法推断关键 schema 细节(如:枚举值范围 / 字段长度上限 / 必填性),先按合理默认推导并在该字段「业务含义」列加 `【人工填写:需用户审阅】` 标注,待步骤 E 用户审阅时调整;**不打断本次推导**。
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 全量校验 DDL ↔ docs/03 一致性 → 校验 config-vars.yaml DB 凭据 5 项非空 → 调 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 做 5 维校验(表/列/类型/索引/外键)DDL ↔ docs/03 一致性 → 连库前置预检(mysql2 模块 + mysql 客户端)→ 校验 config-vars.yaml DB 凭据 5 项非空 → 调 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 *) 5 +allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm install mysql2)
6 --- 6 ---
7 7
8 **所有输出必须使用中文。** 8 **所有输出必须使用中文。**
@@ -36,6 +36,8 @@ allowed-tools: Read Write Edit Skill Bash(node *) @@ -36,6 +36,8 @@ allowed-tools: Read Write Edit Skill Bash(node *)
36 36
37 调 `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 做跨平台、纯 Node 的 5 维校验(表集合 / 列名 / 列类型 / 索引 / 外键)。**注意参数顺序:docs/03 在前,V1.sql 在后。** 37 调 `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 做跨平台、纯 Node 的 5 维校验(表集合 / 列名 / 列类型 / 索引 / 外键)。**注意参数顺序:docs/03 在前,V1.sql 在后。**
38 38
  39 +> **机检边界(勿误解)**:5 维 = 表集合 / 列名 / 列类型 / 索引(名 + UNIQUE\|INDEX 类别 + 列)/ 外键(列 → 表(列) + ON DELETE);表体内联与独立 `CREATE INDEX` / `ALTER TABLE ... ADD FOREIGN KEY` 两种形态都识别。**A.1 要求的「字段顺序 / 可空 / 默认 / 列注释对齐」不在机检范围内**——这几项靠 A.1 翻译时忠实对齐 docs/03(docs/03 已在 A3 人工审阅过),validate-ddl 不会代为兜底,勿因校验通过就认定它们也一致。
  40 +
39 ```bash 41 ```bash
40 node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ 42 node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \
41 docs/03-数据库设计文档.md \ 43 docs/03-数据库设计文档.md \
@@ -54,8 +56,24 @@ node &quot;${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs&quot; \ @@ -54,8 +56,24 @@ node &quot;${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs&quot; \
54 - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成` 56 - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成`
55 - ` - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs)` 57 - ` - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs)`
56 58
57 -### B. 数据库环境检查 59 +### B. 数据库环境检查(连库前置——必须在步骤 C 的任何 DROP 之前完成)
  60 +
  61 +#### B.1 工具链预检
  62 +A4 连库用到两样东西:`apply-ddl.mjs` 依赖 `mysql2` 模块、`setup-test-db.mjs` 依赖 `mysql` 客户端。两者都在 C 阶段才被调用,而 C.1 第一步就是 `DROP DATABASE`——所以必须**在此先探测、缺则补齐**,避免"删到一半才发现缺工具"。
  63 +
  64 +1. **mysql2 驱动**(`apply-ddl.mjs` 从 config-vars.yaml 所在目录解析 mysql2,而非插件目录;按 A4 既定调用 `apply-ddl.mjs config-vars.yaml …`、cwd = 项目根,该目录即项目根,故下面按 cwd 解析的 `node -e` 探测对此调用具代表性):
  65 + ```bash
  66 + node -e "import('mysql2/promise').then(()=>process.exit(0),()=>process.exit(1))"
  67 + ```
  68 + - 退出 `0` → 已就绪。
  69 + - 退出非 `0` → 在项目根执行 `npm i mysql2`(首次会生成 / 更新根 `package.json` + `node_modules`;`.gitignore` 已忽略 `node_modules`),再重跑上面一行确认;仍失败 → 打印 stderr 并停下。
  70 +2. **mysql 客户端**:
  71 + ```bash
  72 + node -e "process.exit(require('node:child_process').spawnSync('mysql',['--version']).status===0?0:1)"
  73 + ```
  74 + - 退出非 `0` → 打印「未找到 mysql 客户端,请安装并加入 PATH 后重跑 /plan-start」并停下(**不进入 C**)。
58 75
  76 +#### B.2 凭据校验
59 用 `Read` 读 `config-vars.yaml` 的 `database:` 段(文件缺失 → 提示重跑 A1 `scope-lock` 并停下),校验 `host` / `port` / `user` / `password` / `schema` 5 项均非空且非 `【人工填写` 占位——任一缺失 → 打印缺失字段并停下。 77 用 `Read` 读 `config-vars.yaml` 的 `database:` 段(文件缺失 → 提示重跑 A1 `scope-lock` 并停下),校验 `host` / `port` / `user` / `password` / `schema` 5 项均非空且非 `【人工填写` 占位——任一缺失 → 打印缺失字段并停下。
60 78
61 连通性无需在此单独探测:步骤 C.1 的 `setup-test-db.mjs` 会用同一份凭据连同一个 MySQL 跑 `DROP+CREATE`,连不上即报错(认证 / 主机不可达 / 端口拒接);且 `DROP DATABASE IF EXISTS` 在连不上时不破坏任何东西,由 C.1 失败即可。 79 连通性无需在此单独探测:步骤 C.1 的 `setup-test-db.mjs` 会用同一份凭据连同一个 MySQL 跑 `DROP+CREATE`,连不上即报错(认证 / 主机不可达 / 端口拒接);且 `DROP DATABASE IF EXISTS` 在连不上时不破坏任何东西,由 C.1 失败即可。
skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs
1 #!/usr/bin/env node 1 #!/usr/bin/env node
2 // scripts/setup-test-db.mjs — DROP + CREATE 测试库。 2 // scripts/setup-test-db.mjs — DROP + CREATE 测试库。
3 // 由 coding.mjs 的 test-gate 调用;schema 由 Flyway 在 Spring Boot 启动时重放。 3 // 由 coding.mjs 的 test-gate 调用;schema 由 Flyway 在 Spring Boot 启动时重放。
4 -// DB 凭据从仓库根 config-vars.yaml 的 database: 段读取(host / schema 完全信任该文件,无额外校验)。 4 +// DB 凭据从仓库根 config-vars.yaml 的 database: 段读取:schema 经标识符校验后才拼进 SQL(防误删 / 注入,见下方守卫);
  5 +// host / user / password 信任该文件,port 仅校验范围。
5 6
6 import { spawnSync } from 'node:child_process' 7 import { spawnSync } from 'node:child_process'
7 import { existsSync, readFileSync } from 'node:fs' 8 import { existsSync, readFileSync } from 'node:fs'
@@ -72,6 +73,16 @@ if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) &lt;= 0 || Number(DB_PORT) &gt; 65535) { @@ -72,6 +73,16 @@ if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) &lt;= 0 || Number(DB_PORT) &gt; 65535) {
72 process.exit(1) 73 process.exit(1)
73 } 74 }
74 75
  76 +// schema 是被无条件 DROP + CREATE 的标识符——必须严格校验后才拼进 SQL:
  77 +// · 空值 → 避免 DROP DATABASE `` 这类无意义/误删语句
  78 +// · 「【人工填写】」占位 → 配置尚未填好,不应连库
  79 +// · 含反引号 → 防止 `erp`; DROP DATABASE `prod` 形态的标识符注入(值来自 config-vars.yaml,按 fail-closed 处理)
  80 +// 注:仅接受 ASCII 标识符;非 ASCII schema 名一律拒绝(即便 MySQL / apply-ddl 允许),与推荐的 test/_dev 命名一致
  81 +if (!/^[A-Za-z0-9_$]+$/.test(DB_SCHEMA)) {
  82 + console.error(`[setup-test-db] database.schema 非法或未填: ${JSON.stringify(DB_SCHEMA)}(需为 [A-Za-z0-9_$] 标识符;空值 / 「【人工填写】」占位 / 含反引号均拒绝)`)
  83 + process.exit(1)
  84 +}
  85 +
75 console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`) 86 console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`)
76 87
77 const sql = 88 const sql =