From e69a73c7757f14704a6a9f20a21fee2981ac8445 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 29 May 2026 18:13:53 +0800 Subject: [PATCH] db-init: parse standalone CREATE INDEX / ALTER ADD FK + harden connect path --- lib/apply-ddl.mjs | 37 +++++++++++++++++++++++++++++++++++-- lib/apply-ddl.test.mjs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- lib/setup-test-db-template.test.mjs | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/validate-ddl.mjs | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- lib/validate-ddl.test.mjs | 243 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ skills/plan/db-design-gen/SKILL.md | 3 ++- skills/plan/db-init/SKILL.md | 24 +++++++++++++++++++++--- skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs | 13 ++++++++++++- 8 files changed, 559 insertions(+), 35 deletions(-) create mode 100644 lib/setup-test-db-template.test.mjs diff --git a/lib/apply-ddl.mjs b/lib/apply-ddl.mjs index 71e06c8..ae36525 100644 --- a/lib/apply-ddl.mjs +++ b/lib/apply-ddl.mjs @@ -1,4 +1,25 @@ import { parseYamlConfig } from './yaml-config.mjs' +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import { dirname, resolve as resolvePath, join } from 'node:path' + +/** + * Resolve the `mysql2/promise` driver from the TARGET PROJECT directory. + * + * ESM resolves a bare specifier relative to the importing module — and this helper + * lives inside the PLUGIN, not the project being scaffolded. A plain + * `import('mysql2/promise')` here would therefore look in the plugin's own + * node_modules and never see a target-project `npm i mysql2`. We instead build a + * `require` rooted at the target project so the documented "install mysql2 in your + * project" actually takes effect. Throws (MODULE_NOT_FOUND) when it is absent. + * + * @param {string} [baseDir] target project root (defaults to cwd) + * @returns {string} absolute path to the resolved mysql2/promise entry + */ +export function resolveMysql2Path(baseDir = process.cwd()) { + const require = createRequire(pathToFileURL(join(baseDir, 'package.json')).href) + return require.resolve('mysql2/promise') +} /** * Apply a DDL file to a MySQL database using mysql2/promise. @@ -16,7 +37,9 @@ export async function applyDDL({ configPath, ddlPath }) { let mysql try { - ;({ default: mysql } = await import('mysql2/promise')) + // 从目标项目(config-vars.yaml 所在目录)解析 mysql2,而非插件自身目录(见 resolveMysql2Path)。 + const resolved = resolveMysql2Path(dirname(resolvePath(configPath))) + ;({ default: mysql } = await import(pathToFileURL(resolved).href)) } catch { throw new MysqlUnavailableError() } @@ -60,6 +83,13 @@ export function resolveDbConfig(config, cfgPath = 'config-vars.yaml') { if (!Number.isInteger(port) || port <= 0 || port > 65535) { throw new Error(`apply-ddl: 端口非法 — ${cfgPath} 的 database.port 必须是 1..65535 的整数`) } + // DDL-7:lib 自保护——拒绝把未填的「【人工填写】」凭据占位直连 MySQL。 + // db-init 步骤 B 已用 LLM 文本检查把关,这里在真正建连的那一层再加一道防御(占位文本绝不应连库)。 + for (const [k, v] of [['host', host], ['user', user], ['password', password], ['schema', database]]) { + if (typeof v === 'string' && v.includes('【人工填写')) { + throw new Error(`apply-ddl: ${cfgPath} 的 database.${k} 仍是「【人工填写】」占位 — 请先填真实凭据`) + } + } return { host, port, user, password, database } } @@ -72,13 +102,16 @@ export class MysqlUnavailableError extends Error { } // CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) -const { pathToFileURL } = await import('node:url') if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [configPath, ddlPath] = process.argv.slice(2) if (!configPath || !ddlPath) { console.error('usage: node lib/apply-ddl.mjs ') process.exit(2) } + // DDL-6:缺文件属用法/路径错 → 退出码 2(与 db-init C.2 文档「2 = 用法错(路径找不到)」及 validate-ddl 对齐)。 + const { existsSync } = await import('node:fs') + if (!existsSync(configPath)) { console.error(`apply-ddl: 配置文件不存在: ${configPath}`); process.exit(2) } + if (!existsSync(ddlPath)) { console.error(`apply-ddl: DDL 文件不存在: ${ddlPath}`); process.exit(2) } try { await applyDDL({ configPath, ddlPath }) console.log(`apply-ddl: applied ${ddlPath} using ${configPath}`) diff --git a/lib/apply-ddl.test.mjs b/lib/apply-ddl.test.mjs index cd0e242..b9d5114 100644 --- a/lib/apply-ddl.test.mjs +++ b/lib/apply-ddl.test.mjs @@ -1,6 +1,13 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { resolveDbConfig } from './apply-ddl.mjs' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resolveDbConfig, resolveMysql2Path } from './apply-ddl.mjs' + +const APPLY = fileURLToPath(new URL('./apply-ddl.mjs', import.meta.url)) // ── resolveDbConfig(直接读 config-vars.yaml 解析后的 database: 段)───────── test('resolveDbConfig maps the database section to mysql2 settings', () => { @@ -31,3 +38,52 @@ test('resolveDbConfig rejects invalid ports', () => { assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: 'abc' } }), /database\.port/) assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: '70000' } }), /database\.port/) }) + +// ── DDL-7:lib 自保护——未填的 【人工填写】 凭据占位不得直连 MySQL ────────── +test('resolveDbConfig rejects unfilled 【人工填写】 placeholders in credentials (DDL-7)', () => { + const base = { host: 'h', port: '3306', user: 'u', password: 'p', schema: 's' } + assert.throws(() => resolveDbConfig({ database: { ...base, schema: '【人工填写:schema 名】' } }), /人工填写/) + assert.throws(() => resolveDbConfig({ database: { ...base, host: '【人工填写:MySQL host】' } }), /人工填写/) + assert.throws(() => resolveDbConfig({ database: { ...base, user: '【人工填写:账号】' } }), /人工填写/) + assert.throws(() => resolveDbConfig({ database: { ...base, password: '【人工填写:密码】' } }), /人工填写/) +}) + +test('resolveDbConfig still accepts a fully-filled real config (DDL-7 no false positive)', () => { + const c = resolveDbConfig({ database: { host: '127.0.0.1', port: '3306', user: 'root', password: 'p@ss', schema: 'erp_dev' } }) + assert.equal(c.database, 'erp_dev') +}) + +// ── DDL-6:缺文件应退出码 2(用法/路径错),与 db-init C.2 文档及 validate-ddl 对齐 ── +test('apply-ddl CLI exits 2 when the config file does not exist (DDL-6)', () => { + const r = spawnSync('node', [APPLY, '/no/such/config-vars.yaml', '/no/such/V1.sql'], { encoding: 'utf8' }) + assert.equal(r.status, 2, 'missing config file should be exit 2, not 1 — stderr: ' + r.stderr) +}) + +test('apply-ddl CLI exits 2 when the DDL file does not exist (DDL-6)', () => { + // config exists (this very test file stands in as an existing path), DDL does not + const r = spawnSync('node', [APPLY, APPLY, '/no/such/V1.sql'], { encoding: 'utf8' }) + assert.equal(r.status, 2, 'missing DDL file should be exit 2 — stderr: ' + r.stderr) +}) + +test('apply-ddl CLI exits 2 on missing arguments (existing behavior preserved)', () => { + const r = spawnSync('node', [APPLY], { encoding: 'utf8' }) + assert.equal(r.status, 2) +}) + +// ── H2:mysql2 必须从「目标项目」解析,而非插件自身目录 ──────────────────── +// ESM 裸说明符按 importer 解析;apply-ddl.mjs 住在插件目录,若直接 import('mysql2/promise') +// 则永远看不到目标项目 `npm i mysql2` 的安装。resolveMysql2Path 用 createRequire(目标根) 修正。 +test('resolveMysql2Path resolves mysql2/promise from the target project dir (H2)', () => { + const dir = mkdtempSync(join(tmpdir(), 'erp-m2-')) + mkdirSync(join(dir, 'node_modules', 'mysql2'), { recursive: true }) + writeFileSync(join(dir, 'node_modules', 'mysql2', 'package.json'), + JSON.stringify({ name: 'mysql2', version: '0.0.0', exports: { './promise': './promise.js' } })) + writeFileSync(join(dir, 'node_modules', 'mysql2', 'promise.js'), 'export default {}') + const p = resolveMysql2Path(dir) + assert.match(p, /node_modules[\\/]mysql2[\\/]promise\.js$/, 'got: ' + p) +}) + +test('resolveMysql2Path throws when mysql2 is absent in the target project (H2)', () => { + const dir = mkdtempSync(join(tmpdir(), 'erp-m2-empty-')) + assert.throws(() => resolveMysql2Path(dir)) +}) diff --git a/lib/setup-test-db-template.test.mjs b/lib/setup-test-db-template.test.mjs new file mode 100644 index 0000000..57189ce --- /dev/null +++ b/lib/setup-test-db-template.test.mjs @@ -0,0 +1,52 @@ +// lib/setup-test-db-template.test.mjs — 校验生成模板 scripts/setup-test-db.mjs 的 schema 守卫。 +// 跑的是真实模板产物:复制到临时 scripts/ 下、写一个 ../config-vars.yaml、再 node 执行。 +// 所有用例的 host/port 故意指向 127.0.0.1:1(必拒连),即便守卫缺失也绝不触碰真实库。 +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs', import.meta.url)) + +function runWithSchema(schemaLine) { + const dir = mkdtempSync(join(tmpdir(), 'erp-stdb-')) + mkdirSync(join(dir, 'scripts')) + copyFileSync(TEMPLATE, join(dir, 'scripts', 'setup-test-db.mjs')) + writeFileSync( + join(dir, 'config-vars.yaml'), + ['database:', ' host: 127.0.0.1', ' port: 1', ' user: root', ' password: x', ' ' + schemaLine, ''].join('\n'), + ) + return spawnSync('node', [join(dir, 'scripts', 'setup-test-db.mjs')], { encoding: 'utf8' }) +} + +// ROBUST-3:空 schema 不应进到 DROP DATABASE `` —— 守卫应先拦下。 +test('setup-test-db: empty schema fails closed with a schema message (ROBUST-3)', () => { + const r = runWithSchema('schema:') + assert.equal(r.status, 1) + assert.match(r.stderr, /schema/, '应是 schema 守卫报错而非连库失败 — stderr: ' + r.stderr) +}) + +// ROBUST-3:未填的 【人工填写】 占位不应被当库名。 +test('setup-test-db: 【人工填写】 placeholder schema fails closed (ROBUST-3)', () => { + const r = runWithSchema('schema: 【人工填写:schema 名】') + assert.equal(r.status, 1) + assert.match(r.stderr, /schema/, 'stderr: ' + r.stderr) +}) + +// DDL-8:含反引号的 schema(标识符注入)应被拒,而不是拼进 DROP/CREATE 语句。 +test('setup-test-db: schema with a backtick is rejected (DDL-8 injection guard)', () => { + const r = runWithSchema('schema: ev`il') + assert.equal(r.status, 1) + assert.match(r.stderr, /schema/, 'stderr: ' + r.stderr) +}) + +// 正例:合法标识符 schema 应通过守卫并继续到连库阶段(此处连 127.0.0.1:1 必失败, +// 但 stderr 应是连库/mysql 错误,而非 schema 守卫错误)——证明守卫不误伤合法名。 +test('setup-test-db: a valid identifier schema passes the guard (no false positive)', () => { + const r = runWithSchema('schema: erp_dev') + // 连不上 127.0.0.1:1 → 非零退出;关键是错误不来自 schema 守卫。 + assert.doesNotMatch(r.stderr, /database\.schema 非法|schema 非法或未填/, 'stderr: ' + r.stderr) +}) diff --git a/lib/validate-ddl.mjs b/lib/validate-ddl.mjs index 20f33c3..51dd473 100644 --- a/lib/validate-ddl.mjs +++ b/lib/validate-ddl.mjs @@ -76,17 +76,16 @@ function parseIndexBullet(line, indexes) { if (!name) return // 散文 bullet 守门:没有括号也没有冒号列段 → 不是索引项 if (!type && !colsRaw) return - if (/^primary$/i.test(type) || /^primary$/i.test(name)) { + // PRIMARY:英文 primary 或恰为中文「主键」(M3:type 槽位可能写中文)。锚定匹配—— + // 「主键索引」「主键候选」等含「主键」但非主键的标签不得被剔除为 PRIMARY(EFFICACY-1)。 + if (/^primary$/i.test(type) || /^primary$/i.test(name) || /^主键$/.test(type)) { indexes.add('PRIMARY') return } - // 列与 UNIQUE/INDEX 类别一并参与等价比较(fix #10) - const cols = colsRaw - .split(',') - .map(c => c.replace(/`/g, '').trim()) - .filter(c => /^[A-Za-z0-9_]+$/.test(c)) - .join(',') - const kind = /^unique$/i.test(type) ? 'UNIQUE' : 'INDEX' + // 列与 UNIQUE/INDEX 类别一并参与等价比较(fix #10);列归一化两侧共用(DDL-9)。 + // UNIQUE 识别英文 unique 或恰为中文「唯一」(M3,锚定),否则视为普通 INDEX。 + const cols = normalizeIndexCols(colsRaw) + const kind = (/^unique$/i.test(type) || /^唯一$/.test(type)) ? 'UNIQUE' : 'INDEX' indexes.add(`${name}:${kind}:${cols}`) } @@ -95,7 +94,8 @@ function parseIndexBullet(line, indexes) { function parseForeignKeyBullet(line, foreignKeys) { // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 - const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([A-Za-z0-9_]+)`?\s*\.\s*(.+)$/) + // 目标表名用 [^`\s.]+(接受反引号包裹的中文表名,H3;以 `.` 与目标列分隔),与 docs headerRe 的非 ASCII 容许度对齐。 + const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([^`\s.]+)`?\s*\.\s*(.+)$/) if (!head) return const fromRaw = head[1] const toTable = head[2] @@ -126,28 +126,138 @@ function parseForeignKeyBullet(line, foreignKeys) { } // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── +// 标识符 token:反引号包裹(任意非反引号字符,支持中文)或裸 ASCII 标识符(含 `$`)。 +// docs 侧表名/索引名以 `[^`]+` 接受中文,DDL 侧此前仅 `[A-Za-z0-9_]+` → 中文名假阳性(H3)。 +const IDENT = '(?:`[^`]+`|[A-Za-z0-9_$]+)' + +// 索引列归一化(两侧共用,消除 DDL-9 假阳性):去反引号 / 去前缀长度 `(N)` / 去 ASC|DESC 排序方向。 +// 例:`sName`(20) → sName;a DESC → a。空 token 丢弃。 +function normalizeIndexCols(raw) { + return String(raw) + .split(',') + .map(c => c.replace(/`/g, '').trim()) + .map(c => c.replace(/\s+(?:asc|desc)\s*$/i, '').trim()) // 先去排序方向:`col(N) DESC` → `col(N)` + .map(c => c.replace(/\(\s*\d+\s*\)?\s*$/, '').trim()) // 再去前缀长度:`col(N)` → `col`(闭括号可缺,容忍被截断的 `(N`) + .filter(Boolean) + .join(',') +} + +// 把 '...' / "..." 字符串字面量内部抹成等长空格(保留首尾引号与总长度),反引号标识符整段保留。 +// 用于独立 CREATE INDEX / ALTER ADD FK 扫描前预处理:DEFAULT / COMMENT 字面量里出现的 "CREATE INDEX …" +// "ALTER TABLE …" 文本不应被当成真实 DDL 语句(REGEX-3)。长度不变 → 偏移可直接用于平衡括号提取。 +function blankStringLiterals(s) { + let out = '' + let i = 0 + while (i < s.length) { + const ch = s[i] + if (ch === "'" || ch === '"') { + const end = advanceLiteral(s, i) // end 指向闭引号之后 + out += ch // 开引号 + for (let k = i + 1; k < end - 1; k++) out += ' ' + if (end - 1 > i) out += s[end - 1] // 闭引号(字面量已终止时) + i = end + continue + } + if (ch === '`') { // 反引号标识符整段保留——它们正是要匹配的标识符 + const end = advanceLiteral(s, i) + out += s.slice(i, end) + i = end + continue + } + out += ch + i++ + } + return out +} + +// 表体内联索引 / 外键的匹配器(与 IDENT 同语法,支持反引号包裹的非 ASCII 名,H3 全路径一致)。 +const INLINE_KEY_RE = new RegExp( + '^(?:UNIQUE\\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\\s+KEY|SPATIAL\\s+KEY)\\s+(' + IDENT + ')\\s*\\(', 'i') +const INLINE_FK_RE = new RegExp( + 'FOREIGN\\s+KEY\\s*\\(([^)]*)\\)\\s*REFERENCES\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(([^)]*)\\)' + + '(?:\\s+ON\\s+DELETE\\s+(CASCADE|RESTRICT|SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION))?', 'i') + // 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。 +// 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX / ALTER TABLE ADD FK,C1)。 export function parseDDL(text) { const tables = new Map() // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 const src = stripSqlComments(String(text)) - // 抓取 CREATE TABLE ( ) ;name 可带反引号;body 到匹配的右括号。 + // 抓取 CREATE TABLE ( ) ;name 反引号可含中文(H3);body 到匹配的右括号。 // 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。 - 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 + const createRe = new RegExp( + 'CREATE\\s+(?:(?:GLOBAL|LOCAL)\\s+)?(?:TEMPORARY\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?' + + '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(', 'gi') let m while ((m = createRe.exec(src)) !== null) { - const tableName = m[1] + const tableName = stripTicks(m[1]) const bodyStart = createRe.lastIndex - 1 // 指向 '(' const body = extractBalancedParens(src, bodyStart) if (body == null) continue - const parsed = parseTableBody(body) - tables.set(tableName, parsed) + // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "FOREIGN KEY …" / "KEY …" 文本被 + // 内联检测误当真实约束(REGEX-3);反引号标识符整段保留,列名/类型解析不读字面量内容,故不受影响。 + tables.set(tableName, parseTableBody(blankStringLiterals(body))) // 继续从 body 之后扫描 createRe.lastIndex = bodyStart + body.length + 2 } + + // 第二遍:db-init A.1/A.2 强制 DDL 形态为 CREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK, + // 索引 / 外键写在表体之外。把这些独立语句并回对应表,否则含索引 / 外键的 schema 首轮校验必报假阳性(C1)。 + // 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" / "ALTER TABLE …" 文本被误当语句(REGEX-3)。 + const scanSrc = blankStringLiterals(src) + mergeStandaloneIndexes(scanSrc, tables) + mergeStandaloneForeignKeys(scanSrc, tables) return tables } +// 独立 `CREATE [UNIQUE] INDEX [USING BTREE|HASH] ON [.] ()` → 并入 table.indexes(C1)。 +// USING 子句可出现在 ON 之前(合法 MySQL),需容忍(REGEX-4)。 +function mergeStandaloneIndexes(src, tables) { + const re = new RegExp( + 'CREATE\\s+(UNIQUE\\s+)?INDEX\\s+(' + IDENT + ')(?:\\s+USING\\s+(?:BTREE|HASH))?\\s+ON\\s+' + + '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(', 'gi') + let m + while ((m = re.exec(src)) !== null) { + const kind = m[1] ? 'UNIQUE' : 'INDEX' + const idxName = stripTicks(m[2]) + const tbl = stripTicks(m[3]) + const colsBody = extractBalancedParens(src, re.lastIndex - 1) // 指向 '(',平衡括号容纳前缀长度 (N) + if (colsBody == null) continue + const t = tables.get(tbl) + if (!t) continue // 索引指向未声明的表 → 维度1(表集合)会另行报缺,这里不凭空造表 + t.indexes.add(`${idxName}:${kind}:${normalizeIndexCols(colsBody)}`) + } +} + +// 独立 `ALTER TABLE
ADD [CONSTRAINT n] FOREIGN KEY (cols) REFERENCES [.] (refcols) [ON DELETE x]` +// → 并入 table.foreignKeys,归一化与 parseTableBody 内联 FK 同形(C1)。 +// 先框定每条 ALTER 语句(到 `;` 或结尾),再在其体内抓所有 ADD…FOREIGN KEY 子句, +// 支持一条 ALTER 内逗号分隔的多个 ADD(REGEX-4)。src 已抹掉字符串字面量,故 `;` 边界与匹配都安全。 +function mergeStandaloneForeignKeys(src, tables) { + const stmtRe = new RegExp( + 'ALTER\\s+TABLE\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')([\\s\\S]*?)(?:;|$)', 'gi') + const clauseRe = new RegExp( + 'ADD\\s+(?:CONSTRAINT\\s+' + IDENT + '\\s+)?FOREIGN\\s+KEY\\s*\\(([^)]*)\\)\\s*REFERENCES\\s+' + + '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(([^)]*)\\)' + + '(?:\\s+ON\\s+DELETE\\s+(CASCADE|RESTRICT|SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION))?', 'gi') + let s + while ((s = stmtRe.exec(src)) !== null) { + const t = tables.get(stripTicks(s[1])) + if (!t) continue + const body = s[2] + clauseRe.lastIndex = 0 + let c + while ((c = clauseRe.exec(body)) !== null) { + const fromCols = c[1].replace(/`/g, '').replace(/\s+/g, '') + const refTable = stripTicks(c[2]) + const toCols = c[3].replace(/`/g, '').replace(/\s+/g, '') + const onDelete = (c[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') + if (!fromCols || !refTable || !toCols) continue + t.foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) + } + } +} + function parseTableBody(body) { const columns = new Map() const indexes = new Set() @@ -159,11 +269,11 @@ function parseTableBody(body) { // 外键约束(可带前缀 CONSTRAINT ) if (/\bFOREIGN\s+KEY\b/i.test(item)) { - // REFERENCES 支持 schema 限定 `db`.`t` / db.t(取末段为表名,与 CREATE TABLE 一致)。 - 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) + // REFERENCES 支持 schema 限定与反引号包裹的非 ASCII 目标表(IDENT,H3 全路径一致;取末段为表名)。 + const fk = item.match(INLINE_FK_RE) if (fk) { const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') - const refTable = fk[2] + const refTable = stripTicks(fk[2]) const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') const onDelete = (fk[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) @@ -183,17 +293,17 @@ function parseTableBody(body) { // `key varchar(10)`),更可能是未加反引号的保留字列名 + 类型,回退到普通列解析避免漏列; // 但下游列正则会显式排斥以 KEY/INDEX/UNIQUE/FULLTEXT/SPATIAL 开头的整项,避免 fix #2 的幽灵列。 if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) { - const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i) + // 名支持反引号包裹的非 ASCII(IDENT,H3);列体用平衡括号提取,避免前缀长度 `(N)` 处的 `)` 提前截断丢列(DDL-9)。 + const nameMatch = item.match(INLINE_KEY_RE) 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 - if (nameMatch && !SQL_TYPE_RE.test(nameMatch[1])) { - const kind = /^UNIQUE/i.test(item) ? 'UNIQUE' : 'INDEX' - const cols = nameMatch[2] - .split(',') - .map(c => c.replace(/`/g, '').trim()) - .filter(Boolean) - .join(',') - indexes.add(`${nameMatch[1]}:${kind}:${cols}`) - continue + if (nameMatch) { + const idxName = stripTicks(nameMatch[1]) // 未加反引号的保留字(如 `key varchar`)启发式仍由 SQL_TYPE_RE 兜住 + if (!SQL_TYPE_RE.test(idxName)) { + const kind = /^UNIQUE/i.test(item) ? 'UNIQUE' : 'INDEX' + const colsBody = extractBalancedParens(item, nameMatch[0].length - 1) // nameMatch[0] 以 '(' 结尾 + indexes.add(`${idxName}:${kind}:${normalizeIndexCols(colsBody || '')}`) // 列归一化两侧共用(DDL-9) + continue + } } } // CONSTRAINT 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 diff --git a/lib/validate-ddl.test.mjs b/lib/validate-ddl.test.mjs index eac3d01..8090923 100644 --- a/lib/validate-ddl.test.mjs +++ b/lib/validate-ddl.test.mjs @@ -464,3 +464,246 @@ test('parseDDL: CREATE TEMPORARY TABLE 也应被解析(fix #16)', () => { const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()]) }) + +// ── C1: 独立语句形态的索引 / 外键(db-init A.1 强制的 DDL 形态)────────── +// db-init A.1/A.2 强制 DDL 形态为:CREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK +// (索引 / 外键写在表体之外的独立语句)。parseDDL 必须把这些独立语句并回对应表的 +// indexes / foreignKeys 集合,否则任何含索引 / 外键的 schema 首轮校验必报假阳性。 +test('parseDDL: 独立 CREATE INDEX 并入对应表的 indexes(C1)', () => { + const ddl = [ + 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', + 'CREATE INDEX `idx_cust` ON `t_order` (`iCustomerId`);', + ].join('\n') + const t = parseDDL(ddl).get('t_order') + assert.ok(t) + assert.ok(t.indexes.has('idx_cust:INDEX:iCustomerId'), + '独立 CREATE INDEX 应并入表索引集 — got: ' + [...t.indexes]) +}) + +test('parseDDL: 独立 CREATE UNIQUE INDEX 归一化为 UNIQUE(C1)', () => { + const ddl = [ + 'CREATE TABLE `t` ( `c` int NOT NULL );', + 'CREATE UNIQUE INDEX `uk_c` ON `t` (`c`);', + ].join('\n') + const t = parseDDL(ddl).get('t') + assert.ok(t.indexes.has('uk_c:UNIQUE:c'), 'got: ' + [...t.indexes]) +}) + +test('parseDDL: 独立 CREATE INDEX 多列归一化(C1)', () => { + const ddl = [ + 'CREATE TABLE `t` ( `sBrandsId` varchar(100), `sSubsidiaryId` varchar(100) );', + 'CREATE INDEX `idx_tenant` ON `t` (`sBrandsId`, `sSubsidiaryId`);', + ].join('\n') + const t = parseDDL(ddl).get('t') + assert.ok(t.indexes.has('idx_tenant:INDEX:sBrandsId,sSubsidiaryId'), 'got: ' + [...t.indexes]) +}) + +test('parseDDL: 独立 ALTER TABLE ADD CONSTRAINT FOREIGN KEY 并入对应表的 foreignKeys(C1)', () => { + const ddl = [ + 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL );', + 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_cust` FOREIGN KEY (`iCustomerId`) REFERENCES `t_customer` (`iIncrement`) ON DELETE RESTRICT;', + ].join('\n') + const t = parseDDL(ddl).get('t_order') + assert.ok(t) + assert.ok(t.foreignKeys.has('iCustomerId->t_customer(iIncrement):RESTRICT'), + '独立 ALTER ADD FK 应并入表外键集 — got: ' + [...t.foreignKeys]) +}) + +test('parseDDL: 独立 ALTER TABLE ADD FOREIGN KEY(无 CONSTRAINT 名)默认 RESTRICT(C1)', () => { + const ddl = [ + 'CREATE TABLE `t` ( `uid` int );', + 'ALTER TABLE `t` ADD FOREIGN KEY (`uid`) REFERENCES `users` (`id`);', + ].join('\n') + const t = parseDDL(ddl).get('t') + assert.ok(t.foreignKeys.has('uid->users(id):RESTRICT'), 'got: ' + [...t.foreignKeys]) +}) + +test('full chain: A.1 形态 DDL(CREATE TABLE → CREATE INDEX → ALTER ADD FK)↔ docs/03 不应有 diff(C1 头号回归)', () => { + const docs = [ + '## `t_customer` — 客户表', + '### 字段', + '| 字段 | 类型 |', + '|---|---|', + '| `iIncrement` | int |', + '', + '## `t_order` — 订单表', + '### 字段', + '| 字段 | 类型 |', + '|---|---|', + '| `iId` | int |', + '| `iCustomerId` | int |', + '### 索引', + '- `idx_cust` (INDEX): iCustomerId', + '### 外键', + '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', + '', + ].join('\n') + const ddl = [ + 'CREATE TABLE `t_customer` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );', + 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', + 'CREATE INDEX `idx_cust` ON `t_order` (`iCustomerId`);', + 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_cust` FOREIGN KEY (`iCustomerId`) REFERENCES `t_customer` (`iIncrement`) ON DELETE RESTRICT;', + ].join('\n') + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) + assert.deepEqual(d.indexMismatches, [], '索引维度应干净 — got: ' + JSON.stringify(d.indexMismatches)) + assert.deepEqual(d.foreignKeyMismatches, [], '外键维度应干净 — got: ' + JSON.stringify(d.foreignKeyMismatches)) + assert.equal(d.hasDiff, false, 'A.1 形态的忠实 schema 不应报 diff') +}) + +test('full chain: 独立 ALTER ADD FK 在 docs 有而 DDL 缺时仍被捕获(C1 不掩盖真实缺失)', () => { + const docs = [ + '## `t_order`', + '### 字段', + '| 列 | 类型 |', + '|---|---|', + '| `iCustomerId` | int |', + '### 外键', + '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', + ].join('\n') + const ddl = 'CREATE TABLE `t_order` ( `iCustomerId` int NOT NULL );' // FK 真的缺失 + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) + assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'iCustomerId->t_customer(iIncrement):RESTRICT'), + '真实缺失的 FK 仍应报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) +}) + +// ── H3: 反引号包裹的非 ASCII 表名(docs 侧 [^`]+ 接受,DDL 侧需对齐)────── +test('parseDDL: 反引号包裹的中文表名应被解析(H3 标识符语法对齐)', () => { + const t = parseDDL('CREATE TABLE `订单表` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );') + assert.ok(t.get('订单表'), '中文表名应入 Map — got: ' + [...t.keys()]) +}) + +test('full chain: docs 与 DDL 同为中文表名时不应误报 missingTables(H3)', () => { + const docs = '## `订单表`\n| 列 | 类型 |\n|---|---|\n| `iIncrement` | int |\n' + const ddl = 'CREATE TABLE `订单表` ( `iIncrement` int NOT NULL );' + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) + assert.deepEqual(d.missingTables, [], 'got: ' + JSON.stringify(d.missingTables)) + assert.deepEqual(d.extraTables, []) +}) + +test('parseDDL: 反引号包裹的 FK 目标表为中文时归一化保留中文(H3)', () => { + const ddl = [ + 'CREATE TABLE `t` ( `uid` int );', + 'ALTER TABLE `t` ADD FOREIGN KEY (`uid`) REFERENCES `用户表` (`id`);', + ].join('\n') + const t = parseDDL(ddl).get('t') + assert.ok(t.foreignKeys.has('uid->用户表(id):RESTRICT'), 'got: ' + [...t.foreignKeys]) +}) + +// ── DDL-9: 索引列归一化两侧对齐(前缀长度 / 排序方向)──────────────────── +test('full chain: 前缀长度索引列 sName(20) docs↔DDL 一致时不应误报(DDL-9)', () => { + const docs = [ + '## `t`', + '### 字段', + '| 列 | 类型 |', + '|---|---|', + '| `sName` | varchar(100) |', + '### 索引', + '- `idx_name` (INDEX): sName(20)', + ].join('\n') + const ddl = [ + 'CREATE TABLE `t` (', + ' `sName` varchar(100),', + ' KEY `idx_name` (`sName`(20))', + ') ENGINE=InnoDB;', + ].join('\n') + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) + assert.deepEqual(d.indexMismatches, [], + '前缀长度索引两侧应归一化为同一列名 — got: ' + JSON.stringify(d.indexMismatches)) +}) + +// ── M3: 索引 type 槽位的中文同义词应映射到与 DDL 一致的 kind ──────────── +test('parseDocsTables: 索引 type 写「唯一」中文标签应归一化为 UNIQUE(M3)', () => { + const docs = '## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `uk_c` (唯一): c\n' + const t = parseDocsTables(docs).get('t') + assert.ok(t.indexes.has('uk_c:UNIQUE:c'), + '中文「唯一」应映射为 UNIQUE 而非 INDEX — got: ' + [...t.indexes]) +}) + +// ── 实现复审回归(REGEX/EFFICACY 系列)──────────────────────────────── + +// REGEX-2:前缀长度 + 排序方向同时出现 `col(N) DESC` 时必须完全归一化到裸列名 `col`, +// 否则 docs 写 `sName(20) DESC`、DDL 写裸 `sName`(或反之)会假阳性。两侧故意不对称以暴露归一化顺序 bug。 +test('full chain: 索引列 `sName(20) DESC` 应完全归一化为裸列名,与裸 `sName` 对齐(REGEX-2 前缀长度+方向)', () => { + const docs = [ + '## `t`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `sName` | varchar(100) |', + '### 索引', '- `idx_name` (INDEX): sName(20) DESC', + ].join('\n') + const ddlInline = [ + 'CREATE TABLE `t` (', ' `sName` varchar(100),', ' KEY `idx_name` (`sName`)', ') ENGINE=InnoDB;', + ].join('\n') + const ddlStandalone = [ + 'CREATE TABLE `t` ( `sName` varchar(100) );', + 'CREATE INDEX `idx_name` ON `t` (`sName`);', + ].join('\n') + for (const ddl of [ddlInline, ddlStandalone]) { + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) + assert.deepEqual(d.indexMismatches, [], '`sName(20) DESC` 应归一化为 sName — got: ' + JSON.stringify(d.indexMismatches)) + } +}) + +// REGEX-1 / EFFICACY-4 / PROSE-1:inline KEY 名 + inline FK 目标表为中文时也应与 docs 对齐。 +test('full chain: inline 中文索引名 + inline 中文 FK 目标表应与 docs 对齐(REGEX-1 / H3 一致)', () => { + const docs = [ + '## `订单`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `user_id` | int |', + '### 索引', '- `中文索引` (INDEX): user_id', + '### 外键', '- `fk_u`: user_id → 用户.id (RESTRICT)', + ].join('\n') + const ddl = [ + 'CREATE TABLE `订单` (', ' `user_id` int,', + ' KEY `中文索引` (`user_id`),', + ' CONSTRAINT `fk_u` FOREIGN KEY (`user_id`) REFERENCES `用户` (`id`)', + ') ENGINE=InnoDB;', + ].join('\n') + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) + assert.deepEqual(d.indexMismatches, [], 'inline 中文索引名应对齐 — got: ' + JSON.stringify(d.indexMismatches)) + assert.deepEqual(d.foreignKeyMismatches, [], 'inline 中文 FK 目标表应对齐 — got: ' + JSON.stringify(d.foreignKeyMismatches)) +}) + +// REGEX-3:字符串字面量里的 CREATE INDEX / ALTER ADD FK 不应被独立语句扫描误当真实定义。 +test('parseDDL: 字符串字面量中的 CREATE INDEX 文本不应注入幽灵索引(REGEX-3)', () => { + const ddl = "CREATE TABLE `t_order` ( `iId` int NOT NULL, `note` varchar(200) DEFAULT 'CREATE INDEX `ghost` ON `t_order` (`iId`)', PRIMARY KEY (`iId`) );" + const t = parseDDL(ddl).get('t_order') + assert.ok(t) + assert.equal([...t.indexes].some(ix => ix.includes('ghost')), false, '字面量内的 CREATE INDEX 不应成为真实索引 — got: ' + [...t.indexes]) +}) + +test('parseDDL: 字符串字面量中的 ALTER ADD FK 文本不应注入幽灵外键(REGEX-3)', () => { + const ddl = "CREATE TABLE `t` ( `c` int, `doc` varchar(300) DEFAULT 'see ALTER TABLE `t` ADD FOREIGN KEY (`c`) REFERENCES `x` (`id`)' );" + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.equal(t.foreignKeys.size, 0, '字面量内的 ALTER ADD FK 不应成为真实外键 — got: ' + [...t.foreignKeys]) +}) + +// REGEX-4:一条 ALTER TABLE 内多个逗号分隔 ADD FK 都应被捕获;CREATE INDEX 的 USING 子句应容忍。 +test('parseDDL: 单条 ALTER 内多个 ADD FOREIGN KEY 都应被捕获(REGEX-4 multi-ADD)', () => { + const ddl = [ + 'CREATE TABLE `t_order` ( `a` int, `b` int );', + 'CREATE TABLE `t_a` ( `id` int );', + 'CREATE TABLE `t_b` ( `id` int );', + 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_a` FOREIGN KEY (`a`) REFERENCES `t_a` (`id`) ON DELETE CASCADE, ADD CONSTRAINT `fk_b` FOREIGN KEY (`b`) REFERENCES `t_b` (`id`);', + ].join('\n') + const t = parseDDL(ddl).get('t_order') + assert.ok(t.foreignKeys.has('a->t_a(id):CASCADE'), '第一个 FK — got: ' + [...t.foreignKeys]) + assert.ok(t.foreignKeys.has('b->t_b(id):RESTRICT'), '同条 ALTER 的第二个 FK — got: ' + [...t.foreignKeys]) +}) + +test('parseDDL: CREATE INDEX ... USING BTREE ON ... 应被解析(REGEX-4 USING)', () => { + const ddl = ['CREATE TABLE `t` ( `c` int );', 'CREATE INDEX `idx_c` USING BTREE ON `t` (`c`);'].join('\n') + const t = parseDDL(ddl).get('t') + assert.ok(t.indexes.has('idx_c:INDEX:c'), 'got: ' + [...t.indexes]) +}) + +// EFFICACY-1:中文 type 映射应锚定——「主键索引」(含「主键」但不是主键) 不得被当 PRIMARY 剔除。 +test('parseDocsTables: 「主键索引」标签不应被误当 PRIMARY(EFFICACY-1 锚定)', () => { + const docs = '## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `idx_c` (主键索引): c\n' + const t = parseDocsTables(docs).get('t') + assert.ok(t.indexes.has('idx_c:INDEX:c'), '「主键索引」应作普通 INDEX 保留 — got: ' + [...t.indexes]) + assert.equal(t.indexes.has('PRIMARY'), false, '不应被剔除为 PRIMARY') +}) + +test('parseDocsTables: 恰为「主键」/「唯一」仍正确映射(EFFICACY-1 锚定不误伤正例)', () => { + const pk = parseDocsTables('## `t`\n### 索引\n- `pk` (主键): c\n').get('t') + assert.ok(pk.indexes.has('PRIMARY'), '「主键」应映射 PRIMARY') + const uk = parseDocsTables('## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `uk` (唯一): c\n').get('t') + assert.ok(uk.indexes.has('uk:UNIQUE:c'), '「唯一」应映射 UNIQUE — got: ' + [...uk.indexes]) +}) diff --git a/skills/plan/db-design-gen/SKILL.md b/skills/plan/db-design-gen/SKILL.md index f8d8ab7..78ca84e 100644 --- a/skills/plan/db-design-gen/SKILL.md +++ b/skills/plan/db-design-gen/SKILL.md @@ -28,7 +28,8 @@ allowed-tools: Read Write Edit Grep Glob 1. 严格套用 `docs/04` 命名规范 + 匈牙利列前缀(`i`=int / `s`=varchar / `t`=datetime) 2. **主键**:标准列 `iIncrement` int 主键。REQ 明确要求不同主键(复合主键 / UUID / 业务主键)时按 REQ,并在该表业务注记里注明偏离原因 3. **外键**:依据 REQ 中的引用关系(如「订单引用客户」),明确列出 `ON DELETE` / `ON UPDATE` 策略;不能确定时默认 `RESTRICT` -4. **索引**:根据 REQ 的查询模式推导业务索引;外键列默认建索引;租户隔离列 `sBrandsId` / `sSubsidiaryId`(标准列)按业务查询模式建组合索引 +4. **索引**:根据 REQ 的查询模式推导业务索引;外键列默认建索引;租户隔离列 `sBrandsId` / `sSubsidiaryId`(标准列)按业务查询模式建组合索引。 + - 索引 bullet 的 `(类别)` 槽位**统一用 ASCII**:唯一索引写 `UNIQUE`、普通/组合索引写 `INDEX`(与 DDL 侧 `UNIQUE KEY` / `KEY` 对齐,validate-ddl 据此比对 UNIQUE\|INDEX 类别);主键不在 `### 索引` 重复列(由标准列 `iIncrement` 治理)。 5. **业务注记**:对每张表用一两句话说明业务用途、关键约束、与其他表的关系 如果某 REQ 表述模糊以致无法推断关键 schema 细节(如:枚举值范围 / 字段长度上限 / 必填性),先按合理默认推导并在该字段「业务含义」列加 `【人工填写:需用户审阅】` 标注,待步骤 E 用户审阅时调整;**不打断本次推导**。 diff --git a/skills/plan/db-init/SKILL.md b/skills/plan/db-init/SKILL.md index a7fddb3..5d96678 100644 --- a/skills/plan/db-init/SKILL.md +++ b/skills/plan/db-init/SKILL.md @@ -1,8 +1,8 @@ --- name: db-init -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。 +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。 user-invocable: false -allowed-tools: Read Write Edit Skill Bash(node *) +allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm install mysql2) --- **所有输出必须使用中文。** @@ -36,6 +36,8 @@ allowed-tools: Read Write Edit Skill Bash(node *) 调 `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 做跨平台、纯 Node 的 5 维校验(表集合 / 列名 / 列类型 / 索引 / 外键)。**注意参数顺序:docs/03 在前,V1.sql 在后。** +> **机检边界(勿误解)**:5 维 = 表集合 / 列名 / 列类型 / 索引(名 + UNIQUE\|INDEX 类别 + 列)/ 外键(列 → 表(列) + ON DELETE);表体内联与独立 `CREATE INDEX` / `ALTER TABLE ... ADD FOREIGN KEY` 两种形态都识别。**A.1 要求的「字段顺序 / 可空 / 默认 / 列注释对齐」不在机检范围内**——这几项靠 A.1 翻译时忠实对齐 docs/03(docs/03 已在 A3 人工审阅过),validate-ddl 不会代为兜底,勿因校验通过就认定它们也一致。 + ```bash node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ docs/03-数据库设计文档.md \ @@ -54,8 +56,24 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成` - ` - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs)` -### B. 数据库环境检查 +### B. 数据库环境检查(连库前置——必须在步骤 C 的任何 DROP 之前完成) + +#### B.1 工具链预检 +A4 连库用到两样东西:`apply-ddl.mjs` 依赖 `mysql2` 模块、`setup-test-db.mjs` 依赖 `mysql` 客户端。两者都在 C 阶段才被调用,而 C.1 第一步就是 `DROP DATABASE`——所以必须**在此先探测、缺则补齐**,避免"删到一半才发现缺工具"。 + +1. **mysql2 驱动**(`apply-ddl.mjs` 从 config-vars.yaml 所在目录解析 mysql2,而非插件目录;按 A4 既定调用 `apply-ddl.mjs config-vars.yaml …`、cwd = 项目根,该目录即项目根,故下面按 cwd 解析的 `node -e` 探测对此调用具代表性): + ```bash + node -e "import('mysql2/promise').then(()=>process.exit(0),()=>process.exit(1))" + ``` + - 退出 `0` → 已就绪。 + - 退出非 `0` → 在项目根执行 `npm i mysql2`(首次会生成 / 更新根 `package.json` + `node_modules`;`.gitignore` 已忽略 `node_modules`),再重跑上面一行确认;仍失败 → 打印 stderr 并停下。 +2. **mysql 客户端**: + ```bash + node -e "process.exit(require('node:child_process').spawnSync('mysql',['--version']).status===0?0:1)" + ``` + - 退出非 `0` → 打印「未找到 mysql 客户端,请安装并加入 PATH 后重跑 /plan-start」并停下(**不进入 C**)。 +#### B.2 凭据校验 用 `Read` 读 `config-vars.yaml` 的 `database:` 段(文件缺失 → 提示重跑 A1 `scope-lock` 并停下),校验 `host` / `port` / `user` / `password` / `schema` 5 项均非空且非 `【人工填写` 占位——任一缺失 → 打印缺失字段并停下。 连通性无需在此单独探测:步骤 C.1 的 `setup-test-db.mjs` 会用同一份凭据连同一个 MySQL 跑 `DROP+CREATE`,连不上即报错(认证 / 主机不可达 / 端口拒接);且 `DROP DATABASE IF EXISTS` 在连不上时不破坏任何东西,由 C.1 失败即可。 diff --git a/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs b/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs index dd53ee4..e028646 100644 --- a/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs +++ b/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node // scripts/setup-test-db.mjs — DROP + CREATE 测试库。 // 由 coding.mjs 的 test-gate 调用;schema 由 Flyway 在 Spring Boot 启动时重放。 -// DB 凭据从仓库根 config-vars.yaml 的 database: 段读取(host / schema 完全信任该文件,无额外校验)。 +// DB 凭据从仓库根 config-vars.yaml 的 database: 段读取:schema 经标识符校验后才拼进 SQL(防误删 / 注入,见下方守卫); +// host / user / password 信任该文件,port 仅校验范围。 import { spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' @@ -72,6 +73,16 @@ if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) { process.exit(1) } +// schema 是被无条件 DROP + CREATE 的标识符——必须严格校验后才拼进 SQL: +// · 空值 → 避免 DROP DATABASE `` 这类无意义/误删语句 +// · 「【人工填写】」占位 → 配置尚未填好,不应连库 +// · 含反引号 → 防止 `erp`; DROP DATABASE `prod` 形态的标识符注入(值来自 config-vars.yaml,按 fail-closed 处理) +// 注:仅接受 ASCII 标识符;非 ASCII schema 名一律拒绝(即便 MySQL / apply-ddl 允许),与推荐的 test/_dev 命名一致 +if (!/^[A-Za-z0-9_$]+$/.test(DB_SCHEMA)) { + console.error(`[setup-test-db] database.schema 非法或未填: ${JSON.stringify(DB_SCHEMA)}(需为 [A-Za-z0-9_$] 标识符;空值 / 「【人工填写】」占位 / 含反引号均拒绝)`) + process.exit(1) +} + console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`) const sql = -- libgit2 0.22.2