fix(process): слияние не обрывает дифф кандидата и называет причину сбоя запуска

Шаг слияния конвейера считает 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
This commit is contained in:
Claude
2026-09-19 10:31:36 +03:00
parent 0ebefbb189
commit 337d85ea4c
3 changed files with 55 additions and 4 deletions
+19 -3
View File
@@ -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({
+12
View File
@@ -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',
+24 -1
View File
@@ -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, '');
});