Commit 6eef75b09b2260ab4812673b17e46b2c4f9631da

Authored by zichun
1 parent d61c07b1

lib: drop duplicated headers, JSDoc restatements, CLI-entry comments; reuse advanceLiteral helper

lib/apply-ddl.mjs
1   -// lib/apply-ddl.mjs
2   -//
3   -// Replaces the inline `set -a; . .env.local; mysql < V1.sql` bash from db-init.
4   -//
5   -// parseEnv(): dotenv-style line parser. Pure parsing, NO variable expansion and
6   -// NO shell execution — `$VAR`, backticks, `$(...)` and other shell constructs are
7   -// kept verbatim as literal characters, which eliminates the shell-injection vector
8   -// of `source`-ing an untrusted .env file.
9   -//
10   -// applyDDL(): connects with mysql2/promise (multipleStatements) to run a DDL file.
11   -
12 1 /**
13 2 * Parse dotenv-style text into a plain object.
14 3 *
... ... @@ -60,14 +49,6 @@ export function parseEnv(text) {
60 49 /**
61 50 * Apply a DDL file to a MySQL database using mysql2/promise.
62 51 *
63   - * Reads connection settings from the parsed env file. Recognised keys (with
64   - * common aliases) — DB_HOST/MYSQL_HOST, DB_PORT/MYSQL_PORT, DB_USER/MYSQL_USER,
65   - * DB_PASS/DB_PASSWORD/MYSQL_PASSWORD, DB_SCHEMA/DB_NAME/MYSQL_DATABASE.
66   - * DB_SCHEMA is the plugin's canonical schema key (see .env.local template); the
67   - * target database MUST resolve to a non-empty value or applyDDL fails closed —
68   - * V1 DDL contains no `USE`/`CREATE DATABASE`, so a missing schema would only
69   - * surface as an opaque `ER_NO_DB_ERROR` on the first CREATE TABLE.
70   - *
71 52 * @param {{envPath: string, ddlPath: string}} opts
72 53 * @returns {Promise<void>}
73 54 */
... ... @@ -104,9 +85,7 @@ export async function applyDDL({ envPath, ddlPath }) {
104 85 * Resolve mysql2 connection settings from a parsed env object. Pure (no I/O),
105 86 * so it is unit-testable without mysql2 installed.
106 87 *
107   - * fail-closed on missing schema: DB_SCHEMA is the plugin's canonical key; V1 DDL
108   - * has no `USE`/`CREATE DATABASE`, so a missing database would otherwise surface
109   - * only as an opaque ER_NO_DB_ERROR on the first CREATE TABLE.
  88 + * Throws if no schema resolves — V1 has no USE/CREATE DATABASE.
110 89 *
111 90 * @param {Record<string,string>} env
112 91 * @param {string} [envPath] only used to make the error message actionable
... ... @@ -132,9 +111,7 @@ export class MysqlUnavailableError extends Error {
132 111 }
133 112 }
134 113  
135   -// CLI entry: node lib/apply-ddl.mjs <envPath> <ddlPath>
136   -// Use pathToFileURL so the guard matches even when the path contains spaces or
137   -// other characters that get percent-encoded in import.meta.url.
  114 +// CLI entry guard (see render.mjs for pathToFileURL rationale)
