From 0956071bc1070b4723469f9270d198cc46eaca0c Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:03:01 +0800 Subject: [PATCH] validate-ddl: fail-closed 名副其实——非追加式 ALTER 硬拒 + 统一抹除底稿 --- lib/validate-ddl.mjs | 46 ++++++++++++++++++++++++++++++---------------- lib/validate-ddl.test.mjs | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ skills/plan/add-req/SKILL.md | 2 +- 3 files changed, 116 insertions(+), 17 deletions(-) diff --git a/lib/validate-ddl.mjs b/lib/validate-ddl.mjs index 1894a14..83011ff 100644 --- a/lib/validate-ddl.mjs +++ b/lib/validate-ddl.mjs @@ -140,35 +140,32 @@ const INLINE_KEY_RE = new RegExp( // 提取每个 CREATE TABLE 的:列名→类型、索引名集合。 // 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX,C1)。 -export function parseDDL(text) { +export function parseDDL(text, opts = {}) { const tables = new Map() - // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 - const src = stripSqlComments(String(text)) + // 剥注释 + 抹字符串字面量,得到单一无害化底稿(REGEX-3 全覆盖); + // blankStringLiterals 保证长度不变 → 偏移可直接用于平衡括号提取。 + const scanSrc = blankStringLiterals(stripSqlComments(String(text))) // 抓取 CREATE TABLE ( ) ;name 反引号可含中文(H3);body 到匹配的右括号。 // 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。 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) { + while ((m = createRe.exec(scanSrc)) !== null) { const tableName = stripTicks(m[1]) const bodyStart = createRe.lastIndex - 1 // 指向 '(' - const body = extractBalancedParens(src, bodyStart) + const body = extractBalancedParens(scanSrc, bodyStart) if (body == null) continue - // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "KEY …" 文本被 - // 内联检测误当真实约束(REGEX-3);反引号标识符整段保留,列名/类型解析不读字面量内容,故不受影响。 - tables.set(tableName, parseTableBody(blankStringLiterals(body))) + tables.set(tableName, parseTableBody(body)) // 继续从 body 之后扫描 createRe.lastIndex = bodyStart + body.length + 2 } // 第二遍:db-init A.1/A.2 强制 DDL 形态为 CREATE TABLE → CREATE INDEX, // 索引写在表体之外。把这些独立语句并回对应表,否则含索引的 schema 首轮校验必报假阳性(C1)。 - // 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" 文本被误当语句(REGEX-3)。 - const scanSrc = blankStringLiterals(src) mergeStandaloneIndexes(scanSrc, tables) // 第三遍:增量 migration(V2+)的 ALTER TABLE ADD COLUMN / ADD [UNIQUE] INDEX|KEY 并入对应表。 - mergeAlterStatements(scanSrc, tables) + mergeAlterStatements(scanSrc, tables, opts.unsupportedAlters) return tables } @@ -178,7 +175,7 @@ export function parseDDL(text) { // docs/03 是累积 SSoT,列/索引最终集合 = V1 CREATE + 各 V_n ADD 的并集,与文件顺序无关, // 故对 `validate-ddl docs/03 sql/migrations/V*.sql`(glob 按字典序展开)的并集校验成立。 // 表未在所提供 DDL 中 CREATE → 跳过(表集合维度另报缺,不凭空造表,与 mergeStandaloneIndexes 一致)。 -function mergeAlterStatements(src, tables) { +function mergeAlterStatements(src, tables, unsupported) { const re = new RegExp('ALTER\\s+TABLE\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s+', 'gi') let m while ((m = re.exec(src)) !== null) { @@ -191,11 +188,21 @@ function mergeAlterStatements(src, tables) { const nextStmt = src.slice(bodyStart).search(/\b(?:ALTER|CREATE|DROP)\s+TABLE\b/i) if (nextStmt !== -1) cand.push(bodyStart + nextStmt) const end = cand.length ? Math.min(...cand) : src.length - const t = tables.get(tbl) - if (!t) continue for (const clauseRaw of splitTopLevelCommas(src.slice(bodyStart, end))) { const clause = clauseRaw.trim() if (!clause) continue + // 非追加式子句 → 登记给调用方 fail-closed 硬拒。静默放行会让「migration 改了列但 + // docs/03 未同步」假阳性通过——这恰是闸门最该抓的场景。DROP FOREIGN KEY 例外: + // 外键维度已移除,与下方 ADD CONSTRAINT/FOREIGN 的跳过对称。 + // ALTER(COLUMN ... SET/DROP DEFAULT 等)虽不动 4 维,仍硬拒:解析器无法廉价证明 + // 某条 ALTER 子句是 4 维中性的,故对整个 ALTER 动词 fail-closed,逃生口见 CLI 第三行文案。 + if (/^(?:MODIFY|CHANGE|RENAME|ALTER)\b/i.test(clause) || + (/^DROP\b/i.test(clause) && !/^DROP\s+FOREIGN\s+KEY\b/i.test(clause))) { + if (unsupported) unsupported.push({ table: tbl, clause: clause.length > 80 ? clause.slice(0, 77) + '…' : clause }) + continue + } + const t = tables.get(tbl) + if (!t) continue // 表未 CREATE:表集合维度另行报缺,不凭空造表 const idxM = clause.match(new RegExp('^ADD\\s+(UNIQUE\\s+)?(?:INDEX|KEY)\\s+(' + IDENT + ')\\s*\\(', 'i')) if (idxM) { const kind = idxM[1] ? 'UNIQUE' : 'INDEX' @@ -212,7 +219,7 @@ function mergeAlterStatements(src, tables) { if (colM[1] !== '`' && /^(?:KEY|INDEX|UNIQUE|FULLTEXT|SPATIAL|PRIMARY|CONSTRAINT|CHECK|FOREIGN|COLUMN)$/i.test(name)) continue t.columns.set(name, extractType(colM[3])) } - // 其它(MODIFY/CHANGE/DROP/RENAME/ALTER COLUMN)→ 追加式校验不处理,跳过。 + // 其它子句(不可识别的 ADD 变体等)→ 静默跳过。 } } } @@ -550,7 +557,14 @@ if (isCliEntry) { // 多个 migration(V1 + V_n...)拼成单一多语句文本:parseDDL 三遍扫描天然按 CREATE→ALTER // 累积出最终 schema;追加式 ALTER 与文件顺序无关,故 glob 字典序展开(V1,V10,V2...)也正确。 const ddlText = ddlPaths.map(p => readFileSync(p, 'utf8')).join('\n;\n') - const ddlTables = parseDDL(ddlText) + const unsupportedAlters = [] + const ddlTables = parseDDL(ddlText, { unsupportedAlters }) + if (unsupportedAlters.length) { + console.error('validate-ddl: ✗ 仅支持追加式 migration(ALTER TABLE ... ADD COLUMN/INDEX/PRIMARY KEY),发现不支持的子句:') + for (const u of unsupportedAlters) console.error(` - ${u.table}: ${u.clause}`) + console.error(' 改类型/重命名/删列请人工写 V_n 并人工核对 docs/03 一致性后,不经本校验直接评审。') + process.exit(1) + } const diff = diffSchema(docsTables, ddlTables) if (diff.hasDiff) { diff --git a/lib/validate-ddl.test.mjs b/lib/validate-ddl.test.mjs index a8a0dfe..0028107 100644 --- a/lib/validate-ddl.test.mjs +++ b/lib/validate-ddl.test.mjs @@ -592,3 +592,88 @@ test('parseDDL: DEFAULT 字面量里的 "ALTER TABLE ..." 文本不被误当真 const t = parseDDL(ddl).get('t_z') assert.deepEqual([...t.columns.keys()], ['iId', 'sNote']) // 无 hacked 列 }) + +// ── 非追加式 ALTER 硬拒(fail-closed)──────────────────────────── +test('增量校验:ALTER MODIFY 被登记为不支持(不静默放行)', () => { + const un = [] + parseDDL('CREATE TABLE `t`(`c` int);\nALTER TABLE `t` MODIFY `c` bigint;', { unsupportedAlters: un }) + assert.equal(un.length, 1) + assert.equal(un[0].table, 't') + assert.match(un[0].clause, /^MODIFY/i) +}) + +test('增量校验:CHANGE / DROP COLUMN 登记;DROP FOREIGN KEY 容忍跳过', () => { + const un = [] + parseDDL([ + 'CREATE TABLE `t`(`c` int);', + 'ALTER TABLE `t` CHANGE `c` `c2` int;', + 'ALTER TABLE `t` DROP COLUMN `c`;', + 'ALTER TABLE `t` DROP FOREIGN KEY `fk_x`;', + ].join('\n'), { unsupportedAlters: un }) + assert.equal(un.length, 2) +}) + +test('增量校验:未传收集器时行为不变(向后兼容)', () => { + const tables = parseDDL('CREATE TABLE `t`(`c` int);\nALTER TABLE `t` MODIFY `c` bigint;') + assert.equal(tables.get('t').columns.get('c'), 'int') +}) + +test('增量校验:RENAME/ALTER COLUMN/DROP INDEX/DROP PRIMARY KEY 各臂均登记;混合 ADD+MODIFY 时 ADD 仍并入', () => { + for (const clause of [ + 'RENAME COLUMN `c` TO `c2`', + 'RENAME TO `t2`', + 'ALTER COLUMN `c` SET DEFAULT 1', + 'DROP INDEX `idx_x`', + 'DROP PRIMARY KEY', + ]) { + const un = [] + parseDDL('CREATE TABLE `t`(`c` int);\nALTER TABLE `t` ' + clause + ';', { unsupportedAlters: un }) + assert.equal(un.length, 1, clause) + } + const un = [] + const tables = parseDDL('CREATE TABLE `t`(`c` int);\nALTER TABLE `t` ADD COLUMN `d` int, MODIFY `c` bigint;', { unsupportedAlters: un }) + assert.equal(un.length, 1) + assert.ok(tables.get('t').columns.has('d')) +}) + +test('REGEX-3:表体之外字符串字面量中的 CREATE TABLE 不产出幽灵表', () => { + const tables = parseDDL("CREATE TABLE `real`(`a` int);\nINSERT INTO log VALUES ('CREATE TABLE ghost (a int)');") + assert.ok(tables.has('real')) + assert.ok(!tables.has('ghost')) +}) + +test('CLI exit-1:migration 含 MODIFY 时退出码为 1 且 stderr 含「不支持的子句」', async () => { + const { spawnSync } = await import('node:child_process') + const { mkdtempSync, writeFileSync, mkdirSync } = await import('node:fs') + const { join } = await import('node:path') + const { tmpdir } = await import('node:os') + + const dir = mkdtempSync(join(tmpdir(), 'validate-ddl-cli-')) + // docs/03 格式:一张表两列 + const docsContent = [ + '## `t_order` — 订单主表', + '', + '### 字段', + '', + '| 字段 | 类型 | Nullable | 默认 | 业务含义 |', + '|---|---|---|---|---|', + '| `iId` | bigint | 否 | 自增 | 主键 |', + '| `sCode` | varchar(50) | 否 | — | 编码 |', + '', + ].join('\n') + const docsPath = join(dir, 'docs-03.md') + writeFileSync(docsPath, docsContent) + + // V1:创建表 + const v1Path = join(dir, 'V1.sql') + writeFileSync(v1Path, 'CREATE TABLE `t_order` (`iId` bigint PRIMARY KEY, `sCode` varchar(50));\n') + + // V2:含 MODIFY(非追加式)→ 触发 exit-1 + const v2Path = join(dir, 'V2.sql') + writeFileSync(v2Path, 'ALTER TABLE `t_order` MODIFY `sCode` varchar(100);\n') + + const cliPath = new URL('./validate-ddl.mjs', import.meta.url).pathname + const result = spawnSync(process.execPath, [cliPath, docsPath, v1Path, v2Path], { encoding: 'utf8' }) + assert.equal(result.status, 1, 'exit code should be 1 — stderr: ' + result.stderr) + assert.ok(result.stderr.includes('不支持的子句'), 'stderr should mention 不支持的子句 — got: ' + result.stderr) +}) diff --git a/skills/plan/add-req/SKILL.md b/skills/plan/add-req/SKILL.md index e7ab833..5f03ad2 100644 --- a/skills/plan/add-req/SKILL.md +++ b/skills/plan/add-req/SKILL.md @@ -81,7 +81,7 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan (validate-ddl 已支持多文件:CREATE TABLE + 各 V_n 的 `ALTER ... ADD` 并集 ↔ docs/03 累积 SSoT 做 4 维比对。) - 退出码 `0` → 一致,继续。 - 退出码 `1` → docs/03 与 migration 并集分叉(stderr 有 diff 明细):**就地修正**——通常是 docs/03 表小节/列与 V_n 不一致,或 V_n 漏写某列的 ALTER。修正后重跑校验,直到 `0`。**绝不带分叉进入步骤 4**(schema 不一致会让下游 docs/05/编码全线偏)。 - - 仅 ADD 追加式(改类型/重命名/删列暂不在 add-req 覆盖范围,见 P2)。 + - 仅 ADD 追加式——校验器对 `MODIFY/CHANGE/DROP/RENAME` 子句**硬拒(exit 1)**,不静默放行。改类型/重命名/删列不在 add-req 覆盖范围:需人工写 V_n 并人工核对 docs/03 一致性。 > **不在此 apply 到数据库**:新 schema 由 Coding 阶段冷起栈时 Flyway 自动 apply 全部 `V*.sql`;本步只做静态 DDL↔docs/03 一致性校验,运行时 apply 与 Seed 真跑由 Coding 阶段兜底。 -- libgit2 0.22.2