validate-ddl.mjs
20.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
// lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验
// 替换 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>, foreignKeys: Set<string> }>
// ── 解析 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
if (/^primary$/i.test(type) || /^primary$/i.test(name)) {
indexes.add('PRIMARY')
return
}
// 列与 UNIQUE/INDEX 类别一并参与等价比较(fix #10)
const cols = colsRaw
.split(',')
.map(c => c.replace(/`/g, '').trim())
.filter(c => /^[A-Za-z0-9_]+$/.test(c))
.join(',')
const kind = /^unique$/i.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``)。
const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([A-Za-z0-9_]+)`?\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 ────────────────────────────────────────
// 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。
export function parseDDL(text) {
const tables = new Map()
// 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。
const src = stripSqlComments(String(text))
// 抓取 CREATE TABLE <name> ( <body> ) ;name 可带反引号;body 到匹配的右括号。
// 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。
const createRe = /CREATE\s+(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`?[A-Za-z0-9_]+`?\s*\.\s*)?`?([A-Za-z0-9_]+)`?\s*\(/gi
let m
while ((m = createRe.exec(src)) !== null) {
const tableName = m[1]
const bodyStart = createRe.lastIndex - 1 // 指向 '('
const body = extractBalancedParens(src, bodyStart)
if (body == null) continue
const parsed = parseTableBody(body)
tables.set(tableName, parsed)
// 继续从 body 之后扫描
createRe.lastIndex = bodyStart + body.length + 2
}
return tables
}
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 <name>)
if (/\bFOREIGN\s+KEY\b/i.test(item)) {
// REFERENCES 支持 schema 限定 `db`.`t` / db.t(取末段为表名,与 CREATE TABLE 一致)。
const fk = item.match(/FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+(?:`?[A-Za-z0-9_]+`?\s*\.\s*)?`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)(?:\s+ON\s+DELETE\s+(CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION))?/i)
if (fk) {
const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '')
const refTable = 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] <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)) {
const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i)
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 && !SQL_TYPE_RE.test(nameMatch[1])) {
const kind = /^UNIQUE/i.test(item) ? 'UNIQUE' : 'INDEX'
const cols = nameMatch[2]
.split(',')
.map(c => c.replace(/`/g, '').trim())
.filter(Boolean)
.join(',')
indexes.add(`${nameMatch[1]}:${kind}:${cols}`)
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, 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 <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 在 5 维(表/列/类型/索引/外键)一致')
process.exit(0)
}