138 115 const { pathToFileURL } = await import('node:url')
139 116 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
140 117 const [envPath, ddlPath] = process.argv.slice(2)
... ...
lib/merge-gitignore.mjs
1 1 // lib/merge-gitignore.mjs
2 2 // 合并两份 .gitignore,对**规则行**逐行判重并集合并;注释行透传(相邻去重),空行丢弃(节由注释头承担)。
3   -// 之所以不对注释去重:两段分组各自的同名注释头(如多次出现的 `# generated`)是分节标题,
4   -// 全局去重会把第二段的标题吞掉,让 add 文件的规则被并入第一段的注释下、破坏分节语义。
5 3 export function mergeGitignore(baseText, addText) {
6 4 const seenRules = new Set()
7 5 const out = []
... ... @@ -25,10 +23,7 @@ export function mergeGitignore(baseText, addText) {
25 23 return text
26 24 }
27 25  
28   -// CLI entry: node lib/merge-gitignore.mjs <basePath> <addPath>
29   -// Use pathToFileURL so the guard matches even when the path contains spaces or
30   -// non-ASCII chars (import.meta.url is percent-encoded; process.argv[1] is raw).
31   -const { pathToFileURL } = await import('node:url')
  26 +const { pathToFileURL } = await import('node:url') // CLI entry guard (see render.mjs)
32 27 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
33 28 const [basePath, addPath] = process.argv.slice(2)
34 29 const { readFileSync, writeFileSync } = await import('node:fs')
... ...
lib/render.mjs
1 1 // lib/render.mjs — literal-safe template render (replaces scope-lock/render.sh)
2 2 //
3   -// 用法(CLI):node lib/render.mjs <tplPath> <varsJsonPath> <outPath>
4   -// 程序内:import { render } from './render.mjs'
5   -//
6 3 // 核心要求:{{key}} 占位替换;值中含 $、{、}、}} 不被二次解释(字面插入);
7 4 // 先剥离 HTML 注释(模板引导文本);缺少变量则 throw(不静默留空)。
8 5 export function render(template, vars) {
... ...
lib/validate-ddl.mjs
1 1 // lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验
2 2 // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。
3   -// 语法基线偏向 MySQL 8(int/varchar/json 等 ANSI + MySQL 类型;KEY/UNIQUE KEY 索引语法)。
4   -// 厂商扩展(Postgres `bytea`、Oracle `nvarchar2` 等)未列入 SQL_TYPE_RE,下游解析可能退化为
5   -// 跳过整项(fix #2 起 KEY/INDEX 项遇未知类型保留字会跳过而非误判为列)。
6 3 //
7 4 // 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath>
8 5 // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。
9 6 // 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs'
10 7 //
11   -// 5 维 diff:
12   -// 1) 表集合(missingTables / extraTables)
13   -// 2) 列名(columnMismatches,side: 'docs'|'ddl')
14   -// 3) 列类型(typeMismatches)
15   -// 4) 索引(indexMismatches,side: 'docs'|'ddl')
16   -// 5) 外键(foreignKeyMismatches,side: 'docs'|'ddl')
17   -//
18 8 // 数据结构(解析结果):Map<tableName, {
19 9 // columns: Map<colName, type>, indexes: Set<string>, foreignKeys: Set<string> }>
20 10  
... ... @@ -102,10 +92,6 @@ function parseIndexBullet(line, indexes) {
102 92  
103 93 // 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete)
104 94 // 归一化为 parseDDL 同形的 `${fromCols}->${toTable}(${toCols})`(注意 docs 用 unicode → / DDL 用 ->)。
105   -// 复合外键的两种合法 docs 写法都支持,避免与 DDL 侧的 `(idA, idB)` 形态不对称:
106   -// - `fk`: colA, colB → other.idA, idB ← 平铺,到列也是逗号分隔
107   -// - `fk`: colA, colB → other.(idA, idB) ← 目标列括起
108   -// - `fk`: colA, colB → other.`idA`,`idB` ← 各列各带反引号
109 95 function parseForeignKeyBullet(line, foreignKeys) {
110 96 // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是
111 97 // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。
... ... @@ -209,8 +195,6 @@ function parseTableBody(body) {
209 195 indexes.add(`${nameMatch[1]}:${kind}:${cols}`)
210 196 continue
211 197 }
212   - // 命名是类型关键字 / 无法定位 → 回退到列定义解析;
213   - // 列正则下游会拒绝以保留字开头的列名(fix #2)。
214 198 }
215 199 // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记
216 200 if (/^CONSTRAINT\b/i.test(upper)) {
... ... @@ -249,7 +233,6 @@ function extractType(rest) {
249 233 return base + paren + mod
250 234 }
251 235  
252   -// ── 5 维 diff ────────────────────────────────────────────────────
253 236 export function diffSchema(docsTables, ddlTables) {
254 237 const diff = {
255 238 missingTables: [], // docs 有、DDL 无
... ... @@ -264,8 +247,7 @@ export function diffSchema(docsTables, ddlTables) {
264 247 const docNames = new Set(docsTables.keys())
265 248 const ddlNames = new Set(ddlTables.keys())
266 249  
267   - for (const t of docNames) if (!ddlNames.has(t)) diff.missingTables.push(t)
268   - for (const t of ddlNames) if (!docNames.has(t)) diff.extraTables.push(t)
  250 + symDiff(docNames, ddlNames, t => diff.missingTables.push(t), t => diff.extraTables.push(t))
269 251 diff.missingTables.sort()
270 252 diff.extraTables.sort()
271 253  
... ... @@ -293,14 +275,14 @@ export function diffSchema(docsTables, ddlTables) {
293 275 // 表内体现 PK、不在 ### 索引 重列 → 从两侧索引集剔除 PRIMARY,避免假阳性;命名二级索引仍比对。
294 276 const dIdx = new Set([...(d.indexes || [])].filter(ix => ix !== 'PRIMARY'))
295 277 const sIdx = new Set([...(s.indexes || [])].filter(ix => ix !== 'PRIMARY'))
296   - for (const ix of dIdx) if (!sIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'docs' })
297   - for (const ix of sIdx) if (!dIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' })
  278 + symDiff(dIdx, sIdx,
  279 + ix => diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }),
  280 + ix => diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' }))
298 281  
299 282 // 维度 5:外键
300   - const dFk = d.foreignKeys || new Set()
301   - const sFk = s.foreignKeys || new Set()
302   - for (const fk of dFk) if (!sFk.has(fk)) diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'docs' })
303   - for (const fk of sFk) if (!dFk.has(fk)) diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'ddl' })
  283 + symDiff(d.foreignKeys || new Set(), s.foreignKeys || new Set(),
  284 + fk => diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'docs' }),
  285 + fk => diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'ddl' }))
