From 5b6d5d1d8f1c9da9d5b520fbc4f6b6bc1fe5e464 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 08:59:17 +0300 Subject: [PATCH] =?UTF-8?q?test:=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20Baseline-Reviewed=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D0=BE=D1=82=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=20=D0=BE=D1=82=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=88=D0=B8=D0=BD=D1=8B=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test/release-gate.test.mjs` строил ожидания из живого состояния репозитория, а `localEvidence` читает `baselines.reviewedRun` из сообщения HEAD-коммита. Пока вершиной был обычный коммит, поле оставалось `null` и всё сходилось. Как только вершиной становился коммит приёмки эталонов, поле становилось непустым, и `evaluateCiProof` законно требовал объявленный прогон — которого заглушка `loadContext` не отдавала. Тест краснел на всей ветке, ради которой #573 и писалась, и вместе с ним краснел бы `dev` сразу после слияния такой ветки. Воспроизведено на `issue/594-form-kit-room@68bb7558`: 2776 pass / 1 fail, из-за чего конвейер ревью не запустился вовсе. Продуктовый код не менялся: контракт «объявленный прогон обязан существовать, быть завершённым, не отменённым и быть Validate» — ровно то, что задумано, и он намеренно не требует `success`, потому что приёмка эталонов по определению ссылается на прогон, где golden покраснел. Что изменено: - зелёный путь теста считается на evidence с `baselines.reviewedRun: null` и больше не зависит от того, каким коммитом оказалась вершина; - контракт объявленного прогона вынесен в отдельный тест и проверяется с трёх сторон: завершённый неотменённый Validate с `conclusion: 'failure'` — зелёно; прогона нет — отказ; прогон отменён — отказ; - мутант `declared-baseline-review-run-never-checked` обнуляет объявление и краснеет на новом тесте. Доказательство на фикстуре «вершина — приёмка эталонов»: старый тест 8 pass / 1 fail, новый 10 pass / 0 fail. Issue: #595 User-Visible: no --- scripts/mutation-registry.mjs | 12 ++++++ test/release-gate.test.mjs | 72 +++++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index b36309e3..b4bdf5b8 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: 'declared-baseline-review-run-never-checked', + guard: 'node --test --test-name-pattern="#595" test/release-gate.test.mjs', + because: '#573/#595: коммит приёмки эталонов объявляет прогон, на кадры которого смотрели. ' + + 'Если объявление не резолвится, трейлер Baseline-Reviewed становится честным словом: ' + + 'сослаться можно на что угодно, включая отменённый или несуществующий прогон', + patches: [{ + file: 'scripts/ci-proof.mjs', + find: ' const declared = proof.evidence.baselines?.reviewedRun ?? null;', + replace: ' const declared = null;', + }], + }, { id: 'reused-marker-key-unchecked-against-candidate', guard: 'node --test --test-name-pattern="#573" test/ci-proof.test.mjs', diff --git a/test/release-gate.test.mjs b/test/release-gate.test.mjs index eabcb1b2..19c3fa4c 100644 --- a/test/release-gate.test.mjs +++ b/test/release-gate.test.mjs @@ -140,21 +140,33 @@ test('#573: ожидания считаются только на checkout ка const own = candidateExpectations({ sha: head, root, log: (line) => notes.push(line) }); assert.deepEqual(own, localEvidence(root)); + // #595: дальше ожидания строятся из `own`, но с ЯВНЫМ `baselines.reviewedRun`. + // Читать это поле из живого HEAD нельзя: `localEvidence` берёт его из сообщения + // последнего коммита, и на вершине-приёмке эталонов оно непустое. Тогда + // `evaluateCiProof` законно требует объявленный прогон, которого заглушка не + // отдавала, — и тест краснел на всей ветке, ради которой #573 и писалась. + const withoutReviewed = (evidence) => { + const copy = structuredClone(evidence); + copy.baselines.reviewedRun = null; + return copy; + }; + const expected = withoutReviewed(own); + // proof без evidence при наличии ожиданий — stale, а старее его нет → missing; с evidence и совпадением — green const legacy = proofContext({ id: 40 }); const verdict = await classifyValidateProofs({ - runs: [legacy.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected: own, + runs: [legacy.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected, loadContext: async () => legacy.context, }); assert.equal(verdict.status, 'missing', verdict.note); const modern = proofContext({ id: 41 }); - modern.context.proof.evidence = structuredClone(own); + modern.context.proof.evidence = structuredClone(expected); const green = await classifyValidateProofs({ - runs: [modern.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected: own, + runs: [modern.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected, loadContext: async () => modern.context, }); assert.equal(green.status, 'green', green.note); - const substituted = structuredClone(own); + const substituted = structuredClone(expected); substituted.keys.golden = '0'.repeat(64); const red = await classifyValidateProofs({ runs: [modern.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected: substituted, @@ -163,3 +175,55 @@ test('#573: ожидания считаются только на checkout ка assert.equal(red.status, 'failed'); assert.match(red.note, /keys\.golden/); }); + +// #595: объявленный `Baseline-Reviewed` прогон — отдельный контракт, и он обязан +// проверяться обеими сторонами, а не случайно попадать в проверку вместе с тем, +// каким коммитом оказалась вершина ветки. +test('#595: объявленный Baseline-Reviewed прогон обязан существовать, но не обязан быть зелёным', async () => { + const root = fileURLToPath(new URL('..', import.meta.url)); + const head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + const base = candidateExpectations({ sha: head, root }); + const expected = structuredClone(base); + expected.baselines.reviewedRun = 35407468491; + + // Приёмка эталонов ПО ОПРЕДЕЛЕНИЮ ссылается на прогон, где golden покраснел: + // именно этот прогон и снял кадры-кандидаты. Поэтому `conclusion: 'failure'` + // здесь не поблажка, а рабочий случай — требуется лишь завершённость, + // неотменённость и то, что это Validate. + const accepted = proofContext({ id: 51 }); + accepted.context.proof.evidence = structuredClone(expected); + accepted.context.reviewedRun = { run: { + databaseId: 35407468491, status: 'completed', conclusion: 'failure', + path: '.github/workflows/validate.yml', + } }; + const green = await classifyValidateProofs({ + runs: [accepted.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected, + loadContext: async () => accepted.context, + }); + assert.equal(green.status, 'green', green.note); + + // А вот прогона нет вовсе — объявление не подтверждается ничем. + const orphan = proofContext({ id: 52 }); + orphan.context.proof.evidence = structuredClone(expected); + orphan.context.reviewedRun = null; + const missing = await classifyValidateProofs({ + runs: [orphan.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected, + loadContext: async () => orphan.context, + }); + assert.equal(missing.status, 'failed'); + assert.match(missing.note, /Baseline-Reviewed run 35407468491/); + + // И отменённый прогон объявлением тоже не считается. + const cancelled = proofContext({ id: 53 }); + cancelled.context.proof.evidence = structuredClone(expected); + cancelled.context.reviewedRun = { run: { + databaseId: 35407468491, status: 'completed', conclusion: 'cancelled', + path: '.github/workflows/validate.yml', + } }; + const dropped = await classifyValidateProofs({ + runs: [cancelled.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected, + loadContext: async () => cancelled.context, + }); + assert.equal(dropped.status, 'failed'); + assert.match(dropped.note, /Baseline-Reviewed run 35407468491/); +});