From 337d85ea4c40db8308e35caac4303254c0874306 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 10:31:36 +0300 Subject: [PATCH] =?UTF-8?q?fix(process):=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BD=D0=B5=20=D0=BE=D0=B1=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=B4=D0=B8=D1=84=D1=84=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D0=B0=20=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D1=87=D0=B8=D0=BD=D1=83=20=D1=81=D0=B1=D0=BE=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шаг слияния конвейера считает patch-id материала и кандидата через `git diff --full-index`, когда `dev` сдвинулся за время ревью. Дифф задачи, пересобирающей бандл, несёт три копии `houseplan-card.js` — у #594 это 7 103 616 байт. `sh()` не задавал `maxBuffer`, умолчание `spawnSync` — 1 МиБ, и процесс убивался по ENOBUFS на 1 027 868 байтах. Вторая половина дефекта прятала первую: `r.error` нигде не читался, а `status: r.status ?? 1` выдавало `null` за «git вернул 1». `must()` брал `r.stderr || r.stdout`, stderr был пуст — и в issue уезжало «Слияние не выполнено: diff: <огрызок диффа>». Огрызок выглядит осмысленным и уводит разбор в сторону. Путь исполняется только при сдвинувшемся `dev`, поэтому дефект дремал. Но конвейер сам пушит в `dev` документ ревью на каждом раунде, так что `devMoved` — норма, а не исключение. Что изменено: - `MAX_COMMAND_OUTPUT_BYTES` (256 МиБ) и явный `maxBuffer` в `sh()`; - `sh()` называет сбой запуска в `stderr` результата вместо молчания; - `sh()` экспортирован, у обоих пунктов есть исполнимый свидетель; - мутант `merge-candidate-truncates-the-candidate-diff` возвращает 1 МиБ. Issue: #596 User-Visible: no --- scripts/merge-candidate.mjs | 22 +++++++++++++++++++--- scripts/mutation-registry.mjs | 12 ++++++++++++ test/merge-candidate.test.mjs | 25 ++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) 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, ''); +});