From 65339f63ad0c8c2688d8e00d59e12c278798fde1 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sun, 30 Aug 2026 15:55:02 +0300 Subject: [PATCH] ci: judge the push range from the last proven-green ancestor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #387 закрыл классификацию — какие job запускать. Здесь остаток того же дефекта: гейты, которые судят сам диапазон коммитов. Провенанс, процессный гейт и «новый код не добавляет any» брали диапазон от головы предыдущего пуша, а concurrency отменяет прогон предыдущего пуша штатно. Тогда его коммиты не судит никто: свой прогон отменён, а следующий пуш сравнивает уже с ними. Окно не закрывается никогда. Уязвим был прямой пуш в dev — основной режим конвейера. На ветках дефекта нет: no-new-any там всегда считает от merge-base, а resolveValidationRange подменяет осиротевший before на origin/dev (#315). База стала последним предком с успешно завершённым Validate. Фолбэк, когда такого нет, сознательно оставлен прежним — before, но с пометкой в summary «диапазон недоказуем». Расширять диапазон здесь нельзя: гейт, который сам красит прогон, лишил бы следующий пуш зелёного предка и запер dev в красноте навсегда. Фолбэк обязан не зависеть от собственного успеха гейта. Дыра сужается с «всегда, когда прогон предыдущего пуша отменён» до «когда во всём окне обхода нет ни одного успешного прогона». Находки no-new-any теперь называют коммит, добавивший строку: диапазон стал шире, и без имени источника сообщение обвиняло бы того, кто пушнул следующим, — ровно то, что чинили в #386 для golden. Issue: #388 User-Visible: no --- .github/workflows/validate.yml | 73 +++++++++++++++++++---- scripts/classify-base.mjs | 101 +++++++++++++++++++++++++++----- scripts/no-new-any.mjs | 30 +++++++++- test/classify-base.test.mjs | 77 +++++++++++++++++++++++- test/no-new-any.test.mjs | 27 ++++++++- test/validate-workflow.test.mjs | 38 +++++++++++- 6 files changed, 315 insertions(+), 31 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 10c42711..889d6cb4 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -32,6 +32,13 @@ jobs: preflight: name: "Предполётные проверки: документация, провенанс, процесс" runs-on: ubuntu-latest + # `actions: read` — чтение списка прогонов Validate (#388), `issues: read` — + # проверка 8 процессного гейта. Права перечислены явно, потому что job + # обращается к API сверх содержимого репозитория. + permissions: + contents: read + actions: read + issues: read steps: # `blob:none` при полной истории (#345): этой job нужны сообщения # коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых @@ -69,12 +76,35 @@ jobs: exit 1 fi + # Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до + # #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша + # штатно отменяется следующим (concurrency), и тогда его коммиты не судит + # никто: свой прогон отменён, а следующий пуш сравнивает уже с ними. + # + # База — последний предок с УСПЕШНО завершённым Validate. Фолбэк остаётся + # прежним `before`: расширять диапазон здесь нельзя, иначе гейт, который + # сам красит прогон, лишает следующий пуш зелёного предка и запирает dev + # в красноте навсегда. + - name: "База диапазона: последний доказанно зелёный предок" + id: range + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.sha }} + REPO: ${{ github.repository }} + FALLBACK: ${{ github.event.before }} + run: | + gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \ + -f branch=dev -f status=success -F per_page=100 \ + > /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json + node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \ + --fallback="$FALLBACK" --runs=/tmp/validate-runs.json - name: "Провенанс коммитов: трейлеры и эталоны" id: provenance continue-on-error: true env: EVENT_NAME: ${{ github.event_name }} - BEFORE_SHA: ${{ github.event.before }} + BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.sha }} DEVELOPMENT_BRANCH: dev @@ -92,7 +122,7 @@ jobs: continue-on-error: true env: EVENT_NAME: ${{ github.event_name }} - BEFORE_SHA: ${{ github.event.before }} + BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.sha }} DEVELOPMENT_BRANCH: dev @@ -150,6 +180,11 @@ jobs: backend: ${{ steps.classify.outputs.backend }} integration: ${{ steps.classify.outputs.integration }} base: ${{ steps.base.outputs.base }} + # Разные вещи под разными именами намеренно: `base` — до какого коммита + # классифицировать файлы ветки (#387), `range_base` — от какого коммита + # судить сам диапазон на dev (#388). Общее имя означало бы, что + # потребитель однажды молча возьмёт чужую базу. + range_base: ${{ steps.base.outputs.range_base }} steps: # `git diff --name-only` содержимого файлов не читает вовсе, поэтому # блобы истории этой job не нужны ни на одном шаге (#345). @@ -161,20 +196,29 @@ jobs: # предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки # без единого исполненного тяжёлого гейта. - id: base - if: github.event_name != 'pull_request' && github.ref != 'refs/heads/dev' + if: github.event_name != 'pull_request' env: GH_TOKEN: ${{ github.token }} HEAD_SHA: ${{ github.sha }} BRANCH: ${{ github.ref_name }} REPO: ${{ github.repository }} + REF: ${{ github.ref }} + FALLBACK: ${{ github.event.before }} run: | git fetch -q origin dev - merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1") - # Недоступность API — не отказ гейта: пустой ответ опускает базу до - # merge-base, то есть в сторону БОЛЬШЕГО объёма проверок. + # Недоступность API — не отказ гейта: пустой ответ уводит базу в + # сторону БОЛЬШЕГО объёма проверок, а не меньшего. gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \ -f branch="$BRANCH" -f status=success -F per_page=100 \ > /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json + if [ "$REF" = "refs/heads/dev" ]; then + # На dev классифицировать нечего (всё true), но база диапазона + # нужна гейту «новый код не добавляет any» в job frontend (#388). + node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \ + --name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json + exit 0 + fi + merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1") node scripts/classify-base.mjs --head="$HEAD_SHA" \ --merge-base="$merge_base" --runs=/tmp/validate-runs.json - id: classify @@ -374,17 +418,24 @@ jobs: EVENT_NAME: ${{ github.event_name }} BEFORE_SHA: ${{ github.event.before }} BASE_SHA: ${{ github.event.pull_request.base.sha }} + PROVEN_BASE: ${{ needs.changes.outputs.range_base }} HEAD_SHA: ${{ github.sha }} REF: ${{ github.ref }} run: | set -euo pipefail git fetch -q origin dev zero=$(printf '%040d' 0) - base="$BEFORE_SHA" - if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi - # Пуш прямо в dev: диапазон — то, что этот пуш добавил. Новая ветка или - # перезаписанная история: точки отсчёта нет, берём merge-base с dev. - if [ "$REF" != "refs/heads/dev" ] || [ -z "$base" ] || [ "$base" = "$zero" ] \ + base="" + # Пуш прямо в dev: диапазон — то, что добавлено с последнего + # ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша: + # его прогон штатно отменяется следующим, и добавленные им строки не + # судил бы никто. Фолбэк — прежний `before`. + if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then + base="${PROVEN_BASE:-$BEFORE_SHA}" + fi + # Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev. + # Там диапазон и так шире, поэтому #388 этих случаев не касается. + if [ -z "$base" ] || [ "$base" = "$zero" ] \ || ! git cat-file -e "$base" 2>/dev/null; then base=$(git merge-base origin/dev "$HEAD_SHA" || echo "") fi diff --git a/scripts/classify-base.mjs b/scripts/classify-base.mjs index fdc0fc33..9038fbe6 100644 --- a/scripts/classify-base.mjs +++ b/scripts/classify-base.mjs @@ -1,4 +1,5 @@ -// База для классификации изменённых файлов (issue #387). +// База диапазона: до какого коммита назад считать, что «уже проверено» +// (issue #387 — классификация файлов, issue #388 — гейты диапазона). // // Job `changes` решает, запускать ли тяжёлые гейты, по списку файлов в // диапазоне. Раньше диапазон брался от `github.event.before` — головы ветки на @@ -24,10 +25,10 @@ // проверки. Одно незавершённое звено рвёт цепочку — и именно оно теперь // заставляет расширить диапазон, а не сузить. // -// `github.event.before` больше не читается вовсе. Отдельная ветка про -// force-push (#347) поэтому не нужна: кандидаты берутся из `rev-list -// ..HEAD`, то есть предки HEAD по построению, а переписанная -// история просто не даёт зелёных совпадений и опускает базу до merge-base. +// Два режима, и разница между ними только в фолбэке — см. `pickBase` и +// `pickRangeBase`. Классификация (#387) может опуститься до merge-base с dev; +// гейтам диапазона (#388) на пуше прямо в dev опускаться некуда, и там фолбэк +// остаётся прежним `before`, но с явной пометкой «недоказуемо». import { execFileSync } from 'node:child_process'; import { appendFileSync, readFileSync } from 'node:fs'; @@ -62,14 +63,60 @@ export function greenShas(payload) { * @param mergeBase merge-base с dev: пол, ниже которого опускаться незачем. */ export function pickBase({ candidates, green, mergeBase }) { - const list = Array.isArray(candidates) ? candidates.slice(0, MAX_CANDIDATES) : []; + const found = firstGreen(candidates, green); + if (found) return { ...found, reason: 'green-ancestor', proven: true }; + return { + base: mergeBase, + reason: 'merge-base', + proven: false, + skipped: capped(candidates).length, + }; +} + +/** Кандидаты в пределах обхода. */ +const capped = (candidates) => + (Array.isArray(candidates) ? candidates : []).slice(0, MAX_CANDIDATES); + +/** Самый новый зелёный предок либо null. */ +function firstGreen(candidates, green) { + const list = capped(candidates); const proven = green instanceof Set ? green : new Set(); for (let i = 0; i < list.length; i += 1) { - if (proven.has(list[i])) { - return { base: list[i], reason: 'green-ancestor', proven: true, skipped: i }; - } + if (proven.has(list[i])) return { base: list[i], skipped: i }; } - return { base: mergeBase, reason: 'merge-base', proven: false, skipped: list.length }; + return null; +} + +/** + * База для гейтов, судящих САМ диапазон коммитов, — провенанс, процессный гейт, + * `no-new-any` (issue #388). Отличие от классификации принципиальное, и оно в + * фолбэке. + * + * У классификации есть естественный пол — merge-base с dev. У пуша прямо в dev + * пола нет: merge-base совпадает с HEAD, и такой фолбэк дал бы ПУСТОЙ диапазон, + * то есть молча проходящий гейт. Поэтому здесь фолбэк — `before` события, как + * было до #388, но с явной пометкой «диапазон недоказуем». + * + * Почему не расширять диапазон, когда зелёного предка не нашлось. Расширение + * кажется строже, но у него два своих провала: процессный гейт начал бы судить + * старые коммиты по сегодняшним правилам, а главное — гейт, который сам красит + * прогон, лишает следующий пуш зелёного предка и запирает dev в красноте + * навсегда. Фолбэк обязан не зависеть от собственного успеха этого гейта. + * + * Дыра при этом не остаётся прежней: она сужается с «всегда, когда прогон + * предыдущего пуша отменён» до «когда во всём окне обхода нет ни одного + * успешного прогона». Первое случается ежедневно, второе — при сломанном CI, + * где красный Validate и так уместен. + */ +export function pickRangeBase({ candidates, green, fallback }) { + const found = firstGreen(candidates, green); + if (found) return { ...found, reason: 'green-ancestor', proven: true }; + return { + base: fallback || '', + reason: 'fallback', + proven: false, + skipped: capped(candidates).length, + }; } const short = (sha) => (typeof sha === 'string' ? sha.slice(0, 8) : '?'); @@ -86,6 +133,15 @@ export function baseSummary(choice, { head, mergeBase }) { + ` для которого Validate завершился успешно.${skipped}`, ]; } + if (choice.reason === 'fallback') { + return [ + '### База диапазона (#388)', + `Ни у одного из ${choice.skipped} предков нет завершённого зелёного Validate.` + + ` Диапазон взят от \`${short(choice.base)}\` — головы предыдущего пуша,` + + ' и это НЕ доказательство проверенности: прогон того пуша мог быть отменён.' + + ' Коммиты в этом окне могли не пройти ни одного гейта.', + ]; + } return [ '### База классификации (#387)', 'Ни у одного предка до merge-base с dev нет завершённого зелёного Validate,' @@ -100,10 +156,13 @@ const arg = (argv, name, fallback = '') => function main(argv) { const head = arg(argv, 'head'); - const mergeBase = arg(argv, 'merge-base'); + const mode = arg(argv, 'mode', 'classify'); + const mergeBase = arg(argv, 'merge-base', mode === 'range' ? head : ''); const runsFile = arg(argv, 'runs'); if (!head || !mergeBase) { - process.stderr.write('usage: classify-base.mjs --head= --merge-base= [--runs=]\n'); + process.stderr.write('usage: classify-base.mjs --head=' + + ' [--mode=classify|range] [--merge-base=] [--fallback=]' + + ' [--name=] [--runs=]\n'); process.exit(2); } let payload = null; @@ -117,15 +176,27 @@ function main(argv) { } // `--skip=1` убирает сам HEAD: его прогон — это текущий, зелёным он быть не // может по определению. + // + // В режиме `range` (пуш прямо в dev) пола нет: обход идёт по истории до + // предела MAX_CANDIDATES, потому что merge-base с dev здесь совпал бы с HEAD. + const span = mode === 'range' ? head : `${mergeBase}..${head}`; const candidates = execFileSync('git', [ - 'rev-list', `--max-count=${MAX_CANDIDATES}`, '--skip=1', `${mergeBase}..${head}`, + 'rev-list', `--max-count=${MAX_CANDIDATES}`, '--skip=1', span, ], { encoding: 'utf8' }).split('\n').map((line) => line.trim()).filter(Boolean); - const choice = pickBase({ candidates, green: greenShas(payload), mergeBase }); + const green = greenShas(payload); + const choice = mode === 'range' + ? pickRangeBase({ candidates, green, fallback: arg(argv, 'fallback') }) + : pickBase({ candidates, green, mergeBase }); const summary = baseSummary(choice, { head, mergeBase }); process.stdout.write(`${summary.join('\n')}\n`); + // Имя выхода задаётся явно: одна и та же job считает базу для двух разных + // потребителей, и общее имя `base` для обоих было бы ловушкой — потребитель + // молча взял бы чужую базу, а разницу между режимами видно только здесь. + const name = arg(argv, 'name', 'base'); if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `base=${choice.base}\nproven=${choice.proven}\n`); + appendFileSync(process.env.GITHUB_OUTPUT, + `${name}=${choice.base}\n${name}_proven=${choice.proven}\n`); } if (process.env.GITHUB_STEP_SUMMARY) { appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.join('\n\n')}\n`); diff --git a/scripts/no-new-any.mjs b/scripts/no-new-any.mjs index 5322ad41..33dfae6c 100644 --- a/scripts/no-new-any.mjs +++ b/scripts/no-new-any.mjs @@ -128,6 +128,34 @@ export function addedLinesByFile(diff) { const isProductTypeScript = (path) => /^src\/.*\.ts$/.test(path); +/** + * Коммит, добавивший строку (issue #388). + * + * Диапазон гейта теперь считается от последнего ДОКАЗАННО зелёного предка, а не + * от головы предыдущего пуша, — и это значит, что находка может относиться к + * чужому коммиту, чей прогон был отменён. Без имени источника такое сообщение + * обвиняет того, кто пушнул следующим: ровно то, что пришлось чинить в #386 для + * golden. + */ +export function blameLine(path, line, runner = defaultBlame) { + const out = runner(path, line); + const match = typeof out === 'string' ? out.match(/^([0-9a-f]{7,40})\s/) : null; + return match ? match[1].slice(0, 8) : ''; +} + +const defaultBlame = (path, line) => { + const run = spawnSync('git', [ + '-C', ROOT, 'blame', '-L', `${line},${line}`, '--porcelain', 'HEAD', '--', path, + ], { encoding: 'utf8' }); + return run.status === 0 ? run.stdout : ''; +}; + +/** Строка отчёта о находке. Источник печатается, только если он известен. */ +export function formatViolation(violation, source) { + const where = source ? ` (добавил ${source})` : ''; + return ` ${violation.path}:${violation.line}${where} — ${violation.reason}`; +} + function main(argv) { const value = (name, fallback) => { const found = argv.find((item) => item.startsWith(`--${name}=`)); @@ -173,7 +201,7 @@ function main(argv) { } console.error(`\nНовый явный any: ${violations.length}\n`); for (const violation of violations) { - console.error(` ${violation.path}:${violation.line} — ${violation.reason}`); + console.error(formatViolation(violation, blameLine(violation.path, violation.line))); console.error(` ${violation.text}`); } console.error('\nЛибо типизируйте, либо обоснуйте на той же строке:'); diff --git a/test/classify-base.test.mjs b/test/classify-base.test.mjs index 72e52fa5..8c2afc4a 100644 --- a/test/classify-base.test.mjs +++ b/test/classify-base.test.mjs @@ -6,7 +6,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { MAX_CANDIDATES, baseSummary, greenShas, pickBase } from '../scripts/classify-base.mjs'; +import { + MAX_CANDIDATES, baseSummary, greenShas, pickBase, pickRangeBase, +} from '../scripts/classify-base.mjs'; const SCRIPT = fileURLToPath(new URL('../scripts/classify-base.mjs', import.meta.url)); @@ -114,3 +116,76 @@ test('CLI считает базу по настоящей истории git (#3 rmSync(dir, { recursive: true, force: true }); } }); + +// --- база гейтов диапазона (#388) ------------------------------------------ + +test('гейты диапазона берут тот же зелёный предок (#388)', () => { + const choice = pickRangeBase({ + candidates: ['отменён2', 'отменён1', 'зелёный'], + green: new Set(['зелёный']), + fallback: 'before', + }); + assert.equal(choice.base, 'зелёный'); + assert.equal(choice.proven, true); + // Ровно те два коммита, которые до #388 не судил никто. + assert.equal(choice.skipped, 2); +}); + +test('без зелёного предка база остаётся прежней, но помечается недоказанной (#388)', () => { + // Расширять диапазон здесь нельзя: гейт, который сам красит прогон, лишил бы + // следующий пуш зелёного предка и запер dev в красноте навсегда. Фолбэк + // обязан не зависеть от собственного успеха гейта. + const choice = pickRangeBase({ candidates: ['a', 'b'], green: new Set(), fallback: 'before' }); + assert.equal(choice.base, 'before'); + assert.equal(choice.proven, false); + const summary = baseSummary(choice, { head: 'head1234', mergeBase: '' }).join('\n'); + assert.match(summary, /НЕ доказательство/); + assert.match(summary, /могли не пройти ни одного гейта/); +}); + +test('пустой фолбэк не выдаёт мусор за базу (#388)', () => { + const choice = pickRangeBase({ candidates: [], green: new Set(), fallback: undefined }); + assert.equal(choice.base, ''); + assert.equal(choice.proven, false); +}); + +test('CLI режима range считает базу по истории и пишет своё имя выхода (#388)', () => { + const dir = mkdtempSync(join(tmpdir(), 'hp-range-')); + const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim(); + try { + git('init', '-q', '-b', 'dev'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'test'); + const commit = (text) => { + writeFileSync(join(dir, 'file.txt'), text); + git('add', '-A'); + git('commit', '-qm', text); + return git('rev-parse', 'HEAD'); + }; + commit('корень'); + const green = commit('прогон зелёный'); + const cancelled = commit('прогон отменён'); + const head = commit('текущий пуш'); + const runsFile = join(dir, 'runs.json'); + const out = join(dir, 'out.txt'); + writeFileSync(runsFile, JSON.stringify({ + workflow_runs: [ + { head_sha: green, conclusion: 'success' }, + { head_sha: cancelled, conclusion: 'cancelled' }, + ], + })); + writeFileSync(out, ''); + const result = spawnSync(process.execPath, [ + SCRIPT, `--head=${head}`, '--mode=range', '--name=range_base', + `--fallback=${cancelled}`, `--runs=${runsFile}`, + ], { cwd: dir, encoding: 'utf8', env: { ...process.env, GITHUB_OUTPUT: out } }); + assert.equal(result.status, 0, result.stderr); + const output = readFileSync(out, 'utf8'); + // Имя выхода своё: общее `base` однажды подсунуло бы потребителю чужую базу. + assert.match(output, new RegExp(`range_base=${green}\\n`)); + assert.match(output, /range_base_proven=true/); + assert.equal(output.includes('\nbase='), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/no-new-any.test.mjs b/test/no-new-any.test.mjs index 6b99f340..146385ef 100644 --- a/test/no-new-any.test.mjs +++ b/test/no-new-any.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - addedLinesByFile, anyKeywordLines, findNewAnyViolations, parseAnyOk, + addedLinesByFile, anyKeywordLines, blameLine, findNewAnyViolations, formatViolation, parseAnyOk, } from '../scripts/no-new-any.mjs'; // #342. Цель гейта — не перетипизировать монолит, а не давать долгу расти. В @@ -106,3 +106,28 @@ test('удалённый файл не даёт нарушений: судить ].join('\n'); assert.equal(addedLinesByFile(diff).size, 0); }); + +// --- источник находки (#388) ----------------------------------------------- + +test('находка называет коммит, который добавил строку (#388)', () => { + // Диапазон теперь считается от последнего зелёного предка, поэтому находка + // может относиться к чужому коммиту с отменённым прогоном. Без имени + // источника сообщение обвиняло бы того, кто пушнул следующим — ровно то, что + // пришлось чинить в #386 для golden. + const porcelain = '3fa1c0de9b8a7654 12 12 1\nauthor Кто-то\nsummary правка\n'; + assert.equal(blameLine('src/a.ts', 12, () => porcelain), '3fa1c0de'); + assert.equal( + formatViolation({ path: 'src/a.ts', line: 12, reason: 'нет обоснования' }, '3fa1c0de'), + ' src/a.ts:12 (добавил 3fa1c0de) — нет обоснования', + ); +}); + +test('недоступный blame не выдумывает источник и не роняет отчёт (#388)', () => { + for (const answer of ['', 'мусор без sha\n', null, undefined]) { + assert.equal(blameLine('src/a.ts', 1, () => answer), ''); + } + assert.equal( + formatViolation({ path: 'src/a.ts', line: 1, reason: 'нет обоснования' }, ''), + ' src/a.ts:1 — нет обоснования', + ); +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index e2e483d7..a340bbef 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -159,11 +159,45 @@ test('классификация опирается на завершённый 'защита от force-push остаётся на месте (#347)'); assert.match(changes, /node scripts\/classify-base\.mjs --head=/); assert.match(changes, /actions: read/, 'чтение прогонов требует прав'); - // База считается только для push вне dev: у PR диапазон задан событием. - assert.match(changes, /if: github\.event_name != 'pull_request' && github\.ref != 'refs\/heads\/dev'/); + // У PR диапазон задан событием, считать его нечем и незачем. + assert.match(changes, /if: github\.event_name != 'pull_request'/); + // На dev классификации нет вовсе — там всё true; шаг там считает базу + // диапазона для другого потребителя (#388), и это разные выходы. + assert.match(changes, /dev: без фильтров, всё true/); + assert.match(changes, /--name=range_base/); // Пустая база означает «доказательства нет» и обязана вести к полному // прогону, а не к пустому диффу, который выглядел бы как «ничего не менялось». const empty = changes.slice(changes.indexOf('if [ -z "$base" ]')); assert.match(empty, /frontend=true\\nbackend=true\\nintegration=true/, 'без базы классификация обязана раскрываться в полный прогон'); }); + +test('гейты диапазона судят от доказанного предка, а не от предыдущего пуша (#388)', () => { + const workflow = read('validate.yml'); + const preflight = workflow.slice( + workflow.indexOf('\n preflight:\n'), workflow.indexOf('\n changes:\n'), + ); + // Оба гейта, судящие сам диапазон коммитов, обязаны читать доказанную базу. + // Раздельные env у них исторические — важно, что обновлены ОБА. + assert.equal( + preflight.match(/BEFORE_SHA: \$\{\{ steps\.range\.outputs\.base \|\| github\.event\.before \}\}/g)?.length, + 2, 'провенанс и процессный гейт читают доказанную базу'); + assert.match(preflight, /--mode=range/); + assert.match(preflight, /actions: read/, 'чтение прогонов требует прав'); + assert.match(preflight, /issues: read/, 'проверка 8 читает issue'); + // Считать базу имеет смысл только на пуше в dev: на ветках диапазон и так + // шире, а у PR он задан событием. + assert.match(preflight, /if: github\.event_name == 'push' && github\.ref == 'refs\/heads\/dev'/); + + // Гейт «новый any» берёт ту же базу, но через выход job changes: свой запрос + // к API из frontend потребовал бы отдельных прав. + const frontend = workflow.slice( + workflow.indexOf('\n frontend:\n'), workflow.indexOf('\n smoke:\n'), + ); + assert.match(frontend, /PROVEN_BASE: \$\{\{ needs\.changes\.outputs\.range_base \}\}/); + assert.match(frontend, /base="\$\{PROVEN_BASE:-\$BEFORE_SHA\}"/); + // Ветки и PR не трогаем: там merge-base с dev даёт диапазон ШИРЕ, и подмена + // его зелёным предком ослабила бы гейт. + assert.match(frontend, /\[ "\$REF" = "refs\/heads\/dev" \]/); + assert.match(frontend, /git merge-base origin\/dev "\$HEAD_SHA"/); +});