Commit 9585c36f7b7a492837c5abd08d7c50c47504c32d
1 parent
18c67809
feat(lib): cross-platform Node helpers + .mjs target scripts (replace bash)
Showing
12 changed files
with
952 additions
and
105 deletions
lib/apply-ddl.mjs
0 → 100644
| 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 | +/** | ||
| 13 | + * Parse dotenv-style text into a plain object. | ||
| 14 | + * | ||
| 15 | + * Rules: | ||
| 16 | + * - one `KEY=VALUE` per line | ||
| 17 | + * - blank lines and full-line comments (first non-space char is `#`) are skipped | ||
| 18 | + * - an optional leading `export ` is stripped | ||
| 19 | + * - key and value are trimmed | ||
| 20 | + * - a single layer of matching surrounding quotes ('...' or "...") is removed | ||
| 21 | + * - NO variable expansion: `$FOO`, `${FOO}`, `$(...)`, backticks stay literal | ||
| 22 | + * | ||
| 23 | + * @param {string} text | ||
| 24 | + * @returns {Record<string, string>} | ||
| 25 | + */ | ||
| 26 | +export function parseEnv(text) { | ||
| 27 | + const env = {} | ||
| 28 | + if (typeof text !== 'string') return env | ||
| 29 | + for (const rawLine of text.split('\n')) { | ||
| 30 | + let line = rawLine.replace(/\r$/, '') // tolerate CRLF | ||
| 31 | + const trimmed = line.trim() | ||
| 32 | + if (trimmed === '' || trimmed.startsWith('#')) continue | ||
| 33 | + | ||
| 34 | + // strip an optional `export ` prefix (off the trimmed-left view) | ||
| 35 | + let body = line.replace(/^\s*export\s+/, '') | ||
| 36 | + | ||
| 37 | + const eq = body.indexOf('=') | ||
| 38 | + if (eq === -1) continue // not a KEY=VALUE line; ignore | ||
| 39 | + | ||
| 40 | + const key = body.slice(0, eq).trim() | ||
| 41 | + if (key === '') continue | ||
| 42 | + | ||
| 43 | + let value = body.slice(eq + 1).trim() | ||
| 44 | + | ||
| 45 | + // remove one layer of matching surrounding quotes, if present. | ||
| 46 | + if ( | ||
| 47 | + value.length >= 2 && | ||
| 48 | + ((value[0] === '"' && value[value.length - 1] === '"') || | ||
| 49 | + (value[0] === "'" && value[value.length - 1] === "'")) | ||
| 50 | + ) { | ||
| 51 | + value = value.slice(1, -1) | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + // NOTE: no variable expansion is performed — value is inserted literally. | ||
| 55 | + env[key] = value | ||
| 56 | + } | ||
| 57 | + return env | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +/** | ||
| 61 | + * Apply a DDL file to a MySQL database using mysql2/promise. | ||
| 62 | + * | ||
| 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_NAME/MYSQL_DATABASE. | ||
| 66 | + * | ||
| 67 | + * @param {{envPath: string, ddlPath: string}} opts | ||
| 68 | + * @returns {Promise<void>} | ||
| 69 | + */ | ||
| 70 | +export async function applyDDL({ envPath, ddlPath }) { | ||
| 71 | + const { readFileSync } = await import('node:fs') | ||
| 72 | + | ||
| 73 | + let mysql | ||
| 74 | + try { | ||
| 75 | + ;({ default: mysql } = await import('mysql2/promise')) | ||
| 76 | + } catch { | ||
| 77 | + throw new MysqlUnavailableError() | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + const env = parseEnv(readFileSync(envPath, 'utf8')) | ||
| 81 | + const ddl = readFileSync(ddlPath, 'utf8') | ||
| 82 | + | ||
| 83 | + const host = env.DB_HOST || env.MYSQL_HOST || '127.0.0.1' | ||
| 84 | + const port = Number(env.DB_PORT || env.MYSQL_PORT || 3306) | ||
| 85 | + const user = env.DB_USER || env.MYSQL_USER || 'root' | ||
| 86 | + const password = env.DB_PASS || env.DB_PASSWORD || env.MYSQL_PASSWORD || '' | ||
| 87 | + const database = env.DB_NAME || env.MYSQL_DATABASE || undefined | ||
| 88 | + | ||
| 89 | + const conn = await mysql.createConnection({ | ||
| 90 | + host, | ||
| 91 | + port, | ||
| 92 | + user, | ||
| 93 | + password, | ||
| 94 | + database, | ||
| 95 | + multipleStatements: true, | ||
| 96 | + }) | ||
| 97 | + try { | ||
| 98 | + await conn.query(ddl) | ||
| 99 | + } finally { | ||
| 100 | + await conn.end() | ||
| 101 | + } | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +/** Distinct error type so the CLI can emit a friendly install hint. */ | ||
| 105 | +export class MysqlUnavailableError extends Error { | ||
| 106 | + constructor() { | ||
| 107 | + super('mysql2 is not installed') | ||
| 108 | + this.name = 'MysqlUnavailableError' | ||
| 109 | + } | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +// CLI entry: node lib/apply-ddl.mjs <envPath> <ddlPath> | ||
| 113 | +// Use pathToFileURL so the guard matches even when the path contains spaces or | ||
| 114 | +// other characters that get percent-encoded in import.meta.url. | ||
| 115 | +const { pathToFileURL } = await import('node:url') | ||
| 116 | +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { | ||
| 117 | + const [envPath, ddlPath] = process.argv.slice(2) | ||
| 118 | + if (!envPath || !ddlPath) { | ||
| 119 | + console.error('usage: node lib/apply-ddl.mjs <envPath> <ddlPath>') | ||
| 120 | + process.exit(2) | ||
| 121 | + } | ||
| 122 | + try { | ||
| 123 | + await applyDDL({ envPath, ddlPath }) | ||
| 124 | + console.log(`apply-ddl: applied ${ddlPath} using ${envPath}`) | ||
| 125 | + } catch (e) { | ||
| 126 | + if (e instanceof MysqlUnavailableError) { | ||
| 127 | + console.error('apply-ddl: mysql2 not found. Please run `npm i mysql2` in the target project.') | ||
| 128 | + process.exit(1) | ||
| 129 | + } | ||
| 130 | + console.error(`apply-ddl: failed — ${e?.message || e}`) | ||
| 131 | + process.exit(1) | ||
| 132 | + } | ||
| 133 | +} |
lib/apply-ddl.test.mjs
0 → 100644
| 1 | +import { test } from 'node:test' | ||
| 2 | +import assert from 'node:assert/strict' | ||
| 3 | +import { parseEnv } from './apply-ddl.mjs' | ||
| 4 | + | ||
| 5 | +test('parseEnv ignores comments, trims, keeps special chars literally', () => { | ||
| 6 | + const env = parseEnv('# c\nDB_PASS=p@ss$word!\nDB_NAME = erp \n') | ||
| 7 | + assert.equal(env.DB_PASS, 'p@ss$word!') | ||
| 8 | + assert.equal(env.DB_NAME, 'erp') | ||
| 9 | +}) | ||
| 10 | + | ||
| 11 | +test('parseEnv does NOT expand variables', () => { | ||
| 12 | + const env = parseEnv('A=1\nB=${A}\nC=$A\nD=$(whoami)\nE=`id`\n') | ||
| 13 | + assert.equal(env.B, '${A}') | ||
| 14 | + assert.equal(env.C, '$A') | ||
| 15 | + assert.equal(env.D, '$(whoami)') | ||
| 16 | + assert.equal(env.E, '`id`') | ||
| 17 | +}) | ||
| 18 | + | ||
| 19 | +test('parseEnv skips blank lines and comment lines', () => { | ||
| 20 | + const env = parseEnv('\n \n# comment\nK=v\n # indented comment\n') | ||
| 21 | + assert.deepEqual(Object.keys(env), ['K']) | ||
| 22 | + assert.equal(env.K, 'v') | ||
| 23 | +}) | ||
| 24 | + | ||
| 25 | +test('parseEnv strips one layer of matching quotes', () => { | ||
| 26 | + const env = parseEnv(`Q1="hello world"\nQ2='a=b=c'\nQ3="$keep"\n`) | ||
| 27 | + assert.equal(env.Q1, 'hello world') | ||
| 28 | + assert.equal(env.Q2, 'a=b=c') | ||
| 29 | + assert.equal(env.Q3, '$keep') | ||
| 30 | +}) | ||
| 31 | + | ||
| 32 | +test('parseEnv keeps = signs inside the value', () => { | ||
| 33 | + const env = parseEnv('URL=mysql://u:p@h:3306/db?x=1&y=2\n') | ||
| 34 | + assert.equal(env.URL, 'mysql://u:p@h:3306/db?x=1&y=2') | ||
| 35 | +}) | ||
| 36 | + | ||
| 37 | +test('parseEnv strips an optional leading export', () => { | ||
| 38 | + const env = parseEnv('export DB_USER=root\n') | ||
| 39 | + assert.equal(env.DB_USER, 'root') | ||
| 40 | +}) | ||
| 41 | + | ||
| 42 | +test('parseEnv tolerates CRLF line endings', () => { | ||
| 43 | + const env = parseEnv('A=1\r\nB=2\r\n') | ||
| 44 | + assert.equal(env.A, '1') | ||
| 45 | + assert.equal(env.B, '2') | ||
| 46 | +}) | ||
| 47 | + | ||
| 48 | +test('parseEnv ignores lines without an = sign', () => { | ||
| 49 | + const env = parseEnv('NOEQUALS\nK=v\n') | ||
| 50 | + assert.deepEqual(Object.keys(env), ['K']) | ||
| 51 | +}) | ||
| 52 | + | ||
| 53 | +test('parseEnv on empty / non-string input returns empty object', () => { | ||
| 54 | + assert.deepEqual(parseEnv(''), {}) | ||
| 55 | + assert.deepEqual(parseEnv(undefined), {}) | ||
| 56 | + assert.deepEqual(parseEnv(null), {}) | ||
| 57 | +}) |
lib/merge-gitignore.mjs
0 → 100644
| 1 | +// lib/merge-gitignore.mjs | ||
| 2 | +export function mergeGitignore(baseText, addText) { | ||
| 3 | + const seen = new Set() | ||
| 4 | + const out = [] | ||
| 5 | + const push = (line) => { | ||
| 6 | + const key = line.trim() | ||
| 7 | + if (!key) return // drop blank lines | ||
| 8 | + if (seen.has(key)) return // dedupe by trimmed content | ||
| 9 | + seen.add(key) | ||
| 10 | + out.push(line) | ||
| 11 | + } | ||
| 12 | + for (const l of baseText.split('\n')) push(l) | ||
| 13 | + for (const l of addText.split('\n')) push(l) | ||
| 14 | + let text = out.join('\n').replace(/\n+$/,'') + '\n' | ||
| 15 | + return text | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +if (import.meta.url === `file://${process.argv[1]}`) { | ||
| 19 | + const [basePath, addPath] = process.argv.slice(2) | ||
| 20 | + const { readFileSync, writeFileSync } = await import('node:fs') | ||
| 21 | + const base = readFileSync(basePath, 'utf8') | ||
| 22 | + const add = readFileSync(addPath, 'utf8') | ||
| 23 | + writeFileSync(basePath, mergeGitignore(base, add)) | ||
| 24 | +} |
lib/merge-gitignore.test.mjs
0 → 100644
| 1 | +// lib/merge-gitignore.test.mjs | ||
| 2 | +import { test } from 'node:test' | ||
| 3 | +import assert from 'node:assert/strict' | ||
| 4 | +import { mergeGitignore } from './merge-gitignore.mjs' | ||
| 5 | + | ||
| 6 | +test('union dedupes and preserves base order, appends new', () => { | ||
| 7 | + const base = 'node_modules\n.env\n' | ||
| 8 | + const add = '.env\ndist\n.DS_Store\n' | ||
| 9 | + assert.equal(mergeGitignore(base, add), 'node_modules\n.env\ndist\n.DS_Store\n') | ||
| 10 | +}) | ||
| 11 | + | ||
| 12 | +test('blank lines and comments in add are ignored for dedupe but kept once', () => { | ||
| 13 | + assert.equal(mergeGitignore('a\n', '\n# c\nb\n'), 'a\n# c\nb\n') | ||
| 14 | +}) |
lib/render.mjs
0 → 100644
| 1 | +// lib/render.mjs — literal-safe template render (replaces scope-lock/render.sh) | ||
| 2 | +// | ||
| 3 | +// 用法(CLI):node lib/render.mjs <tplPath> <varsJsonPath> <outPath> | ||
| 4 | +// 程序内:import { render } from './render.mjs' | ||
| 5 | +// | ||
| 6 | +// 核心要求:{{key}} 占位替换;值中含 $、{、}、}} 不被二次解释(字面插入); | ||
| 7 | +// 先剥离 HTML 注释(模板引导文本);缺少变量则 throw(不静默留空)。 | ||
| 8 | +export function render(template, vars) { | ||
| 9 | + const withoutComments = template.replace(/<!--[\s\S]*?-->/g, '') | ||
| 10 | + return withoutComments.replace(/\{\{(\w+)\}\}/g, (_, key) => { | ||
| 11 | + if (!(key in vars)) throw new Error(`render: missing var "${key}"`) | ||
| 12 | + return String(vars[key]) // 字面插入,不二次解释 $ 或 {} | ||
| 13 | + }) | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +// 入口判定用 pathToFileURL 规范化 process.argv[1],使其与 import.meta.url 编码一致 | ||
| 17 | +// (路径含空格/非 ASCII/Windows 反斜杠时,字面 `file://${argv[1]}` 比较会失配)。 | ||
| 18 | +const { pathToFileURL } = await import('node:url') | ||
| 19 | +if (import.meta.url === pathToFileURL(process.argv[1]).href) { | ||
| 20 | + const { readFileSync, writeFileSync } = await import('node:fs') | ||
| 21 | + const [tplPath, jsonPath, outPath] = process.argv.slice(2) | ||
| 22 | + const tpl = readFileSync(tplPath, 'utf8') | ||
| 23 | + const vars = JSON.parse(readFileSync(jsonPath, 'utf8')) | ||
| 24 | + writeFileSync(outPath, render(tpl, vars)) | ||
| 25 | +} |
lib/render.test.mjs
0 → 100644
| 1 | +import { test } from 'node:test' | ||
| 2 | +import assert from 'node:assert/strict' | ||
| 3 | +import { render } from './render.mjs' | ||
| 4 | + | ||
| 5 | +test('replaces placeholders', () => { | ||
| 6 | + assert.equal(render('Hi {{name}}', { name: 'Al' }), 'Hi Al') | ||
| 7 | +}) | ||
| 8 | +test('value containing $ and braces is inserted literally', () => { | ||
| 9 | + assert.equal(render('v={{x}}', { x: '${FOO} a{b}c }}' }), 'v=${FOO} a{b}c }}') | ||
| 10 | +}) | ||
| 11 | +test('strips HTML comments used as template guides', () => { | ||
| 12 | + assert.equal(render('a<!-- note -->b', {}), 'ab') | ||
| 13 | +}) | ||
| 14 | +test('missing key throws (no silent blank)', () => { | ||
| 15 | + assert.throws(() => render('{{missing}}', {}), /missing/) | ||
| 16 | +}) |
lib/validate-ddl.mjs
0 → 100644
| 1 | +// lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验 | ||
| 2 | +// 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 | ||
| 3 | +// | ||
| 4 | +// 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath> | ||
| 5 | +// 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。 | ||
| 6 | +// 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' | ||
| 7 | +// | ||
| 8 | +// 5 维 diff: | ||
| 9 | +// 1) 表集合(missingTables / extraTables) | ||
| 10 | +// 2) 列名(columnMismatches,side: 'docs'|'ddl') | ||
| 11 | +// 3) 列类型(typeMismatches) | ||
| 12 | +// 4) 索引(indexMismatches,side: 'docs'|'ddl') | ||
| 13 | +// 5) 外键(foreignKeyMismatches,side: 'docs'|'ddl') | ||
| 14 | +// | ||
| 15 | +// 数据结构(解析结果):Map<tableName, { | ||
| 16 | +// columns: Map<colName, type>, indexes: Set<string>, foreignKeys: Set<string> }> | ||
| 17 | + | ||
| 18 | +// ── 解析 docs/03 markdown 表定义 ───────────────────────────────── | ||
| 19 | +// 约定:每张表一节,节标题形如 ## `表名` 或 ## `表名` — 业务含义 | ||
| 20 | +// 节内的 markdown 表格首列是列名(可含反引号),次列是类型。 | ||
| 21 | +// 跳过表头行(列/字段/类型等标签)与分隔行(---)。 | ||
| 22 | +// 形如「## 一、全局约定」这类非反引号标题不视为表。 | ||
| 23 | +export function parseDocsTables(text) { | ||
| 24 | + const tables = new Map() | ||
| 25 | + const lines = String(text).split('\n') | ||
| 26 | + // 反引号包裹的表名:## `name` 或 ## `name` — purpose | ||
| 27 | + const headerRe = /^##\s+`([^`]+)`/ | ||
| 28 | + let current = null // { columns: Map } | ||
| 29 | + | ||
| 30 | + for (const raw of lines) { | ||
| 31 | + const line = raw.replace(/\r$/, '') | ||
| 32 | + const h2 = line.match(headerRe) | ||
| 33 | + if (h2) { | ||
| 34 | + current = { columns: new Map(), indexes: new Set(), foreignKeys: new Set() } | ||
| 35 | + tables.set(h2[1].trim(), current) | ||
| 36 | + continue | ||
| 37 | + } | ||
| 38 | + // 任何其它二级(或更高)非反引号标题 → 结束当前表块(如 ## 一、全局约定) | ||
| 39 | + if (/^##\s/.test(line) && !headerRe.test(line)) { | ||
| 40 | + current = null | ||
| 41 | + continue | ||
| 42 | + } | ||
| 43 | + if (!current) continue | ||
| 44 | + // markdown 表格行:以 | 开头 | ||
| 45 | + if (!/^\s*\|/.test(line)) continue | ||
| 46 | + const cells = splitMarkdownRow(line) | ||
| 47 | + if (cells.length < 2) continue | ||
| 48 | + const name = stripTicks(cells[0]) | ||
| 49 | + const type = stripTicks(cells[1]) | ||
| 50 | + // 跳过分隔行(---)、表头标签行、空名行 | ||
| 51 | + if (!name) continue | ||
| 52 | + if (isSeparatorCell(name)) continue | ||
| 53 | + if (isHeaderLabel(name)) continue | ||
| 54 | + current.columns.set(name, type) | ||
| 55 | + } | ||
| 56 | + return tables | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// ── 解析 CREATE TABLE DDL ──────────────────────────────────────── | ||
| 60 | +// 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。 | ||
| 61 | +export function parseDDL(text) { | ||
| 62 | + const tables = new Map() | ||
| 63 | + const src = String(text) | ||
| 64 | + // 抓取 CREATE TABLE <name> ( <body> ) ;name 可带反引号;body 到匹配的右括号 | ||
| 65 | + const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*\(/gi | ||
| 66 | + let m | ||
| 67 | + while ((m = createRe.exec(src)) !== null) { | ||
| 68 | + const tableName = m[1] | ||
| 69 | + const bodyStart = createRe.lastIndex - 1 // 指向 '(' | ||
| 70 | + const body = extractBalancedParens(src, bodyStart) | ||
| 71 | + if (body == null) continue | ||
| 72 | + const parsed = parseTableBody(body) | ||
| 73 | + tables.set(tableName, parsed) | ||
| 74 | + // 继续从 body 之后扫描 | ||
| 75 | + createRe.lastIndex = bodyStart + body.length + 2 | ||
| 76 | + } | ||
| 77 | + return tables | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +function parseTableBody(body) { | ||
| 81 | + const columns = new Map() | ||
| 82 | + const indexes = new Set() | ||
| 83 | + const foreignKeys = new Set() | ||
| 84 | + for (const itemRaw of splitTopLevelCommas(body)) { | ||
| 85 | + const item = itemRaw.trim() | ||
| 86 | + if (!item) continue | ||
| 87 | + const upper = item.toUpperCase() | ||
| 88 | + | ||
| 89 | + // 外键约束(可带前缀 CONSTRAINT <name>) | ||
| 90 | + if (/\bFOREIGN\s+KEY\b/i.test(item)) { | ||
| 91 | + const fk = item.match(/FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i) | ||
| 92 | + if (fk) { | ||
| 93 | + const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') | ||
| 94 | + const refTable = fk[2] | ||
| 95 | + const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') | ||
| 96 | + foreignKeys.add(`${fromCols}->${refTable}(${toCols})`) | ||
| 97 | + } else { | ||
| 98 | + foreignKeys.add(item) | ||
| 99 | + } | ||
| 100 | + continue | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + // PRIMARY KEY (...) | ||
| 104 | + if (/^PRIMARY\s+KEY/i.test(item)) { | ||
| 105 | + indexes.add('PRIMARY') | ||
| 106 | + continue | ||
| 107 | + } | ||
| 108 | + // UNIQUE [KEY|INDEX] <name> (...) / KEY <name> (...) / INDEX <name> (...) | ||
| 109 | + if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) { | ||
| 110 | + const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?/i) | ||
| 111 | + indexes.add(nameMatch ? nameMatch[1] : item) | ||
| 112 | + continue | ||
| 113 | + } | ||
| 114 | + // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 | ||
| 115 | + if (/^CONSTRAINT\b/i.test(upper)) { | ||
| 116 | + const cn = item.match(/^CONSTRAINT\s+`?([A-Za-z0-9_]+)`?/i) | ||
| 117 | + indexes.add(cn ? cn[1] : item) | ||
| 118 | + continue | ||
| 119 | + } | ||
| 120 | + // CHECK (...) | ||
| 121 | + if (/^CHECK\b/i.test(upper)) continue | ||
| 122 | + | ||
| 123 | + // 普通列:<name> <type> ... name 可带反引号;type 取到第一个属性关键字/逗号前 | ||
| 124 | + const col = item.match(/^`?([A-Za-z0-9_]+)`?\s+(.+)$/s) | ||
| 125 | + if (!col) continue | ||
| 126 | + const name = col[1] | ||
| 127 | + const type = extractType(col[2]) | ||
| 128 | + columns.set(name, type) | ||
| 129 | + } | ||
| 130 | + return { columns, indexes, foreignKeys } | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +// 从列定义剩余部分提取类型(含括号内长度),到下一个属性关键字前停止。 | ||
| 134 | +function extractType(rest) { | ||
| 135 | + const s = rest.trim() | ||
| 136 | + // 类型形如 varchar(100) / decimal(10,2) / int unsigned / bigint | ||
| 137 | + const m = s.match(/^([A-Za-z]+(?:\s+(?:unsigned|signed|zerofill))*)\s*(\([^)]*\))?/i) | ||
| 138 | + if (!m) return s.split(/\s+/)[0] | ||
| 139 | + let type = m[1].trim() | ||
| 140 | + // 仅保留基础类型词 + 括号;丢弃 unsigned/signed 这类修饰以贴近 docs/03 写法(docs 一般只写基础类型) | ||
| 141 | + const base = type.split(/\s+/)[0] | ||
| 142 | + return base + (m[2] ? m[2].replace(/\s+/g, '') : '') | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +// ── 5 维 diff ──────────────────────────────────────────────────── | ||
| 146 | +export function diffSchema(docsTables, ddlTables) { | ||
| 147 | + const diff = { | ||
| 148 | + missingTables: [], // docs 有、DDL 无 | ||
| 149 | + extraTables: [], // DDL 有、docs 无 | ||
| 150 | + columnMismatches: [], // { table, column, side: 'docs'|'ddl' } | ||
| 151 | + typeMismatches: [], // { table, column, docsType, ddlType } | ||
| 152 | + indexMismatches: [], // { table, index, side: 'docs'|'ddl' } | ||
| 153 | + foreignKeyMismatches: [],// { table, foreignKey, side: 'docs'|'ddl' } | ||
| 154 | + hasDiff: false, | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + const docNames = new Set(docsTables.keys()) | ||
| 158 | + const ddlNames = new Set(ddlTables.keys()) | ||
| 159 | + | ||
| 160 | + for (const t of docNames) if (!ddlNames.has(t)) diff.missingTables.push(t) | ||
| 161 | + for (const t of ddlNames) if (!docNames.has(t)) diff.extraTables.push(t) | ||
| 162 | + diff.missingTables.sort() | ||
| 163 | + diff.extraTables.sort() | ||
| 164 | + | ||
| 165 | + // 仅对共有表做列/类型/索引/外键比对 | ||
| 166 | + for (const t of [...docNames].filter(n => ddlNames.has(n)).sort()) { | ||
| 167 | + const d = docsTables.get(t) | ||
| 168 | + const s = ddlTables.get(t) | ||
| 169 | + | ||
| 170 | + // 维度 2/3:列名 + 列类型 | ||
| 171 | + for (const [col, dType] of d.columns) { | ||
| 172 | + if (!s.columns.has(col)) { | ||
| 173 | + diff.columnMismatches.push({ table: t, column: col, side: 'docs' }) | ||
| 174 | + } else { | ||
| 175 | + const sType = s.columns.get(col) | ||
| 176 | + if (!typesEqual(dType, sType)) { | ||
| 177 | + diff.typeMismatches.push({ table: t, column: col, docsType: dType, ddlType: sType }) | ||
| 178 | + } | ||
| 179 | + } | ||
| 180 | + } | ||
| 181 | + for (const col of s.columns.keys()) { | ||
| 182 | + if (!d.columns.has(col)) diff.columnMismatches.push({ table: t, column: col, side: 'ddl' }) | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + // 维度 4:索引 | ||
| 186 | + const dIdx = d.indexes || new Set() | ||
| 187 | + const sIdx = s.indexes || new Set() | ||
| 188 | + for (const ix of dIdx) if (!sIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }) | ||
| 189 | + for (const ix of sIdx) if (!dIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' }) | ||
| 190 | + | ||
| 191 | + // 维度 5:外键 | ||
| 192 | + const dFk = d.foreignKeys || new Set() | ||
| 193 | + const sFk = s.foreignKeys || new Set() | ||
| 194 | + for (const fk of dFk) if (!sFk.has(fk)) diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'docs' }) | ||
| 195 | + for (const fk of sFk) if (!dFk.has(fk)) diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'ddl' }) | ||
| 196 | + } | ||
| 197 | + | ||
| 198 | + diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 || | ||
| 199 | + diff.columnMismatches.length > 0 || diff.typeMismatches.length > 0 || | ||
| 200 | + diff.indexMismatches.length > 0 || diff.foreignKeyMismatches.length > 0 | ||
| 201 | + return diff | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +// ── 工具函数 ───────────────────────────────────────────────────── | ||
| 205 | +function stripTicks(s) { | ||
| 206 | + return String(s).replace(/`/g, '').trim() | ||
| 207 | +} | ||
| 208 | + | ||
| 209 | +function splitMarkdownRow(line) { | ||
| 210 | + // 去掉首尾管道再按 | 切分 | ||
| 211 | + let t = line.trim() | ||
| 212 | + if (t.startsWith('|')) t = t.slice(1) | ||
| 213 | + if (t.endsWith('|')) t = t.slice(0, -1) | ||
| 214 | + return t.split('|').map(c => c.trim()) | ||
| 215 | +} | ||
| 216 | + | ||
| 217 | +function isSeparatorCell(cell) { | ||
| 218 | + // 形如 --- / :--- / ---: / :---: | ||
| 219 | + return /^:?-{1,}:?$/.test(cell.trim()) | ||
| 220 | +} | ||
| 221 | + | ||
| 222 | +function isHeaderLabel(cell) { | ||
| 223 | + // 表头标签:列 / 字段 / 字段名 / 类型 / 列名(避免把表头行当列) | ||
| 224 | + return ['列', '字段', '字段名', '列名', '类型', 'name', 'type', 'column'].includes(cell.trim()) | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +// 提取从 openIdx(指向 '(')开始的平衡括号内部内容(不含最外层括号)。 | ||
| 228 | +function extractBalancedParens(src, openIdx) { | ||
| 229 | + if (src[openIdx] !== '(') return null | ||
| 230 | + let depth = 0 | ||
| 231 | + for (let i = openIdx; i < src.length; i++) { | ||
| 232 | + const ch = src[i] | ||
| 233 | + if (ch === '(') depth++ | ||
| 234 | + else if (ch === ')') { | ||
| 235 | + depth-- | ||
| 236 | + if (depth === 0) return src.slice(openIdx + 1, i) | ||
| 237 | + } | ||
| 238 | + } | ||
| 239 | + return null | ||
| 240 | +} | ||
| 241 | + | ||
| 242 | +// 在顶层(括号深度 0)按逗号切分 DDL body,保护 varchar(100) / decimal(10,2) 内的逗号。 | ||
| 243 | +function splitTopLevelCommas(body) { | ||
| 244 | + const out = [] | ||
| 245 | + let depth = 0 | ||
| 246 | + let buf = '' | ||
| 247 | + for (let i = 0; i < body.length; i++) { | ||
| 248 | + const ch = body[i] | ||
| 249 | + if (ch === '(') { depth++; buf += ch } | ||
| 250 | + else if (ch === ')') { depth--; buf += ch } | ||
| 251 | + else if (ch === ',' && depth === 0) { out.push(buf); buf = '' } | ||
| 252 | + else buf += ch | ||
| 253 | + } | ||
| 254 | + if (buf.trim()) out.push(buf) | ||
| 255 | + return out | ||
| 256 | +} | ||
| 257 | + | ||
| 258 | +// 类型相等比较:大小写不敏感、忽略空白。 | ||
| 259 | +function typesEqual(a, b) { | ||
| 260 | + const norm = (x) => String(x).toLowerCase().replace(/\s+/g, '') | ||
| 261 | + return norm(a) === norm(b) | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | +// ── 报告(供 CLI 与外部复用)──────────────────────────────────── | ||
| 265 | +export function formatDiff(diff) { | ||
| 266 | + const out = [] | ||
| 267 | + if (diff.missingTables.length) { | ||
| 268 | + out.push('=== 维度1 表集合:docs/03 有但 DDL 无 ===') | ||
| 269 | + for (const t of diff.missingTables) out.push(` - ${t}`) | ||
| 270 | + } | ||
| 271 | + if (diff.extraTables.length) { | ||
| 272 | + out.push('=== 维度1 表集合:DDL 有但 docs/03 无 ===') | ||
| 273 | + for (const t of diff.extraTables) out.push(` - ${t}`) | ||
| 274 | + } | ||
| 275 | + if (diff.columnMismatches.length) { | ||
| 276 | + out.push('=== 维度2 列名 ===') | ||
| 277 | + for (const m of diff.columnMismatches) { | ||
| 278 | + out.push(` - ${m.table}.${m.column} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) | ||
| 279 | + } | ||
| 280 | + } | ||
| 281 | + if (diff.typeMismatches.length) { | ||
| 282 | + out.push('=== 维度3 列类型 ===') | ||
| 283 | + for (const m of diff.typeMismatches) { | ||
| 284 | + out.push(` - ${m.table}.${m.column}: docs/03=${m.docsType} ≠ DDL=${m.ddlType}`) | ||
| 285 | + } | ||
| 286 | + } | ||
| 287 | + if (diff.indexMismatches.length) { | ||
| 288 | + out.push('=== 维度4 索引 ===') | ||
| 289 | + for (const m of diff.indexMismatches) { | ||
| 290 | + out.push(` - ${m.table} 索引 ${m.index} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) | ||
| 291 | + } | ||
| 292 | + } | ||
| 293 | + if (diff.foreignKeyMismatches.length) { | ||
| 294 | + out.push('=== 维度5 外键 ===') | ||
| 295 | + for (const m of diff.foreignKeyMismatches) { | ||
| 296 | + out.push(` - ${m.table} 外键 ${m.foreignKey} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) | ||
| 297 | + } | ||
| 298 | + } | ||
| 299 | + return out.join('\n') | ||
| 300 | +} | ||
| 301 | + | ||
| 302 | +// ── CLI 入口 ───────────────────────────────────────────────────── | ||
| 303 | +// 用 pathToFileURL 做比较:路径含空格/非 ASCII 时 import.meta.url 是百分号编码, | ||
| 304 | +// 而 process.argv[1] 是原始路径,直接 `file://${argv1}` 拼接永远不相等。 | ||
| 305 | +const { pathToFileURL } = await import('node:url') | ||
| 306 | +const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href | ||
| 307 | +if (isCliEntry) { | ||
| 308 | + const { readFileSync, existsSync } = await import('node:fs') | ||
| 309 | + const [docsPath, ddlPath] = process.argv.slice(2) | ||
| 310 | + if (!docsPath || !ddlPath) { | ||
| 311 | + console.error('用法: node lib/validate-ddl.mjs <docs/03 path> <V1.sql path>') | ||
| 312 | + process.exit(2) | ||
| 313 | + } | ||
| 314 | + if (!existsSync(docsPath)) { console.error(`validate-ddl: docs 不存在: ${docsPath}`); process.exit(2) } | ||
| 315 | + if (!existsSync(ddlPath)) { console.error(`validate-ddl: DDL 不存在: ${ddlPath}`); process.exit(2) } | ||
| 316 | + | ||
| 317 | + const docsTables = parseDocsTables(readFileSync(docsPath, 'utf8')) | ||
| 318 | + const ddlTables = parseDDL(readFileSync(ddlPath, 'utf8')) | ||
| 319 | + const diff = diffSchema(docsTables, ddlTables) | ||
| 320 | + | ||
| 321 | + if (diff.hasDiff) { | ||
| 322 | + console.error(formatDiff(diff)) | ||
| 323 | + process.exit(1) | ||
| 324 | + } | ||
| 325 | + console.log('validate-ddl: ✓ docs/03 与 DDL 在 5 维(表/列/类型/索引/外键)一致') | ||
| 326 | + process.exit(0) | ||
| 327 | +} |
lib/validate-ddl.test.mjs
0 → 100644
| 1 | +// lib/validate-ddl.test.mjs — 单测:docs/03 表格 ↔ DDL 5 维 diff | ||
| 2 | +import { test } from 'node:test' | ||
| 3 | +import assert from 'node:assert/strict' | ||
| 4 | +import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' | ||
| 5 | + | ||
| 6 | +const DOCS = `## \`t_user\`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n| sName | varchar(50) |\n` | ||
| 7 | +const DDL = `CREATE TABLE t_user ( iId bigint PRIMARY KEY, sName varchar(50) );` | ||
| 8 | + | ||
| 9 | +test('matching schema yields empty diff', () => { | ||
| 10 | + const d = diffSchema(parseDocsTables(DOCS), parseDDL(DDL)) | ||
| 11 | + assert.deepEqual(d.missingTables, []) | ||
| 12 | + assert.deepEqual(d.columnMismatches, []) | ||
| 13 | +}) | ||
| 14 | + | ||
| 15 | +test('missing column is reported', () => { | ||
| 16 | + const ddl2 = `CREATE TABLE t_user ( iId bigint );` | ||
| 17 | + const d = diffSchema(parseDocsTables(DOCS), parseDDL(ddl2)) | ||
| 18 | + assert.ok(d.columnMismatches.some(m => m.table === 't_user' && m.column === 'sName')) | ||
| 19 | +}) | ||
| 20 | + | ||
| 21 | +// ── parseDocsTables ────────────────────────────────────────────── | ||
| 22 | +test('parseDocsTables: 列名/类型 from markdown rows under ## `table` header', () => { | ||
| 23 | + const tables = parseDocsTables(DOCS) | ||
| 24 | + assert.equal(tables.size, 1) | ||
| 25 | + const t = tables.get('t_user') | ||
| 26 | + assert.ok(t, 'table t_user parsed') | ||
| 27 | + assert.deepEqual([...t.columns.keys()], ['iId', 'sName']) | ||
| 28 | + assert.equal(t.columns.get('iId'), 'bigint') | ||
| 29 | + assert.equal(t.columns.get('sName'), 'varchar(50)') | ||
| 30 | +}) | ||
| 31 | + | ||
| 32 | +test('parseDocsTables: real docs/03 format — ## `t` — purpose + ### 字段 + backtick cols', () => { | ||
| 33 | + const docs = [ | ||
| 34 | + '## `t_order` — 订单主表', | ||
| 35 | + '', | ||
| 36 | + '### 字段', | ||
| 37 | + '', | ||
| 38 | + '| 字段 | 类型 | Nullable | 默认 | 业务含义 |', | ||
| 39 | + '|---|---|---|---|---|', | ||
| 40 | + '| `iIncrement` | int | 否 | 自增 | 主键 |', | ||
| 41 | + '| `sId` | varchar(100) | 是 | uuid | 业务ID |', | ||
| 42 | + '', | ||
| 43 | + '### 索引', | ||
| 44 | + '- `pk` (PRIMARY): iIncrement', | ||
| 45 | + '', | ||
| 46 | + '## `t_item` — 明细', | ||
| 47 | + '', | ||
| 48 | + '| 列 | 类型 |', | ||
| 49 | + '|---|---|', | ||
| 50 | + '| iId | bigint |', | ||
| 51 | + '', | ||
| 52 | + ].join('\n') | ||
| 53 | + const tables = parseDocsTables(docs) | ||
| 54 | + assert.deepEqual([...tables.keys()].sort(), ['t_item', 't_order']) | ||
| 55 | + const order = tables.get('t_order') | ||
| 56 | + assert.deepEqual([...order.columns.keys()], ['iIncrement', 'sId']) | ||
| 57 | + assert.equal(order.columns.get('iIncrement'), 'int') | ||
| 58 | + assert.equal(order.columns.get('sId'), 'varchar(100)') | ||
| 59 | + // header separator row and header label row must be skipped | ||
| 60 | + assert.equal(order.columns.has('字段'), false) | ||
| 61 | + assert.equal(order.columns.has('---'), false) | ||
| 62 | +}) | ||
| 63 | + | ||
| 64 | +test('parseDocsTables: top-level ## headers like "## 一、全局约定" are NOT tables', () => { | ||
| 65 | + const docs = [ | ||
| 66 | + '## 一、全局约定(人工填)', | ||
| 67 | + '- 数据库名: erp', | ||
| 68 | + '', | ||
| 69 | + '## `t_user`', | ||
| 70 | + '| 列 | 类型 |', | ||
| 71 | + '|---|---|', | ||
| 72 | + '| iId | bigint |', | ||
| 73 | + '', | ||
| 74 | + ].join('\n') | ||
| 75 | + const tables = parseDocsTables(docs) | ||
| 76 | + assert.deepEqual([...tables.keys()], ['t_user']) | ||
| 77 | +}) | ||
| 78 | + | ||
| 79 | +// ── parseDDL ───────────────────────────────────────────────────── | ||
| 80 | +test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => { | ||
| 81 | + const ddl = [ | ||
| 82 | + 'CREATE TABLE `t_order` (', | ||
| 83 | + ' `iIncrement` int NOT NULL AUTO_INCREMENT,', | ||
| 84 | + ' `sId` varchar(100) DEFAULT NULL,', | ||
| 85 | + ' `sUserId` varchar(100) DEFAULT NULL,', | ||
| 86 | + ' PRIMARY KEY (`iIncrement`),', | ||
| 87 | + ' UNIQUE KEY `uk_sid` (`sId`),', | ||
| 88 | + ' KEY `idx_user` (`sUserId`),', | ||
| 89 | + ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`)', | ||
| 90 | + ') ENGINE=InnoDB;', | ||
| 91 | + ].join('\n') | ||
| 92 | + const tables = parseDDL(ddl) | ||
| 93 | + const t = tables.get('t_order') | ||
| 94 | + assert.ok(t) | ||
| 95 | + assert.deepEqual([...t.columns.keys()], ['iIncrement', 'sId', 'sUserId']) | ||
| 96 | + assert.equal(t.columns.get('sId'), 'varchar(100)') | ||
| 97 | + // index keys (named) collected; PRIMARY collected too | ||
| 98 | + assert.ok(t.indexes.has('uk_sid')) | ||
| 99 | + assert.ok(t.indexes.has('idx_user')) | ||
| 100 | + assert.ok([...t.indexes].some(i => i.toUpperCase().includes('PRIMARY'))) | ||
| 101 | + // foreign key collected | ||
| 102 | + assert.ok([...t.foreignKeys].some(fk => fk.includes('sUserId') && fk.includes('t_user'))) | ||
| 103 | +}) | ||
| 104 | + | ||
| 105 | +test('parseDDL: unquoted identifiers and inline PRIMARY KEY', () => { | ||
| 106 | + const tables = parseDDL(DDL) | ||
| 107 | + const t = tables.get('t_user') | ||
| 108 | + assert.ok(t) | ||
| 109 | + assert.deepEqual([...t.columns.keys()], ['iId', 'sName']) | ||
| 110 | + assert.equal(t.columns.get('iId'), 'bigint') | ||
| 111 | +}) | ||
| 112 | + | ||
| 113 | +test('parseDDL: multiple tables', () => { | ||
| 114 | + const ddl = 'CREATE TABLE a (x int); CREATE TABLE b (y bigint);' | ||
| 115 | + const tables = parseDDL(ddl) | ||
| 116 | + assert.deepEqual([...tables.keys()].sort(), ['a', 'b']) | ||
| 117 | +}) | ||
| 118 | + | ||
| 119 | +// ── diffSchema 5 dimensions ────────────────────────────────────── | ||
| 120 | +test('diffSchema: missing table (in docs, not in DDL) reported', () => { | ||
| 121 | + const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') | ||
| 122 | + const ddl = parseDDL('CREATE TABLE other ( z int );') | ||
| 123 | + const d = diffSchema(docs, ddl) | ||
| 124 | + assert.ok(d.missingTables.includes('t_user')) | ||
| 125 | + assert.ok(d.extraTables.includes('other')) | ||
| 126 | +}) | ||
| 127 | + | ||
| 128 | +test('diffSchema: type mismatch reported', () => { | ||
| 129 | + const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') | ||
| 130 | + const ddl = parseDDL('CREATE TABLE t_user ( iId int );') | ||
| 131 | + const d = diffSchema(docs, ddl) | ||
| 132 | + assert.ok(d.typeMismatches.some(m => m.table === 't_user' && m.column === 'iId' && m.docsType === 'bigint' && m.ddlType === 'int')) | ||
| 133 | +}) | ||
| 134 | + | ||
| 135 | +test('diffSchema: extra column in DDL reported as columnMismatch', () => { | ||
| 136 | + const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') | ||
| 137 | + const ddl = parseDDL('CREATE TABLE t_user ( iId bigint, extra varchar(10) );') | ||
| 138 | + const d = diffSchema(docs, ddl) | ||
| 139 | + assert.ok(d.columnMismatches.some(m => m.table === 't_user' && m.column === 'extra' && m.side === 'ddl')) | ||
| 140 | +}) | ||
| 141 | + | ||
| 142 | +test('diffSchema: index dimension diff reported', () => { | ||
| 143 | + const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c']), foreignKeys: new Set() }]]) | ||
| 144 | + const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes | ||
| 145 | + const d = diffSchema(docs, ddl) | ||
| 146 | + assert.ok(d.indexMismatches.some(m => m.table === 't' && m.index === 'idx_c')) | ||
| 147 | +}) | ||
| 148 | + | ||
| 149 | +test('diffSchema: foreign-key dimension diff reported', () => { | ||
| 150 | + const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(), foreignKeys: new Set(['c->other']) }]]) | ||
| 151 | + const ddl = parseDDL('CREATE TABLE t ( c int );') // no FKs | ||
| 152 | + const d = diffSchema(docs, ddl) | ||
| 153 | + assert.ok(d.foreignKeyMismatches.some(m => m.table === 't' && m.foreignKey === 'c->other')) | ||
| 154 | +}) | ||
| 155 | + | ||
| 156 | +test('diffSchema: hasDiff is false when everything matches, true otherwise', () => { | ||
| 157 | + const ok = diffSchema(parseDocsTables(DOCS), parseDDL(DDL)) | ||
| 158 | + assert.equal(ok.hasDiff, false) | ||
| 159 | + const bad = diffSchema(parseDocsTables(DOCS), parseDDL('CREATE TABLE t_user ( iId bigint );')) | ||
| 160 | + assert.equal(bad.hasDiff, true) | ||
| 161 | +}) |
skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs
0 → 100644
| 1 | +#!/usr/bin/env node | ||
| 2 | +// scripts/setup-test-db.mjs — 数据库重置脚本:drop + create 空库。 | ||
| 3 | +// schema apply 由 Flyway 在 Spring Boot 启动时自动处理(见 docs/04 技术栈 + sql/migrations/V*.sql)。 | ||
| 4 | +// seed 数据由测试框架负责(Spring @Sql / Flyway R__seed.sql / data.sql)。 | ||
| 5 | +// | ||
| 6 | +// 使用场景: | ||
| 7 | +// - scripts/test.mjs 开头:清空库,让 Spring 启动时 Flyway 从 V1 开始重放所有 migration | ||
| 8 | +// - scripts/test.mjs 结尾:清空库,避免测试遗留污染下次运行 | ||
| 9 | +// - 手动调试时:reset 到零状态 | ||
| 10 | +// | ||
| 11 | +// 跨平台:用纯 JS 解析 .env.local(dotenv 风格,逐行 KEY=VALUE),**绝不** shell-source, | ||
| 12 | +// 因此 mac / Windows 原生 node 均可运行,且消除 shell 注入 / 变量展开隐患。 | ||
| 13 | +// DROP/CREATE 通过 `mysql` 客户端以 argv 数组方式执行(不经 shell),密码不进命令行解析层。 | ||
| 14 | +// | ||
| 15 | +// 防护:本脚本只允许在本地 host + 测试库名上执行;非预期目标会被拒绝, | ||
| 16 | +// 避免 .env.local 误指向 staging/prod 时触发不可逆 DROP。 | ||
| 17 | + | ||
| 18 | +import { spawnSync } from 'node:child_process' | ||
| 19 | +import { existsSync, readFileSync } from 'node:fs' | ||
| 20 | +import { dirname, join } from 'node:path' | ||
| 21 | +import { fileURLToPath } from 'node:url' | ||
| 22 | + | ||
| 23 | +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) | ||
| 24 | +const ENV_FILE = join(SCRIPT_DIR, '..', '.env.local') | ||
| 25 | + | ||
| 26 | +// dotenv 风格解析:逐行 KEY=VALUE,跳过空行与 # 注释,去除两侧空白, | ||
| 27 | +// 可选地剥离一层成对单/双引号。**不做**变量展开,特殊字符按字面保留。 | ||
| 28 | +function parseEnv(text) { | ||
| 29 | + const env = {} | ||
| 30 | + for (const rawLine of text.split(/\r?\n/)) { | ||
| 31 | + const line = rawLine.trim() | ||
| 32 | + if (line === '' || line.startsWith('#')) continue | ||
| 33 | + const eq = line.indexOf('=') | ||
| 34 | + if (eq === -1) continue | ||
| 35 | + const key = line.slice(0, eq).trim() | ||
| 36 | + if (!key) continue | ||
| 37 | + let value = line.slice(eq + 1).trim() | ||
| 38 | + if ( | ||
| 39 | + value.length >= 2 && | ||
| 40 | + ((value.startsWith("'") && value.endsWith("'")) || | ||
| 41 | + (value.startsWith('"') && value.endsWith('"'))) | ||
| 42 | + ) { | ||
| 43 | + value = value.slice(1, -1) | ||
| 44 | + } | ||
| 45 | + env[key] = value | ||
| 46 | + } | ||
| 47 | + return env | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +if (!existsSync(ENV_FILE)) { | ||
| 51 | + console.error(`[setup-test-db] .env.local 不存在(${ENV_FILE})`) | ||
| 52 | + process.exit(1) | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +const env = parseEnv(readFileSync(ENV_FILE, 'utf8')) | ||
| 56 | + | ||
| 57 | +const DB_HOST = env.DB_HOST ?? '' | ||
| 58 | +const DB_PORT = env.DB_PORT ?? '3306' | ||
| 59 | +const DB_USER = env.DB_USER ?? '' | ||
| 60 | +const DB_PASSWORD = env.DB_PASSWORD ?? '' | ||
| 61 | +const DB_SCHEMA = env.DB_SCHEMA ?? '' | ||
| 62 | +const TEST_DB_ALLOW_REMOTE = env.TEST_DB_ALLOW_REMOTE ?? process.env.TEST_DB_ALLOW_REMOTE ?? '0' | ||
| 63 | +const TEST_DB_ALLOW_PROD_NAME = | ||
| 64 | + env.TEST_DB_ALLOW_PROD_NAME ?? process.env.TEST_DB_ALLOW_PROD_NAME ?? '0' | ||
| 65 | + | ||
| 66 | +// 防护 1:默认只允许本地 host(localhost / 127.0.0.1 / ::1)。 | ||
| 67 | +// 额外允许的远程 host 在 .env.local 的 TEST_DB_ALLOWED_HOSTS 中(空格或逗号分隔)。 | ||
| 68 | +const extraHosts = (env.TEST_DB_ALLOWED_HOSTS ?? '') | ||
| 69 | + .split(/[\s,]+/) | ||
| 70 | + .filter(Boolean) | ||
| 71 | +const allowedHosts = ['localhost', '127.0.0.1', '::1', ...extraHosts] | ||
| 72 | +if (!allowedHosts.includes(DB_HOST)) { | ||
| 73 | + console.error(`[setup-test-db] 拒绝在非白名单 host (${DB_HOST}) 上执行 DROP DATABASE`) | ||
| 74 | + console.error(` 当前白名单:${allowedHosts.join(' ')}`) | ||
| 75 | + console.error(' 加入 host:在 .env.local 追加 TEST_DB_ALLOWED_HOSTS="<host1> <host2>"') | ||
| 76 | + console.error(' 一次性绕过:在 .env.local 设 TEST_DB_ALLOW_REMOTE=1') | ||
| 77 | + if (TEST_DB_ALLOW_REMOTE !== '1') process.exit(1) | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +// 防护 2:schema 名需像测试/开发库(含 test / _dev / _local / _ci),否则要求显式确认。 | ||
| 81 | +const schemaLooksLikeTest = | ||
| 82 | + /test/.test(DB_SCHEMA) || /_dev$/.test(DB_SCHEMA) || /_local$/.test(DB_SCHEMA) || /_ci$/.test(DB_SCHEMA) | ||
| 83 | +if (!schemaLooksLikeTest) { | ||
| 84 | + console.error( | ||
| 85 | + `[setup-test-db] schema '${DB_SCHEMA}' 不像测试库(期望命名含 test / _dev / _local / _ci)` | ||
| 86 | + ) | ||
| 87 | + console.error(' 如确为期望行为,请显式声明:在 .env.local 设 TEST_DB_ALLOW_PROD_NAME=1') | ||
| 88 | + if (TEST_DB_ALLOW_PROD_NAME !== '1') process.exit(1) | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +// 防护 3:显式 banner,让人看见自己在 drop 什么;远程 host 额外提示白名单内容。 | ||
| 92 | +console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`) | ||
| 93 | +if (!['localhost', '127.0.0.1', '::1'].includes(DB_HOST)) { | ||
| 94 | + console.log( | ||
| 95 | + '[setup-test-db] 目标是 **远程** host(已在 TEST_DB_ALLOWED_HOSTS 白名单中,每次 test.mjs 都会 DROP)' | ||
| 96 | + ) | ||
| 97 | + console.log(`[setup-test-db] 当前白名单: ${allowedHosts.join(' ')}`) | ||
| 98 | + console.log( | ||
| 99 | + '[setup-test-db] 若不希望每次自动 DROP,从 .env.local 的 TEST_DB_ALLOWED_HOSTS 删掉此 host' | ||
| 100 | + ) | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +const sql = | ||
| 104 | + `DROP DATABASE IF EXISTS \`${DB_SCHEMA}\`; ` + | ||
| 105 | + `CREATE DATABASE \`${DB_SCHEMA}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;` | ||
| 106 | + | ||
| 107 | +// 以 argv 数组调用 mysql(不经 shell):密码不进 shell 解析,跨平台一致。 | ||
| 108 | +const mysqlArgs = [ | ||
| 109 | + `-h${DB_HOST}`, | ||
| 110 | + `-P${DB_PORT}`, | ||
| 111 | + `-u${DB_USER}`, | ||
| 112 | + `-p${DB_PASSWORD}`, | ||
| 113 | + '-e', | ||
| 114 | + sql, | ||
| 115 | +] | ||
| 116 | +const res = spawnSync('mysql', mysqlArgs, { stdio: 'inherit' }) | ||
| 117 | +if (res.error) { | ||
| 118 | + console.error(`[setup-test-db] FATAL: 无法执行 mysql(请确认其在 PATH 中): ${res.error.message}`) | ||
| 119 | + process.exit(1) | ||
| 120 | +} | ||
| 121 | +if (res.status !== 0) { | ||
| 122 | + console.error(`[setup-test-db] FAIL: mysql exit=${res.status}`) | ||
| 123 | + process.exit(res.status === null ? 1 : res.status) | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +console.log('[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts') |
skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.sh deleted
| 1 | -#!/usr/bin/env bash | ||
| 2 | -# scripts/setup-test-db.sh — 数据库重置脚本:drop + create 空库。 | ||
| 3 | -# schema apply 由 Flyway 在 Spring Boot 启动时自动处理(见 docs/04 技术栈 + sql/migrations/V*.sql)。 | ||
| 4 | -# seed 数据由测试框架负责(Spring @Sql / Flyway R__seed.sql / data.sql)。 | ||
| 5 | -# | ||
| 6 | -# 使用场景: | ||
| 7 | -# - scripts/test.sh 开头:清空库,让 Spring 启动时 Flyway 从 V1 开始重放所有 migration | ||
| 8 | -# - scripts/test.sh 结尾:清空库,避免测试遗留污染下次运行 | ||
| 9 | -# - 手动调试时:reset 到零状态 | ||
| 10 | -# | ||
| 11 | -# 防护:本脚本只允许在本地 host + 测试库名上执行;非预期目标会被拒绝, | ||
| 12 | -# 避免 .env.local 误指向 staging/prod 时触发不可逆 DROP。 | ||
| 13 | - | ||
| 14 | -set -euo pipefail | ||
| 15 | - | ||
| 16 | -ENV_FILE="$(dirname "$0")/../.env.local" | ||
| 17 | -[ -f "$ENV_FILE" ] || { echo "[setup-test-db] ⚠️ .env.local 不存在($ENV_FILE)" >&2; exit 1; } | ||
| 18 | - | ||
| 19 | -# 用 set -a 加载,让 KEY=VALUE 导出为环境变量;密码中含特殊字符时 .env.local 请用单引号包裹 | ||
| 20 | -set -a; . "$ENV_FILE"; set +a | ||
| 21 | - | ||
| 22 | -# 防护 1:默认只允许本地 host(localhost / 127.0.0.1 / ::1)。 | ||
| 23 | -# 若要为本项目额外允许某些远程 host(如公司测试 MySQL),在 .env.local 里设: | ||
| 24 | -# TEST_DB_ALLOWED_HOSTS="118.178.19.35 test-mysql.internal" # 空格或逗号分隔 | ||
| 25 | -# 被列入者可直接 DROP CREATE,不再需要 TEST_DB_ALLOW_REMOTE=1。 | ||
| 26 | -ALLOWED_HOSTS="localhost 127.0.0.1 ::1 ${TEST_DB_ALLOWED_HOSTS//,/ }" | ||
| 27 | -host_allowed=0 | ||
| 28 | -for h in $ALLOWED_HOSTS; do | ||
| 29 | - [ "${DB_HOST:-}" = "$h" ] && { host_allowed=1; break; } | ||
| 30 | -done | ||
| 31 | -if [ "$host_allowed" -ne 1 ]; then | ||
| 32 | - echo "[setup-test-db] ⚠️ 拒绝在非白名单 host (${DB_HOST}) 上执行 DROP DATABASE" >&2 | ||
| 33 | - echo " 当前白名单:${ALLOWED_HOSTS}" >&2 | ||
| 34 | - echo " 加入 host:在 .env.local 追加 TEST_DB_ALLOWED_HOSTS=\"<host1> <host2>\"" >&2 | ||
| 35 | - echo " 一次性绕过:TEST_DB_ALLOW_REMOTE=1 $0" >&2 | ||
| 36 | - [ "${TEST_DB_ALLOW_REMOTE:-0}" = "1" ] || exit 1 | ||
| 37 | -fi | ||
| 38 | - | ||
| 39 | -# 防护 2:schema 名需像测试/开发库(含 test / _dev / _local),否则要求显式确认 | ||
| 40 | -case "${DB_SCHEMA:-}" in | ||
| 41 | - *test*|*_dev|*_local|*_ci) | ||
| 42 | - ;; | ||
| 43 | - *) | ||
| 44 | - echo "[setup-test-db] ⚠️ schema '${DB_SCHEMA}' 不像测试库(期望命名含 test / _dev / _local / _ci)" >&2 | ||
| 45 | - echo " 如确为期望行为,请显式声明:TEST_DB_ALLOW_PROD_NAME=1 $0" >&2 | ||
| 46 | - [ "${TEST_DB_ALLOW_PROD_NAME:-0}" = "1" ] || exit 1 | ||
| 47 | - ;; | ||
| 48 | -esac | ||
| 49 | - | ||
| 50 | -# 防护 3:显式 banner,让人看见自己在 drop 什么;远程 host 额外提示白名单内容 | ||
| 51 | -echo "[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}" | ||
| 52 | -case "${DB_HOST:-}" in | ||
| 53 | - localhost|127.0.0.1|::1) ;; | ||
| 54 | - *) | ||
| 55 | - echo "[setup-test-db] ⚠️ 目标是 **远程** host(已在 TEST_DB_ALLOWED_HOSTS 白名单中,每次 test.sh 都会 DROP)" | ||
| 56 | - echo "[setup-test-db] 当前白名单: ${ALLOWED_HOSTS}" | ||
| 57 | - echo "[setup-test-db] 若不希望每次自动 DROP,从 .env.local 的 TEST_DB_ALLOWED_HOSTS 删掉此 host" | ||
| 58 | - ;; | ||
| 59 | -esac | ||
| 60 | - | ||
| 61 | -MYSQL_CMD="mysql -h${DB_HOST} -P${DB_PORT} -u${DB_USER} -p${DB_PASSWORD}" | ||
| 62 | - | ||
| 63 | -$MYSQL_CMD -e "DROP DATABASE IF EXISTS \`${DB_SCHEMA}\`; CREATE DATABASE \`${DB_SCHEMA}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" | ||
| 64 | - | ||
| 65 | -echo "[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts" |
skills/plan/skeleton-gen/templates/scripts-test-template.mjs
0 → 100644
| 1 | +#!/usr/bin/env node | ||
| 2 | +// scripts/test.mjs —— 合并到默认分支(main / master)前的测试闸门。 | ||
| 3 | +// 顺序:detect → setup-db → build → lint → unit+integration → e2e → reset-db | ||
| 4 | +// 由 coding.mjs 的 test-gate stage(通过子会话)调用。 | ||
| 5 | +// | ||
| 6 | +// 跨平台:所有命令经 child_process.spawnSync(cmd, { shell:true }) 执行, | ||
| 7 | +// 在 Windows 走 cmd.exe,在 *nix 走 /bin/sh,无需 WSL / Git-Bash。 | ||
| 8 | +// 命令字符串来自 docs/04 §零(构建/lint/单测/e2e)——由 skeleton-gen 在 Plan 期填充。 | ||
| 9 | + | ||
| 10 | +import { spawnSync } from 'node:child_process' | ||
| 11 | +import { existsSync } from 'node:fs' | ||
| 12 | +import { dirname, join } from 'node:path' | ||
| 13 | +import { fileURLToPath } from 'node:url' | ||
| 14 | + | ||
| 15 | +const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') | ||
| 16 | + | ||
| 17 | +// 在指定子目录下跑一条 shell 命令;非零退出码即终止整个闸门并透传该码。 | ||
| 18 | +function run(label, command, cwd = PROJECT_ROOT) { | ||
| 19 | + console.log(`[test.mjs] ${label}: ${command}`) | ||
| 20 | + const res = spawnSync(command, { cwd, shell: true, stdio: 'inherit' }) | ||
| 21 | + if (res.error) { | ||
| 22 | + console.error(`[test.mjs] FATAL: 无法执行 (${label}): ${res.error.message}`) | ||
| 23 | + process.exit(1) | ||
| 24 | + } | ||
| 25 | + if (res.status !== 0) { | ||
| 26 | + console.error(`[test.mjs] FAIL (${label}) exit=${res.status}`) | ||
| 27 | + process.exit(res.status === null ? 1 : res.status) | ||
| 28 | + } | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +// Stack detection (runtime, mode-agnostic) | ||
| 32 | +const hasBackend = existsSync(join(PROJECT_ROOT, 'backend')) | ||
| 33 | +const hasFrontend = existsSync(join(PROJECT_ROOT, 'frontend')) | ||
| 34 | +if (!hasBackend && !hasFrontend) { | ||
| 35 | + console.error('[test.mjs] FATAL: neither backend/ nor frontend/ exists') | ||
| 36 | + process.exit(1) | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +const backendDir = join(PROJECT_ROOT, 'backend') | ||
| 40 | +const frontendDir = join(PROJECT_ROOT, 'frontend') | ||
| 41 | + | ||
| 42 | +console.log('[test.mjs] 1/6 setup test db') | ||
| 43 | +run('setup-test-db', `node ${JSON.stringify(join('scripts', 'setup-test-db.mjs'))}`) | ||
| 44 | + | ||
| 45 | +console.log('[test.mjs] 2/6 build') | ||
| 46 | +if (hasBackend) run('backend build', '{{backend_build}}', backendDir) | ||
| 47 | +else console.log('[test.mjs] skip backend build') | ||
| 48 | +if (hasFrontend) run('frontend build', '{{frontend_build}}', frontendDir) | ||
| 49 | +else console.log('[test.mjs] skip frontend build') | ||
| 50 | + | ||
| 51 | +console.log('[test.mjs] 3/6 lint') | ||
| 52 | +if (hasBackend) run('backend lint', '{{backend_lint}}', backendDir) | ||
| 53 | +else console.log('[test.mjs] skip backend lint') | ||
| 54 | +if (hasFrontend) run('frontend lint', '{{frontend_lint}}', frontendDir) | ||
| 55 | +else console.log('[test.mjs] skip frontend lint') | ||
| 56 | + | ||
| 57 | +console.log('[test.mjs] 4/6 unit + integration') | ||
| 58 | +if (hasBackend) run('backend test', '{{backend_test}}', backendDir) | ||
| 59 | +else console.log('[test.mjs] skip backend test') | ||
| 60 | +if (hasFrontend) run('frontend test', '{{frontend_test}}', frontendDir) | ||
| 61 | +else console.log('[test.mjs] skip frontend test') | ||
| 62 | + | ||
| 63 | +console.log('[test.mjs] 5/6 E2E') | ||
| 64 | +run('e2e', '{{e2e_cmd}}') | ||
| 65 | + | ||
| 66 | +console.log('[test.mjs] 6/6 reset test db') | ||
| 67 | +run('reset-test-db', `node ${JSON.stringify(join('scripts', 'setup-test-db.mjs'))}`) | ||
| 68 | + | ||
| 69 | +console.log('[test.mjs] GREEN') |
skills/plan/skeleton-gen/templates/scripts-test-template.sh deleted
| 1 | -#!/usr/bin/env bash | ||
| 2 | -# scripts/test.sh —— 合并到默认分支(main / master)前的测试闸门。 | ||
| 3 | -# 顺序:detect → setup-db → build → lint → unit+integration → e2e → reset-db | ||
| 4 | -# 由 test-gate skill(通过子会话)调用。 | ||
| 5 | - | ||
| 6 | -set -euo pipefail | ||
| 7 | - | ||
| 8 | -PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" | ||
| 9 | -cd "$PROJECT_ROOT" | ||
| 10 | - | ||
| 11 | -# Stack detection (runtime, mode-agnostic) | ||
| 12 | -HAS_BACKEND=0; [ -d backend ] && HAS_BACKEND=1 | ||
| 13 | -HAS_FRONTEND=0; [ -d frontend ] && HAS_FRONTEND=1 | ||
| 14 | -if [ $HAS_BACKEND -eq 0 ] && [ $HAS_FRONTEND -eq 0 ]; then | ||
| 15 | - echo "[test.sh] FATAL: neither backend/ nor frontend/ exists" >&2 | ||
| 16 | - exit 1 | ||
| 17 | -fi | ||
| 18 | - | ||
| 19 | -echo "[test.sh] 1/6 setup test db" | ||
| 20 | -./scripts/setup-test-db.sh | ||
| 21 | - | ||
| 22 | -echo "[test.sh] 2/6 build" | ||
| 23 | -if [ $HAS_BACKEND -eq 1 ]; then (cd backend && {{backend_build}}); else echo "[test.sh] skip backend build"; fi | ||
| 24 | -if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && {{frontend_build}}); else echo "[test.sh] skip frontend build"; fi | ||
| 25 | - | ||
| 26 | -echo "[test.sh] 3/6 lint" | ||
| 27 | -if [ $HAS_BACKEND -eq 1 ]; then (cd backend && {{backend_lint}}); else echo "[test.sh] skip backend lint"; fi | ||
| 28 | -if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && {{frontend_lint}}); else echo "[test.sh] skip frontend lint"; fi | ||
| 29 | - | ||
| 30 | -echo "[test.sh] 4/6 unit + integration" | ||
| 31 | -if [ $HAS_BACKEND -eq 1 ]; then (cd backend && {{backend_test}}); else echo "[test.sh] skip backend test"; fi | ||
| 32 | -if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && {{frontend_test}}); else echo "[test.sh] skip frontend test"; fi | ||
| 33 | - | ||
| 34 | -echo "[test.sh] 5/6 E2E" | ||
| 35 | -{{e2e_cmd}} | ||
| 36 | - | ||
| 37 | -echo "[test.sh] 6/6 reset test db" | ||
| 38 | -./scripts/setup-test-db.sh | ||
| 39 | - | ||
| 40 | -echo "[test.sh] GREEN" |