// lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验 // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 // // 用法(CLI):node lib/validate-ddl.mjs // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。 // 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' // // 数据结构(解析结果):Map, indexes: Set, foreignKeys: Set }> // ── 解析 docs/03 markdown 表定义 ───────────────────────────────── // 约定:每张表一节,节标题形如 ## `表名` 或 ## `表名` — 业务含义 // 节内分 ### 字段(markdown 表格,首列列名、次列类型)、### 索引、### 外键(项目符号列表)。 // 索引/外键的 bullet 形态见 db-design-gen/templates/docs-03-table-template.md: // ### 索引 → - `name` (type): cols // ### 外键 → - `name`: from_col → to_table.to_col (on_delete) // 跳过表头行(列/字段/类型等标签)与分隔行(---)。 // 形如「## 一、全局约定」这类非反引号标题不视为表。 export function parseDocsTables(text) { const tables = new Map() const lines = String(text).split('\n') // 反引号包裹的表名:## `name` 或 ## `name` — purpose const headerRe = /^##\s+`([^`]+)`/ let current = null // { columns, indexes, foreignKeys } let mode = 'col' // 当前子区块:'col'(字段表格)/ 'idx'(索引)/ 'fk'(外键) for (const raw of lines) { const line = raw.replace(/\r$/, '') const h2 = line.match(headerRe) if (h2) { current = { columns: new Map(), indexes: new Set(), foreignKeys: new Set() } mode = 'col' tables.set(h2[1].trim(), current) continue } // 任何其它二级(或更高)非反引号标题 → 结束当前表块(如 ## 一、全局约定) if (/^##\s/.test(line) && !headerRe.test(line)) { current = null continue } if (!current) continue // ### 子区块切换(### 索引 / ### 外键 / 其它如 ### 字段、### 业务注记 → col) const h3 = line.match(/^###\s+(.+)$/) if (h3) { const title = h3[1].trim() mode = /索引|index/i.test(title) ? 'idx' : /外键|foreign/i.test(title) ? 'fk' : 'col' continue } if (mode === 'idx') { parseIndexBullet(line, current.indexes); continue } if (mode === 'fk') { parseForeignKeyBullet(line, current.foreignKeys); continue } // mode === 'col':markdown 表格行(以 | 开头) if (!/^\s*\|/.test(line)) continue const cells = splitMarkdownRow(line) if (cells.length < 2) continue const name = stripTicks(cells[0]) const type = stripTicks(cells[1]) // 跳过分隔行(---)、表头标签行、空名行 if (!name) continue if (isSeparatorCell(name)) continue if (isHeaderLabel(name)) continue current.columns.set(name, type) } return tables } // 解析索引 bullet: - `name` (type): cols // type 为 PRIMARY(不分大小写)→ 记 'PRIMARY'(匹配 parseDDL 对主键的归一化); // 否则记索引名 name(匹配 parseDDL 对命名索引存 name)。 function parseIndexBullet(line, indexes) { // 真正的索引 bullet 必须有 `(type)` 或 `: cols`(或两者皆有);纯散文 bullet 拒绝匹配。 const m = line.match(/^\s*-\s+`?([^`():]+)`?\s*(?:\(([^)]*)\))?\s*(?::\s*(.+))?$/) if (!m) return const name = m[1].trim() const type = (m[2] || '').trim() const colsRaw = (m[3] || '').trim() if (!name) return // 散文 bullet 守门:没有括号也没有冒号列段 → 不是索引项 if (!type && !colsRaw) return // PRIMARY:英文 primary 或恰为中文「主键」(M3:type 槽位可能写中文)。锚定匹配—— // 「主键索引」「主键候选」等含「主键」但非主键的标签不得被剔除为 PRIMARY(EFFICACY-1)。 if (/^primary$/i.test(type) || /^primary$/i.test(name) || /^主键$/.test(type)) { indexes.add('PRIMARY') return } // 列与 UNIQUE/INDEX 类别一并参与等价比较(fix #10);列归一化两侧共用(DDL-9)。 // UNIQUE 识别英文 unique 或恰为中文「唯一」(M3,锚定),否则视为普通 INDEX。 const cols = normalizeIndexCols(colsRaw) const kind = (/^unique$/i.test(type) || /^唯一$/.test(type)) ? 'UNIQUE' : 'INDEX' indexes.add(`${name}:${kind}:${cols}`) } // 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete) // 归一化为 parseDDL 同形的 `${fromCols}->${toTable}(${toCols})`(注意 docs 用 unicode → / DDL 用 ->)。 function parseForeignKeyBullet(line, foreignKeys) { // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 // 目标表名用 [^`\s.]+(接受反引号包裹的中文表名,H3;以 `.` 与目标列分隔),与 docs headerRe 的非 ASCII 容许度对齐。 const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([^`\s.]+)`?\s*\.\s*(.+)$/) if (!head) return const fromRaw = head[1] const toTable = head[2] let toRaw = head[3] if (!fromRaw || !toTable || !toRaw) return const fromCols = fromRaw.replace(/`/g, '').replace(/\s+/g, '') // 2) 目标列:剥掉一对外层圆括号(如果有),按逗号切分,去反引号 / 空白;遇到第一个非 // `[A-Za-z0-9_]` 列分隔符以外的字符(如 `(CASCADE)`、` on delete ...`)就停止收集。 toRaw = toRaw.trim() // 在分列前先尝试抓取尾部的 on-delete 标记:(CASCADE) / (RESTRICT) / (SET NULL) / (NO ACTION) / // (SET DEFAULT);docs 模板规约把 action 写在一对独立括号里,紧跟在目标列之后。 const onDeleteMatch = toRaw.match(/\((CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION)\)\s*$/i) const onDelete = onDeleteMatch ? onDeleteMatch[1].toUpperCase().replace(/\s+/g, ' ') : 'RESTRICT' // 剥外层括号:(idA, idB) → idA, idB const paren = toRaw.match(/^\(([^)]*)\)/) let toBody = paren ? paren[1] : toRaw // 截断到第一个 `(`(如 `(CASCADE)`)或行尾。 toBody = toBody.split('(')[0] const toCols = toBody .split(',') .map(s => s.replace(/`/g, '').trim()) .filter(s => /^[A-Za-z0-9_]+$/.test(s)) .join(',') if (!fromCols || !toTable || !toCols) return foreignKeys.add(`${fromCols}->${toTable}(${toCols}):${onDelete}`) } // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── // 标识符 token:反引号包裹(任意非反引号字符,支持中文)或裸 ASCII 标识符(含 `$`)。 // docs 侧表名/索引名以 `[^`]+` 接受中文,DDL 侧此前仅 `[A-Za-z0-9_]+` → 中文名假阳性(H3)。 const IDENT = '(?:`[^`]+`|[A-Za-z0-9_$]+)' // 索引列归一化(两侧共用,消除 DDL-9 假阳性):去反引号 / 去前缀长度 `(N)` / 去 ASC|DESC 排序方向。 // 例:`sName`(20) → sName;a DESC → a。空 token 丢弃。 function normalizeIndexCols(raw) { return String(raw) .split(',') .map(c => c.replace(/`/g, '').trim()) .map(c => c.replace(/\s+(?:asc|desc)\s*$/i, '').trim()) // 先去排序方向:`col(N) DESC` → `col(N)` .map(c => c.replace(/\(\s*\d+\s*\)?\s*$/, '').trim()) // 再去前缀长度:`col(N)` → `col`(闭括号可缺,容忍被截断的 `(N`) .filter(Boolean) .join(',') } // 把 '...' / "..." 字符串字面量内部抹成等长空格(保留首尾引号与总长度),反引号标识符整段保留。 // 用于独立 CREATE INDEX / ALTER ADD FK 扫描前预处理:DEFAULT / COMMENT 字面量里出现的 "CREATE INDEX …" // "ALTER TABLE …" 文本不应被当成真实 DDL 语句(REGEX-3)。长度不变 → 偏移可直接用于平衡括号提取。 function blankStringLiterals(s) { let out = '' let i = 0 while (i < s.length) { const ch = s[i] if (ch === "'" || ch === '"') { const end = advanceLiteral(s, i) // end 指向闭引号之后 out += ch // 开引号 for (let k = i + 1; k < end - 1; k++) out += ' ' if (end - 1 > i) out += s[end - 1] // 闭引号(字面量已终止时) i = end continue } if (ch === '`') { // 反引号标识符整段保留——它们正是要匹配的标识符 const end = advanceLiteral(s, i) out += s.slice(i, end) i = end continue } out += ch i++ } return out } // 表体内联索引 / 外键的匹配器(与 IDENT 同语法,支持反引号包裹的非 ASCII 名,H3 全路径一致)。 const INLINE_KEY_RE = new RegExp( '^(?:UNIQUE\\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\\s+KEY|SPATIAL\\s+KEY)\\s+(' + IDENT + ')\\s*\\(', 'i') const INLINE_FK_RE = new RegExp( 'FOREIGN\\s+KEY\\s*\\(([^)]*)\\)\\s*REFERENCES\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(([^)]*)\\)' + '(?:\\s+ON\\s+DELETE\\s+(CASCADE|RESTRICT|SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION))?', 'i') // 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。 // 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX / ALTER TABLE ADD FK,C1)。 export function parseDDL(text) { const tables = new Map() // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 const src = 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) { const tableName = stripTicks(m[1]) const bodyStart = createRe.lastIndex - 1 // 指向 '(' const body = extractBalancedParens(src, bodyStart) if (body == null) continue // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "FOREIGN KEY …" / "KEY …" 文本被 // 内联检测误当真实约束(REGEX-3);反引号标识符整段保留,列名/类型解析不读字面量内容,故不受影响。 tables.set(tableName, parseTableBody(blankStringLiterals(body))) // 继续从 body 之后扫描 createRe.lastIndex = bodyStart + body.length + 2 } // 第二遍:db-init A.1/A.2 强制 DDL 形态为 CREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK, // 索引 / 外键写在表体之外。把这些独立语句并回对应表,否则含索引 / 外键的 schema 首轮校验必报假阳性(C1)。 // 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" / "ALTER TABLE …" 文本被误当语句(REGEX-3)。 const scanSrc = blankStringLiterals(src) mergeStandaloneIndexes(scanSrc, tables) mergeStandaloneForeignKeys(scanSrc, tables) return tables } // 独立 `CREATE [UNIQUE] INDEX [USING BTREE|HASH] ON [.] ()` → 并入 table.indexes(C1)。 // USING 子句可出现在 ON 之前(合法 MySQL),需容忍(REGEX-4)。 function mergeStandaloneIndexes(src, tables) { const re = new RegExp( 'CREATE\\s+(UNIQUE\\s+)?INDEX\\s+(' + IDENT + ')(?:\\s+USING\\s+(?:BTREE|HASH))?\\s+ON\\s+' + '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(', 'gi') let m while ((m = re.exec(src)) !== null) { const kind = m[1] ? 'UNIQUE' : 'INDEX' const idxName = stripTicks(m[2]) const tbl = stripTicks(m[3]) const colsBody = extractBalancedParens(src, re.lastIndex - 1) // 指向 '(',平衡括号容纳前缀长度 (N) if (colsBody == null) continue const t = tables.get(tbl) if (!t) continue // 索引指向未声明的表 → 维度1(表集合)会另行报缺,这里不凭空造表 t.indexes.add(`${idxName}:${kind}:${normalizeIndexCols(colsBody)}`) } } // 独立 `ALTER TABLE
ADD [CONSTRAINT n] FOREIGN KEY (cols) REFERENCES [.] (refcols) [ON DELETE x]` // → 并入 table.foreignKeys,归一化与 parseTableBody 内联 FK 同形(C1)。 // 先框定每条 ALTER 语句(到 `;` 或结尾),再在其体内抓所有 ADD…FOREIGN KEY 子句, // 支持一条 ALTER 内逗号分隔的多个 ADD(REGEX-4)。src 已抹掉字符串字面量,故 `;` 边界与匹配都安全。 function mergeStandaloneForeignKeys(src, tables) { const stmtRe = new RegExp( 'ALTER\\s+TABLE\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')([\\s\\S]*?)(?:;|$)', 'gi') const clauseRe = new RegExp( 'ADD\\s+(?:CONSTRAINT\\s+' + IDENT + '\\s+)?FOREIGN\\s+KEY\\s*\\(([^)]*)\\)\\s*REFERENCES\\s+' + '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(([^)]*)\\)' + '(?:\\s+ON\\s+DELETE\\s+(CASCADE|RESTRICT|SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION))?', 'gi') let s while ((s = stmtRe.exec(src)) !== null) { const t = tables.get(stripTicks(s[1])) if (!t) continue const body = s[2] clauseRe.lastIndex = 0 let c while ((c = clauseRe.exec(body)) !== null) { const fromCols = c[1].replace(/`/g, '').replace(/\s+/g, '') const refTable = stripTicks(c[2]) const toCols = c[3].replace(/`/g, '').replace(/\s+/g, '') const onDelete = (c[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') if (!fromCols || !refTable || !toCols) continue t.foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) } } } function parseTableBody(body) { const columns = new Map() const indexes = new Set() const foreignKeys = new Set() for (const itemRaw of splitTopLevelCommas(body)) { const item = itemRaw.trim() if (!item) continue const upper = item.toUpperCase() // 外键约束(可带前缀 CONSTRAINT ) if (/\bFOREIGN\s+KEY\b/i.test(item)) { // REFERENCES 支持 schema 限定与反引号包裹的非 ASCII 目标表(IDENT,H3 全路径一致;取末段为表名)。 const fk = item.match(INLINE_FK_RE) if (fk) { const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') const refTable = stripTicks(fk[2]) const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') const onDelete = (fk[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) } else { foreignKeys.add(item) } continue } // PRIMARY KEY (...) if (/^PRIMARY\s+KEY/i.test(item)) { indexes.add('PRIMARY') continue } // UNIQUE [KEY|INDEX] (...) / KEY (...) / INDEX (...) // 启发式消歧:若 ` (...)` 中 ident 是 SQL 标量类型关键字(如 // `key varchar(10)`),更可能是未加反引号的保留字列名 + 类型,回退到普通列解析避免漏列; // 但下游列正则会显式排斥以 KEY/INDEX/UNIQUE/FULLTEXT/SPATIAL 开头的整项,避免 fix #2 的幽灵列。 if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) { // 名支持反引号包裹的非 ASCII(IDENT,H3);列体用平衡括号提取,避免前缀长度 `(N)` 处的 `)` 提前截断丢列(DDL-9)。 const nameMatch = item.match(INLINE_KEY_RE) const SQL_TYPE_RE = /^(?:int|integer|bigint|smallint|tinyint|mediumint|varchar|char|text|blob|date|datetime|timestamp|time|year|decimal|numeric|float|double|real|bit|enum|set|json|binary|varbinary|longtext|longblob|mediumtext|mediumblob|tinytext|tinyblob)$/i if (nameMatch) { const idxName = stripTicks(nameMatch[1]) // 未加反引号的保留字(如 `key varchar`)启发式仍由 SQL_TYPE_RE 兜住 if (!SQL_TYPE_RE.test(idxName)) { const kind = /^UNIQUE/i.test(item) ? 'UNIQUE' : 'INDEX' const colsBody = extractBalancedParens(item, nameMatch[0].length - 1) // nameMatch[0] 以 '(' 结尾 indexes.add(`${idxName}:${kind}:${normalizeIndexCols(colsBody || '')}`) // 列归一化两侧共用(DDL-9) continue } } } // CONSTRAINT 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 if (/^CONSTRAINT\b/i.test(upper)) { const cn = item.match(/^CONSTRAINT\s+`?([A-Za-z0-9_]+)`?/i) indexes.add(cn ? cn[1] : item) continue } // CHECK (...) if (/^CHECK\b/i.test(upper)) continue // 普通列: ... name 可带反引号;type 取到第一个属性关键字/逗号前 const col = item.match(/^(`?)([A-Za-z0-9_]+)\1\s+(.+)$/s) if (!col) continue const quoted = col[1] === '`' const name = col[2] // 未加反引号时拒绝索引保留字开头的"列",避免把 `UNIQUE KEY foo (c)` 等误吃成列(fix #2)。 if (!quoted && /^(KEY|INDEX|UNIQUE|FULLTEXT|SPATIAL|PRIMARY|CONSTRAINT|CHECK|FOREIGN)$/i.test(name)) continue const type = extractType(col[3]) columns.set(name, type) } return { columns, indexes, foreignKeys } } // 从列定义剩余部分提取类型(含括号内长度),到下一个属性关键字前停止。 function extractType(rest) { const s = rest.trim() // 类型形如 varchar(100) / decimal(10,2) / int unsigned / bigint const m = s.match(/^([A-Za-z]+(?:\s+(?:unsigned|signed|zerofill))*)\s*(\([^)]*\))?/i) if (!m) return s.split(/\s+/)[0] const type = m[1].trim() const base = type.split(/\s+/)[0] const paren = m[2] ? m[2].replace(/\s+/g, '') : '' // 保留 unsigned / signed 修饰,避免与 docs/03 写法(如 `int unsigned`)产生假阳性类型 mismatch。 // zerofill 较罕见且 docs 通常不写,仍丢弃。 const mod = /\bunsigned\b/i.test(type) ? ' unsigned' : /\bsigned\b/i.test(type) ? ' signed' : '' return base + paren + mod } export function diffSchema(docsTables, ddlTables) { const diff = { missingTables: [], // docs 有、DDL 无 extraTables: [], // DDL 有、docs 无 columnMismatches: [], // { table, column, side: 'docs'|'ddl' } typeMismatches: [], // { table, column, docsType, ddlType } indexMismatches: [], // { table, index, side: 'docs'|'ddl' } foreignKeyMismatches: [],// { table, foreignKey, side: 'docs'|'ddl' } hasDiff: false, } const docNames = new Set(docsTables.keys()) const ddlNames = new Set(ddlTables.keys()) symDiff(docNames, ddlNames, t => diff.missingTables.push(t), t => diff.extraTables.push(t)) diff.missingTables.sort() diff.extraTables.sort() // 仅对共有表做列/类型/索引/外键比对 for (const t of [...docNames].filter(n => ddlNames.has(n)).sort()) { const d = docsTables.get(t) const s = ddlTables.get(t) // 维度 2/3:列名 + 列类型 for (const [col, dType] of d.columns) { if (!s.columns.has(col)) { diff.columnMismatches.push({ table: t, column: col, side: 'docs' }) } else { const sType = s.columns.get(col) if (!typesEqual(dType, sType)) { diff.typeMismatches.push({ table: t, column: col, docsType: dType, ddlType: sType }) } } } for (const col of s.columns.keys()) { if (!d.columns.has(col)) diff.columnMismatches.push({ table: t, column: col, side: 'ddl' }) } // 维度 4:索引。PRIMARY 由列级主键约定治理(已在列维度校验),且 docs/03 常只在 ### 字段 // 表内体现 PK、不在 ### 索引 重列 → 从两侧索引集剔除 PRIMARY,避免假阳性;命名二级索引仍比对。 const dIdx = new Set([...(d.indexes || [])].filter(ix => ix !== 'PRIMARY')) const sIdx = new Set([...(s.indexes || [])].filter(ix => ix !== 'PRIMARY')) symDiff(dIdx, sIdx, ix => diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }), ix => diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' })) // 维度 5:外键 symDiff(d.foreignKeys || new Set(), s.foreignKeys || new Set(), fk => diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'docs' }), fk => diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'ddl' })) } diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 || diff.columnMismatches.length > 0 || diff.typeMismatches.length > 0 || diff.indexMismatches.length > 0 || diff.foreignKeyMismatches.length > 0 return diff } // 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。 // **字符串字面量感知**:单引号 / 双引号 / 反引号字面量内部的注释符按原文保留(DEFAULT 'a--b' / // DEFAULT '#tag' 之类不会被错剥成"列丢失")。转义引号支持 SQL 标准的 '' 与反斜杠 \\'。 function stripSqlComments(sql) { const s = String(sql) let out = '' let i = 0 while (i < s.length) { const ch = s[i] const next = s[i + 1] // 进入字符串 / 反引号:原样吐出整个字面量 if (ch === "'" || ch === '"' || ch === '`') { const end = advanceLiteral(s, i) out += s.slice(i, end) i = end continue } // /* ... */ 块注释(吞到下一个 */) if (ch === '/' && next === '*') { i += 2 while (i < s.length && !(s[i] === '*' && s[i + 1] === '/')) i++ i += 2 out += ' ' continue } // -- 行注释(吞到行尾,不含换行) if (ch === '-' && next === '-') { while (i < s.length && s[i] !== '\n') i++ continue } // # 行注释(吞到行尾,不含换行) if (ch === '#') { while (i < s.length && s[i] !== '\n') i++ continue } out += ch i++ } return out } function stripTicks(s) { return String(s).replace(/`/g, '').trim() } function splitMarkdownRow(line) { // 去掉首尾管道再按 | 切分 let t = line.trim() if (t.startsWith('|')) t = t.slice(1) if (t.endsWith('|')) t = t.slice(0, -1) return t.split('|').map(c => c.trim()) } function isSeparatorCell(cell) { // 形如 --- / :--- / ---: / :---: return /^:?-{1,}:?$/.test(cell.trim()) } function isHeaderLabel(cell) { // 表头标签:列 / 字段 / 字段名 / 类型 / 列名(避免把表头行当列) return ['列', '字段', '字段名', '列名', '类型', 'name', 'type', 'column'].includes(cell.trim()) } // 推进字符串字面量游标:从指针指向开引号开始,返回字面量结束后(含闭引号)的下标。 // 支持 '' / "" 转义与反斜杠转义(反引号字面量不支持反斜杠转义)。 function advanceLiteral(src, i) { const q = src[i] i++ while (i < src.length) { const c = src[i] if (c === q && src[i + 1] === q) { i += 2; continue } if (c === '\\' && i + 1 < src.length && q !== '`') { i += 2; continue } i++ if (c === q) return i } return i } // 提取从 openIdx(指向 '(')开始的平衡括号内部内容(不含最外层括号)。 // **字符串字面量感知**:DEFAULT ')' / DEFAULT '(a,b)' 等不会让 depth 提前减为 0 截断表体。 function extractBalancedParens(src, openIdx) { if (src[openIdx] !== '(') return null let depth = 0 let i = openIdx while (i < src.length) { const ch = src[i] if (ch === "'" || ch === '"' || ch === '`') { i = advanceLiteral(src, i) continue } if (ch === '(') { depth++; i++; continue } if (ch === ')') { depth-- if (depth === 0) return src.slice(openIdx + 1, i) i++ continue } i++ } return null } // 在顶层(括号深度 0、字符串字面量外)按逗号切分 DDL body。 // 保护 varchar(100) / decimal(10,2) 内的逗号,也保护 DEFAULT 'a,b' / COMMENT '..., ...' 内的逗号。 function splitTopLevelCommas(body) { const out = [] let depth = 0 let buf = '' let i = 0 while (i < body.length) { const ch = body[i] if (ch === "'" || ch === '"' || ch === '`') { const end = advanceLiteral(body, i) buf += body.slice(i, end) i = end continue } if (ch === '(') { depth++; buf += ch; i++; continue } if (ch === ')') { depth--; buf += ch; i++; continue } if (ch === ',' && depth === 0) { out.push(buf); buf = ''; i++; continue } buf += ch i++ } if (buf.trim()) out.push(buf) return out } // 类型相等比较:大小写不敏感、忽略空白。 function typesEqual(a, b) { const norm = (x) => String(x).toLowerCase().replace(/\s+/g, '') return norm(a) === norm(b) } // 集合对称差:对 left\right 调用 onlyLeft,对 right\left 调用 onlyRight。 function symDiff(left, right, onlyLeft, onlyRight) { for (const x of left) if (!right.has(x)) onlyLeft(x) for (const x of right) if (!left.has(x)) onlyRight(x) } export function formatDiff(diff) { const out = [] if (diff.missingTables.length) { out.push('=== 维度1 表集合:docs/03 有但 DDL 无 ===') for (const t of diff.missingTables) out.push(` - ${t}`) } if (diff.extraTables.length) { out.push('=== 维度1 表集合:DDL 有但 docs/03 无 ===') for (const t of diff.extraTables) out.push(` - ${t}`) } if (diff.columnMismatches.length) { out.push('=== 维度2 列名 ===') for (const m of diff.columnMismatches) { out.push(` - ${m.table}.${m.column} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) } } if (diff.typeMismatches.length) { out.push('=== 维度3 列类型 ===') for (const m of diff.typeMismatches) { out.push(` - ${m.table}.${m.column}: docs/03=${m.docsType} ≠ DDL=${m.ddlType}`) } } if (diff.indexMismatches.length) { out.push('=== 维度4 索引 ===') for (const m of diff.indexMismatches) { out.push(` - ${m.table} 索引 ${m.index} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) } } if (diff.foreignKeyMismatches.length) { out.push('=== 维度5 外键 ===') for (const m of diff.foreignKeyMismatches) { out.push(` - ${m.table} 外键 ${m.foreignKey} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) } } return out.join('\n') } const { pathToFileURL } = await import('node:url') // CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) 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 ') 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) } const docsTables = parseDocsTables(readFileSync(docsPath, 'utf8')) const ddlTables = parseDDL(readFileSync(ddlPath, 'utf8')) const diff = diffSchema(docsTables, ddlTables) if (diff.hasDiff) { console.error(formatDiff(diff)) process.exit(1) } console.log('validate-ddl: ✓ docs/03 与 DDL 在 5 维(表/列/类型/索引/外键)一致') process.exit(0) }