Commit aee9252a3a67a55c5510f3df6b1a4b2d476b7452

Authored by yanghl
1 parent 1117bb65

feat: validate-ddl 多文件累积校验(P1#2) + coding 首跑建 req-ledger 基线(P1#3)

P1#2 — validate-ddl 支持增量 migration:
- parseDDL 新增第三遍 mergeAlterStatements:ALTER TABLE ADD COLUMN /
  ADD [UNIQUE] INDEX|KEY / ADD PRIMARY KEY 并入对应表(仅追加式,
  MODIFY/CHANGE/DROP/RENAME 不处理;并集与文件顺序无关)。
- CLI 接受多个 DDL 文件:node validate-ddl docs/03 sql/migrations/V*.sql,
  CREATE + 各 V_n 的 ALTER ADD 并集 ↔ docs/03 累积 SSoT 做 4 维比对。
- add-req SKILL 新增「增量 DDL 校验(fail-closed)」闸门,替换原「多 migration 不适用」说明。
- 7 个新测试(含 DEFAULT 字面量里的 ALTER 文本不被误吃);52/52 通过。

P1#3 — coding 首跑自动建需求台账基线:
- coding-start 透传 pluginRoot;coding.mjs ensureLedgerBaseline()
  首跑若 .req-ledger.json 缺失则 commit 基线(幂等、best-effort、缺 pluginRoot 静默跳过),
  使日后 /add-req 能识别增量、免空跑。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lib/validate-ddl.mjs
1 1 // lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 4 维校验
2 2 // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。
3 3 //
4   -// 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath>
  4 +// 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath> [ddlPath2 ...]
  5 +// 单文件 = V1 整库校验(A4 db-init);多文件 = 累积校验(增量 add-req:传 sql/migrations/V*.sql,
  6 +// CREATE TABLE + ALTER ADD 并集 ↔ docs/03 累积 SSoT)。
5 7 // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。
6 8 // 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs'
7 9 //
... ... @@ -165,9 +167,56 @@ export function parseDDL(text) {
165 167 // 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" 文本被误当语句(REGEX-3)。
166 168 const scanSrc = blankStringLiterals(src)
167 169 mergeStandaloneIndexes(scanSrc, tables)
  170 + // 第三遍:增量 migration(V2+)的 ALTER TABLE ADD COLUMN / ADD [UNIQUE] INDEX|KEY 并入对应表。
  171 + mergeAlterStatements(scanSrc, tables)
168 172 return tables
169 173 }
170 174  
  175 +// 第三遍:把增量 migration 的 `ALTER TABLE [db.]<t> ADD ...` 追加式变更并入对应表(P1#2)。
  176 +// 支持 ADD [COLUMN] <col> <type> / ADD [UNIQUE] INDEX|KEY <name> (<cols>) / ADD PRIMARY KEY;
  177 +// 一条 ALTER 内多个逗号分隔变更逐个处理。**仅追加式**——MODIFY/CHANGE/DROP/RENAME 不处理:
  178 +// docs/03 是累积 SSoT,列/索引最终集合 = V1 CREATE + 各 V_n ADD 的并集,与文件顺序无关,
  179 +// 故对 `validate-ddl docs/03 sql/migrations/V*.sql`(glob 按字典序展开)的并集校验成立。
  180 +// 表未在所提供 DDL 中 CREATE → 跳过(表集合维度另报缺,不凭空造表,与 mergeStandaloneIndexes 一致)。
  181 +function mergeAlterStatements(src, tables) {
  182 + const re = new RegExp('ALTER\\s+TABLE\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s+', 'gi')
  183 + let m
  184 + while ((m = re.exec(src)) !== null) {
  185 + const tbl = stripTicks(m[1])
  186 + const bodyStart = re.lastIndex
  187 + // 语句体到下一个分号 / 下一条 TABLE 级语句(防某些 migration 漏写分号时吞并后续语句)。
  188 + const cand = []
  189 + const semi = src.indexOf(';', bodyStart)
  190 + if (semi !== -1) cand.push(semi)
  191 + const nextStmt = src.slice(bodyStart).search(/\b(?:ALTER|CREATE|DROP)\s+TABLE\b/i)
  192 + if (nextStmt !== -1) cand.push(bodyStart + nextStmt)
  193 + 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))) {
  197 + const clause = clauseRaw.trim()
  198 + if (!clause) continue
  199 + const idxM = clause.match(new RegExp('^ADD\\s+(UNIQUE\\s+)?(?:INDEX|KEY)\\s+(' + IDENT + ')\\s*\\(', 'i'))
  200 + if (idxM) {
  201 + const kind = idxM[1] ? 'UNIQUE' : 'INDEX'
  202 + const colsBody = extractBalancedParens(clause, idxM[0].length - 1)
  203 + t.indexes.add(`${stripTicks(idxM[2])}:${kind}:${normalizeIndexCols(colsBody || '')}`)
  204 + continue
  205 + }
  206 + if (/^ADD\s+PRIMARY\s+KEY/i.test(clause)) { t.indexes.add('PRIMARY'); continue }
  207 + if (/^ADD\s+(?:CONSTRAINT|FOREIGN)\b/i.test(clause)) continue // 外键维度已移除
  208 + const colM = clause.match(/^ADD\s+(?:COLUMN\s+)?(`?)([A-Za-z0-9_]+)\1\s+(.+)$/is)
  209 + if (colM) {
  210 + const name = colM[2]
  211 + // 未加反引号时拒绝索引/约束保留字开头,避免误吃成列。
  212 + 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]))
  214 + }
  215 + // 其它(MODIFY/CHANGE/DROP/RENAME/ALTER COLUMN)→ 追加式校验不处理,跳过。
  216 + }
  217 + }
  218 +}
  219 +
171 220 // 独立 `CREATE [UNIQUE] INDEX <name> [USING BTREE|HASH] ON [<db>.]<table> (<cols>)` → 并入 table.indexes(C1)。
172 221 // USING 子句可出现在 ON 之前(合法 MySQL),需容忍(REGEX-4)。
173 222 function mergeStandaloneIndexes(src, tables) {
... ... @@ -486,16 +535,22 @@ const { pathToFileURL } = await import(&#39;node:url&#39;) // CLI entry guard:pathToFi
486 535 const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
487 536 if (isCliEntry) {
488 537 const { readFileSync, existsSync } = await import('node:fs')
489   - const [docsPath, ddlPath] = process.argv.slice(2)
490   - if (!docsPath || !ddlPath) {
491   - console.error('用法: node lib/validate-ddl.mjs <docs/03 path> <V1.sql path>')
  538 + const [docsPath, ...ddlPaths] = process.argv.slice(2)
  539 + if (!docsPath || ddlPaths.length === 0) {
  540 + console.error('用法: node lib/validate-ddl.mjs <docs/03 path> <V1.sql> [V2.sql ...]')
  541 + console.error(' 增量校验:node lib/validate-ddl.mjs docs/03-数据库设计文档.md sql/migrations/V*.sql')
492 542 process.exit(2)
493 543 }
494 544 if (!existsSync(docsPath)) { console.error(`validate-ddl: docs 不存在: ${docsPath}`); process.exit(2) }
495   - if (!existsSync(ddlPath)) { console.error(`validate-ddl: DDL 不存在: ${ddlPath}`); process.exit(2) }
  545 + for (const p of ddlPaths) {
  546 + if (!existsSync(p)) { console.error(`validate-ddl: DDL 不存在: ${p}`); process.exit(2) }
  547 + }
496 548  
497 549 const docsTables = parseDocsTables(readFileSync(docsPath, 'utf8'))
498   - const ddlTables = parseDDL(readFileSync(ddlPath, 'utf8'))
  550 + // 多个 migration(V1 + V_n...)拼成单一多语句文本:parseDDL 三遍扫描天然按 CREATE→ALTER
  551 + // 累积出最终 schema;追加式 ALTER 与文件顺序无关,故 glob 字典序展开(V1,V10,V2...)也正确。
  552 + const ddlText = ddlPaths.map(p => readFileSync(p, 'utf8')).join('\n;\n')
  553 + const ddlTables = parseDDL(ddlText)
499 554 const diff = diffSchema(docsTables, ddlTables)
500 555  
501 556 if (diff.hasDiff) {
... ...
lib/validate-ddl.test.mjs
... ... @@ -532,3 +532,63 @@ test(&#39;parseDocsTables: 恰为「主键」/「唯一」仍正确映射(EFFICACY
532 532 const uk = parseDocsTables('## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `uk` (唯一): c\n').get('t')
533 533 assert.ok(uk.indexes.has('uk:UNIQUE:c'), '「唯一」应映射 UNIQUE — got: ' + [...uk.indexes])
534 534 })
  535 +
  536 +// ── P1#2:ALTER TABLE ADD(增量 migration)并入 + 多语句累积校验 ──────────────
  537 +test('parseDDL: ALTER TABLE ADD COLUMN 并入已有表的列集', () => {
  538 + const ddl = [
  539 + 'CREATE TABLE t_order ( iId bigint PRIMARY KEY, sCode varchar(50) );',
  540 + 'ALTER TABLE t_order ADD COLUMN dRefund decimal(18,6);',
  541 + ].join('\n')
  542 + const t = parseDDL(ddl).get('t_order')
  543 + assert.deepEqual([...t.columns.keys()], ['iId', 'sCode', 'dRefund'])
  544 + assert.equal(t.columns.get('dRefund'), 'decimal(18,6)')
  545 +})
  546 +
  547 +test('parseDDL: 一条 ALTER 内多个 ADD COLUMN(逗号分隔)逐个并入', () => {
  548 + const ddl = [
  549 + 'CREATE TABLE t_x ( iId bigint );',
  550 + 'ALTER TABLE t_x ADD COLUMN sA varchar(10), ADD COLUMN sB varchar(20);',
  551 + ].join('\n')
  552 + const t = parseDDL(ddl).get('t_x')
  553 + assert.deepEqual([...t.columns.keys()], ['iId', 'sA', 'sB'])
  554 + assert.equal(t.columns.get('sB'), 'varchar(20)')
  555 +})
  556 +
  557 +test('parseDDL: ALTER TABLE ADD UNIQUE INDEX 并入索引集', () => {
  558 + const ddl = [
  559 + 'CREATE TABLE t_y ( iId bigint, sEmail varchar(50) );',
  560 + 'ALTER TABLE t_y ADD UNIQUE INDEX uq_email (sEmail);',
  561 + ].join('\n')
  562 + const t = parseDDL(ddl).get('t_y')
  563 + assert.ok([...t.indexes].some(ix => ix === 'uq_email:UNIQUE:sEmail'))
  564 +})
  565 +
  566 +test('parseDDL: ALTER 指向未 CREATE 的表 → 不凭空造表', () => {
  567 + const tables = parseDDL('ALTER TABLE t_ghost ADD COLUMN sFoo varchar(10);')
  568 + assert.equal(tables.has('t_ghost'), false)
  569 +})
  570 +
  571 +test('累积校验:docs/03(含新列)↔ V1+V2 并集一致 → 空 diff', () => {
  572 + const docs = '## `t_order`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n| sCode | varchar(50) |\n| dRefund | decimal(18,6) |\n'
  573 + // 模拟 CLI 把 V1 + V2 拼成一段多语句文本
  574 + const ddlUnion = [
  575 + 'CREATE TABLE t_order ( iId bigint PRIMARY KEY, sCode varchar(50) );', // V1
  576 + 'ALTER TABLE t_order ADD COLUMN dRefund decimal(18,6);', // V2
  577 + ].join('\n;\n')
  578 + const d = diffSchema(parseDocsTables(docs), parseDDL(ddlUnion))
  579 + assert.equal(d.hasDiff, false)
  580 +})
  581 +
  582 +test('累积校验:docs/03 加了列但 V_n 漏写 ALTER → 报列缺失(fail-closed)', () => {
  583 + const docs = '## `t_order`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n| dRefund | decimal(18,6) |\n'
  584 + const ddlOnlyV1 = 'CREATE TABLE t_order ( iId bigint PRIMARY KEY );' // 漏了 dRefund 的 ALTER
  585 + const d = diffSchema(parseDocsTables(docs), parseDDL(ddlOnlyV1))
  586 + assert.ok(d.hasDiff)
  587 + assert.ok(d.columnMismatches.some(m => m.table === 't_order' && m.column === 'dRefund' && m.side === 'docs'))
  588 +})
  589 +
  590 +test('parseDDL: DEFAULT 字面量里的 "ALTER TABLE ..." 文本不被误当真实 ALTER', () => {
  591 + const ddl = "CREATE TABLE t_z ( iId bigint, sNote varchar(200) DEFAULT 'ALTER TABLE t_z ADD COLUMN hacked int' );"
  592 + const t = parseDDL(ddl).get('t_z')
  593 + assert.deepEqual([...t.columns.keys()], ['iId', 'sNote']) // 无 hacked 列
  594 +})
... ...
skills/coding/coding-start/SKILL.md
... ... @@ -94,10 +94,15 @@ allowed-tools: Read Glob Workflow Bash(git rev-parse *) Bash(git tag *)
94 94 ```
95 95 Workflow({
96 96 scriptPath: "${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs",
97   - args: { projectRoot: "<git rev-parse --show-toplevel 的 stdout>" }
  97 + args: {
  98 + projectRoot: "<git rev-parse --show-toplevel 的 stdout>",
  99 + pluginRoot: "${CLAUDE_PLUGIN_ROOT}"
  100 + }
98 101 })
99 102 ```
100 103  
  104 +> `pluginRoot` 供 coding.mjs 调用插件 `lib/req-ledger.mjs` 在首跑时自动建立需求台账基线(P1#3,使日后 `/add-req` 能识别增量、免空跑);缺省则该增强静默跳过。
  105 +
101 106 ### 步骤 5:告知用户 Workflow 已启动
102 107  
103 108 启动后向用户输出:
... ...
skills/plan/add-req/SKILL.md
... ... @@ -72,8 +72,16 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan &lt;root&gt;
72 72 - 新表 → `CREATE TABLE`;给已有表加列 → `ALTER TABLE ... ADD COLUMN`。**追加式**,套用与 A3/A4 相同的命名规范、匈牙利列前缀(`i/s/t/b/d`)、标准列约定(主表 15 列 / 从表 12 列 / 基础 11 列)与 DDL 默认值翻译规则(见 `CLAUDE.md` Schema 演化规约 + db-init 约定)。
73 73 2. **同步 docs/03**:参照 `${CLAUDE_PLUGIN_ROOT}/skills/plan/db-design-gen/templates/docs-03-table-template.md`,新表追加表小节 / 已有表小节增列,保持 docs/03 为 schema SSoT。
74 74 3. **回填卡片**:把该 REQ 卡片 `依赖表:` 的 `TBD` / 占位 `Edit` 成实际表名。
  75 +4. **增量 DDL 校验(fail-closed)**:写完 V_n + 同步 docs/03 后,对**全部 migration 的累积并集**校验 docs/03 一致:
  76 + ```
  77 + node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs docs/03-数据库设计文档.md sql/migrations/V*.sql
  78 + ```
  79 + (validate-ddl 已支持多文件:CREATE TABLE + 各 V_n 的 `ALTER ... ADD` 并集 ↔ docs/03 累积 SSoT 做 4 维比对。)
  80 + - 退出码 `0` → 一致,继续。
  81 + - 退出码 `1` → docs/03 与 migration 并集分叉(stderr 有 diff 明细):**就地修正**——通常是 docs/03 表小节/列与 V_n 不一致,或 V_n 漏写某列的 ALTER。修正后重跑校验,直到 `0`。**绝不带分叉进入步骤 4**(schema 不一致会让下游 docs/05/编码全线偏)。
  82 + - 仅 ADD 追加式(改类型/重命名/删列暂不在 add-req 覆盖范围,见 P2)。
75 83  
76   -> **不在此 apply 到数据库、不跑 validate-ddl**:新 schema 由 Coding 阶段冷起栈时 Flyway 自动 apply 全部 `V*.sql`;validate-ddl 是「docs/03 ↔ 单一 V 文件」整库 4 维比对,多 migration 场景不适用。schema 一致性由 Coding 阶段 testGate / Seed 冷起栈兜底。
  84 +> **不在此 apply 到数据库**:新 schema 由 Coding 阶段冷起栈时 Flyway 自动 apply 全部 `V*.sql`;本步只做静态 DDL↔docs/03 一致性校验,运行时 apply 与 Seed 真跑由 Coding 阶段兜底。
77 85  
78 86 ## 步骤 4:A5-delta — 下游文档增量(每个新增 REQ)
79 87  
... ... @@ -139,6 +147,7 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan &lt;root&gt;
139 147 ## 参考
140 148  
141 149 - `${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs`(台账:scan / commit)
  150 +- `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs`(增量 DDL↔docs/03 校验,支持多 V 文件累积并集)
142 151 - `docs/01-需求清单/<module>/<req_id>.md`(REQ SSoT,人工先填)
143 152 - `docs/03-数据库设计文档.md` + `sql/migrations/V*.sql`(schema 演化,追加 V_n)
144 153 - `docs/05-API接口契约.md` / `docs/02-开发计划.md` / `docs/08-模块任务管理.md`(下游增量)
... ...
workflows/coding.mjs
... ... @@ -200,6 +200,9 @@ const ROOT = ARGS?.projectRoot || &#39;.&#39;
200 200 if (ROOT === '.' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) {
201 201 throw new Error(`HALT invalid-projectRoot: must be absolute, got ${JSON.stringify(ROOT)}. coding-start 必须把绝对路径传入 args.projectRoot。`)
202 202 }
  203 +// 插件根(coding-start 透传):用于调用插件 lib/*.mjs(如 req-ledger 建需求台账基线)。
  204 +// 缺省(旧 coding-start 未传)则相关增强静默跳过——由 /add-req 首跑兜底建基线。
  205 +const PLUGIN = ARGS?.pluginRoot || ''
203 206  
204 207 // ── Feature-loop stage prompts(共享非交互契约见 featureStageContract)──
205 208  
... ... @@ -1036,6 +1039,33 @@ async function recordResume(sectionMd) {
1036 1039 } catch (e) { log(`resume-journal 异常(不阻断):${String(e?.message || e)}`) }
1037 1040 }
1038 1041  
  1042 +// ── 需求台账基线(P1#3):coding 首跑时若 .req-ledger.json 缺失,自动建立并提交 ──────
  1043 +// 语义 = 把「coding 开始时的需求快照」定为基线,使日后 /add-req 能正确 diff 出新增/变更,
  1044 +// 免去现有项目首次 add-req 只为建基线的空跑。幂等(已存在则跳过)、best-effort(失败不阻断)。
  1045 +function ledgerBaselinePromptM() {
  1046 + return [
  1047 + '# 需求台账基线(首跑)— 非交互静默',
  1048 + microStepContract(),
  1049 + '',
  1050 + `## 任务:若 \`${ROOT}/.req-ledger.json\` 不存在,则建立并 commit;已存在则跳过`,
  1051 + `1. 检查 \`${ROOT}/.req-ledger.json\` 是否存在。存在 → 直接返回 \`{ "success": true, "detail": "ledger 已存在,跳过" }\`。`,
  1052 + `2. 不存在 → 建立基线:\`node ${PLUGIN}/lib/req-ledger.mjs commit ${ROOT}\`(扫 docs/01 REQ 卡 + docs/08 §三 FE 行写哈希)。`,
  1053 + `3. commit 到当前分支:\`git -C ${ROOT} add .req-ledger.json\` → \`git -C ${ROOT} commit -m "chore(req-ledger): 建立需求台账基线(coding 首跑)"\`。`,
  1054 + ' (只 add .req-ledger.json 这一个文件;其余未提交的 Plan 产物由后续 runBranchSetup 的脏树恢复处理,勿在此一并提交。)',
  1055 + '',
  1056 + '## 输出(ACTION_RESULT_SCHEMA)',
  1057 + '- 成功 / 已存在 → `{ "success": true }`;失败 → `{ "success": false, "error": "...", "detail": "..." }`(不要抛错)。',
  1058 + ].join('\n')
  1059 +}
  1060 +async function ensureLedgerBaseline() {
  1061 + if (!PLUGIN) { log('req-ledger 基线跳过:未透传 pluginRoot(由 /add-req 首跑兜底)'); return }
  1062 + try {
  1063 + const r = await agent(ledgerBaselinePromptM(), { label:'req-ledger-baseline', phase:'Router', schema: ACTION_RESULT_SCHEMA })
  1064 + if (r && r.success) log('req-ledger 基线就绪')
  1065 + else log(`req-ledger 基线失败(不阻断):${(r && r.error) || ''}`)
  1066 + } catch (e) { log(`req-ledger 基线异常(不阻断):${String(e?.message || e)}`) }
  1067 +}
  1068 +
1039 1069 // recoverDirtyWorktreePromptM:branchSetup / milestone 前置的"工作树干净"被打破时的自主恢复(class D 部分)。
1040 1070 // 子代理检查脏文件——全是本阶段合法产物 → 自动 commit 后继续;含越界/不明改动 → 不提交、返回失败让上层 halt。
1041 1071 // **分支护栏(branch)**:自动 commit 只允许发生在目标功能分支上。若当前 HEAD 不在 branch(如里程碑后 HEAD
... ... @@ -2049,6 +2079,9 @@ for (const m of routed.modules) {
2049 2079 const todo = routed.modules.filter(m => !m.done)
2050 2080 log(`coding: ${todo.length}/${routed.modules.length} modules to run`)
2051 2081  
  2082 +// P1#3:首跑建需求台账基线(幂等、best-effort),使日后 /add-req 能正确识别增量、免空跑。
  2083 +await ensureLedgerBaseline()
  2084 +
2052 2085 const results = []
2053 2086 let haltedAtIdx = -1
2054 2087 for (const [idx, module] of todo.entries()) {
... ...