Commit 0956071bc1070b4723469f9270d198cc46eaca0c

Authored by zichun
1 parent 94816863

validate-ddl: fail-closed 名副其实——非追加式 ALTER 硬拒 + 统一抹除底稿

- mergeAlterStatements 对 MODIFY/CHANGE/RENAME/ALTER/DROP(除 DROP FOREIGN KEY,
  外键维度已移除故对称放行)登记 unsupportedAlters;CLI 非空即 exit 1 并列出子句。
  此前静默跳过导致「migration 改了列但 docs/03 未同步」绿灯假阳性——恰是闸门最该抓
  的场景。未 CREATE 的表同样登记(检测先于 if(!t) 跳过)。parseDDL(text, opts) 不传
  收集器时行为不变(向后兼容)。
- parseDDL 三遍扫描统一单一抹除底稿(剥注释+抹字面量一次完成,REGEX-3 全覆盖;
  blankStringLiterals 长度不变故偏移可复用);顺带修掉第一遍在未抹除底稿上扫描、
  字面量内 "CREATE TABLE ghost(...)" 产出幽灵表的缺口。
- 测试 52→58:全部拒绝臂参数化覆盖(RENAME/ALTER COLUMN/DROP INDEX/DROP PRIMARY
  KEY)、混合 ADD+MODIFY(ADD 仍并入且登记)、CLI exit-1 冒烟(spawnSync)、幽灵表
  回归、DROP FOREIGN KEY 容忍。
