#!/usr/bin/env node // Приёмка веток исполнителей одной командой (TASK-GROK-3 п.1–2). // // node scripts/test/accept-branch.mjs [опции] <ветка> [<ветка>…] // // Для основы (--base) и каждой ветки: // 1. git worktree в $ACCEPT_ROOT (по умолчанию ../krona-staff-acceptance), detached HEAD; // node_modules — symlink на текущий репозиторий, если package-lock.json совпадает, иначе npm ci; // 2. оверлей ТЕКУЩЕГО стенда: tests/e2e/** и scripts/test/** из этого checkout // (tests/integration, src, миграции — версии ветки, не трогаются); // 3. `npm run check` без переменных БД; // 4. КАЖДЫЙ tests/integration/*.test.ts отдельно на свежей мигрированной БД PostgreSQL; // красный — по строке `Test Files` (ловит ошибки beforeAll/afterAll, при которых // `Tests N passed`), плюс ненулевой exit; // 5. e2e (scripts/test/run-e2e.mjs, миграции roundtrip) и сравнение с основой по ID сценария; // 6. попарно `git merge-tree --write-tree` — все ветки между собой и с основой; // 7. «форматирование без поведения» для веток с `format` в имени и для --format-check <ветка>: // версия файла из merge-base → prettier (конфиг основы) → сравнение с версией ветки. // Итог — markdown в stdout (и в --out). Логи — в tests/e2e/evidence/accept/<время>/. // // БД: по умолчанию E2E_PG_ADMIN_URL (bootstrap-админ) один раз создаёт роли со случайными // паролями, дальше только пересоздаёт БД `krona_staff_audit_acc_it`/`krona_acc_e2e` (provision-pg --roles keep). // Интеграционная БД в этом режиме заведомо изолирована, поэтому для тестов с «предохранителем» // (codex/fix-live-database: DATABASE_TEST_ISOLATED, DATABASE_TEST_GUARD_TOKEN, таблица // krona_staff_test_guard, имя БД krona_staff_audit_*) он выставляется со случайным маркером. // Файл, где все тесты пропущены, — отдельный вердикт SKIP (ничего не проверил). // Если заданы E2E_MIGRATOR_URL и E2E_APP_URL — роли не создаются и пароли не меняются, // все прогоны идут последовательно в БД из этих URL со сбросом через откат всех миграций. // // Опции: --base (origin/codex/bootstrap-platform-foundation), --format-check // (повторяемая; ветка проверяется только на формат, без worktree), --skip-check, // --skip-integration, --skip-e2e, --out <файл.md>. // Ветка задаётся как `origin/<имя>`, `<имя>` (ищется origin/<имя>, затем локальный ref) или SHA. import { spawn, spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import pg from 'pg'; import * as prettier from 'prettier'; const repoRoot = path.resolve(import.meta.dirname, '../..'); const acceptRoot = path.resolve( process.env.ACCEPT_ROOT ?? path.join(repoRoot, '..', 'krona-staff-acceptance'), ); // ---------- аргументы ---------- const argv = process.argv.slice(2); const opts = { base: 'origin/codex/bootstrap-platform-foundation', formatOnly: [], skipCheck: false, skipIntegration: false, skipE2e: false, out: null, branches: [], }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--base') opts.base = argv[++i]; else if (arg === '--format-check') opts.formatOnly.push(argv[++i]); else if (arg === '--skip-check') opts.skipCheck = true; else if (arg === '--skip-integration') opts.skipIntegration = true; else if (arg === '--skip-e2e') opts.skipE2e = true; else if (arg === '--out') opts.out = argv[++i]; else if (arg === '-h' || arg === '--help') { console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\nimport ')[0]); process.exit(0); } else if (arg.startsWith('--')) { console.error(`неизвестная опция ${arg}`); process.exit(2); } else opts.branches.push(arg); } if (opts.branches.length === 0 && opts.formatOnly.length === 0) { console.error('Usage: node scripts/test/accept-branch.mjs [--base ref] […]'); process.exit(2); } // ---------- утилиты ---------- const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const logDir = path.join(repoRoot, 'tests/e2e/evidence/accept', stamp); mkdirSync(logDir, { recursive: true }); const progress = (msg) => process.stderr.write(`[accept] ${msg}\n`); const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, ''); function git(args, { cwd = repoRoot, allowFail = false } = {}) { const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); if (r.status !== 0 && !allowFail) { throw new Error(`git ${args.join(' ')} → exit ${r.status}: ${r.stderr}`); } return { code: r.status, out: r.stdout, err: r.stderr }; } function run(command, args, { cwd, env, logFile, timeoutMs = 20 * 60_000 }) { return new Promise((resolve) => { const child = spawn(command, args, { cwd, env }); let out = ''; const timer = setTimeout(() => { out += `\n[accept] TIMEOUT ${timeoutMs} ms — процесс убит\n`; child.kill('SIGKILL'); }, timeoutMs); for (const stream of [child.stdout, child.stderr]) { stream.on('data', (chunk) => { out += chunk.toString(); }); } child.on('close', (code) => { clearTimeout(timer); const clean = stripAnsi(out); if (logFile) writeFileSync(logFile, `$ ${[command, ...args].join(' ')}\n${redact(clean)}`); resolve({ code: code ?? 1, out: clean }); }); }); } /** Пароли в URL не должны попасть в логи/отчёт. */ function redact(text) { return text.replace(/(postgres(?:ql)?:\/\/[^:\s/]+:)[^@\s]+@/g, '$1***@'); } /** Окружение без переменных БД и секретов стенда. */ function cleanEnv(extra = {}) { const env = {}; for (const [key, value] of Object.entries(process.env)) { if (/^(DATABASE_|E2E_|TEST_DATABASE|POSTGRES_|KRONA_)/.test(key)) continue; env[key] = value; } return { ...env, NO_COLOR: '1', FORCE_COLOR: '0', ...extra }; } function resolveRef(name) { const candidates = name.startsWith('origin/') ? [name] : [`origin/${name}`, name]; for (const candidate of candidates) { const r = git(['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`], { allowFail: true, }); if (r.code === 0) return { name, ref: candidate, sha: r.out.trim() }; } throw new Error(`ветка ${name} не найдена (ни origin/${name}, ни локально)`); } const slugOf = (name) => name .replace(/^origin\//, '') .replace(/[^A-Za-z0-9._-]+/g, '-') .slice(0, 80); const short = (sha) => sha.slice(0, 7); function firstError(text) { const lines = text.split('\n'); const patterns = [ /((?:[A-Za-z]+Error|error): .+)$/, /(AssertionError.+)$/, /(Error: .+)$/, /(FAIL .+)$/, ]; for (const pattern of patterns) { for (const line of lines) { const m = pattern.exec(line.trim()); if (m) return m[1].replace(/\|/g, '\\|').slice(0, 180); } } return ''; } // ---------- БД ---------- const presetMigrator = process.env.E2E_MIGRATOR_URL; const presetApp = process.env.E2E_APP_URL; const adminUrl = process.env.E2E_PG_ADMIN_URL; const dbMode = presetMigrator && presetApp ? 'preset' : 'admin'; let roleUrls = null; function withDatabase(url, database) { const u = new URL(url); u.pathname = `/${database}`; return u.toString(); } async function prepareRoles() { if (dbMode === 'preset') { roleUrls = { migrator: presetMigrator, app: presetApp }; return 'роли из E2E_MIGRATOR_URL/E2E_APP_URL (не пересоздавались)'; } if (!adminUrl) { throw new Error('нужен E2E_PG_ADMIN_URL или пара E2E_MIGRATOR_URL + E2E_APP_URL'); } const dir = mkdtempSync(path.join(tmpdir(), 'krona-accept-')); const envFile = path.join(dir, 'db.env'); const r = await run( process.execPath, ['scripts/test/provision-pg.mjs', '--out', envFile, '--db', 'krona_acc_roles'], { cwd: repoRoot, env: { ...cleanEnv(), E2E_PG_ADMIN_URL: adminUrl } }, ); if (r.code !== 0) throw new Error(`provision: ${r.out}`); const env = Object.fromEntries( readFileSync(envFile, 'utf8') .split('\n') .filter((l) => l.includes('=')) .map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]), ); rmSync(dir, { recursive: true, force: true }); roleUrls = { migrator: env.DATABASE_MIGRATION_URL, app: env.DATABASE_URL }; return 'роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep'; } /** Чистая БД: в admin-режиме пересоздаётся, в preset — одна и та же (сброс откатом миграций). */ async function freshDatabase(name) { if (dbMode === 'preset') return { ...roleUrls, reset: 'roundtrip' }; const r = await run( process.execPath, ['scripts/test/provision-pg.mjs', '--roles', 'keep', '--db', name], { cwd: repoRoot, env: { ...cleanEnv(), E2E_PG_ADMIN_URL: adminUrl } }, ); if (r.code !== 0) throw new Error(`provision --roles keep ${name}: ${r.out}`); return { migrator: withDatabase(roleUrls.migrator, name), app: withDatabase(roleUrls.app, name), admin: withDatabase(adminUrl, name), reset: 'up', }; } // ---------- worktree ---------- const repoLockHash = createHash('sha256') .update(readFileSync(path.join(repoRoot, 'package-lock.json'))) .digest('hex'); async function prepareWorktree(target) { const dir = path.join(acceptRoot, `wt-${slugOf(target.name)}`); mkdirSync(acceptRoot, { recursive: true }); const registered = git(['worktree', 'list', '--porcelain']) .out.split('\n') .some((l) => l === `worktree ${dir}`); if (registered) git(['worktree', 'remove', '--force', dir]); else if (existsSync(dir)) throw new Error(`${dir} существует, но не является worktree`); git(['worktree', 'add', '--detach', '--force', dir, target.sha]); const lock = git(['show', `${target.sha}:package-lock.json`]).out; const lockSame = createHash('sha256').update(lock).digest('hex') === repoLockHash; if (lockSame) { symlinkSync(path.join(repoRoot, 'node_modules'), path.join(dir, 'node_modules'), 'dir'); } else { progress(`${target.name}: package-lock.json отличается — npm ci`); const r = await run('npm', ['ci', '--no-audit', '--no-fund'], { cwd: dir, env: cleanEnv(), logFile: path.join(logDir, `${slugOf(target.name)}-npm-ci.txt`), }); if (r.code !== 0) throw new Error(`${target.name}: npm ci exit ${r.code}`); } // Оверлей стенда: tests/e2e/** (без evidence) и scripts/test/**. const overlayTouched = git([ 'diff', '--name-only', `${opts.baseTarget.sha}...${target.sha}`, '--', 'tests/e2e', 'scripts/test', ]).out.trim(); for (const rel of ['tests/e2e', 'scripts/test']) { rmSync(path.join(dir, rel), { recursive: true, force: true }); cpSync(path.join(repoRoot, rel), path.join(dir, rel), { recursive: true, filter: (src) => !src.includes(`${path.sep}tests${path.sep}e2e${path.sep}evidence`), }); } return { dir, deps: lockSame ? 'symlink' : 'npm ci', overlayTouched: overlayTouched ? overlayTouched.split('\n') : [], }; } // ---------- шаги ---------- async function stepCheck(target, wt) { progress(`${target.name}: npm run check`); const r = await run('npm', ['run', 'check'], { cwd: wt.dir, env: cleanEnv(), logFile: path.join(logDir, `${slugOf(target.name)}-check.txt`), }); const tests = /Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? ''; return { ok: r.code === 0, code: r.code, tests, error: r.code === 0 ? '' : firstError(r.out) }; } async function migrate(wt, db, logFile) { const r = await run(process.execPath, ['scripts/test/migrate-roundtrip.mjs', db.reset], { cwd: wt.dir, env: cleanEnv({ DATABASE_MIGRATION_URL: db.migrator }), logFile, }); return r; } /** Предохранитель изолированной тестовой БД (соглашение теста codex/fix-live-database). */ async function createGuard(db) { const marker = randomBytes(16).toString('hex'); const client = new pg.Client({ connectionString: db.migrator }); await client.connect(); try { await client.query( 'CREATE TABLE IF NOT EXISTS krona_staff_test_guard (marker text PRIMARY KEY)', ); await client.query('INSERT INTO krona_staff_test_guard (marker) VALUES ($1)', [marker]); await client.query('GRANT SELECT ON krona_staff_test_guard TO krona_staff_app'); } finally { await client.end(); } return marker; } async function stepIntegration(target, wt) { const dir = path.join(wt.dir, 'tests/integration'); const files = existsSync(dir) ? readdirSync(dir) .filter((f) => f.endsWith('.test.ts')) .sort() : []; const results = []; for (const file of files) { progress(`${target.name}: integration ${file}`); const slug = `${slugOf(target.name)}-it-${file.replace(/\.test\.ts$/, '')}`; const db = await freshDatabase('krona_staff_audit_acc_it'); const m = await migrate(wt, db, path.join(logDir, `${slug}-migrate.txt`)); const guard = m.code === 0 && dbMode === 'admin' ? await createGuard(db) : null; if (m.code !== 0) { results.push({ file, ok: false, files: '', tests: '', error: `миграции: ${firstError(m.out)}`, }); continue; } const r = await run( process.execPath, [path.join(wt.dir, 'node_modules/vitest/vitest.mjs'), 'run', `tests/integration/${file}`], { cwd: wt.dir, env: cleanEnv({ DATABASE_TEST_URL: db.app, DATABASE_TEST_MIGRATION_URL: db.migrator, DATABASE_MIGRATION_URL: db.migrator, ...(guard ? { DATABASE_TEST_ISOLATED: 'true', DATABASE_TEST_GUARD_TOKEN: guard } : {}), }), logFile: path.join(logDir, `${slug}.txt`), }, ); const filesLine = /Test Files\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '(нет строки Test Files)'; const testsLine = /^\s*Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? ''; const ok = r.code === 0 && /\bpassed\b/.test(filesLine) && !/failed/.test(filesLine); const skipped = r.code === 0 && /skipped/.test(filesLine) && !/passed|failed/.test(filesLine); results.push({ file, ok, skipped, files: filesLine, tests: testsLine, error: ok || skipped ? '' : firstError(r.out), }); } return results; } function loadE2e(file) { const map = new Map(); for (const f of JSON.parse(readFileSync(file, 'utf8')).testResults) { for (const t of f.assertionResults) { const id = /^\[([A-Z0-9/-]+)\]/.exec(t.title)?.[1] ?? 'UNTAGGED'; const e = map.get(id) ?? { pass: 0, fail: 0, msg: '' }; if (t.status === 'passed') e.pass += 1; else if (t.status === 'failed') { e.fail += 1; e.msg ||= stripAnsi(t.failureMessages?.[0] ?? '') .split('\n')[0] .replace(/\s+/g, ' ') .replace(/\|/g, '\\|') .slice(0, 160); } map.set(id, e); } } return map; } async function stepE2e(target, wt) { progress(`${target.name}: e2e`); const db = await freshDatabase('krona_acc_e2e'); const slug = slugOf(target.name); const env = cleanEnv({ E2E_MIGRATOR_URL: db.migrator, E2E_APP_URL: db.app, E2E_MIGRATIONS: 'roundtrip', ...(db.admin ? { E2E_DB_ADMIN_URL: db.admin } : {}), }); const r = await run(process.execPath, ['scripts/test/run-e2e.mjs'], { cwd: wt.dir, env, logFile: path.join(logDir, `${slug}-e2e.txt`), }); const jsonPath = path.join(wt.dir, 'tests/e2e/evidence/e2e-results.json'); if (!existsSync(jsonPath)) return { ok: false, code: r.code, map: null, error: firstError(r.out) }; cpSync(jsonPath, path.join(logDir, `${slug}-e2e-results.json`)); const map = loadE2e(jsonPath); const red = [...map.values()].filter((e) => e.fail > 0).length; const verdict = /\[run-e2e\] (OK|FAIL)[^\n]*/.exec(r.out)?.[0] ?? ''; return { ok: r.code === 0, code: r.code, map, red, green: map.size - red, verdict }; } function diffE2e(baseMap, map) { const color = (e) => (e ? (e.fail ? 'RED' : 'GREEN') : 'нет'); const turnedGreen = []; const turnedRed = []; const other = []; for (const id of [...new Set([...baseMap.keys(), ...map.keys()])].sort()) { const a = color(baseMap.get(id)); const b = color(map.get(id)); if (a === b) continue; if (a === 'RED' && b === 'GREEN') turnedGreen.push(id); else if (a === 'GREEN' && b === 'RED') turnedRed.push(`${id} (${map.get(id).msg})`); else other.push(`${id}: ${a}→${b}`); } return { turnedGreen, turnedRed, other }; } function mergeTree(a, b) { const r = git(['merge-tree', '--write-tree', '--name-only', '--no-messages', a.sha, b.sha], { allowFail: true, }); if (r.code === 0) return { status: 'чисто', files: [] }; if (r.code === 1) { const files = r.out.split('\n').slice(1).filter(Boolean); return { status: 'КОНФЛИКТ', files }; } return { status: `ошибка git (exit ${r.code})`, files: [r.err.trim()] }; } // ---------- форматирование без поведения ---------- async function prettierConfigFor(file) { const anchor = path.join(opts.baseWorktree ?? repoRoot, file); const config = (await prettier.resolveConfig(anchor)) ?? {}; const info = await prettier.getFileInfo(anchor, { ignorePath: path.join(opts.baseWorktree ?? repoRoot, '.prettierignore'), }); return { config, info }; } async function normalize(content, file) { const { config, info } = await prettierConfigFor(file); if (info.ignored || !info.inferredParser) return content; return prettier.format(content, { ...config, filepath: file }); } function showFile(sha, file) { const r = git(['show', `${sha}:${file}`], { allowFail: true }); return r.code === 0 ? r.out : null; } function changedLines(before, after) { const dir = mkdtempSync(path.join(tmpdir(), 'krona-fmt-')); writeFileSync(path.join(dir, 'a'), before); writeFileSync(path.join(dir, 'b'), after); const r = git(['diff', '--no-index', '-U0', '--no-color', 'a', 'b'], { cwd: dir, allowFail: true, }); rmSync(dir, { recursive: true, force: true }); return r.out .split('\n') .filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)) .map((l) => `${l[0]}${l.slice(1).trim()}`) .filter((l) => l.length > 1); } async function formatCheck(target, others) { const mergeBase = git(['merge-base', opts.baseTarget.sha, target.sha]).out.trim(); const changes = git(['diff', '--name-status', '--no-renames', mergeBase, target.sha]) .out.trim() .split('\n') .filter(Boolean) .map((l) => ({ status: l[0], file: l.split('\t')[1] })); const rows = []; for (const { status, file } of changes) { const touchesConfig = /(^|\/)\.prettier(rc|ignore)|prettier\.config\./.test(file); if (status === 'D') { rows.push({ file, verdict: 'удалён', lines: 0, same: [] }); continue; } const after = showFile(target.sha, file); const before = status === 'A' ? null : showFile(mergeBase, file); if (before === null) { rows.push({ file, verdict: 'новый файл', lines: 0, same: [] }); continue; } const formatted = await normalize(before, file); if (formatted === after && !touchesConfig) continue; // чистое форматирование const lines = changedLines(formatted, after); const same = []; for (const other of others) { const otherContent = showFile(other.sha, file); if (otherContent === null) continue; const otherMergeBase = git(['merge-base', opts.baseTarget.sha, other.sha]).out.trim(); const otherBase = showFile(otherMergeBase, file) ?? ''; const otherLines = new Set( changedLines(await normalize(otherBase, file), await normalize(otherContent, file)), ); const matched = lines.filter((l) => otherLines.has(l)).length; if (matched > 0) same.push(`${other.name}: ${matched}/${lines.length}`); } rows.push({ file, verdict: touchesConfig ? 'меняет конфиг prettier' : 'не совпадает с prettier(база)', lines: lines.length, same, }); } return { mergeBase, files: changes.length, rows }; } // ---------- main ---------- opts.baseTarget = resolveRef(opts.base); const targets = opts.branches.map(resolveRef); const formatTargets = [ ...targets.filter((t) => /format/i.test(t.name)), ...opts.formatOnly.map(resolveRef), ]; const harnessHead = git(['rev-parse', '--short', 'HEAD']).out.trim(); const harnessDirty = git(['status', '--porcelain', '--', 'tests/e2e', 'scripts/test']).out.trim() ? ' (+ незакоммиченные изменения стенда)' : ''; const needRuns = !(opts.skipCheck && opts.skipIntegration && opts.skipE2e) && targets.length > 0; let dbNote = 'не использовалась'; if (needRuns && !(opts.skipIntegration && opts.skipE2e)) dbNote = await prepareRoles(); const results = new Map(); const all = [opts.baseTarget, ...targets]; for (const target of all) { const res = { target }; results.set(target, res); if (!needRuns && target !== opts.baseTarget) continue; try { const wt = await prepareWorktree(target); res.wt = wt; if (target === opts.baseTarget) opts.baseWorktree = wt.dir; if (!needRuns) continue; if (!opts.skipCheck) res.check = await stepCheck(target, wt); if (!opts.skipIntegration) res.integration = await stepIntegration(target, wt); if (!opts.skipE2e) res.e2e = await stepE2e(target, wt); } catch (error) { res.fatal = redact(String(error?.message ?? error)).slice(0, 300); progress(`${target.name}: ${res.fatal}`); } } const formatResults = []; for (const target of formatTargets) { progress(`${target.name}: проверка форматирования`); const others = targets.filter((t) => t !== target && !/format/i.test(t.name)); formatResults.push({ target, ...(await formatCheck(target, others)) }); } // ---------- markdown ---------- const md = []; const baseRes = results.get(opts.baseTarget); md.push(`## accept-branch — ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC`); md.push(''); md.push( `База: \`${opts.baseTarget.ref}\` = ${short(opts.baseTarget.sha)}; стенд (оверлей tests/e2e, scripts/test): ${harnessHead}${harnessDirty}; БД: ${dbNote}. Логи: \`${path.relative(repoRoot, logDir)}/\`.`, ); md.push(''); if (needRuns) { md.push('### Сводка'); md.push(''); md.push( '| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |', ); md.push('|---|---|---|---|---|---|---|'); for (const res of results.values()) { const t = res.target; const isBase = t === opts.baseTarget; const name = isBase ? `${t.ref} (база)` : t.ref; if (res.fatal) { md.push(`| ${name} | ${short(t.sha)} | ОШИБКА: ${res.fatal} | | | | |`); continue; } const check = res.check ? res.check.ok ? `OK (${res.check.tests})` : `FAIL exit=${res.check.code}: ${res.check.error}` : 'не запускался'; const reds = res.integration?.filter((r) => !r.ok && !r.skipped).map((r) => r.file) ?? []; const skips = res.integration?.filter((r) => r.skipped).map((r) => r.file) ?? []; const greens = (res.integration?.length ?? 0) - reds.length - skips.length; const integration = res.integration ? `${greens}/${res.integration.length} зелёных${reds.length ? `; FAIL: ${reds.join(', ')}` : ''}${skips.length ? `; SKIP (файл пропущен целиком): ${skips.join(', ')}` : ''}` : 'не запускались'; let e2e = 'не запускался'; let green = ''; let red = ''; if (res.e2e?.map) { e2e = `${res.e2e.red}/${res.e2e.green}`; if (!isBase && baseRes.e2e?.map) { const d = diffE2e(baseRes.e2e.map, res.e2e.map); green = d.turnedGreen.join(', ') || '—'; red = [...d.turnedRed, ...d.other].join('; ') || '—'; } } else if (res.e2e) e2e = `стенд не поднялся: ${res.e2e.error}`; if (isBase) { green = 'n/a'; red = 'n/a'; } md.push( `| ${name} | ${short(t.sha)} | ${check} | ${integration} | ${e2e} | ${green} | ${red} |`, ); } md.push(''); if ( results.values().next().value.integration || targets.some((t) => results.get(t).integration) ) { md.push('### Интеграционные тесты (каждый файл отдельно, свежая БД)'); md.push(''); md.push('| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |'); md.push('|---|---|---|---|---|---|'); for (const res of results.values()) { for (const r of res.integration ?? []) { md.push( `| ${res.target.ref} | ${r.file} | ${r.ok ? 'OK' : r.skipped ? 'SKIP' : 'FAIL'} | ${r.files} | ${r.tests} | ${r.error} |`, ); } } md.push(''); } const overlayNotes = [...results.values()].filter((r) => r.wt?.overlayTouched.length); if (overlayNotes.length) { md.push('Внимание: ветка меняла файлы стенда, при приёмке они заменены оверлеем:'); for (const r of overlayNotes) md.push(`- ${r.target.ref}: ${r.wt.overlayTouched.join(', ')}`); md.push(''); } const npmCi = [...results.values()].filter((r) => r.wt?.deps === 'npm ci'); if (npmCi.length) md.push(`npm ci (lock отличается): ${npmCi.map((r) => r.target.ref).join(', ')}\n`); } if (targets.length > 0) { md.push('### Попарные слияния (`git merge-tree --write-tree`)'); md.push(''); md.push('| Ветка A | Ветка B | Результат | Конфликтные файлы |'); md.push('|---|---|---|---|'); const pairs = []; for (const t of targets) pairs.push([opts.baseTarget, t]); for (let i = 0; i < targets.length; i += 1) { for (let j = i + 1; j < targets.length; j += 1) pairs.push([targets[i], targets[j]]); } for (const [a, b] of pairs) { const r = mergeTree(a, b); md.push(`| ${a.ref} | ${b.ref} | ${r.status} | ${r.files.join(', ')} |`); } md.push(''); } if (formatResults.length) { md.push('### Форматирование без поведения'); md.push(''); for (const f of formatResults) { const verdict = f.rows.length === 0 ? 'ТОЛЬКО ФОРМАТИРОВАНИЕ' : 'ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER'; md.push( `**${f.target.ref}** (${short(f.target.sha)}), merge-base ${short(f.mergeBase)}, изменено файлов: ${f.files} — ${verdict}`, ); md.push(''); if (f.rows.length) { md.push('| Файл | Почему | Строк сверх prettier | Эти же строки в ветке |'); md.push('|---|---|---|---|'); for (const r of f.rows) { md.push(`| ${r.file} | ${r.verdict} | ${r.lines} | ${r.same.join('; ') || '—'} |`); } md.push(''); } } } const text = redact(md.join('\n')); writeFileSync(path.join(logDir, 'summary.md'), text); if (opts.out) writeFileSync(opts.out, text); console.log(text);