304 286 }
305 287  
306 288 diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 ||
... ... @@ -309,7 +291,6 @@ export function diffSchema(docsTables, ddlTables) {
309 291 return diff
310 292 }
311 293  
312   -// ── 工具函数 ─────────────────────────────────────────────────────
313 294 // 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。
314 295 // **字符串字面量感知**:单引号 / 双引号 / 反引号字面量内部的注释符按原文保留(DEFAULT 'a--b' /
315 296 // DEFAULT '#tag' 之类不会被错剥成"列丢失")。转义引号支持 SQL 标准的 '' 与反斜杠 \\'。
... ... @@ -322,19 +303,9 @@ function stripSqlComments(sql) {
322 303 const next = s[i + 1]
323 304 // 进入字符串 / 反引号:原样吐出整个字面量
324 305 if (ch === "'" || ch === '"' || ch === '`') {
325   - const q = ch
326   - out += ch
327   - i++
328   - while (i < s.length) {
329   - const c = s[i]
330   - // SQL 标准的双引号转义:'' 或 ""
331   - if (c === q && s[i + 1] === q) { out += c + c; i += 2; continue }
332   - // 反斜杠转义:\' / \" / \\ 等(MySQL 默认开启 NO_BACKSLASH_ESCAPES 才禁,保守按开启处理)
333   - if (c === '\\' && i + 1 < s.length && q !== '`') { out += c + s[i + 1]; i += 2; continue }
334   - out += c
335   - i++
336   - if (c === q) break
337   - }
  306 + const end = advanceLiteral(s, i)
  307 + out += s.slice(i, end)
  308 + i = end
338 309 continue
339 310 }
340 311 // /* ... */ 块注释(吞到下一个 */)
... ... @@ -453,7 +424,12 @@ function typesEqual(a, b) {
453 424 return norm(a) === norm(b)
454 425 }
455 426  
456   -// ── 报告(供 CLI 与外部复用)────────────────────────────────────
  427 +// 集合对称差:对 left\right 调用 onlyLeft,对 right\left 调用 onlyRight。
  428 +function symDiff(left, right, onlyLeft, onlyRight) {
  429 + for (const x of left) if (!right.has(x)) onlyLeft(x)
  430 + for (const x of right) if (!left.has(x)) onlyRight(x)
  431 +}
  432 +
457 433 export function formatDiff(diff) {
458 434 const out = []
459 435 if (diff.missingTables.length) {
... ... @@ -491,10 +467,7 @@ export function formatDiff(diff) {
491 467 return out.join('\n')
492 468 }
493 469  
494   -// ── CLI 入口 ─────────────────────────────────────────────────────
495   -// 用 pathToFileURL 做比较:路径含空格/非 ASCII 时 import.meta.url 是百分号编码,
496   -// 而 process.argv[1] 是原始路径,直接 `file://${argv1}` 拼接永远不相等。
497   -const { pathToFileURL } = await import('node:url')
  470 +const { pathToFileURL } = await import('node:url') // CLI entry guard (see render.mjs)
498 471 const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
499 472 if (isCliEntry) {
500 473 const { readFileSync, existsSync } = await import('node:fs')
... ...