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 * Parse dotenv-style text into a plain object. 2 * Parse dotenv-style text into a plain object.
14 * 3 *
@@ -60,14 +49,6 @@ export function parseEnv(text) { @@ -60,14 +49,6 @@ export function parseEnv(text) {
60 /** 49 /**
61 * Apply a DDL file to a MySQL database using mysql2/promise. 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 * @param {{envPath: string, ddlPath: string}} opts 52 * @param {{envPath: string, ddlPath: string}} opts
72 * @returns {Promise<void>} 53 * @returns {Promise<void>}
73 */ 54 */
@@ -104,9 +85,7 @@ export async function applyDDL({ envPath, ddlPath }) { @@ -104,9 +85,7 @@ export async function applyDDL({ envPath, ddlPath }) {
104 * Resolve mysql2 connection settings from a parsed env object. Pure (no I/O), 85 * Resolve mysql2 connection settings from a parsed env object. Pure (no I/O),
105 * so it is unit-testable without mysql2 installed. 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 * @param {Record<string,string>} env 90 * @param {Record<string,string>} env
112 * @param {string} [envPath] only used to make the error message actionable 91 * @param {string} [envPath] only used to make the error message actionable
@@ -132,9 +111,7 @@ export class MysqlUnavailableError extends Error { @@ -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 const { pathToFileURL } = await import('node:url') 115 const { pathToFileURL } = await import('node:url')
139 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { 116 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
140 const [envPath, ddlPath] = process.argv.slice(2) 117 const [envPath, ddlPath] = process.argv.slice(2)
lib/merge-gitignore.mjs
1 // lib/merge-gitignore.mjs 1 // lib/merge-gitignore.mjs
2 // 合并两份 .gitignore,对**规则行**逐行判重并集合并;注释行透传(相邻去重),空行丢弃(节由注释头承担)。 2 // 合并两份 .gitignore,对**规则行**逐行判重并集合并;注释行透传(相邻去重),空行丢弃(节由注释头承担)。
3 -// 之所以不对注释去重:两段分组各自的同名注释头(如多次出现的 `# generated`)是分节标题,  
4 -// 全局去重会把第二段的标题吞掉,让 add 文件的规则被并入第一段的注释下、破坏分节语义。  
5 export function mergeGitignore(baseText, addText) { 3 export function mergeGitignore(baseText, addText) {
6 const seenRules = new Set() 4 const seenRules = new Set()
7 const out = [] 5 const out = []
@@ -25,10 +23,7 @@ export function mergeGitignore(baseText, addText) { @@ -25,10 +23,7 @@ export function mergeGitignore(baseText, addText) {
25 return text 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 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { 27 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
33 const [basePath, addPath] = process.argv.slice(2) 28 const [basePath, addPath] = process.argv.slice(2)
34 const { readFileSync, writeFileSync } = await import('node:fs') 29 const { readFileSync, writeFileSync } = await import('node:fs')
lib/render.mjs
1 // lib/render.mjs — literal-safe template render (replaces scope-lock/render.sh) 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 // 核心要求:{{key}} 占位替换;值中含 $、{、}、}} 不被二次解释(字面插入); 3 // 核心要求:{{key}} 占位替换;值中含 $、{、}、}} 不被二次解释(字面插入);
7 // 先剥离 HTML 注释(模板引导文本);缺少变量则 throw(不静默留空)。 4 // 先剥离 HTML 注释(模板引导文本);缺少变量则 throw(不静默留空)。
8 export function render(template, vars) { 5 export function render(template, vars) {
lib/validate-ddl.mjs
1 // lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验 1 // lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验
2 // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 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 // 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath> 4 // 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath>
8 // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。 5 // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。
9 // 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' 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 // 数据结构(解析结果):Map<tableName, { 8 // 数据结构(解析结果):Map<tableName, {
19 // columns: Map<colName, type>, indexes: Set<string>, foreignKeys: Set<string> }> 9 // columns: Map<colName, type>, indexes: Set<string>, foreignKeys: Set<string> }>
20 10
@@ -102,10 +92,6 @@ function parseIndexBullet(line, indexes) { @@ -102,10 +92,6 @@ function parseIndexBullet(line, indexes) {
102 92
103 // 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete) 93 // 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete)
104 // 归一化为 parseDDL 同形的 `${fromCols}->${toTable}(${toCols})`(注意 docs 用 unicode → / DDL 用 ->)。 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 function parseForeignKeyBullet(line, foreignKeys) { 95 function parseForeignKeyBullet(line, foreignKeys) {
110 // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 96 // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是
111 // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 97 // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。
@@ -209,8 +195,6 @@ function parseTableBody(body) { @@ -209,8 +195,6 @@ function parseTableBody(body) {
209 indexes.add(`${nameMatch[1]}:${kind}:${cols}`) 195 indexes.add(`${nameMatch[1]}:${kind}:${cols}`)
210 continue 196 continue
211 } 197 }
212 - // 命名是类型关键字 / 无法定位 → 回退到列定义解析;  
213 - // 列正则下游会拒绝以保留字开头的列名(fix #2)。  
214 } 198 }
215 // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 199 // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记
216 if (/^CONSTRAINT\b/i.test(upper)) { 200 if (/^CONSTRAINT\b/i.test(upper)) {
@@ -249,7 +233,6 @@ function extractType(rest) { @@ -249,7 +233,6 @@ function extractType(rest) {
249 return base + paren + mod 233 return base + paren + mod
250 } 234 }
251 235
252 -// ── 5 维 diff ────────────────────────────────────────────────────  
253 export function diffSchema(docsTables, ddlTables) { 236 export function diffSchema(docsTables, ddlTables) {
254 const diff = { 237 const diff = {
255 missingTables: [], // docs 有、DDL 无 238 missingTables: [], // docs 有、DDL 无
@@ -264,8 +247,7 @@ export function diffSchema(docsTables, ddlTables) { @@ -264,8 +247,7 @@ export function diffSchema(docsTables, ddlTables) {
264 const docNames = new Set(docsTables.keys()) 247 const docNames = new Set(docsTables.keys())
265 const ddlNames = new Set(ddlTables.keys()) 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 diff.missingTables.sort() 251 diff.missingTables.sort()
270 diff.extraTables.sort() 252 diff.extraTables.sort()
271 253
@@ -293,14 +275,14 @@ export function diffSchema(docsTables, ddlTables) { @@ -293,14 +275,14 @@ export function diffSchema(docsTables, ddlTables) {
293 // 表内体现 PK、不在 ### 索引 重列 → 从两侧索引集剔除 PRIMARY,避免假阳性;命名二级索引仍比对。 275 // 表内体现 PK、不在 ### 索引 重列 → 从两侧索引集剔除 PRIMARY,避免假阳性;命名二级索引仍比对。
294 const dIdx = new Set([...(d.indexes || [])].filter(ix => ix !== 'PRIMARY')) 276 const dIdx = new Set([...(d.indexes || [])].filter(ix => ix !== 'PRIMARY'))
295 const sIdx = new Set([...(s.indexes || [])].filter(ix => ix !== 'PRIMARY')) 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 // 维度 5:外键 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 diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 || 288 diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 ||
@@ -309,7 +291,6 @@ export function diffSchema(docsTables, ddlTables) { @@ -309,7 +291,6 @@ export function diffSchema(docsTables, ddlTables) {
309 return diff 291 return diff
310 } 292 }
311 293
312 -// ── 工具函数 ─────────────────────────────────────────────────────  
313 // 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。 294 // 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。
314 // **字符串字面量感知**:单引号 / 双引号 / 反引号字面量内部的注释符按原文保留(DEFAULT 'a--b' / 295 // **字符串字面量感知**:单引号 / 双引号 / 反引号字面量内部的注释符按原文保留(DEFAULT 'a--b' /
315 // DEFAULT '#tag' 之类不会被错剥成"列丢失")。转义引号支持 SQL 标准的 '' 与反斜杠 \\'。 296 // DEFAULT '#tag' 之类不会被错剥成"列丢失")。转义引号支持 SQL 标准的 '' 与反斜杠 \\'。
@@ -322,19 +303,9 @@ function stripSqlComments(sql) { @@ -322,19 +303,9 @@ function stripSqlComments(sql) {
322 const next = s[i + 1] 303 const next = s[i + 1]
323 // 进入字符串 / 反引号:原样吐出整个字面量 304 // 进入字符串 / 反引号:原样吐出整个字面量
324 if (ch === "'" || ch === '"' || ch === '`') { 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 continue 309 continue
339 } 310 }
340 // /* ... */ 块注释(吞到下一个 */) 311 // /* ... */ 块注释(吞到下一个 */)
@@ -453,7 +424,12 @@ function typesEqual(a, b) { @@ -453,7 +424,12 @@ function typesEqual(a, b) {
453 return norm(a) === norm(b) 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 export function formatDiff(diff) { 433 export function formatDiff(diff) {
458 const out = [] 434 const out = []
459 if (diff.missingTables.length) { 435 if (diff.missingTables.length) {
@@ -491,10 +467,7 @@ export function formatDiff(diff) { @@ -491,10 +467,7 @@ export function formatDiff(diff) {
491 return out.join('\n') 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 const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href 471 const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
499 if (isCliEntry) { 472 if (isCliEntry) {
500 const { readFileSync, existsSync } = await import('node:fs') 473 const { readFileSync, existsSync } = await import('node:fs')