validate-ddl.mjs
21.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
// lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 4 维校验
// 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。
//
// 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath>
// 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。
// 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs'
//
// 数据结构(解析结果):Map<tableName, {
// columns: Map<colName, type>, indexes: Set<string> }>
// ── 解析 docs/03 markdown 表定义 ─────────────────────────────────
// 约定:每张表一节,节标题形如 ## `表名` 或 ## `表名` — 业务含义
// 节内分 ### 字段(markdown 表格,首列列名、次列类型)、### 索引(项目符号列表)。
// 索引的 bullet 形态见 db-design-gen/templates/docs-03-table-template.md:
// ### 索引 → - `name` (type): cols
// 跳过表头行(列/字段/类型等标签)与分隔行(---)。
// 形如「## 一、全局约定」这类非反引号标题不视为表。
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 }
let mode = 'col' // 当前子区块:'col'(字段表格)/ 'idx'(索引)
for (const raw of lines) {
const line = raw.replace(/\r$/, '')
const h2 = line.match(headerRe)
if (h2) {
current = { columns: new Map(), indexes: 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' : 'col'
continue
}
if (mode === 'idx') { parseIndexBullet(line, current.indexes); 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}`)
}
// ── 解析 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')
// 提取每个 CREATE TABLE 的:列名→类型、索引名集合。
// 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX,C1)。
export function parseDDL(text) {
const tables = new Map()
// 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。
const src = stripSqlComments(String(text))
// 抓取 CREATE TABLE <name> ( <body> ) ;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 里出现 "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,
// 索引写在表体之外。把这些独立语句并回对应表,否则含索引的 schema 首轮校验必报假阳性(C1)。
// 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" 文本被误当语句(REGEX-3)。
const scanSrc = blankStringLiterals(src)
mergeStandaloneIndexes(scanSrc, tables)
return tables
}
// 独立 `CREATE [UNIQUE] INDEX <name> [USING BTREE|HASH] ON [<db>.]<table> (<cols>)` → 并入 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)}`)
}
}
function parseTableBody(body) {
const columns = new Map()
const indexes = new Set()
for (const itemRaw of splitTopLevelCommas(body)) {
const item = itemRaw.trim()
if (!item) continue
const upper = item.toUpperCase()
// 外键约束(可带前缀 CONSTRAINT <name>)→ 已去掉外键维度,直接跳过(不进 indexes/约束)。
if (/\bFOREIGN\s+KEY\b/i.test(item)) {
continue
}
// PRIMARY KEY (...)
if (/^PRIMARY\s+KEY/i.test(item)) {
indexes.add('PRIMARY')
continue
}
// UNIQUE [KEY|INDEX] <name> (...) / KEY <name> (...) / INDEX <name> (...)
// 启发式消歧:若 `<KEY|INDEX> <ident> (...)` 中 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 <name> 但非外键(如 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> ... 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 }
}
// 从列定义剩余部分提取类型(含括号内长度),到下一个属性关键字前停止。
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' }
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' }))
}
diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 ||
diff.columnMismatches.length > 0 || diff.typeMismatches.length > 0 ||
diff.indexMismatches.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'}`)
}
}
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 <docs/03 path> <V1.sql path>')
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 在 4 维(表/列/类型/索引)一致')
process.exit(0)
}