From 9585c36f7b7a492837c5abd08d7c50c47504c32d Mon Sep 17 00:00:00 2001 From: zichun Date: Tue, 26 May 2026 10:19:22 +0800 Subject: [PATCH] feat(lib): cross-platform Node helpers + .mjs target scripts (replace bash) --- lib/apply-ddl.mjs | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/apply-ddl.test.mjs | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/merge-gitignore.mjs | 24 ++++++++++++++++++++++++ lib/merge-gitignore.test.mjs | 14 ++++++++++++++ lib/render.mjs | 25 +++++++++++++++++++++++++ lib/render.test.mjs | 16 ++++++++++++++++ lib/validate-ddl.mjs | 327 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/validate-ddl.test.mjs | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.sh | 65 ----------------------------------------------------------------- skills/plan/skeleton-gen/templates/scripts-test-template.mjs | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ skills/plan/skeleton-gen/templates/scripts-test-template.sh | 40 ---------------------------------------- 12 files changed, 952 insertions(+), 105 deletions(-) create mode 100644 lib/apply-ddl.mjs create mode 100644 lib/apply-ddl.test.mjs create mode 100644 lib/merge-gitignore.mjs create mode 100644 lib/merge-gitignore.test.mjs create mode 100644 lib/render.mjs create mode 100644 lib/render.test.mjs create mode 100644 lib/validate-ddl.mjs create mode 100644 lib/validate-ddl.test.mjs create mode 100644 skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs delete mode 100644 skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.sh create mode 100644 skills/plan/skeleton-gen/templates/scripts-test-template.mjs delete mode 100644 skills/plan/skeleton-gen/templates/scripts-test-template.sh diff --git a/lib/apply-ddl.mjs b/lib/apply-ddl.mjs new file mode 100644 index 0000000..089cec9 --- /dev/null +++ b/lib/apply-ddl.mjs @@ -0,0 +1,133 @@ +// lib/apply-ddl.mjs +// +// Replaces the inline `set -a; . .env.local; mysql < V1.sql` bash from db-init. +// +// parseEnv(): dotenv-style line parser. Pure parsing, NO variable expansion and +// NO shell execution — `$VAR`, backticks, `$(...)` and other shell constructs are +// kept verbatim as literal characters, which eliminates the shell-injection vector +// of `source`-ing an untrusted .env file. +// +// applyDDL(): connects with mysql2/promise (multipleStatements) to run a DDL file. + +/** + * Parse dotenv-style text into a plain object. + * + * Rules: + * - one `KEY=VALUE` per line + * - blank lines and full-line comments (first non-space char is `#`) are skipped + * - an optional leading `export ` is stripped + * - key and value are trimmed + * - a single layer of matching surrounding quotes ('...' or "...") is removed + * - NO variable expansion: `$FOO`, `${FOO}`, `$(...)`, backticks stay literal + * + * @param {string} text + * @returns {Record} + */ +export function parseEnv(text) { + const env = {} + if (typeof text !== 'string') return env + for (const rawLine of text.split('\n')) { + let line = rawLine.replace(/\r$/, '') // tolerate CRLF + const trimmed = line.trim() + if (trimmed === '' || trimmed.startsWith('#')) continue + + // strip an optional `export ` prefix (off the trimmed-left view) + let body = line.replace(/^\s*export\s+/, '') + + const eq = body.indexOf('=') + if (eq === -1) continue // not a KEY=VALUE line; ignore + + const key = body.slice(0, eq).trim() + if (key === '') continue + + let value = body.slice(eq + 1).trim() + + // remove one layer of matching surrounding quotes, if present. + if ( + value.length >= 2 && + ((value[0] === '"' && value[value.length - 1] === '"') || + (value[0] === "'" && value[value.length - 1] === "'")) + ) { + value = value.slice(1, -1) + } + + // NOTE: no variable expansion is performed — value is inserted literally. + env[key] = value + } + return env +} + +/** + * Apply a DDL file to a MySQL database using mysql2/promise. + * + * Reads connection settings from the parsed env file. Recognised keys (with + * common aliases) — DB_HOST/MYSQL_HOST, DB_PORT/MYSQL_PORT, DB_USER/MYSQL_USER, + * DB_PASS/DB_PASSWORD/MYSQL_PASSWORD, DB_NAME/MYSQL_DATABASE. + * + * @param {{envPath: string, ddlPath: string}} opts + * @returns {Promise} + */ +export async function applyDDL({ envPath, ddlPath }) { + const { readFileSync } = await import('node:fs') + + let mysql + try { + ;({ default: mysql } = await import('mysql2/promise')) + } catch { + throw new MysqlUnavailableError() + } + + const env = parseEnv(readFileSync(envPath, 'utf8')) + const ddl = readFileSync(ddlPath, 'utf8') + + const host = env.DB_HOST || env.MYSQL_HOST || '127.0.0.1' + const port = Number(env.DB_PORT || env.MYSQL_PORT || 3306) + const user = env.DB_USER || env.MYSQL_USER || 'root' + const password = env.DB_PASS || env.DB_PASSWORD || env.MYSQL_PASSWORD || '' + const database = env.DB_NAME || env.MYSQL_DATABASE || undefined + + const conn = await mysql.createConnection({ + host, + port, + user, + password, + database, + multipleStatements: true, + }) + try { + await conn.query(ddl) + } finally { + await conn.end() + } +} + +/** Distinct error type so the CLI can emit a friendly install hint. */ +export class MysqlUnavailableError extends Error { + constructor() { + super('mysql2 is not installed') + this.name = 'MysqlUnavailableError' + } +} + +// CLI entry: node lib/apply-ddl.mjs +// Use pathToFileURL so the guard matches even when the path contains spaces or +// other characters that get percent-encoded in import.meta.url. +const { pathToFileURL } = await import('node:url') +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [envPath, ddlPath] = process.argv.slice(2) + if (!envPath || !ddlPath) { + console.error('usage: node lib/apply-ddl.mjs ') + process.exit(2) + } + try { + await applyDDL({ envPath, ddlPath }) + console.log(`apply-ddl: applied ${ddlPath} using ${envPath}`) + } catch (e) { + if (e instanceof MysqlUnavailableError) { + console.error('apply-ddl: mysql2 not found. Please run `npm i mysql2` in the target project.') + process.exit(1) + } + console.error(`apply-ddl: failed — ${e?.message || e}`) + process.exit(1) + } +} diff --git a/lib/apply-ddl.test.mjs b/lib/apply-ddl.test.mjs new file mode 100644 index 0000000..86480da --- /dev/null +++ b/lib/apply-ddl.test.mjs @@ -0,0 +1,57 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { parseEnv } from './apply-ddl.mjs' + +test('parseEnv ignores comments, trims, keeps special chars literally', () => { + const env = parseEnv('# c\nDB_PASS=p@ss$word!\nDB_NAME = erp \n') + assert.equal(env.DB_PASS, 'p@ss$word!') + assert.equal(env.DB_NAME, 'erp') +}) + +test('parseEnv does NOT expand variables', () => { + const env = parseEnv('A=1\nB=${A}\nC=$A\nD=$(whoami)\nE=`id`\n') + assert.equal(env.B, '${A}') + assert.equal(env.C, '$A') + assert.equal(env.D, '$(whoami)') + assert.equal(env.E, '`id`') +}) + +test('parseEnv skips blank lines and comment lines', () => { + const env = parseEnv('\n \n# comment\nK=v\n # indented comment\n') + assert.deepEqual(Object.keys(env), ['K']) + assert.equal(env.K, 'v') +}) + +test('parseEnv strips one layer of matching quotes', () => { + const env = parseEnv(`Q1="hello world"\nQ2='a=b=c'\nQ3="$keep"\n`) + assert.equal(env.Q1, 'hello world') + assert.equal(env.Q2, 'a=b=c') + assert.equal(env.Q3, '$keep') +}) + +test('parseEnv keeps = signs inside the value', () => { + const env = parseEnv('URL=mysql://u:p@h:3306/db?x=1&y=2\n') + assert.equal(env.URL, 'mysql://u:p@h:3306/db?x=1&y=2') +}) + +test('parseEnv strips an optional leading export', () => { + const env = parseEnv('export DB_USER=root\n') + assert.equal(env.DB_USER, 'root') +}) + +test('parseEnv tolerates CRLF line endings', () => { + const env = parseEnv('A=1\r\nB=2\r\n') + assert.equal(env.A, '1') + assert.equal(env.B, '2') +}) + +test('parseEnv ignores lines without an = sign', () => { + const env = parseEnv('NOEQUALS\nK=v\n') + assert.deepEqual(Object.keys(env), ['K']) +}) + +test('parseEnv on empty / non-string input returns empty object', () => { + assert.deepEqual(parseEnv(''), {}) + assert.deepEqual(parseEnv(undefined), {}) + assert.deepEqual(parseEnv(null), {}) +}) diff --git a/lib/merge-gitignore.mjs b/lib/merge-gitignore.mjs new file mode 100644 index 0000000..c4191e8 --- /dev/null +++ b/lib/merge-gitignore.mjs @@ -0,0 +1,24 @@ +// lib/merge-gitignore.mjs +export function mergeGitignore(baseText, addText) { + const seen = new Set() + const out = [] + const push = (line) => { + const key = line.trim() + if (!key) return // drop blank lines + if (seen.has(key)) return // dedupe by trimmed content + seen.add(key) + out.push(line) + } + for (const l of baseText.split('\n')) push(l) + for (const l of addText.split('\n')) push(l) + let text = out.join('\n').replace(/\n+$/,'') + '\n' + return text +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const [basePath, addPath] = process.argv.slice(2) + const { readFileSync, writeFileSync } = await import('node:fs') + const base = readFileSync(basePath, 'utf8') + const add = readFileSync(addPath, 'utf8') + writeFileSync(basePath, mergeGitignore(base, add)) +} diff --git a/lib/merge-gitignore.test.mjs b/lib/merge-gitignore.test.mjs new file mode 100644 index 0000000..a8458cf --- /dev/null +++ b/lib/merge-gitignore.test.mjs @@ -0,0 +1,14 @@ +// lib/merge-gitignore.test.mjs +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mergeGitignore } from './merge-gitignore.mjs' + +test('union dedupes and preserves base order, appends new', () => { + const base = 'node_modules\n.env\n' + const add = '.env\ndist\n.DS_Store\n' + assert.equal(mergeGitignore(base, add), 'node_modules\n.env\ndist\n.DS_Store\n') +}) + +test('blank lines and comments in add are ignored for dedupe but kept once', () => { + assert.equal(mergeGitignore('a\n', '\n# c\nb\n'), 'a\n# c\nb\n') +}) diff --git a/lib/render.mjs b/lib/render.mjs new file mode 100644 index 0000000..e0bcd8a --- /dev/null +++ b/lib/render.mjs @@ -0,0 +1,25 @@ +// lib/render.mjs — literal-safe template render (replaces scope-lock/render.sh) +// +// 用法(CLI):node lib/render.mjs +// 程序内:import { render } from './render.mjs' +// +// 核心要求:{{key}} 占位替换;值中含 $、{、}、}} 不被二次解释(字面插入); +// 先剥离 HTML 注释(模板引导文本);缺少变量则 throw(不静默留空)。 +export function render(template, vars) { + const withoutComments = template.replace(//g, '') + return withoutComments.replace(/\{\{(\w+)\}\}/g, (_, key) => { + if (!(key in vars)) throw new Error(`render: missing var "${key}"`) + return String(vars[key]) // 字面插入,不二次解释 $ 或 {} + }) +} + +// 入口判定用 pathToFileURL 规范化 process.argv[1],使其与 import.meta.url 编码一致 +// (路径含空格/非 ASCII/Windows 反斜杠时,字面 `file://${argv[1]}` 比较会失配)。 +const { pathToFileURL } = await import('node:url') +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const { readFileSync, writeFileSync } = await import('node:fs') + const [tplPath, jsonPath, outPath] = process.argv.slice(2) + const tpl = readFileSync(tplPath, 'utf8') + const vars = JSON.parse(readFileSync(jsonPath, 'utf8')) + writeFileSync(outPath, render(tpl, vars)) +} diff --git a/lib/render.test.mjs b/lib/render.test.mjs new file mode 100644 index 0000000..9529d24 --- /dev/null +++ b/lib/render.test.mjs @@ -0,0 +1,16 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { render } from './render.mjs' + +test('replaces placeholders', () => { + assert.equal(render('Hi {{name}}', { name: 'Al' }), 'Hi Al') +}) +test('value containing $ and braces is inserted literally', () => { + assert.equal(render('v={{x}}', { x: '${FOO} a{b}c }}' }), 'v=${FOO} a{b}c }}') +}) +test('strips HTML comments used as template guides', () => { + assert.equal(render('ab', {}), 'ab') +}) +test('missing key throws (no silent blank)', () => { + assert.throws(() => render('{{missing}}', {}), /missing/) +}) diff --git a/lib/validate-ddl.mjs b/lib/validate-ddl.mjs new file mode 100644 index 0000000..a84ef9c --- /dev/null +++ b/lib/validate-ddl.mjs @@ -0,0 +1,327 @@ +// lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验 +// 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 +// +// 用法(CLI):node lib/validate-ddl.mjs +// 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。 +// 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' +// +// 5 维 diff: +// 1) 表集合(missingTables / extraTables) +// 2) 列名(columnMismatches,side: 'docs'|'ddl') +// 3) 列类型(typeMismatches) +// 4) 索引(indexMismatches,side: 'docs'|'ddl') +// 5) 外键(foreignKeyMismatches,side: 'docs'|'ddl') +// +// 数据结构(解析结果):Map, indexes: Set, foreignKeys: Set }> + +// ── 解析 docs/03 markdown 表定义 ───────────────────────────────── +// 约定:每张表一节,节标题形如 ## `表名` 或 ## `表名` — 业务含义 +// 节内的 markdown 表格首列是列名(可含反引号),次列是类型。 +// 跳过表头行(列/字段/类型等标签)与分隔行(---)。 +// 形如「## 一、全局约定」这类非反引号标题不视为表。 +export function parseDocsTables(text) { + const tables = new Map() + const lines = String(text).split('\n') + // 反引号包裹的表名:## `name` 或 ## `name` — purpose + const headerRe = /^##\s+`([^`]+)`/ + let current = null // { columns: Map } + + for (const raw of lines) { + const line = raw.replace(/\r$/, '') + const h2 = line.match(headerRe) + if (h2) { + current = { columns: new Map(), indexes: new Set(), foreignKeys: new Set() } + tables.set(h2[1].trim(), current) + continue + } + // 任何其它二级(或更高)非反引号标题 → 结束当前表块(如 ## 一、全局约定) + if (/^##\s/.test(line) && !headerRe.test(line)) { + current = null + continue + } + if (!current) continue + // markdown 表格行:以 | 开头 + if (!/^\s*\|/.test(line)) continue + const cells = splitMarkdownRow(line) + if (cells.length < 2) continue + const name = stripTicks(cells[0]) + const type = stripTicks(cells[1]) + // 跳过分隔行(---)、表头标签行、空名行 + if (!name) continue + if (isSeparatorCell(name)) continue + if (isHeaderLabel(name)) continue + current.columns.set(name, type) + } + return tables +} + +// ── 解析 CREATE TABLE DDL ──────────────────────────────────────── +// 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。 +export function parseDDL(text) { + const tables = new Map() + const src = String(text) + // 抓取 CREATE TABLE ( ) ;name 可带反引号;body 到匹配的右括号 + const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*\(/gi + let m + while ((m = createRe.exec(src)) !== null) { + const tableName = m[1] + const bodyStart = createRe.lastIndex - 1 // 指向 '(' + const body = extractBalancedParens(src, bodyStart) + if (body == null) continue + const parsed = parseTableBody(body) + tables.set(tableName, parsed) + // 继续从 body 之后扫描 + createRe.lastIndex = bodyStart + body.length + 2 + } + return tables +} + +function parseTableBody(body) { + const columns = new Map() + const indexes = new Set() + const foreignKeys = new Set() + for (const itemRaw of splitTopLevelCommas(body)) { + const item = itemRaw.trim() + if (!item) continue + const upper = item.toUpperCase() + + // 外键约束(可带前缀 CONSTRAINT ) + if (/\bFOREIGN\s+KEY\b/i.test(item)) { + const fk = item.match(/FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i) + if (fk) { + const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') + const refTable = fk[2] + const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') + foreignKeys.add(`${fromCols}->${refTable}(${toCols})`) + } else { + foreignKeys.add(item) + } + continue + } + + // PRIMARY KEY (...) + if (/^PRIMARY\s+KEY/i.test(item)) { + indexes.add('PRIMARY') + continue + } + // UNIQUE [KEY|INDEX] (...) / KEY (...) / INDEX (...) + if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) { + const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?/i) + indexes.add(nameMatch ? nameMatch[1] : item) + continue + } + // CONSTRAINT 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 + if (/^CONSTRAINT\b/i.test(upper)) { + const cn = item.match(/^CONSTRAINT\s+`?([A-Za-z0-9_]+)`?/i) + indexes.add(cn ? cn[1] : item) + continue + } + // CHECK (...) + if (/^CHECK\b/i.test(upper)) continue + + // 普通列: ... name 可带反引号;type 取到第一个属性关键字/逗号前 + const col = item.match(/^`?([A-Za-z0-9_]+)`?\s+(.+)$/s) + if (!col) continue + const name = col[1] + const type = extractType(col[2]) + columns.set(name, type) + } + return { columns, indexes, foreignKeys } +} + +// 从列定义剩余部分提取类型(含括号内长度),到下一个属性关键字前停止。 +function extractType(rest) { + const s = rest.trim() + // 类型形如 varchar(100) / decimal(10,2) / int unsigned / bigint + const m = s.match(/^([A-Za-z]+(?:\s+(?:unsigned|signed|zerofill))*)\s*(\([^)]*\))?/i) + if (!m) return s.split(/\s+/)[0] + let type = m[1].trim() + // 仅保留基础类型词 + 括号;丢弃 unsigned/signed 这类修饰以贴近 docs/03 写法(docs 一般只写基础类型) + const base = type.split(/\s+/)[0] + return base + (m[2] ? m[2].replace(/\s+/g, '') : '') +} + +// ── 5 维 diff ──────────────────────────────────────────────────── +export function diffSchema(docsTables, ddlTables) { + const diff = { + missingTables: [], // docs 有、DDL 无 + extraTables: [], // DDL 有、docs 无 + columnMismatches: [], // { table, column, side: 'docs'|'ddl' } + typeMismatches: [], // { table, column, docsType, ddlType } + indexMismatches: [], // { table, index, side: 'docs'|'ddl' } + foreignKeyMismatches: [],// { table, foreignKey, side: 'docs'|'ddl' } + hasDiff: false, + } + + const docNames = new Set(docsTables.keys()) + const ddlNames = new Set(ddlTables.keys()) + + for (const t of docNames) if (!ddlNames.has(t)) diff.missingTables.push(t) + for (const t of ddlNames) if (!docNames.has(t)) diff.extraTables.push(t) + diff.missingTables.sort() + diff.extraTables.sort() + + // 仅对共有表做列/类型/索引/外键比对 + for (const t of [...docNames].filter(n => ddlNames.has(n)).sort()) { + const d = docsTables.get(t) + const s = ddlTables.get(t) + + // 维度 2/3:列名 + 列类型 + for (const [col, dType] of d.columns) { + if (!s.columns.has(col)) { + diff.columnMismatches.push({ table: t, column: col, side: 'docs' }) + } else { + const sType = s.columns.get(col) + if (!typesEqual(dType, sType)) { + diff.typeMismatches.push({ table: t, column: col, docsType: dType, ddlType: sType }) + } + } + } + for (const col of s.columns.keys()) { + if (!d.columns.has(col)) diff.columnMismatches.push({ table: t, column: col, side: 'ddl' }) + } + + // 维度 4:索引 + const dIdx = d.indexes || new Set() + const sIdx = s.indexes || new Set() + for (const ix of dIdx) if (!sIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }) + for (const ix of sIdx) if (!dIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' }) + + // 维度 5:外键 + const dFk = d.foreignKeys || new Set() + const sFk = s.foreignKeys || new Set() + for (const fk of dFk) if (!sFk.has(fk)) diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'docs' }) + for (const fk of sFk) if (!dFk.has(fk)) diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'ddl' }) + } + + diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 || + diff.columnMismatches.length > 0 || diff.typeMismatches.length > 0 || + diff.indexMismatches.length > 0 || diff.foreignKeyMismatches.length > 0 + return diff +} + +// ── 工具函数 ───────────────────────────────────────────────────── +function stripTicks(s) { + return String(s).replace(/`/g, '').trim() +} + +function splitMarkdownRow(line) { + // 去掉首尾管道再按 | 切分 + let t = line.trim() + if (t.startsWith('|')) t = t.slice(1) + if (t.endsWith('|')) t = t.slice(0, -1) + return t.split('|').map(c => c.trim()) +} + +function isSeparatorCell(cell) { + // 形如 --- / :--- / ---: / :---: + return /^:?-{1,}:?$/.test(cell.trim()) +} + +function isHeaderLabel(cell) { + // 表头标签:列 / 字段 / 字段名 / 类型 / 列名(避免把表头行当列) + return ['列', '字段', '字段名', '列名', '类型', 'name', 'type', 'column'].includes(cell.trim()) +} + +// 提取从 openIdx(指向 '(')开始的平衡括号内部内容(不含最外层括号)。 +function extractBalancedParens(src, openIdx) { + if (src[openIdx] !== '(') return null + let depth = 0 + for (let i = openIdx; i < src.length; i++) { + const ch = src[i] + if (ch === '(') depth++ + else if (ch === ')') { + depth-- + if (depth === 0) return src.slice(openIdx + 1, i) + } + } + return null +} + +// 在顶层(括号深度 0)按逗号切分 DDL body,保护 varchar(100) / decimal(10,2) 内的逗号。 +function splitTopLevelCommas(body) { + const out = [] + let depth = 0 + let buf = '' + for (let i = 0; i < body.length; i++) { + const ch = body[i] + if (ch === '(') { depth++; buf += ch } + else if (ch === ')') { depth--; buf += ch } + else if (ch === ',' && depth === 0) { out.push(buf); buf = '' } + else buf += ch + } + if (buf.trim()) out.push(buf) + return out +} + +// 类型相等比较:大小写不敏感、忽略空白。 +function typesEqual(a, b) { + const norm = (x) => String(x).toLowerCase().replace(/\s+/g, '') + return norm(a) === norm(b) +} + +// ── 报告(供 CLI 与外部复用)──────────────────────────────────── +export function formatDiff(diff) { + const out = [] + if (diff.missingTables.length) { + out.push('=== 维度1 表集合:docs/03 有但 DDL 无 ===') + for (const t of diff.missingTables) out.push(` - ${t}`) + } + if (diff.extraTables.length) { + out.push('=== 维度1 表集合:DDL 有但 docs/03 无 ===') + for (const t of diff.extraTables) out.push(` - ${t}`) + } + if (diff.columnMismatches.length) { + out.push('=== 维度2 列名 ===') + for (const m of diff.columnMismatches) { + out.push(` - ${m.table}.${m.column} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) + } + } + if (diff.typeMismatches.length) { + out.push('=== 维度3 列类型 ===') + for (const m of diff.typeMismatches) { + out.push(` - ${m.table}.${m.column}: docs/03=${m.docsType} ≠ DDL=${m.ddlType}`) + } + } + if (diff.indexMismatches.length) { + out.push('=== 维度4 索引 ===') + for (const m of diff.indexMismatches) { + out.push(` - ${m.table} 索引 ${m.index} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) + } + } + if (diff.foreignKeyMismatches.length) { + out.push('=== 维度5 外键 ===') + for (const m of diff.foreignKeyMismatches) { + out.push(` - ${m.table} 外键 ${m.foreignKey} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`) + } + } + return out.join('\n') +} + +// ── CLI 入口 ───────────────────────────────────────────────────── +// 用 pathToFileURL 做比较:路径含空格/非 ASCII 时 import.meta.url 是百分号编码, +// 而 process.argv[1] 是原始路径,直接 `file://${argv1}` 拼接永远不相等。 +const { pathToFileURL } = await import('node:url') +const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (isCliEntry) { + const { readFileSync, existsSync } = await import('node:fs') + const [docsPath, ddlPath] = process.argv.slice(2) + if (!docsPath || !ddlPath) { + console.error('用法: node lib/validate-ddl.mjs ') + process.exit(2) + } + if (!existsSync(docsPath)) { console.error(`validate-ddl: docs 不存在: ${docsPath}`); process.exit(2) } + if (!existsSync(ddlPath)) { console.error(`validate-ddl: DDL 不存在: ${ddlPath}`); process.exit(2) } + + const docsTables = parseDocsTables(readFileSync(docsPath, 'utf8')) + const ddlTables = parseDDL(readFileSync(ddlPath, 'utf8')) + const diff = diffSchema(docsTables, ddlTables) + + if (diff.hasDiff) { + console.error(formatDiff(diff)) + process.exit(1) + } + console.log('validate-ddl: ✓ docs/03 与 DDL 在 5 维(表/列/类型/索引/外键)一致') + process.exit(0) +} diff --git a/lib/validate-ddl.test.mjs b/lib/validate-ddl.test.mjs new file mode 100644 index 0000000..e8e34fd --- /dev/null +++ b/lib/validate-ddl.test.mjs @@ -0,0 +1,161 @@ +// lib/validate-ddl.test.mjs — 单测:docs/03 表格 ↔ DDL 5 维 diff +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' + +const DOCS = `## \`t_user\`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n| sName | varchar(50) |\n` +const DDL = `CREATE TABLE t_user ( iId bigint PRIMARY KEY, sName varchar(50) );` + +test('matching schema yields empty diff', () => { + const d = diffSchema(parseDocsTables(DOCS), parseDDL(DDL)) + assert.deepEqual(d.missingTables, []) + assert.deepEqual(d.columnMismatches, []) +}) + +test('missing column is reported', () => { + const ddl2 = `CREATE TABLE t_user ( iId bigint );` + const d = diffSchema(parseDocsTables(DOCS), parseDDL(ddl2)) + assert.ok(d.columnMismatches.some(m => m.table === 't_user' && m.column === 'sName')) +}) + +// ── parseDocsTables ────────────────────────────────────────────── +test('parseDocsTables: 列名/类型 from markdown rows under ## `table` header', () => { + const tables = parseDocsTables(DOCS) + assert.equal(tables.size, 1) + const t = tables.get('t_user') + assert.ok(t, 'table t_user parsed') + assert.deepEqual([...t.columns.keys()], ['iId', 'sName']) + assert.equal(t.columns.get('iId'), 'bigint') + assert.equal(t.columns.get('sName'), 'varchar(50)') +}) + +test('parseDocsTables: real docs/03 format — ## `t` — purpose + ### 字段 + backtick cols', () => { + const docs = [ + '## `t_order` — 订单主表', + '', + '### 字段', + '', + '| 字段 | 类型 | Nullable | 默认 | 业务含义 |', + '|---|---|---|---|---|', + '| `iIncrement` | int | 否 | 自增 | 主键 |', + '| `sId` | varchar(100) | 是 | uuid | 业务ID |', + '', + '### 索引', + '- `pk` (PRIMARY): iIncrement', + '', + '## `t_item` — 明细', + '', + '| 列 | 类型 |', + '|---|---|', + '| iId | bigint |', + '', + ].join('\n') + const tables = parseDocsTables(docs) + assert.deepEqual([...tables.keys()].sort(), ['t_item', 't_order']) + const order = tables.get('t_order') + assert.deepEqual([...order.columns.keys()], ['iIncrement', 'sId']) + assert.equal(order.columns.get('iIncrement'), 'int') + assert.equal(order.columns.get('sId'), 'varchar(100)') + // header separator row and header label row must be skipped + assert.equal(order.columns.has('字段'), false) + assert.equal(order.columns.has('---'), false) +}) + +test('parseDocsTables: top-level ## headers like "## 一、全局约定" are NOT tables', () => { + const docs = [ + '## 一、全局约定(人工填)', + '- 数据库名: erp', + '', + '## `t_user`', + '| 列 | 类型 |', + '|---|---|', + '| iId | bigint |', + '', + ].join('\n') + const tables = parseDocsTables(docs) + assert.deepEqual([...tables.keys()], ['t_user']) +}) + +// ── parseDDL ───────────────────────────────────────────────────── +test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => { + const ddl = [ + 'CREATE TABLE `t_order` (', + ' `iIncrement` int NOT NULL AUTO_INCREMENT,', + ' `sId` varchar(100) DEFAULT NULL,', + ' `sUserId` varchar(100) DEFAULT NULL,', + ' PRIMARY KEY (`iIncrement`),', + ' UNIQUE KEY `uk_sid` (`sId`),', + ' KEY `idx_user` (`sUserId`),', + ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`)', + ') ENGINE=InnoDB;', + ].join('\n') + const tables = parseDDL(ddl) + const t = tables.get('t_order') + assert.ok(t) + assert.deepEqual([...t.columns.keys()], ['iIncrement', 'sId', 'sUserId']) + assert.equal(t.columns.get('sId'), 'varchar(100)') + // index keys (named) collected; PRIMARY collected too + assert.ok(t.indexes.has('uk_sid')) + assert.ok(t.indexes.has('idx_user')) + assert.ok([...t.indexes].some(i => i.toUpperCase().includes('PRIMARY'))) + // foreign key collected + assert.ok([...t.foreignKeys].some(fk => fk.includes('sUserId') && fk.includes('t_user'))) +}) + +test('parseDDL: unquoted identifiers and inline PRIMARY KEY', () => { + const tables = parseDDL(DDL) + const t = tables.get('t_user') + assert.ok(t) + assert.deepEqual([...t.columns.keys()], ['iId', 'sName']) + assert.equal(t.columns.get('iId'), 'bigint') +}) + +test('parseDDL: multiple tables', () => { + const ddl = 'CREATE TABLE a (x int); CREATE TABLE b (y bigint);' + const tables = parseDDL(ddl) + assert.deepEqual([...tables.keys()].sort(), ['a', 'b']) +}) + +// ── diffSchema 5 dimensions ────────────────────────────────────── +test('diffSchema: missing table (in docs, not in DDL) reported', () => { + const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') + const ddl = parseDDL('CREATE TABLE other ( z int );') + const d = diffSchema(docs, ddl) + assert.ok(d.missingTables.includes('t_user')) + assert.ok(d.extraTables.includes('other')) +}) + +test('diffSchema: type mismatch reported', () => { + const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') + const ddl = parseDDL('CREATE TABLE t_user ( iId int );') + const d = diffSchema(docs, ddl) + assert.ok(d.typeMismatches.some(m => m.table === 't_user' && m.column === 'iId' && m.docsType === 'bigint' && m.ddlType === 'int')) +}) + +test('diffSchema: extra column in DDL reported as columnMismatch', () => { + const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') + const ddl = parseDDL('CREATE TABLE t_user ( iId bigint, extra varchar(10) );') + const d = diffSchema(docs, ddl) + assert.ok(d.columnMismatches.some(m => m.table === 't_user' && m.column === 'extra' && m.side === 'ddl')) +}) + +test('diffSchema: index dimension diff reported', () => { + const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c']), foreignKeys: new Set() }]]) + const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes + const d = diffSchema(docs, ddl) + assert.ok(d.indexMismatches.some(m => m.table === 't' && m.index === 'idx_c')) +}) + +test('diffSchema: foreign-key dimension diff reported', () => { + const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(), foreignKeys: new Set(['c->other']) }]]) + const ddl = parseDDL('CREATE TABLE t ( c int );') // no FKs + const d = diffSchema(docs, ddl) + assert.ok(d.foreignKeyMismatches.some(m => m.table === 't' && m.foreignKey === 'c->other')) +}) + +test('diffSchema: hasDiff is false when everything matches, true otherwise', () => { + const ok = diffSchema(parseDocsTables(DOCS), parseDDL(DDL)) + assert.equal(ok.hasDiff, false) + const bad = diffSchema(parseDocsTables(DOCS), parseDDL('CREATE TABLE t_user ( iId bigint );')) + assert.equal(bad.hasDiff, true) +}) diff --git a/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs b/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs new file mode 100644 index 0000000..ec56d45 --- /dev/null +++ b/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// scripts/setup-test-db.mjs — 数据库重置脚本:drop + create 空库。 +// schema apply 由 Flyway 在 Spring Boot 启动时自动处理(见 docs/04 技术栈 + sql/migrations/V*.sql)。 +// seed 数据由测试框架负责(Spring @Sql / Flyway R__seed.sql / data.sql)。 +// +// 使用场景: +// - scripts/test.mjs 开头:清空库,让 Spring 启动时 Flyway 从 V1 开始重放所有 migration +// - scripts/test.mjs 结尾:清空库,避免测试遗留污染下次运行 +// - 手动调试时:reset 到零状态 +// +// 跨平台:用纯 JS 解析 .env.local(dotenv 风格,逐行 KEY=VALUE),**绝不** shell-source, +// 因此 mac / Windows 原生 node 均可运行,且消除 shell 注入 / 变量展开隐患。 +// DROP/CREATE 通过 `mysql` 客户端以 argv 数组方式执行(不经 shell),密码不进命令行解析层。 +// +// 防护:本脚本只允许在本地 host + 测试库名上执行;非预期目标会被拒绝, +// 避免 .env.local 误指向 staging/prod 时触发不可逆 DROP。 + +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ENV_FILE = join(SCRIPT_DIR, '..', '.env.local') + +// dotenv 风格解析:逐行 KEY=VALUE,跳过空行与 # 注释,去除两侧空白, +// 可选地剥离一层成对单/双引号。**不做**变量展开,特殊字符按字面保留。 +function parseEnv(text) { + const env = {} + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim() + if (line === '' || line.startsWith('#')) continue + const eq = line.indexOf('=') + if (eq === -1) continue + const key = line.slice(0, eq).trim() + if (!key) continue + let value = line.slice(eq + 1).trim() + if ( + value.length >= 2 && + ((value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('"') && value.endsWith('"'))) + ) { + value = value.slice(1, -1) + } + env[key] = value + } + return env +} + +if (!existsSync(ENV_FILE)) { + console.error(`[setup-test-db] .env.local 不存在(${ENV_FILE})`) + process.exit(1) +} + +const env = parseEnv(readFileSync(ENV_FILE, 'utf8')) + +const DB_HOST = env.DB_HOST ?? '' +const DB_PORT = env.DB_PORT ?? '3306' +const DB_USER = env.DB_USER ?? '' +const DB_PASSWORD = env.DB_PASSWORD ?? '' +const DB_SCHEMA = env.DB_SCHEMA ?? '' +const TEST_DB_ALLOW_REMOTE = env.TEST_DB_ALLOW_REMOTE ?? process.env.TEST_DB_ALLOW_REMOTE ?? '0' +const TEST_DB_ALLOW_PROD_NAME = + env.TEST_DB_ALLOW_PROD_NAME ?? process.env.TEST_DB_ALLOW_PROD_NAME ?? '0' + +// 防护 1:默认只允许本地 host(localhost / 127.0.0.1 / ::1)。 +// 额外允许的远程 host 在 .env.local 的 TEST_DB_ALLOWED_HOSTS 中(空格或逗号分隔)。 +const extraHosts = (env.TEST_DB_ALLOWED_HOSTS ?? '') + .split(/[\s,]+/) + .filter(Boolean) +const allowedHosts = ['localhost', '127.0.0.1', '::1', ...extraHosts] +if (!allowedHosts.includes(DB_HOST)) { + console.error(`[setup-test-db] 拒绝在非白名单 host (${DB_HOST}) 上执行 DROP DATABASE`) + console.error(` 当前白名单:${allowedHosts.join(' ')}`) + console.error(' 加入 host:在 .env.local 追加 TEST_DB_ALLOWED_HOSTS=" "') + console.error(' 一次性绕过:在 .env.local 设 TEST_DB_ALLOW_REMOTE=1') + if (TEST_DB_ALLOW_REMOTE !== '1') process.exit(1) +} + +// 防护 2:schema 名需像测试/开发库(含 test / _dev / _local / _ci),否则要求显式确认。 +const schemaLooksLikeTest = + /test/.test(DB_SCHEMA) || /_dev$/.test(DB_SCHEMA) || /_local$/.test(DB_SCHEMA) || /_ci$/.test(DB_SCHEMA) +if (!schemaLooksLikeTest) { + console.error( + `[setup-test-db] schema '${DB_SCHEMA}' 不像测试库(期望命名含 test / _dev / _local / _ci)` + ) + console.error(' 如确为期望行为,请显式声明:在 .env.local 设 TEST_DB_ALLOW_PROD_NAME=1') + if (TEST_DB_ALLOW_PROD_NAME !== '1') process.exit(1) +} + +// 防护 3:显式 banner,让人看见自己在 drop 什么;远程 host 额外提示白名单内容。 +console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`) +if (!['localhost', '127.0.0.1', '::1'].includes(DB_HOST)) { + console.log( + '[setup-test-db] 目标是 **远程** host(已在 TEST_DB_ALLOWED_HOSTS 白名单中,每次 test.mjs 都会 DROP)' + ) + console.log(`[setup-test-db] 当前白名单: ${allowedHosts.join(' ')}`) + console.log( + '[setup-test-db] 若不希望每次自动 DROP,从 .env.local 的 TEST_DB_ALLOWED_HOSTS 删掉此 host' + ) +} + +const sql = + `DROP DATABASE IF EXISTS \`${DB_SCHEMA}\`; ` + + `CREATE DATABASE \`${DB_SCHEMA}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;` + +// 以 argv 数组调用 mysql(不经 shell):密码不进 shell 解析,跨平台一致。 +const mysqlArgs = [ + `-h${DB_HOST}`, + `-P${DB_PORT}`, + `-u${DB_USER}`, + `-p${DB_PASSWORD}`, + '-e', + sql, +] +const res = spawnSync('mysql', mysqlArgs, { stdio: 'inherit' }) +if (res.error) { + console.error(`[setup-test-db] FATAL: 无法执行 mysql(请确认其在 PATH 中): ${res.error.message}`) + process.exit(1) +} +if (res.status !== 0) { + console.error(`[setup-test-db] FAIL: mysql exit=${res.status}`) + process.exit(res.status === null ? 1 : res.status) +} + +console.log('[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts') diff --git a/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.sh b/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.sh deleted file mode 100644 index 41a3970..0000000 --- a/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# scripts/setup-test-db.sh — 数据库重置脚本:drop + create 空库。 -# schema apply 由 Flyway 在 Spring Boot 启动时自动处理(见 docs/04 技术栈 + sql/migrations/V*.sql)。 -# seed 数据由测试框架负责(Spring @Sql / Flyway R__seed.sql / data.sql)。 -# -# 使用场景: -# - scripts/test.sh 开头:清空库,让 Spring 启动时 Flyway 从 V1 开始重放所有 migration -# - scripts/test.sh 结尾:清空库,避免测试遗留污染下次运行 -# - 手动调试时:reset 到零状态 -# -# 防护:本脚本只允许在本地 host + 测试库名上执行;非预期目标会被拒绝, -# 避免 .env.local 误指向 staging/prod 时触发不可逆 DROP。 - -set -euo pipefail - -ENV_FILE="$(dirname "$0")/../.env.local" -[ -f "$ENV_FILE" ] || { echo "[setup-test-db] ⚠️ .env.local 不存在($ENV_FILE)" >&2; exit 1; } - -# 用 set -a 加载,让 KEY=VALUE 导出为环境变量;密码中含特殊字符时 .env.local 请用单引号包裹 -set -a; . "$ENV_FILE"; set +a - -# 防护 1:默认只允许本地 host(localhost / 127.0.0.1 / ::1)。 -# 若要为本项目额外允许某些远程 host(如公司测试 MySQL),在 .env.local 里设: -# TEST_DB_ALLOWED_HOSTS="118.178.19.35 test-mysql.internal" # 空格或逗号分隔 -# 被列入者可直接 DROP CREATE,不再需要 TEST_DB_ALLOW_REMOTE=1。 -ALLOWED_HOSTS="localhost 127.0.0.1 ::1 ${TEST_DB_ALLOWED_HOSTS//,/ }" -host_allowed=0 -for h in $ALLOWED_HOSTS; do - [ "${DB_HOST:-}" = "$h" ] && { host_allowed=1; break; } -done -if [ "$host_allowed" -ne 1 ]; then - echo "[setup-test-db] ⚠️ 拒绝在非白名单 host (${DB_HOST}) 上执行 DROP DATABASE" >&2 - echo " 当前白名单:${ALLOWED_HOSTS}" >&2 - echo " 加入 host:在 .env.local 追加 TEST_DB_ALLOWED_HOSTS=\" \"" >&2 - echo " 一次性绕过:TEST_DB_ALLOW_REMOTE=1 $0" >&2 - [ "${TEST_DB_ALLOW_REMOTE:-0}" = "1" ] || exit 1 -fi - -# 防护 2:schema 名需像测试/开发库(含 test / _dev / _local),否则要求显式确认 -case "${DB_SCHEMA:-}" in - *test*|*_dev|*_local|*_ci) - ;; - *) - echo "[setup-test-db] ⚠️ schema '${DB_SCHEMA}' 不像测试库(期望命名含 test / _dev / _local / _ci)" >&2 - echo " 如确为期望行为,请显式声明:TEST_DB_ALLOW_PROD_NAME=1 $0" >&2 - [ "${TEST_DB_ALLOW_PROD_NAME:-0}" = "1" ] || exit 1 - ;; -esac - -# 防护 3:显式 banner,让人看见自己在 drop 什么;远程 host 额外提示白名单内容 -echo "[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}" -case "${DB_HOST:-}" in - localhost|127.0.0.1|::1) ;; - *) - echo "[setup-test-db] ⚠️ 目标是 **远程** host(已在 TEST_DB_ALLOWED_HOSTS 白名单中,每次 test.sh 都会 DROP)" - echo "[setup-test-db] 当前白名单: ${ALLOWED_HOSTS}" - echo "[setup-test-db] 若不希望每次自动 DROP,从 .env.local 的 TEST_DB_ALLOWED_HOSTS 删掉此 host" - ;; -esac - -MYSQL_CMD="mysql -h${DB_HOST} -P${DB_PORT} -u${DB_USER} -p${DB_PASSWORD}" - -$MYSQL_CMD -e "DROP DATABASE IF EXISTS \`${DB_SCHEMA}\`; CREATE DATABASE \`${DB_SCHEMA}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" - -echo "[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts" diff --git a/skills/plan/skeleton-gen/templates/scripts-test-template.mjs b/skills/plan/skeleton-gen/templates/scripts-test-template.mjs new file mode 100644 index 0000000..9f0ab8c --- /dev/null +++ b/skills/plan/skeleton-gen/templates/scripts-test-template.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// scripts/test.mjs —— 合并到默认分支(main / master)前的测试闸门。 +// 顺序:detect → setup-db → build → lint → unit+integration → e2e → reset-db +// 由 coding.mjs 的 test-gate stage(通过子会话)调用。 +// +// 跨平台:所有命令经 child_process.spawnSync(cmd, { shell:true }) 执行, +// 在 Windows 走 cmd.exe,在 *nix 走 /bin/sh,无需 WSL / Git-Bash。 +// 命令字符串来自 docs/04 §零(构建/lint/单测/e2e)——由 skeleton-gen 在 Plan 期填充。 + +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') + +// 在指定子目录下跑一条 shell 命令;非零退出码即终止整个闸门并透传该码。 +function run(label, command, cwd = PROJECT_ROOT) { + console.log(`[test.mjs] ${label}: ${command}`) + const res = spawnSync(command, { cwd, shell: true, stdio: 'inherit' }) + if (res.error) { + console.error(`[test.mjs] FATAL: 无法执行 (${label}): ${res.error.message}`) + process.exit(1) + } + if (res.status !== 0) { + console.error(`[test.mjs] FAIL (${label}) exit=${res.status}`) + process.exit(res.status === null ? 1 : res.status) + } +} + +// Stack detection (runtime, mode-agnostic) +const hasBackend = existsSync(join(PROJECT_ROOT, 'backend')) +const hasFrontend = existsSync(join(PROJECT_ROOT, 'frontend')) +if (!hasBackend && !hasFrontend) { + console.error('[test.mjs] FATAL: neither backend/ nor frontend/ exists') + process.exit(1) +} + +const backendDir = join(PROJECT_ROOT, 'backend') +const frontendDir = join(PROJECT_ROOT, 'frontend') + +console.log('[test.mjs] 1/6 setup test db') +run('setup-test-db', `node ${JSON.stringify(join('scripts', 'setup-test-db.mjs'))}`) + +console.log('[test.mjs] 2/6 build') +if (hasBackend) run('backend build', '{{backend_build}}', backendDir) +else console.log('[test.mjs] skip backend build') +if (hasFrontend) run('frontend build', '{{frontend_build}}', frontendDir) +else console.log('[test.mjs] skip frontend build') + +console.log('[test.mjs] 3/6 lint') +if (hasBackend) run('backend lint', '{{backend_lint}}', backendDir) +else console.log('[test.mjs] skip backend lint') +if (hasFrontend) run('frontend lint', '{{frontend_lint}}', frontendDir) +else console.log('[test.mjs] skip frontend lint') + +console.log('[test.mjs] 4/6 unit + integration') +if (hasBackend) run('backend test', '{{backend_test}}', backendDir) +else console.log('[test.mjs] skip backend test') +if (hasFrontend) run('frontend test', '{{frontend_test}}', frontendDir) +else console.log('[test.mjs] skip frontend test') + +console.log('[test.mjs] 5/6 E2E') +run('e2e', '{{e2e_cmd}}') + +console.log('[test.mjs] 6/6 reset test db') +run('reset-test-db', `node ${JSON.stringify(join('scripts', 'setup-test-db.mjs'))}`) + +console.log('[test.mjs] GREEN') diff --git a/skills/plan/skeleton-gen/templates/scripts-test-template.sh b/skills/plan/skeleton-gen/templates/scripts-test-template.sh deleted file mode 100644 index 861a224..0000000 --- a/skills/plan/skeleton-gen/templates/scripts-test-template.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# scripts/test.sh —— 合并到默认分支(main / master)前的测试闸门。 -# 顺序:detect → setup-db → build → lint → unit+integration → e2e → reset-db -# 由 test-gate skill(通过子会话)调用。 - -set -euo pipefail - -PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$PROJECT_ROOT" - -# Stack detection (runtime, mode-agnostic) -HAS_BACKEND=0; [ -d backend ] && HAS_BACKEND=1 -HAS_FRONTEND=0; [ -d frontend ] && HAS_FRONTEND=1 -if [ $HAS_BACKEND -eq 0 ] && [ $HAS_FRONTEND -eq 0 ]; then - echo "[test.sh] FATAL: neither backend/ nor frontend/ exists" >&2 - exit 1 -fi - -echo "[test.sh] 1/6 setup test db" -./scripts/setup-test-db.sh - -echo "[test.sh] 2/6 build" -if [ $HAS_BACKEND -eq 1 ]; then (cd backend && {{backend_build}}); else echo "[test.sh] skip backend build"; fi -if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && {{frontend_build}}); else echo "[test.sh] skip frontend build"; fi - -echo "[test.sh] 3/6 lint" -if [ $HAS_BACKEND -eq 1 ]; then (cd backend && {{backend_lint}}); else echo "[test.sh] skip backend lint"; fi -if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && {{frontend_lint}}); else echo "[test.sh] skip frontend lint"; fi - -echo "[test.sh] 4/6 unit + integration" -if [ $HAS_BACKEND -eq 1 ]; then (cd backend && {{backend_test}}); else echo "[test.sh] skip backend test"; fi -if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && {{frontend_test}}); else echo "[test.sh] skip frontend test"; fi - -echo "[test.sh] 5/6 E2E" -{{e2e_cmd}} - -echo "[test.sh] 6/6 reset test db" -./scripts/setup-test-db.sh - -echo "[test.sh] GREEN" -- libgit2 0.22.2