- add-req SKILL.md 闸门说明同步硬拒语义,消掉悬空的「见 P2」引用。
lib/validate-ddl.mjs
@@ -140,35 +140,32 @@ const INLINE_KEY_RE = new RegExp( @@ -140,35 +140,32 @@ const INLINE_KEY_RE = new RegExp(
140 140
141 // 提取每个 CREATE TABLE 的:列名→类型、索引名集合。 141 // 提取每个 CREATE TABLE 的:列名→类型、索引名集合。
142 // 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX,C1)。 142 // 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX,C1)。
143 -export function parseDDL(text) { 143 +export function parseDDL(text, opts = {}) {
144 const tables = new Map() 144 const tables = new Map()
145 - // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。  
146 - const src = stripSqlComments(String(text)) 145 + // 剥注释 + 抹字符串字面量,得到单一无害化底稿(REGEX-3 全覆盖);
  146 + // blankStringLiterals 保证长度不变 → 偏移可直接用于平衡括号提取。
  147 + const scanSrc = blankStringLiterals(stripSqlComments(String(text)))
147 // 抓取 CREATE TABLE <name> ( <body> ) ;name 反引号可含中文(H3);body 到匹配的右括号。 148 // 抓取 CREATE TABLE <name> ( <body> ) ;name 反引号可含中文(H3);body 到匹配的右括号。
148 // 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。 149 // 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。
149 const createRe = new RegExp( 150 const createRe = new RegExp(
150 'CREATE\\s+(?:(?:GLOBAL|LOCAL)\\s+)?(?:TEMPORARY\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?' + 151 'CREATE\\s+(?:(?:GLOBAL|LOCAL)\\s+)?(?:TEMPORARY\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?' +
151 '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(', 'gi') 152 '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(', 'gi')
152 let m 153 let m
153 - while ((m = createRe.exec(src)) !== null) { 154 + while ((m = createRe.exec(scanSrc)) !== null) {
154 const tableName = stripTicks(m[1]) 155 const tableName = stripTicks(m[1])
155 const bodyStart = createRe.lastIndex - 1 // 指向 '(' 156 const bodyStart = createRe.lastIndex - 1 // 指向 '('
156 - const body = extractBalancedParens(src, bodyStart) 157 + const body = extractBalancedParens(scanSrc, bodyStart)
157 if (body == null) continue 158 if (body == null) continue
158 - // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "KEY …" 文本被  
159 - // 内联检测误当真实约束(REGEX-3);反引号标识符整段保留,列名/类型解析不读字面量内容,故不受影响。  
160 - tables.set(tableName, parseTableBody(blankStringLiterals(body))) 159 + tables.set(tableName, parseTableBody(body))
161 // 继续从 body 之后扫描 160 // 继续从 body 之后扫描
162 createRe.lastIndex = bodyStart + body.length + 2 161 createRe.lastIndex = bodyStart + body.length + 2
163 } 162 }
164 163
165 // 第二遍:db-init A.1/A.2 强制 DDL 形态为 CREATE TABLE → CREATE INDEX, 164 // 第二遍:db-init A.1/A.2 强制 DDL 形态为 CREATE TABLE → CREATE INDEX,
166 // 索引写在表体之外。把这些独立语句并回对应表,否则含索引的 schema 首轮校验必报假阳性(C1)。 165 // 索引写在表体之外。把这些独立语句并回对应表,否则含索引的 schema 首轮校验必报假阳性(C1)。
167 - // 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" 文本被误当语句(REGEX-3)。  
168 - const scanSrc = blankStringLiterals(src)  
169 mergeStandaloneIndexes(scanSrc, tables) 166 mergeStandaloneIndexes(scanSrc, tables)
170 // 第三遍:增量 migration(V2+)的 ALTER TABLE ADD COLUMN / ADD [UNIQUE] INDEX|KEY 并入对应表。 167 // 第三遍:增量 migration(V2+)的 ALTER TABLE ADD COLUMN / ADD [UNIQUE] INDEX|KEY 并入对应表。
171 - mergeAlterStatements(scanSrc, tables) 168 + mergeAlterStatements(scanSrc, tables, opts.unsupportedAlters)
172 return tables 169 return tables
173 } 170 }
174 171
@@ -178,7 +175,7 @@ export function parseDDL(text) { @@ -178,7 +175,7 @@ export function parseDDL(text) {
178 // docs/03 是累积 SSoT,列/索引最终集合 = V1 CREATE + 各 V_n ADD 的并集,与文件顺序无关, 175 // docs/03 是累积 SSoT,列/索引最终集合 = V1 CREATE + 各 V_n ADD 的并集,与文件顺序无关,
179 // 故对 `validate-ddl docs/03 sql/migrations/V*.sql`(glob 按字典序展开)的并集校验成立。 176 // 故对 `validate-ddl docs/03 sql/migrations/V*.sql`(glob 按字典序展开)的并集校验成立。
180 // 表未在所提供 DDL 中 CREATE → 跳过(表集合维度另报缺,不凭空造表,与 mergeStandaloneIndexes 一致)。 177 // 表未在所提供 DDL 中 CREATE → 跳过(表集合维度另报缺,不凭空造表,与 mergeStandaloneIndexes 一致)。
181 -function mergeAlterStatements(src, tables) { 178 +function mergeAlterStatements(src, tables, unsupported) {
182 const re = new RegExp('ALTER\\s+TABLE\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s+', 'gi') 179 const re = new RegExp('ALTER\\s+TABLE\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s+', 'gi')
183 let m 180 let m
184 while ((m = re.exec(src)) !== null) { 181 while ((m = re.exec(src)) !== null) {
@@ -191,11 +188,21 @@ function mergeAlterStatements(src, tables) { @@ -191,11 +188,21 @@ function mergeAlterStatements(src, tables) {
191 const nextStmt = src.slice(bodyStart).search(/\b(?:ALTER|CREATE|DROP)\s+TABLE\b/i) 188 const nextStmt = src.slice(bodyStart).search(/\b(?:ALTER|CREATE|DROP)\s+TABLE\b/i)
192 if (nextStmt !== -1) cand.push(bodyStart + nextStmt) 189 if (nextStmt !== -1) cand.push(bodyStart + nextStmt)
193 const end = cand.length ? Math.min(...cand) : src.length 190 const end = cand.length ? Math.min(...cand) : src.length
194 - const t = tables.get(tbl)  
195 - if (!t) continue  
196 for (const clauseRaw of splitTopLevelCommas(src.slice(bodyStart, end))) { 191 for (const clauseRaw of splitTopLevelCommas(src.slice(bodyStart, end))) {
197 const clause = clauseRaw.trim() 192 const clause = clauseRaw.trim()
198 if (!clause) continue 193 if (!clause) continue
  194 + // 非追加式子句 → 登记给调用方 fail-closed 硬拒。静默放行会让「migration 改了列但
  195 + // docs/03 未同步」假阳性通过——这恰是闸门最该抓的场景。DROP FOREIGN KEY 例外:
  196 + // 外键维度已移除,与下方 ADD CONSTRAINT/FOREIGN 的跳过对称。
  197 + // ALTER(COLUMN ... SET/DROP DEFAULT 等)虽不动 4 维,仍硬拒:解析器无法廉价证明
  198 + // 某条 ALTER 子句是 4 维中性的,故对整个 ALTER 动词 fail-closed,逃生口见 CLI 第三行文案。
  199 + if (/^(?:MODIFY|CHANGE|RENAME|ALTER)\b/i.test(clause) ||
  200 + (/^DROP\b/i.test(clause) && !/^DROP\s+FOREIGN\s+KEY\b/i.test(clause))) {
  201 + if (unsupported) unsupported.push({ table: tbl, clause: clause.length > 80 ? clause.slice(0, 77) + '…' : clause })
  202 + continue
  203 + }
  204 + const t = tables.get(tbl)
  205 + if (!t) continue // 表未 CREATE:表集合维度另行报缺,不凭空造表
199 const idxM = clause.match(new RegExp('^ADD\\s+(UNIQUE\\s+)?(?:INDEX|KEY)\\s+(' + IDENT + ')\\s*\\(', 'i')) 206 const idxM = clause.match(new RegExp('^ADD\\s+(UNIQUE\\s+)?(?:INDEX|KEY)\\s+(' + IDENT + ')\\s*\\(', 'i'))
200 if (idxM) { 207 if (idxM) {
201 const kind = idxM[1] ? 'UNIQUE' : 'INDEX' 208 const kind = idxM[1] ? 'UNIQUE' : 'INDEX'
@@ -212,7 +219,7 @@ function mergeAlterStatements(src, tables) { @@ -212,7 +219,7 @@ function mergeAlterStatements(src, tables) {
212 if (colM[1] !== '`' && /^(?:KEY|INDEX|UNIQUE|FULLTEXT|SPATIAL|PRIMARY|CONSTRAINT|CHECK|FOREIGN|COLUMN)$/i.test(name)) continue 219 if (colM[1] !== '`' && /^(?:KEY|INDEX|UNIQUE|FULLTEXT|SPATIAL|PRIMARY|CONSTRAINT|CHECK|FOREIGN|COLUMN)$/i.test(name)) continue
213 t.columns.set(name, extractType(colM[3])) 220 t.columns.set(name, extractType(colM[3]))
214 } 221 }
215 - // 其它(MODIFY/CHANGE/DROP/RENAME/ALTER COLUMN)→ 追加式校验不处理,跳过。 222 + // 其它子句(不可识别的 ADD 变体等)→ 静默跳过。
216 } 223 }
217 } 224 }
218 } 225 }
@@ -550,7 +557,14 @@ if (isCliEntry) { @@ -550,7 +557,14 @@ if (isCliEntry) {
550 // 多个 migration(V1 + V_n...)拼成单一多语句文本:parseDDL 三遍扫描天然按 CREATE→ALTER 557 // 多个 migration(V1 + V_n...)拼成单一多语句文本:parseDDL 三遍扫描天然按 CREATE→ALTER
551 // 累积出最终 schema;追加式 ALTER 与文件顺序无关,故 glob 字典序展开(V1,V10,V2...)也正确。 558 // 累积出最终 schema;追加式 ALTER 与文件顺序无关,故 glob 字典序展开(V1,V10,V2...)也正确。
552 const ddlText = ddlPaths.map(p => readFileSync(p, 'utf8')).join('\n;\n') 559 const ddlText = ddlPaths.map(p => readFileSync(p, 'utf8')).join('\n;\n')
553 - const ddlTables = parseDDL(ddlText) 560 + const unsupportedAlters = []
  561 + const ddlTables = parseDDL(ddlText, { unsupportedAlters })
  562 + if (unsupportedAlters.length) {
  563 + console.error('validate-ddl: ✗ 仅支持追加式 migration(ALTER TABLE ... ADD COLUMN/INDEX/PRIMARY KEY),发现不支持的子句:')
  564 + for (const u of unsupportedAlters) console.error(` - ${u.table}: ${u.clause}`)
  565 + console.error(' 改类型/重命名/删列请人工写 V_n 并人工核对 docs/03 一致性后,不经本校验直接评审。')
  566 + process.exit(1)
  567 + }
554 const diff = diffSchema(docsTables, ddlTables) 568 const diff = diffSchema(docsTables, ddlTables)
555 569
556 if (diff.hasDiff) { 570 if (diff.hasDiff) {
lib/validate-ddl.test.mjs
@@ -592,3 +592,88 @@ test(&#39;parseDDL: DEFAULT å­—é¢é‡é‡Œçš„ &quot;ALTER TABLE ...&quot; 文本ä¸è¢«è¯¯å½“真å @@ -592,3 +592,88 @@ test(&#39;parseDDL: DEFAULT å­—é¢é‡é‡Œçš„ &quot;ALTER TABLE ...&quot; 文本ä¸è¢«è¯¯å½“真å
592 const t = parseDDL(ddl).get('t_z') 592 const t = parseDDL(ddl).get('t_z')
593 assert.deepEqual([...t.columns.keys()], ['iId', 'sNote']) // 无 hacked 列 593 assert.deepEqual([...t.columns.keys()], ['iId', 'sNote']) // 无 hacked 列
594 }) 594 })
  595 +
  596 +// ── éžè¿½åŠ å¼ ALTER 硬拒(fail-closed)────────────────────────────
  597 +test('å¢žé‡æ ¡éªŒï¼šALTER MODIFY è¢«ç™»è®°ä¸ºä¸æ”¯æŒï¼ˆä¸é™é»˜æ”¾è¡Œï¼‰', () => {
  598 + const un = []
  599 + parseDDL('CREATE TABLE `t`(`c` int);\nALTER TABLE `t` MODIFY `c` bigint;', { unsupportedAlters: un })
  600 + assert.equal(un.length, 1)
  601 + assert.equal(un[0].table, 't')
  602 + assert.match(un[0].clause, /^MODIFY/i)
  603 +})
  604 +
  605 +test('å¢žé‡æ ¡éªŒï¼šCHANGE / DROP COLUMN 登记;DROP FOREIGN KEY 容å¿è·³è¿‡', () => {
  606 + const un = []
  607 + parseDDL([
  608 + 'CREATE TABLE `t`(`c` int);',
  609 + 'ALTER TABLE `t` CHANGE `c` `c2` int;',
  610 + 'ALTER TABLE `t` DROP COLUMN `c`;',
  611 + 'ALTER TABLE `t` DROP FOREIGN KEY `fk_x`;',
  612 + ].join('\n'), { unsupportedAlters: un })
  613 + assert.equal(un.length, 2)
  614 +})
  615 +
  616 +test('å¢žé‡æ ¡éªŒï¼šæœªä¼ æ”¶é›†å™¨æ—¶è¡Œä¸ºä¸å˜ï¼ˆå‘åŽå…¼å®¹ï¼‰', () => {
  617 + const tables = parseDDL('CREATE TABLE `t`(`c` int);\nALTER TABLE `t` MODIFY `c` bigint;')
  618 + assert.equal(tables.get('t').columns.get('c'), 'int')
  619 +})
  620 +
  621 +test('å¢žé‡æ ¡éªŒï¼šRENAME/ALTER COLUMN/DROP INDEX/DROP PRIMARY KEY å„臂å‡ç™»è®°ï¼›æ··åˆ ADD+MODIFY æ—¶ ADD ä»å¹¶å…¥', () => {
  622 + for (const clause of [
  623 + 'RENAME COLUMN `c` TO `c2`',
  624 + 'RENAME TO `t2`',
  625 + 'ALTER COLUMN `c` SET DEFAULT 1',
  626 + 'DROP INDEX `idx_x`',
  627 + 'DROP PRIMARY KEY',
  628 + ]) {
  629 + const un = []
  630 + parseDDL('CREATE TABLE `t`(`c` int);\nALTER TABLE `t` ' + clause + ';', { unsupportedAlters: un })
  631 + assert.equal(un.length, 1, clause)
  632 + }
  633 + const un = []
  634 + const tables = parseDDL('CREATE TABLE `t`(`c` int);\nALTER TABLE `t` ADD COLUMN `d` int, MODIFY `c` bigint;', { unsupportedAlters: un })
  635 + assert.equal(un.length, 1)
  636 + assert.ok(tables.get('t').columns.has('d'))
  637 +})
  638 +
  639 +test('REGEX-3:表体之外字符串字é¢é‡ä¸­çš„ CREATE TABLE ä¸äº§å‡ºå¹½çµè¡¨', () => {
  640 + const tables = parseDDL("CREATE TABLE `real`(`a` int);\nINSERT INTO log VALUES ('CREATE TABLE ghost (a int)');")
  641 + assert.ok(tables.has('real'))
  642 + assert.ok(!tables.has('ghost'))
  643 +})
  644 +
  645 +test('CLI exit-1:migration å« MODIFY 时退出ç ä¸º 1 且 stderr å«ã€Œä¸æ”¯æŒçš„å­å¥ã€', async () => {
  646 + const { spawnSync } = await import('node:child_process')
  647 + const { mkdtempSync, writeFileSync, mkdirSync } = await import('node:fs')
  648 + const { join } = await import('node:path')
  649 + const { tmpdir } = await import('node:os')
  650 +
  651 + const dir = mkdtempSync(join(tmpdir(), 'validate-ddl-cli-'))
  652 + // docs/03 æ ¼å¼ï¼šä¸€å¼ è¡¨ä¸¤åˆ—
  653 + const docsContent = [
  654 + '## `t_order` — 订å•主表',
  655 + '',
  656 + '### 字段',
  657 + '',
  658 + '| 字段 | 类型 | Nullable | 默认 | 业务å«ä¹‰ |',
  659 + '|---|---|---|---|---|',
  660 + '| `iId` | bigint | å¦ | 自增 | 主键 |',
  661 + '| `sCode` | varchar(50) | å¦ | — | ç¼–ç  |',
  662 + '',
  663 + ].join('\n')
  664 + const docsPath = join(dir, 'docs-03.md')
  665 + writeFileSync(docsPath, docsContent)
  666 +
  667 + // V1:创建表
  668 + const v1Path = join(dir, 'V1.sql')
  669 + writeFileSync(v1Path, 'CREATE TABLE `t_order` (`iId` bigint PRIMARY KEY, `sCode` varchar(50));\n')
  670 +
  671 + // V2ï¼šå« MODIFY(éžè¿½åŠ å¼ï¼‰â†’ è§¦å‘ exit-1
  672 + const v2Path = join(dir, 'V2.sql')
  673 + writeFileSync(v2Path, 'ALTER TABLE `t_order` MODIFY `sCode` varchar(100);\n')
  674 +
  675 + const cliPath = new URL('./validate-ddl.mjs', import.meta.url).pathname
  676 + const result = spawnSync(process.execPath, [cliPath, docsPath, v1Path, v2Path], { encoding: 'utf8' })
  677 + assert.equal(result.status, 1, 'exit code should be 1 — stderr: ' + result.stderr)
  678 + assert.ok(result.stderr.includes('䏿”¯æŒçš„å­å¥'), 'stderr should mention 䏿”¯æŒçš„å­å¥ — got: ' + result.stderr)
  679 +})
skills/plan/add-req/SKILL.md
@@ -81,7 +81,7 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan &lt;root&gt; @@ -81,7 +81,7 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan &lt;root&gt;
81 (validate-ddl 已支持多文件:CREATE TABLE + 各 V_n 的 `ALTER ... ADD` 并集 ↔ docs/03 累积 SSoT 做 4 维比对。) 81 (validate-ddl 已支持多文件:CREATE TABLE + 各 V_n 的 `ALTER ... ADD` 并集 ↔ docs/03 累积 SSoT 做 4 维比对。)
82 - 退出码 `0` → 一致,继续。 82 - 退出码 `0` → 一致,继续。
83 - 退出码 `1` → docs/03 与 migration 并集分叉(stderr 有 diff 明细):**就地修正**——通常是 docs/03 表小节/列与 V_n 不一致,或 V_n 漏写某列的 ALTER。修正后重跑校验,直到 `0`。**绝不带分叉进入步骤 4**(schema 不一致会让下游 docs/05/编码全线偏)。 83 - 退出码 `1` → docs/03 与 migration 并集分叉(stderr 有 diff 明细):**就地修正**——通常是 docs/03 表小节/列与 V_n 不一致,或 V_n 漏写某列的 ALTER。修正后重跑校验,直到 `0`。**绝不带分叉进入步骤 4**(schema 不一致会让下游 docs/05/编码全线偏)。
84 - - 仅 ADD 追加式(改类型/重命名/删列暂不在 add-req 覆盖范围,见 P2) 84 + - 仅 ADD 追加式——校验器对 `MODIFY/CHANGE/DROP/RENAME` 子句**硬拒(exit 1)**,不静默放行。改类型/重命名/删列不在 add-req 覆盖范围:需人工写 V_n 并人工核对 docs/03 一致性
85 85
86 > **不在此 apply 到数据库**:新 schema 由 Coding 阶段冷起栈时 Flyway 自动 apply 全部 `V*.sql`;本步只做静态 DDL↔docs/03 一致性校验,运行时 apply 与 Seed 真跑由 Coding 阶段兜底。 86 > **不在此 apply 到数据库**:新 schema 由 Coding 阶段冷起栈时 Flyway 自动 apply 全部 `V*.sql`;本步只做静态 DDL↔docs/03 一致性校验,运行时 apply 与 Seed 真跑由 Coding 阶段兜底。
87 87