diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs index 74129ec9..bd0d7b60 100755 --- a/scripts/merge-candidate.mjs +++ b/scripts/merge-candidate.mjs @@ -99,9 +99,25 @@ export function commentFor(action, ctx) { // --------------------------------------------------------------------------- // Исполнение: git + gh через `ops`, чтобы тест подменял их целиком. -const sh = (cmd, args, opts = {}) => { - const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts }); - return { status: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }; +/** + * #596: сколько вывода готов принять один вызов. Умолчание `spawnSync` — 1 МиБ, + * а `git diff` кандидата несёт три копии бандла: у #594 это 7,1 МБ. Процесс + * убивался по ENOBUFS, `status` становился `null`, и `status ?? 1` выдавало это + * за «git вернул 1» — с УСЕЧЁННЫМ stdout в сообщении об ошибке. Разбор уходил + * в сторону: огрызок диффа выглядит осмысленным. + */ +export const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024 * 1024; + +/** + * Запуск с двумя гарантиями: вывод не обрезается молча, а сбой самого запуска + * не выдаёт себя за ненулевой код возврата. `r.error` (ENOBUFS, ENOENT, таймаут) + * уезжает в `stderr` результата, откуда его печатает `must()`. + */ +export const sh = (cmd, args, opts = {}) => { + const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: MAX_COMMAND_OUTPUT_BYTES, ...opts }); + const failure = r.error ? `${cmd} не выполнился: ${r.error.code || r.error.message}` : ''; + const stderr = [failure, (r.stderr || '').trim()].filter(Boolean).join('\n'); + return { status: failure ? 1 : (r.status ?? 1), stdout: (r.stdout || '').trim(), stderr }; }; export function realOps({ diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index b4bdf5b8..1f5b3aa0 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -9937,6 +9937,18 @@ const MUTANT_DEFINITIONS = [ replace: ' const mismatch = evidenceMismatch(proof.evidence, proof.evidence);', }], }, + { + id: 'merge-candidate-truncates-the-candidate-diff', + guard: 'node --test --test-name-pattern="#596" test/merge-candidate.test.mjs', + because: '#596: дифф кандидата с тремя копиями бандла — семь мегабайт. Без явного maxBuffer ' + + 'spawnSync убивает git по ENOBUFS, patch-id считается по огрызку, и слияние падает ' + + 'с усечённым диффом вместо причины', + patches: [{ + file: 'scripts/merge-candidate.mjs', + find: " const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: MAX_COMMAND_OUTPUT_BYTES, ...opts });", + replace: " const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 1024 * 1024, ...opts });", + }], + }, { id: 'declared-baseline-review-run-never-checked', guard: 'node --test --test-name-pattern="#595" test/release-gate.test.mjs', diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs index 2fd25eb8..321379e8 100755 --- a/test/merge-candidate.test.mjs +++ b/test/merge-candidate.test.mjs @@ -5,7 +5,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { MAX_ATTEMPTS, commentFor, decideMerge, mergeCandidate, realOps } from '../scripts/merge-candidate.mjs'; +import { + MAX_ATTEMPTS, MAX_COMMAND_OUTPUT_BYTES, commentFor, decideMerge, mergeCandidate, realOps, sh, +} from '../scripts/merge-candidate.mjs'; import { buildCiProof } from '../scripts/ci-proof.mjs'; const mergeProofContext = (row, sha, tree) => { @@ -387,3 +389,24 @@ test('#516 AC1: dev moved only by review documents and the branch carries its ow rmSync(dir, { recursive: true, force: true }); } }); + +// #596: шаг слияния считает patch-id через `git diff` кандидата, а тот несёт три +// копии бандла — семь мегабайт у #594. С умолчанием spawnSync в 1 МиБ процесс +// убивался по ENOBUFS, `status` приходил `null`, и вывод обрезался посередине. +test('#596: вывод больше мегабайта доезжает целиком, а не обрывается по буферу', () => { + const bytes = 3 * 1024 * 1024; + const r = sh(process.execPath, ['-e', `process.stdout.write('x'.repeat(${bytes}))`]); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.length, bytes); + assert.ok(MAX_COMMAND_OUTPUT_BYTES > bytes, 'предел выбран с запасом над проверяемым объёмом'); +}); + +// #596: вторая половина дефекта — сбой ЗАПУСКА выдавался за ненулевой код +// возврата, и в сообщение уезжал усечённый stdout. Огрызок диффа выглядит +// осмысленным и уводит разбор в сторону; причина обязана быть названа. +test('#596: сбой запуска называет причину, а не притворяется кодом возврата', () => { + const r = sh('houseplan-no-such-command-596', []); + assert.equal(r.status, 1); + assert.match(r.stderr, /houseplan-no-such-command-596 не выполнился: ENOENT/); + assert.equal(r.stdout, ''); +});