diff --git a/lib/validate-ddl.mjs b/lib/validate-ddl.mjs index 20178a9..1894a14 100644 --- a/lib/validate-ddl.mjs +++ b/lib/validate-ddl.mjs @@ -1,7 +1,9 @@ // lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 4 维校验 // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 // -// 用法(CLI):node lib/validate-ddl.mjs +// 用法(CLI):node lib/validate-ddl.mjs [ddlPath2 ...] +// 单文件 = V1 整库校验(A4 db-init);多文件 = 累积校验(增量 add-req:传 sql/migrations/V*.sql, +// CREATE TABLE + ALTER ADD 并集 ↔ docs/03 累积 SSoT)。 // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。 // 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' // @@ -165,9 +167,56 @@ export function parseDDL(text) { // 扫描前先抹掉字符串字面量内部,避免 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) return tables } +// 第三遍:把增量 migration 的 `ALTER TABLE [db.] ADD ...` 追加式变更并入对应表(P1#2)。 +// 支持 ADD [COLUMN] / ADD [UNIQUE] INDEX|KEY () / ADD PRIMARY KEY; +// 一条 ALTER 内多个逗号分隔变更逐个处理。**仅追加式**——MODIFY/CHANGE/DROP/RENAME 不处理: +// docs/03 是累积 SSoT,列/索引最终集合 = V1 CREATE + 各 V_n ADD 的并集,与文件顺序无关, +// 故对 `validate-ddl docs/03 sql/migrations/V*.sql`(glob 按字典序展开)的并集校验成立。 +// 表未在所提供 DDL 中 CREATE → 跳过(表集合维度另报缺,不凭空造表,与 mergeStandaloneIndexes 一致)。 +function mergeAlterStatements(src, tables) { + const re = new RegExp('ALTER\\s+TABLE\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s+', 'gi') + let m + while ((m = re.exec(src)) !== null) { + const tbl = stripTicks(m[1]) + const bodyStart = re.lastIndex + // 语句体到下一个分号 / 下一条 TABLE 级语句(防某些 migration 漏写分号时吞并后续语句)。 + const cand = [] + const semi = src.indexOf(';', bodyStart) + if (semi !== -1) cand.push(semi) + 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 + const idxM = clause.match(new RegExp('^ADD\\s+(UNIQUE\\s+)?(?:INDEX|KEY)\\s+(' + IDENT + ')\\s*\\(', 'i')) + if (idxM) { + const kind = idxM[1] ? 'UNIQUE' : 'INDEX' + const colsBody = extractBalancedParens(clause, idxM[0].length - 1) + t.indexes.add(`${stripTicks(idxM[2])}:${kind}:${normalizeIndexCols(colsBody || '')}`) + continue + } + if (/^ADD\s+PRIMARY\s+KEY/i.test(clause)) { t.indexes.add('PRIMARY'); continue } + if (/^ADD\s+(?:CONSTRAINT|FOREIGN)\b/i.test(clause)) continue // 外键维度已移除 + const colM = clause.match(/^ADD\s+(?:COLUMN\s+)?(`?)([A-Za-z0-9_]+)\1\s+(.+)$/is) + if (colM) { + const name = colM[2] + // 未加反引号时拒绝索引/约束保留字开头,避免误吃成列。 + 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)→ 追加式校验不处理,跳过。 + } + } +} + // 独立 `CREATE [UNIQUE] INDEX [USING BTREE|HASH] ON [.] ()` → 并入 table.indexes(C1)。 // USING 子句可出现在 ON 之前(合法 MySQL),需容忍(REGEX-4)。 function mergeStandaloneIndexes(src, tables) { @@ -486,16 +535,22 @@ const { pathToFileURL } = await import('node:url') // CLI entry guard:pathToFi const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href if (isCliEntry) { const { readFileSync, existsSync } = await import('node:fs') - const [docsPath, ddlPath] = process.argv.slice(2) - if (!docsPath || !ddlPath) { - console.error('用法: node lib/validate-ddl.mjs ') + const [docsPath, ...ddlPaths] = process.argv.slice(2) + if (!docsPath || ddlPaths.length === 0) { + console.error('用法: node lib/validate-ddl.mjs [V2.sql ...]') + console.error(' 增量校验:node lib/validate-ddl.mjs docs/03-数据库设计文档.md sql/migrations/V*.sql') process.exit(2) } if (!existsSync(docsPath)) { console.error(`validate-ddl: docs 不存在: ${docsPath}`); process.exit(2) } - if (!existsSync(ddlPath)) { console.error(`validate-ddl: DDL 不存在: ${ddlPath}`); process.exit(2) } + for (const p of ddlPaths) { + if (!existsSync(p)) { console.error(`validate-ddl: DDL 不存在: ${p}`); process.exit(2) } + } const docsTables = parseDocsTables(readFileSync(docsPath, 'utf8')) - const ddlTables = parseDDL(readFileSync(ddlPath, 'utf8')) + // 多个 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 diff = diffSchema(docsTables, ddlTables) if (diff.hasDiff) { diff --git a/lib/validate-ddl.test.mjs b/lib/validate-ddl.test.mjs index 3c608a4..a8a0dfe 100644 --- a/lib/validate-ddl.test.mjs +++ b/lib/validate-ddl.test.mjs @@ -532,3 +532,63 @@ test('parseDocsTables: 恰为「主键」/「唯一」仍正确映射(EFFICACY 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]) }) + +// ── P1#2:ALTER TABLE ADD(增量 migration)并入 + 多语句累积校验 ────────────── +test('parseDDL: ALTER TABLE ADD COLUMN 并入已有表的列集', () => { + const ddl = [ + 'CREATE TABLE t_order ( iId bigint PRIMARY KEY, sCode varchar(50) );', + 'ALTER TABLE t_order ADD COLUMN dRefund decimal(18,6);', + ].join('\n') + const t = parseDDL(ddl).get('t_order') + assert.deepEqual([...t.columns.keys()], ['iId', 'sCode', 'dRefund']) + assert.equal(t.columns.get('dRefund'), 'decimal(18,6)') +}) + +test('parseDDL: 一条 ALTER 内多个 ADD COLUMN(逗号分隔)逐个并入', () => { + const ddl = [ + 'CREATE TABLE t_x ( iId bigint );', + 'ALTER TABLE t_x ADD COLUMN sA varchar(10), ADD COLUMN sB varchar(20);', + ].join('\n') + const t = parseDDL(ddl).get('t_x') + assert.deepEqual([...t.columns.keys()], ['iId', 'sA', 'sB']) + assert.equal(t.columns.get('sB'), 'varchar(20)') +}) + +test('parseDDL: ALTER TABLE ADD UNIQUE INDEX 并入索引集', () => { + const ddl = [ + 'CREATE TABLE t_y ( iId bigint, sEmail varchar(50) );', + 'ALTER TABLE t_y ADD UNIQUE INDEX uq_email (sEmail);', + ].join('\n') + const t = parseDDL(ddl).get('t_y') + assert.ok([...t.indexes].some(ix => ix === 'uq_email:UNIQUE:sEmail')) +}) + +test('parseDDL: ALTER 指向未 CREATE 的表 → 不凭空造表', () => { + const tables = parseDDL('ALTER TABLE t_ghost ADD COLUMN sFoo varchar(10);') + assert.equal(tables.has('t_ghost'), false) +}) + +test('累积校验:docs/03(含新列)↔ V1+V2 并集一致 → 空 diff', () => { + const docs = '## `t_order`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n| sCode | varchar(50) |\n| dRefund | decimal(18,6) |\n' + // 模拟 CLI 把 V1 + V2 拼成一段多语句文本 + const ddlUnion = [ + 'CREATE TABLE t_order ( iId bigint PRIMARY KEY, sCode varchar(50) );', // V1 + 'ALTER TABLE t_order ADD COLUMN dRefund decimal(18,6);', // V2 + ].join('\n;\n') + const d = diffSchema(parseDocsTables(docs), parseDDL(ddlUnion)) + assert.equal(d.hasDiff, false) +}) + +test('累积校验:docs/03 加了列但 V_n 漏写 ALTER → 报列缺失(fail-closed)', () => { + const docs = '## `t_order`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n| dRefund | decimal(18,6) |\n' + const ddlOnlyV1 = 'CREATE TABLE t_order ( iId bigint PRIMARY KEY );' // 漏了 dRefund 的 ALTER + const d = diffSchema(parseDocsTables(docs), parseDDL(ddlOnlyV1)) + assert.ok(d.hasDiff) + assert.ok(d.columnMismatches.some(m => m.table === 't_order' && m.column === 'dRefund' && m.side === 'docs')) +}) + +test('parseDDL: DEFAULT 字面量里的 "ALTER TABLE ..." 文本不被误当真实 ALTER', () => { + const ddl = "CREATE TABLE t_z ( iId bigint, sNote varchar(200) DEFAULT 'ALTER TABLE t_z ADD COLUMN hacked int' );" + const t = parseDDL(ddl).get('t_z') + assert.deepEqual([...t.columns.keys()], ['iId', 'sNote']) // 无 hacked 列 +}) diff --git a/skills/coding/coding-start/SKILL.md b/skills/coding/coding-start/SKILL.md index 9001503..a502235 100644 --- a/skills/coding/coding-start/SKILL.md +++ b/skills/coding/coding-start/SKILL.md @@ -94,10 +94,15 @@ allowed-tools: Read Glob Workflow Bash(git rev-parse *) Bash(git tag *) ``` Workflow({ scriptPath: "${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", - args: { projectRoot: "" } + args: { + projectRoot: "", + pluginRoot: "${CLAUDE_PLUGIN_ROOT}" + } }) ``` +> `pluginRoot` 供 coding.mjs 调用插件 `lib/req-ledger.mjs` 在首跑时自动建立需求台账基线(P1#3,使日后 `/add-req` 能识别增量、免空跑);缺省则该增强静默跳过。 + ### 步骤 5:告知用户 Workflow 已启动 启动后向用户输出: diff --git a/skills/plan/add-req/SKILL.md b/skills/plan/add-req/SKILL.md index 8c49f5f..31771e9 100644 --- a/skills/plan/add-req/SKILL.md +++ b/skills/plan/add-req/SKILL.md @@ -72,8 +72,16 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan - 新表 → `CREATE TABLE`;给已有表加列 → `ALTER TABLE ... ADD COLUMN`。**追加式**,套用与 A3/A4 相同的命名规范、匈牙利列前缀(`i/s/t/b/d`)、标准列约定(主表 15 列 / 从表 12 列 / 基础 11 列)与 DDL 默认值翻译规则(见 `CLAUDE.md` Schema 演化规约 + db-init 约定)。 2. **同步 docs/03**:参照 `${CLAUDE_PLUGIN_ROOT}/skills/plan/db-design-gen/templates/docs-03-table-template.md`,新表追加表小节 / 已有表小节增列,保持 docs/03 为 schema SSoT。 3. **回填卡片**:把该 REQ 卡片 `依赖表:` 的 `TBD` / 占位 `Edit` 成实际表名。 +4. **增量 DDL 校验(fail-closed)**:写完 V_n + 同步 docs/03 后,对**全部 migration 的累积并集**校验 docs/03 一致: + ``` + node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs docs/03-数据库设计文档.md sql/migrations/V*.sql + ``` + (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)。 -> **不在此 apply 到数据库、不跑 validate-ddl**:新 schema 由 Coding 阶段冷起栈时 Flyway 自动 apply 全部 `V*.sql`;validate-ddl 是「docs/03 ↔ 单一 V 文件」整库 4 维比对,多 migration 场景不适用。schema 一致性由 Coding 阶段 testGate / Seed 冷起栈兜底。 +> **不在此 apply 到数据库**:新 schema 由 Coding 阶段冷起栈时 Flyway 自动 apply 全部 `V*.sql`;本步只做静态 DDL↔docs/03 一致性校验,运行时 apply 与 Seed 真跑由 Coding 阶段兜底。 ## 步骤 4:A5-delta — 下游文档增量(每个新增 REQ) @@ -139,6 +147,7 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan ## 参考 - `${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs`(台账:scan / commit) +- `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs`(增量 DDL↔docs/03 校验,支持多 V 文件累积并集) - `docs/01-需求清单//.md`(REQ SSoT,人工先填) - `docs/03-数据库设计文档.md` + `sql/migrations/V*.sql`(schema 演化,追加 V_n) - `docs/05-API接口契约.md` / `docs/02-开发计划.md` / `docs/08-模块任务管理.md`(下游增量) diff --git a/workflows/coding.mjs b/workflows/coding.mjs index b5d2a0b..6eb9194 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -200,6 +200,9 @@ const ROOT = ARGS?.projectRoot || '.' if (ROOT === '.' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) { throw new Error(`HALT invalid-projectRoot: must be absolute, got ${JSON.stringify(ROOT)}. coding-start 必须把绝对路径传入 args.projectRoot。`) } +// 插件根(coding-start 透传):用于调用插件 lib/*.mjs(如 req-ledger 建需求台账基线)。 +// 缺省(旧 coding-start 未传)则相关增强静默跳过——由 /add-req 首跑兜底建基线。 +const PLUGIN = ARGS?.pluginRoot || '' // ── Feature-loop stage prompts(共享非交互契约见 featureStageContract)── @@ -1036,6 +1039,33 @@ async function recordResume(sectionMd) { } catch (e) { log(`resume-journal 异常(不阻断):${String(e?.message || e)}`) } } +// ── 需求台账基线(P1#3):coding 首跑时若 .req-ledger.json 缺失,自动建立并提交 ────── +// 语义 = 把「coding 开始时的需求快照」定为基线,使日后 /add-req 能正确 diff 出新增/变更, +// 免去现有项目首次 add-req 只为建基线的空跑。幂等(已存在则跳过)、best-effort(失败不阻断)。 +function ledgerBaselinePromptM() { + return [ + '# 需求台账基线(首跑)— 非交互静默', + microStepContract(), + '', + `## 任务:若 \`${ROOT}/.req-ledger.json\` 不存在,则建立并 commit;已存在则跳过`, + `1. 检查 \`${ROOT}/.req-ledger.json\` 是否存在。存在 → 直接返回 \`{ "success": true, "detail": "ledger 已存在,跳过" }\`。`, + `2. 不存在 → 建立基线:\`node ${PLUGIN}/lib/req-ledger.mjs commit ${ROOT}\`(扫 docs/01 REQ 卡 + docs/08 §三 FE 行写哈希)。`, + `3. commit 到当前分支:\`git -C ${ROOT} add .req-ledger.json\` → \`git -C ${ROOT} commit -m "chore(req-ledger): 建立需求台账基线(coding 首跑)"\`。`, + ' (只 add .req-ledger.json 这一个文件;其余未提交的 Plan 产物由后续 runBranchSetup 的脏树恢复处理,勿在此一并提交。)', + '', + '## 输出(ACTION_RESULT_SCHEMA)', + '- 成功 / 已存在 → `{ "success": true }`;失败 → `{ "success": false, "error": "...", "detail": "..." }`(不要抛错)。', + ].join('\n') +} +async function ensureLedgerBaseline() { + if (!PLUGIN) { log('req-ledger 基线跳过:未透传 pluginRoot(由 /add-req 首跑兜底)'); return } + try { + const r = await agent(ledgerBaselinePromptM(), { label:'req-ledger-baseline', phase:'Router', schema: ACTION_RESULT_SCHEMA }) + if (r && r.success) log('req-ledger 基线就绪') + else log(`req-ledger 基线失败(不阻断):${(r && r.error) || ''}`) + } catch (e) { log(`req-ledger 基线异常(不阻断):${String(e?.message || e)}`) } +} + // recoverDirtyWorktreePromptM:branchSetup / milestone 前置的"工作树干净"被打破时的自主恢复(class D 部分)。 // 子代理检查脏文件——全是本阶段合法产物 → 自动 commit 后继续;含越界/不明改动 → 不提交、返回失败让上层 halt。 // **分支护栏(branch)**:自动 commit 只允许发生在目标功能分支上。若当前 HEAD 不在 branch(如里程碑后 HEAD @@ -2049,6 +2079,9 @@ for (const m of routed.modules) { const todo = routed.modules.filter(m => !m.done) log(`coding: ${todo.length}/${routed.modules.length} modules to run`) +// P1#3:首跑建需求台账基线(幂等、best-effort),使日后 /add-req 能正确识别增量、免空跑。 +await ensureLedgerBaseline() + const results = [] let haltedAtIdx = -1 for (const [idx, module] of todo.entries()) {