Commit 9585c36f7b7a492837c5abd08d7c50c47504c32d

Authored by zichun
1 parent 18c67809

feat(lib): cross-platform Node helpers + .mjs target scripts (replace bash)

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"