Commit 0956071bc1070b4723469f9270d198cc46eaca0c
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」引用。
Showing
3 changed files
with
116 additions
and
17 deletions
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('parseDDL: DEFAULT å—é¢é‡é‡Œçš„ "ALTER TABLE ..." 文本ä¸è¢«è¯¯å½“真å | @@ -592,3 +592,88 @@ test('parseDDL: DEFAULT å—é¢é‡é‡Œçš„ "ALTER TABLE ..." 文本ä¸è¢«è¯¯å½“真å | ||
| 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 <root> | @@ -81,7 +81,7 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan <root> | ||
| 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 |