From 44ee23ee33b6b1b4c4370a78697b6da4cff81faf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 04:37:09 +0300 Subject: [PATCH] ci(mutants): skip the nightly registry on a proven tree; install only the shard's environment #620. Mutants were ~70 % of CI machine time. 1. mutation-gate.yml: a green full run (aggregator verified all six shards) leaves a cache marker keyed by material tree + workflow SHA. A scheduled night with the same tree and workflow, marker not older than 7 days, skips the shards and writes "reused from run N" to the run summary. Red runs leave no marker, so the next night runs again and still files the issue (#472). Manual dispatch always runs the full registry. Decision is a pure function in scripts/mutation-nightly-reuse.mjs. 2. changed_mutants: the shard plan (already computed before setup, #518) now names the environment of its guards (plan-browser=/plan-python=, from scripts/mutation-environment.mjs). Python + backend deps only for shards with pytest guards, Chromium only for shards whose guards reach Playwright; pip wheels cached. Every shard still runs and reports, so the six mutant jobs of the review proof are unchanged. Item 3 of the issue (smoke guards -> node --test) is out of scope here. Issue: #620 User-Visible: no --- .github/workflows/mutation-gate.yml | 84 ++++++++++++++++- .github/workflows/validate.yml | 32 +++++-- docs/TESTING.md | 22 ++++- scripts/mutation-environment.mjs | 114 +++++++++++++++++++++++ scripts/mutation-gate.mjs | 6 ++ scripts/mutation-nightly-reuse.mjs | 132 +++++++++++++++++++++++++++ scripts/mutation-registry.mjs | 111 ++++++++++++++++++++++ test/mutation-gate.test.mjs | 121 +++++++++++++++++++++++- test/mutation-nightly-reuse.test.mjs | 98 ++++++++++++++++++++ test/validate-workflow.test.mjs | 30 ++++++ 10 files changed, 739 insertions(+), 11 deletions(-) create mode 100644 scripts/mutation-environment.mjs create mode 100644 scripts/mutation-nightly-reuse.mjs create mode 100644 test/mutation-nightly-reuse.test.mjs diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index b1a8825d..bee8fa3e 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -19,6 +19,13 @@ name: Мутационный гейт # tsc), а реестр режется на четыре чересполосных шарда — полный прогон # укладывается в десятки минут вместо часов. Локальный дифф-режим: # node scripts/mutation-gate.mjs --changed origin/dev..HEAD +# +# #620: ночь по расписанию на дереве, уже доказанном зелёным полным прогоном +# (тот же tree материала, тот же SHA workflow, маркер не старше недели), шарды не +# гоняет — в сводке «reused from run N». Маркер пишет только зелёный агрегатор, +# поэтому красный не переносится: следующая ночь гонит реестр заново и снова +# заводит issue (#472). Ручной dispatch гонит полный реестр всегда. Решение — +# чистая функция scripts/mutation-nightly-reuse.mjs. on: workflow_dispatch: @@ -54,6 +61,8 @@ jobs: sha: ${{ steps.identity.outputs.sha }} tree: ${{ steps.identity.outputs.tree }} ref: ${{ steps.identity.outputs.ref }} + reuse: ${{ steps.reuse.outputs.reuse }} + reused_run: ${{ steps.reuse.outputs.reused_run }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -65,10 +74,48 @@ jobs: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT" + # #620: самый свежий маркер зелёного прогона этого tree и этого workflow. + # Ключ уникален на прогон (кэш неизменяем), восстанавливается по префиксу — + # иначе перепроверка после недели не смогла бы освежить маркер. + - name: Маркер зелёного прогона этого дерева + if: github.event_name == 'schedule' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: artifacts/mutation-green + key: mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ github.workflow_sha }}-${{ github.run_id }} + restore-keys: | + mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ github.workflow_sha }}- + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + # Любой сбой решения — полный прогон: пропуск должен быть доказан, а не + # выведен из ошибки. Материал без скрипта (dispatch старого ref) — тоже. + - name: Нужен ли прогон + id: reuse + env: + EVENT_NAME: ${{ github.event_name }} + TREE: ${{ steps.identity.outputs.tree }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + RUN_URL_BASE: ${{ github.server_url }}/${{ github.repository }}/actions/runs + run: | + decision="$RUNNER_TEMP/mutation-reuse.txt" + if [ -f scripts/mutation-nightly-reuse.mjs ] && node scripts/mutation-nightly-reuse.mjs --decide \ + --event="$EVENT_NAME" --tree="$TREE" --workflow-sha="$WORKFLOW_SHA" \ + --marker=artifacts/mutation-green/marker.json \ + --summary="$GITHUB_STEP_SUMMARY" --run-url-base="$RUN_URL_BASE" > "$decision"; then + cat "$decision" >> "$GITHUB_OUTPUT" + if grep -qx 'reuse=true' "$decision"; then + echo "::notice::reused from run $(sed -n 's/^reused_run=//p' "$decision")" + fi + else + echo "::warning::решение о повторном использовании не получено — полный прогон" + echo "reuse=false" >> "$GITHUB_OUTPUT" + fi mutants: name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)" needs: material + if: needs.material.outputs.reuse != 'true' runs-on: ubuntu-latest strategy: fail-fast: false @@ -164,7 +211,7 @@ jobs: evidence: name: "Доказать единый material всех шардов" needs: [material, mutants] - if: always() + if: always() && needs.material.outputs.reuse != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -189,6 +236,36 @@ jobs: --workflow-sha=${{ github.workflow_sha }} \ --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} + # #620: маркер пишется ТОЛЬКО после зелёного агрегатора — все шесть шардов + # доказаны на одном material. Красный или неполный прогон маркера не оставляет, + # и следующая ночь гонит реестр заново. + green_marker: + name: "Записать маркер зелёного прогона" + needs: [material, mutants, evidence] + if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.material.outputs.sha }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + - name: Маркер + run: | + node scripts/mutation-nightly-reuse.mjs \ + --write-marker=artifacts/mutation-green/marker.json \ + --tree=${{ needs.material.outputs.tree }} \ + --sha=${{ needs.material.outputs.sha }} \ + --workflow-sha=${{ github.workflow_sha }} \ + --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ + --event=${{ github.event_name }} + - name: Сохранить маркер + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: artifacts/mutation-green + key: mutation-green-v1-${{ needs.material.outputs.tree }}-${{ github.workflow_sha }}-${{ github.run_id }} + # Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся # для отладки самого гейта, его результат смотрят в прогоне — issue на # каждый такой отказ был бы шумом, который снова перестанут читать. @@ -198,7 +275,10 @@ jobs: report: name: "Отказ расписания: issue и Telegram" needs: [material, mutants, evidence] - if: always() && github.event_name == 'schedule' && (needs.mutants.result != 'success' || needs.evidence.result != 'success') + # #620: ночь, принявшая доказательство прошлого зелёного прогона, отказом не + # является — её шарды пропущены намеренно. Любой другой пропуск шардов + # (упал material) по-прежнему заводит issue. + if: always() && github.event_name == 'schedule' && needs.material.outputs.reuse != 'true' && (needs.mutants.result != 'success' || needs.evidence.result != 'success') runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1b7ee317..0b0b5a81 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -652,29 +652,47 @@ jobs: fi echo "диапазон: $base..$HEAD_SHA" echo "base=$base" >> "$GITHUB_OUTPUT" - count=$(node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \ - --ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only \ - | tee /dev/stderr | sed -n 's/^plan=//p' | tail -1) + plan="$RUNNER_TEMP/mutation-plan-$SHARD.txt" + node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \ + --ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only > "$plan" + cat "$plan" + count=$(sed -n 's/^plan=//p' "$plan" | tail -1) echo "к прогону в этом шарде: ${count:-0}" echo "count=${count:-0}" >> "$GITHUB_OUTPUT" + # #620: план называет и окружение своих гардов. Непрочитанная строка + # при непустом плане значит «ставить»: лишняя установка стоит минуты, + # недоустановка — красный чистый прогон и лишний круг задачи. + if [ "${count:-0}" != "0" ]; then + browser=$(sed -n 's/^plan-browser=//p' "$plan" | tail -1) + python=$(sed -n 's/^plan-python=//p' "$plan" | tail -1) + echo "окружение шарда: браузер ${browser:-true}, Python ${python:-true}" + echo "browser=${browser:-true}" >> "$GITHUB_OUTPUT" + echo "python=${python:-true}" >> "$GITHUB_OUTPUT" + fi - if: steps.plan.outputs.count != '0' run: npm ci - - if: steps.plan.outputs.count != '0' + # Python и зависимости бэкенда — только шарду с pytest-гардами (#620); + # колёса pip кэшируются по requirements.txt. + - if: steps.plan.outputs.count != '0' && steps.plan.outputs.python == 'true' uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.14' + cache: pip + cache-dependency-path: tests_backend/requirements.txt - name: Backend test dependencies - if: steps.plan.outputs.count != '0' + if: steps.plan.outputs.count != '0' && steps.plan.outputs.python == 'true' run: pip install -r tests_backend/requirements.txt + # Chromium — только шарду с браузерными гардами (#620): смоки, golden и + # юниты, чей граф исполнения импортирует Playwright. - name: Кэш браузеров Playwright id: pw - if: steps.plan.outputs.count != '0' + if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - name: Установить Chromium - if: steps.plan.outputs.count != '0' && steps.pw.outputs.cache-hit != 'true' + if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'true' && steps.pw.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium - name: Затронутые мутанты ловятся if: steps.plan.outputs.count != '0' diff --git a/docs/TESTING.md b/docs/TESTING.md index a395c0f4..4d893b8c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -45,7 +45,15 @@ identity и исход шага прогона, а отдельный агрег rerun; лог шарда считается зелёным только с итоговой строкой `поймано N из M`) каждую ночь по расписанию (01:00 UTC); в релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам -заводит issue с отчётом (#472, #513). Дешёвая половина +заводит issue с отчётом (#472, #513). С #620 ночь по расписанию на дереве, уже +доказанном зелёным полным прогоном, шарды не гоняет: зелёный агрегатор +оставляет маркер в кэше Actions (tree материала, `github.workflow_sha`, номер +прогона), следующая ночь с тем же tree и тем же workflow принимает его и пишет в +сводку `reused from run N`. Маркер старше семи суток не принимается (tree +фиксирует код, а не раннер), ручной dispatch гоняет реестр всегда, а красный +прогон маркера не оставляет — следующая ночь гонит реестр заново и снова +заводит issue. Решение — чистая функция `scripts/mutation-nightly-reuse.mjs` +(`test/mutation-nightly-reuse.test.mjs`). Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`. Локально для дельты задачи — `node scripts/mutation-gate.mjs --changed origin/dev..HEAD`: гоняются только мутанты, чьи patch-файлы или **входы гарда** задеты диффом (#332, #475, #492). @@ -103,6 +111,18 @@ job-минут и в основном отменялись следующим п на точном SHA; зелёный push-прогон им не является. Релизный гейт mutant-jobs не требует (`CI_PROOF_POLICIES.release.mutants = false`). +План шарда (`--plan-only`) считается до установки окружения (#518) и с #620 +называет окружение своих гардов строками `plan-browser=` и `plan-python=` +(`scripts/mutation-environment.mjs`). Python с зависимостями бэкенда ставится +только шарду с pytest-гардами (в графе исполнения гарда есть `.py` или запуск +`'python3'`/pytest), Chromium — только шарду, чей граф исполнения импортирует +Playwright (смоки, golden, юниты через `demo/serve.mjs`). Граф исполнения — не +замыкание входов для отбора: файлы из строки гарда, объявленные входы обёрток, +пути, названные в этих точках входа, и их относительные импорты. Шард без +таких гардов окружение не ставит, но исполняется и отчитывается — шесть +mutant-jobs в доказательстве ревью (#541) не меняются. Ошибка признака +ложной зелени не даёт: гард без своей среды краснеет уже чистым прогоном. + `changed_mutants` добавляет `--ledger=<файл>` — журнал доказанных свидетелей (#481, #550): после каждого пойманного мутанта в файл пишутся тип доказательства (`assertion` или явно объявленный `compile`) и отпечаток diff --git a/scripts/mutation-environment.mjs b/scripts/mutation-environment.mjs new file mode 100644 index 00000000..a34baf9a --- /dev/null +++ b/scripts/mutation-environment.mjs @@ -0,0 +1,114 @@ +// Окружение, которое нужно гардам плана (#620). Чистые функции: команда гарда +// и чтение файлов приходят извне, файловой системы модуль не трогает. +// +// Зачем. `changed_mutants` ставил Python с зависимостями бэкенда и Chromium на +// каждом шарде с непустым планом, хотя план уже знает своих гардов: дифф с +// одними юнит-гардами браузер не открывает, а установка стоит минуты на шард. +// +// Что считается кодом гарда. Не замыкание входов из mutation-selection — оно +// нарочно широкое (пути в строках, данные check-inputs) и отвечает на другой +// вопрос: «что может изменить исход». Здесь вопрос «что исполнится», поэтому +// граф такой: точки входа — файлы из строки гарда, объявленные входы обёрток и +// пути, названные в самих точках входа (тест, запускающий `node scripts/x.mjs`, +// порождает процесс); от каждой точки входа — транзитивные относительные +// импорты (тот же процесс). Файл графа, импортирующий Playwright, требует +// браузер; `.py` в графе или запуск `'python3'`/pytest — Python. +// +// Направление ошибки. Лишняя установка стоит минуты. Недоустановка ложной +// зелени не даёт: чистый прогон гарда без Chromium или pytest падает ДО +// мутантов (`runCleanGuards` → код 2), а гард, пропустивший себя без среды, +// остаётся зелёным и на мутанте — это `survived`, тоже красный. Цена ошибки +// здесь — лишний круг задачи, не пропущенная поломка. +import { posix } from 'node:path'; + +import { guardNeedsBundle } from './mutation-execution.mjs'; +import { guardFiles, wrapperInputs } from './mutation-selection.mjs'; + +const GUARD_BROWSER_RE = /playwright|chromium/i; +const GUARD_PYTHON_RE = /\bpython3?\b|\bpytest\b/; +/** Импорт Playwright — статический или динамический. */ +const SOURCE_BROWSER_RE = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]playwright(?:-core)?['"]/; +/** Запуск Python литералом (`spawnSync('python3', …)`, `'pytest'`, `PYTHON`). */ +const SOURCE_PYTHON_RE = /['"`](?:python3?|pytest)['"`]|process\.env\.PYTHON\b/; +/** Относительный импорт: тот же процесс. */ +const IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(\s*|^\s*import\s+)['"](\.{1,2}\/[^'"]+)['"]/gm; +/** Путь-литерал в точке входа: относительный или от корня репозитория. */ +const PATH_LITERAL_RE = /['"`]((?:\.{1,2}\/)+[\w./-]+\.(?:mjs|cjs|js|py)|(?:scripts|demo|test|tests_backend)\/[\w./-]+\.(?:mjs|cjs|js|py))['"`]/g; +const isScript = (file) => /\.(?:mjs|cjs|js)$/.test(file); +const isWrapper = (file) => /^scripts\/[\w-]+-guard\.mjs$/.test(file); + +function resolveFrom(file, spec) { + const target = spec.startsWith('.') ? posix.join(posix.dirname(file), spec) : posix.normalize(spec); + return target.startsWith('..') ? null : target; +} + +/** + * Файлы, чей код исполнится при запуске гарда (см. шапку модуля). + * @param {string} guard + * @param {{ read: (file: string) => string, exists: (file: string) => boolean }} io + * @returns {string[]} + */ +export function guardRuntimeFiles(guard, { read, exists }) { + const named = guardFiles(guard, exists); + const explicit = named.some((file) => !isWrapper(file)); + const declared = explicit ? [] : named.filter(isWrapper).flatMap((file) => wrapperInputs(file, read)); + const seen = new Set(); + const queue = [...new Set([...named, ...declared])].filter(exists).map((file) => ({ file, entry: true })); + while (queue.length) { + const { file, entry } = queue.shift(); + if (seen.has(file)) continue; + seen.add(file); + if (!isScript(file)) continue; + const text = String(read(file) || ''); + const push = (spec, asEntry) => { + const target = resolveFrom(file, spec); + if (target && !seen.has(target) && exists(target)) queue.push({ file: target, entry: asEntry }); + }; + for (const match of text.matchAll(IMPORT_RE)) push(match[1], false); + if (entry) { + for (const match of text.matchAll(PATH_LITERAL_RE)) { + const spec = match[1]; + // Путь от корня разрешается от корня, относительный — от файла. + push(spec.startsWith('.') ? spec : `./${posix.relative(posix.dirname(file), spec)}`, true); + } + } + } + return [...seen].sort(); +} + +/** + * @param {string} guard команда гарда + * @param {{ read: (file: string) => string, exists: (file: string) => boolean }} io + * @returns {{ browser: boolean, python: boolean }} + */ +export function guardEnvironment(guard, io) { + const text = String(guard || ''); + const files = guardRuntimeFiles(text, io); + const sources = files.filter(isScript).map((file) => String(io.read(file) || '')); + return { + browser: guardNeedsBundle(text) || GUARD_BROWSER_RE.test(text) + || sources.some((source) => SOURCE_BROWSER_RE.test(source)), + python: GUARD_PYTHON_RE.test(text) || files.some((file) => file.endsWith('.py')) + || sources.some((source) => SOURCE_PYTHON_RE.test(source)), + }; +} + +/** + * Окружение шарда — объединение по его гардам. Пустой план не требует ничего. + * @param {{ guard: string }[]} mutants + * @param {(guard: string) => { browser: boolean, python: boolean }} environmentOf + */ +export function planEnvironment(mutants, environmentOf) { + const need = { browser: false, python: false }; + for (const guard of new Set(mutants.map((mutant) => mutant.guard))) { + const env = environmentOf(guard); + need.browser ||= Boolean(env.browser); + need.python ||= Boolean(env.python); + } + return need; +} + +/** Строки, которые читает шаг плана в validate.yml. Формат менять синхронно. */ +export function planEnvironmentLines(need) { + return [`plan-browser=${need.browser ? 'true' : 'false'}`, `plan-python=${need.python ? 'true' : 'false'}`]; +} diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 79a57595..435b6df6 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -23,6 +23,7 @@ import { LEDGER_SCHEMA, readLedger, recordCaught, splitByLedger, witnessFingerprint, } from './mutation-evidence.mjs'; import { attributeSetupFailure } from './mutation-attribution.mjs'; +import { guardEnvironment, planEnvironment, planEnvironmentLines } from './mutation-environment.mjs'; import { MUTATION_OUTCOME, isProofOutcome } from './mutation-guard-outcome.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -215,6 +216,11 @@ export async function main(argv) { // ДО установки окружения (npm ci, python, Chromium ≈ 3 минуты на шард). if (argv.includes('--plan-only')) { console.log(`plan=${toRun.length}`); + // #620: и какое окружение нужно гардам плана — шаг ставит только его. + const exists = (file) => existsSync(join(repoRoot, file)); + const read = (file) => (exists(file) ? readFileSync(join(repoRoot, file), 'utf8') : ''); + const need = planEnvironment(toRun, (guard) => guardEnvironment(guard, { read, exists })); + for (const line of planEnvironmentLines(need)) console.log(line); reportPlanMetrics(); return 0; } diff --git a/scripts/mutation-nightly-reuse.mjs b/scripts/mutation-nightly-reuse.mjs new file mode 100644 index 00000000..e89c1301 --- /dev/null +++ b/scripts/mutation-nightly-reuse.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +// #620: ночной полный реестр не гоняется повторно на том же дереве. +// +// Что было. `mutation-gate.yml` каждую ночь прогонял все 800+ мутантов — +// ≈ 212 job-минут, — даже когда `dev` не менялся: 16–20.09 четыре ночи подряд +// ушли на один SHA. Доказательство прогона content-адресно уже с #549 +// (evidence несёт tree), но им никто не пользовался. +// +// Как теперь. Зелёный полный прогон (все шесть шардов доказаны агрегатором) +// оставляет маркер: tree материала, SHA workflow, номер прогона, время. Ночь +// по расписанию читает самый свежий маркер своего tree и workflow и, если он +// действителен, не гоняет шарды — в сводке прогона стоит «reused from run N». +// +// Чего маркер не делает. Он не переносит красный: маркер пишется только после +// зелёного агрегатора, так что ночь после отказа гонит реестр заново и снова +// заводит issue (#472). Ручной dispatch — отладка гейта — всегда гонит полный +// реестр. Маркер старше MAX_REUSE_AGE_DAYS не принимается: tree фиксирует код, +// но не раннер (образ ubuntu, патч Python, кэш Chromium), и раз в неделю реестр +// обязан пройти на свежем окружении даже на неизменном дереве. SHA workflow в +// ключе — по той же причине: другой workflow (делитель шардов, шаги) — другое +// доказательство. +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { isMainModule } from './spawn-portable.mjs'; + +export const REUSE_MARKER_SCHEMA = 'houseplan-mutation-green/v1'; +export const MAX_REUSE_AGE_DAYS = 7; +const DAY_MS = 24 * 60 * 60 * 1000; +// Часы раннеров расходятся на секунды; маркер «из будущего» дальше этого — подделка +// или сломанные часы, и принимать его нельзя. +const CLOCK_SKEW_MS = 10 * 60 * 1000; +const FULL_SHA = /^[0-9a-f]{40}$/; +const positiveInteger = (value) => /^[1-9]\d*$/.test(String(value ?? '')); + +/** Маркер зелёного полного прогона. Бросает на неполной identity. */ +export function reuseMarker(input) { + const marker = { + schema: REUSE_MARKER_SCHEMA, + tree: String(input.tree || ''), + sha: String(input.sha || ''), + workflowSha: String(input.workflowSha || ''), + runId: String(input.runId ?? ''), + runAttempt: String(input.runAttempt ?? ''), + event: String(input.event || ''), + provenAt: new Date(input.now ?? Date.now()).toISOString(), + }; + if (!FULL_SHA.test(marker.tree) || !FULL_SHA.test(marker.sha) || !FULL_SHA.test(marker.workflowSha) + || !positiveInteger(marker.runId) || !positiveInteger(marker.runAttempt) || !marker.event) { + throw new Error('invalid mutation reuse marker identity'); + } + return marker; +} + +/** + * Чистое решение: можно ли не гонять реестр этой ночью. + * + * @param {object} p + * @param {string} p.event github.event_name текущего прогона + * @param {string} p.tree tree зафиксированного материала + * @param {string} p.workflowSha github.workflow_sha текущего прогона + * @param {object|null} p.marker восстановленный маркер либо null + * @param {number} p.now мс эпохи + * @param {number} [p.maxAgeDays] + * @returns {{ reuse: boolean, reason: string, runId?: string }} + */ +export function decideNightlyReuse({ event, tree, workflowSha, marker, now, maxAgeDays = MAX_REUSE_AGE_DAYS }) { + const no = (reason) => ({ reuse: false, reason }); + if (event !== 'schedule') return no('not a scheduled run — manual dispatch always runs the full registry'); + if (!FULL_SHA.test(String(tree || ''))) return no('material tree is not a full SHA'); + if (!marker || typeof marker !== 'object') return no('no green marker for this tree'); + if (marker.schema !== REUSE_MARKER_SCHEMA) return no('marker schema is unknown'); + if (marker.tree !== tree) return no('marker proves another tree'); + if (!FULL_SHA.test(String(workflowSha || '')) || marker.workflowSha !== workflowSha) { + return no('marker was proved by another workflow revision'); + } + if (!positiveInteger(marker.runId)) return no('marker names no run'); + const provenAt = Date.parse(String(marker.provenAt || '')); + if (!Number.isFinite(provenAt)) return no('marker has no proof time'); + if (provenAt - now > CLOCK_SKEW_MS) return no('marker is dated in the future'); + if (now - provenAt > maxAgeDays * DAY_MS) return no(`marker is older than ${maxAgeDays} days`); + return { reuse: true, reason: `tree ${tree} already proved green`, runId: String(marker.runId) }; +} + +function readMarker(file) { + if (!file || !existsSync(file)) return null; + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return null; + } +} + +const arg = (argv, name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3); + +export function main(argv, { now = Date.now(), log = (line) => console.log(line), warn = (line) => console.error(line) } = {}) { + const writeTo = arg(argv, 'write-marker'); + if (writeTo) { + const marker = reuseMarker({ + tree: arg(argv, 'tree'), sha: arg(argv, 'sha'), workflowSha: arg(argv, 'workflow-sha'), + runId: arg(argv, 'run-id'), runAttempt: arg(argv, 'run-attempt'), event: arg(argv, 'event'), now, + }); + mkdirSync(dirname(writeTo), { recursive: true }); + writeFileSync(writeTo, `${JSON.stringify(marker, null, 2)}\n`); + warn(`маркер зелёного прогона: tree ${marker.tree}, run ${marker.runId}`); + return 0; + } + if (!argv.includes('--decide')) { + warn('usage: mutation-nightly-reuse.mjs --decide --event= --tree= --workflow-sha= --marker= [--summary=] [--run-url-base=]\n' + + ' mutation-nightly-reuse.mjs --write-marker= --tree= --sha= --workflow-sha= --run-id= --run-attempt= --event='); + return 2; + } + const decision = decideNightlyReuse({ + event: arg(argv, 'event'), tree: arg(argv, 'tree'), workflowSha: arg(argv, 'workflow-sha'), + marker: readMarker(arg(argv, 'marker')), now, + }); + // stdout — только строки key=value для $GITHUB_OUTPUT; человеку — stderr и сводка. + log(`reuse=${decision.reuse ? 'true' : 'false'}`); + if (decision.reuse) log(`reused_run=${decision.runId}`); + warn(decision.reuse ? `reused from run ${decision.runId}: ${decision.reason}` : `full run: ${decision.reason}`); + const summary = arg(argv, 'summary'); + if (summary) { + const base = arg(argv, 'run-url-base'); + const link = decision.reuse && base ? ` (${base}/${decision.runId})` : ''; + appendFileSync(summary, decision.reuse + ? `### Мутационный реестр: reused from run ${decision.runId}${link}\n\n` + + `Дерево \`${arg(argv, 'tree')}\` уже доказано зелёным полным прогоном; шарды не запускались (#620).\n` + : `### Мутационный реестр: полный прогон\n\nПовторное использование отклонено: ${decision.reason}.\n`); + } + return 0; +} + +if (isMainModule(import.meta.url)) process.exit(main(process.argv.slice(2))); diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 8322625a..dcc35a49 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -4209,6 +4209,117 @@ const MUTANT_DEFINITIONS = [ replace: " if (false && INTERRUPTED_OUTCOMES.has(evidence.outcome)) {\n errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);\n }", }], }, + // #620: ночь по неизменённому дереву и окружение шарда по гардам плана. + { + id: 'nightly-reuse-ignores-tree', + guard: 'node --test --test-name-pattern="маркер другого дерева" test/mutation-nightly-reuse.test.mjs', + because: 'a green marker proves exactly one tree; accepting it for another tree would skip the ' + + 'nightly registry on changed code and hide rotted witnesses until release (#620)', + patches: [{ + file: 'scripts/mutation-nightly-reuse.mjs', + find: " if (marker.tree !== tree) return no('marker proves another tree');", + replace: " if (false && marker.tree !== tree) return no('marker proves another tree');", + }], + }, + { + id: 'nightly-reuse-accepts-stale-marker', + guard: 'node --test --test-name-pattern="старше" test/mutation-nightly-reuse.test.mjs', + because: 'the tree pins code, not the runner; without the age limit an unchanged dev would never ' + + 'run the registry on a fresh environment again (#620)', + patches: [{ + file: 'scripts/mutation-nightly-reuse.mjs', + find: ' if (now - provenAt > maxAgeDays * DAY_MS) return no(', + replace: ' if (now - provenAt > maxAgeDays * DAY_MS * 1000) return no(', + }], + }, + { + id: 'nightly-reuse-on-manual-dispatch', + guard: 'node --test --test-name-pattern="ручной dispatch" test/mutation-nightly-reuse.test.mjs', + because: 'manual dispatch is how the gate itself is debugged; reusing a marker there would ' + + 'answer a debugging run with an old result (#620)', + patches: [{ + file: 'scripts/mutation-nightly-reuse.mjs', + find: " if (event !== 'schedule') return no(", + replace: " if (event === 'mutant-never-an-event') return no(", + }], + }, + { + id: 'green-marker-without-green-aggregator', + guard: 'node --test --test-name-pattern="#620: пропуск ночи" test/mutation-gate.test.mjs', + because: 'a marker written after a red or partial run would let the next night skip the registry ' + + 'and never file the failure issue again (#620, #472)', + patches: [{ + file: '.github/workflows/mutation-gate.yml', + find: " if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success'", + replace: " if: always() && needs.material.outputs.reuse != 'true'", + }], + }, + { + id: 'nightly-reuse-decision-error-skips-registry', + guard: 'node --test --test-name-pattern="#620: пропуск ночи" test/mutation-gate.test.mjs', + because: 'a skipped night must be proved by a marker; a failed decision step has to mean ' + + 'a full run, not a silent skip (#620)', + patches: [{ + file: '.github/workflows/mutation-gate.yml', + find: ' echo "reuse=false" >> "$GITHUB_OUTPUT"', + replace: ' echo "reuse=true" >> "$GITHUB_OUTPUT"', + }], + }, + { + id: 'browser-shard-skips-chromium', + guard: 'node --test --test-name-pattern="#620 AC2" test/validate-workflow.test.mjs', + because: 'a shard whose guards open a browser must install Chromium, or its clean run goes red ' + + 'and the task loses a review round for nothing (#620)', + patches: [{ + file: '.github/workflows/validate.yml', + find: " if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'true' && steps.pw.outputs.cache-hit != 'true'", + replace: " if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'yes' && steps.pw.outputs.cache-hit != 'true'", + }], + }, + { + id: 'unread-plan-environment-skips-install', + guard: 'node --test --test-name-pattern="#620 AC2" test/validate-workflow.test.mjs', + because: 'a plan line that was not read must mean "install": skipping the environment on an ' + + 'unknown need trades minutes for a red shard (#620)', + patches: [{ + file: '.github/workflows/validate.yml', + find: ' echo "browser=${browser:-true}" >> "$GITHUB_OUTPUT"', + replace: ' echo "browser=${browser:-false}" >> "$GITHUB_OUTPUT"', + }], + }, + { + id: 'environment-misses-playwright-import', + guard: 'node --test --test-name-pattern="#620: браузер нужен" test/mutation-gate.test.mjs', + because: 'a unit guard that reaches Playwright through an import still needs Chromium; the ' + + 'command line alone does not show it (#620)', + patches: [{ + file: 'scripts/mutation-environment.mjs', + find: ' || sources.some((source) => SOURCE_BROWSER_RE.test(source)),', + replace: ' || sources.length < 0,', + }], + }, + { + id: 'environment-misses-backend-files', + guard: 'node --test --test-name-pattern="#620: Python нужен" test/mutation-gate.test.mjs', + because: 'a wrapper guard runs pytest on the declared .py files; missing them leaves the shard ' + + 'without backend dependencies (#620)', + patches: [{ + file: 'scripts/mutation-environment.mjs', + find: " python: GUARD_PYTHON_RE.test(text) || files.some((file) => file.endsWith('.py'))", + replace: " python: GUARD_PYTHON_RE.test(text) || files.some((file) => file.endsWith('.pyc'))", + }], + }, + { + id: 'environment-ignores-spawned-scripts', + guard: 'node --test --test-name-pattern="#620: Python нужен" test/mutation-gate.test.mjs', + because: 'a test that spawns a script runs that script as a process; its needs are the ' + + "guard's needs (#620)", + patches: [{ + file: 'scripts/mutation-environment.mjs', + find: ' if (entry) {', + replace: ' if (entry && file.length < 0) {', + }], + }, // #481: журнал пойманных свидетелей — каждый защитный контракт под свидетелем. { id: 'ledger-records-escaped', diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index c602517e..48c7ecc4 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -332,7 +332,8 @@ test('#604: исход шага прогона едет в evidence шарда', test('#472 AC5: job report — только по расписанию, только при не-успехе, с полными правами', () => { const report = mutationWorkflow.slice(mutationWorkflow.indexOf(' report:')); - assert.match(report, /if: always\(\) && github\.event_name == 'schedule' && \(needs\.mutants\.result != 'success' \|\| needs\.evidence\.result != 'success'\)/); + // #620: принятое доказательство прошлого зелёного прогона отказом не считается. + assert.match(report, /if: always\(\) && github\.event_name == 'schedule' && needs\.material\.outputs\.reuse != 'true' && \(needs\.mutants\.result != 'success' \|\| needs\.evidence\.result != 'success'\)/); const permissions = report.slice(report.indexOf('permissions:'), report.indexOf('steps:')); for (const grant of ['contents: read', 'actions: read', 'issues: write']) { assert.ok(permissions.includes(grant), `нет права ${grant} у job report`); @@ -915,3 +916,121 @@ test('#496: добавленный script в package.json гварды не за assert.equal(packageJsonRelevance(base, deps).relevant, true); assert.equal(packageJsonRelevance('{not json', added).relevant, true, 'неразобранное — задевает: сторона ошибки — лишний прогон'); }); + +// #620. План шарда называет окружение своих гардов, и `changed_mutants` ставит +// Python и Chromium только шарду, которому они нужны. Ложной зелени отсюда не +// бывает (гард без среды краснеет чистым прогоном), но недоустановка стоит +// лишнего круга задачи — поэтому признаки проверяются на обе стороны. +import { + guardEnvironment, guardRuntimeFiles, planEnvironment, planEnvironmentLines, +} from '../scripts/mutation-environment.mjs'; + +function fakeRepo(files) { + return { read: (file) => files[file] ?? '', exists: (file) => Object.hasOwn(files, file) }; +} + +test('#620: юнит-гард без браузера и Python не требует окружения', () => { + const io = fakeRepo({ + 'test/pure.test.mjs': "import { f } from '../scripts/pure.mjs';\n// python3 в комментарии без кавычек — не запуск", + 'scripts/pure.mjs': 'export const f = () => 1;', + }); + assert.deepEqual(guardEnvironment('node --test test/pure.test.mjs', io), { browser: false, python: false }); + assert.deepEqual(guardRuntimeFiles('node --test test/pure.test.mjs', io), ['scripts/pure.mjs', 'test/pure.test.mjs']); +}); + +test('#620: браузер нужен смоку и юниту, чей граф импортов дотягивается до Playwright', () => { + const io = fakeRepo({ + 'demo/smoke_x.mjs': "import { serve } from './serve.mjs';", + 'demo/serve.mjs': "import { chromium } from 'playwright';", + 'test/uses-browser.test.mjs': "import { page } from './helpers/page.mjs';", + 'test/helpers/page.mjs': "export const page = async () => (await import('playwright')).chromium;", + }); + assert.equal(guardEnvironment('node demo/smoke_x.mjs', io).browser, true, 'смок'); + assert.equal(guardEnvironment('node --test test/uses-browser.test.mjs', io).browser, true, + 'юнит через динамический импорт помощника'); +}); + +test('#620: Python нужен pytest-гарду, обёртке и тесту, запускающему скрипт с python3', () => { + const io = fakeRepo({ + 'scripts/backend-test-guard.mjs': "export const GUARD_INPUTS = ['tests_backend/test_a.py'];\nspawnSync(python, ['-m', mod]);", + 'tests_backend/test_a.py': 'def test_a(): pass', + 'test/spawns.test.mjs': "spawnSync(process.execPath, ['scripts/runner.mjs']);", + 'scripts/runner.mjs': "spawnSync('python3', ['x.py']);", + }); + assert.equal(guardEnvironment('python3 -m pytest tests_backend/test_a.py -q', io).python, true, 'строка гарда'); + assert.equal(guardEnvironment('node scripts/backend-test-guard.mjs pattern', io).python, true, 'обёртка → .py'); + assert.equal(guardEnvironment('node --test test/spawns.test.mjs', io).python, true, + 'путь-литерал точки входа — порождённый процесс'); + assert.equal(guardEnvironment('node --test test/spawns.test.mjs', io).browser, false); +}); + +test('#620: окружение шарда — объединение гардов; пустой план не требует ничего', () => { + const env = { a: { browser: true, python: false }, b: { browser: false, python: true }, c: { browser: false, python: false } }; + const of = (guard) => env[guard]; + assert.deepEqual(planEnvironment([], of), { browser: false, python: false }); + assert.deepEqual(planEnvironment([{ guard: 'c' }, { guard: 'c' }], of), { browser: false, python: false }); + assert.deepEqual(planEnvironment([{ guard: 'a' }, { guard: 'c' }], of), { browser: true, python: false }); + assert.deepEqual(planEnvironment([{ guard: 'a' }, { guard: 'b' }], of), { browser: true, python: true }); + assert.deepEqual(planEnvironmentLines({ browser: true, python: false }), ['plan-browser=true', 'plan-python=false']); +}); + +test('#620 (реестр): каждый смок-гард получает браузер, каждый pytest-гард — Python, юниты — не все', () => { + const io = { + read: (file) => (existsSync(join(repoRoot, file)) ? readFileSync(join(repoRoot, file), 'utf8') : ''), + exists: (file) => existsSync(join(repoRoot, file)), + }; + const guards = [...new Set(MUTANTS.map((mutant) => mutant.guard))]; + let bare = 0; + for (const guard of guards) { + const env = guardEnvironment(guard, io); + if (/\bnode demo\//.test(guard)) assert.equal(env.browser, true, `смок без браузера: ${guard}`); + if (/python3? -m pytest|backend-test-guard\.mjs/.test(guard)) assert.equal(env.python, true, `pytest без Python: ${guard}`); + if (!env.browser && !env.python) bare++; + } + // Без этого признак выродился бы в «ставить всё» — и правило перестало бы экономить. + assert.ok(bare > guards.length / 3, `гардов без окружения ${bare} из ${guards.length}`); +}); + +test('#620: --plan-only печатает окружение плана', () => { + const script = join(repoRoot, 'scripts/mutation-gate.mjs'); + const plan = (id) => spawnSync(process.execPath, [script, `--id=${id}`, '--plan-only'], { encoding: 'utf8' }); + const smoke = plan('discard-confirm-action-icon-falls-back-to-lock'); + assert.equal(smoke.status, 0, smoke.stderr); + assert.match(smoke.stdout, /^plan=1$/m); + assert.match(smoke.stdout, /^plan-browser=true$/m); + const pytest = plan('frontend-registration-skips-retry'); + assert.match(pytest.stdout, /^plan-python=true$/m); + assert.match(pytest.stdout, /^plan-browser=false$/m); + const unit = plan('view-conflict-requires-editor-runtime'); + assert.match(unit.stdout, /^plan-browser=false$/m); + assert.match(unit.stdout, /^plan-python=false$/m); +}); + +// #620. Ночь по расписанию не гоняет реестр на дереве, уже доказанном зелёным +// полным прогоном. Решение — чистая функция (test/mutation-nightly-reuse.test.mjs); +// здесь — что workflow пропускает ровно шарды и не глушит адресата отказа. +test('#620: пропуск ночи — только по маркеру, маркер — только после зелёного агрегатора', () => { + const job = (name, next) => mutationWorkflow.slice(mutationWorkflow.indexOf(`\n ${name}:\n`), + next ? mutationWorkflow.indexOf(`\n ${next}:\n`) : undefined); + const material = job('material', 'mutants'); + const mutants = job('mutants', 'evidence'); + const evidence = job('evidence', 'green_marker'); + const marker = job('green_marker', 'report'); + const report = job('report'); + assert.match(material, /reuse: \$\{\{ steps\.reuse\.outputs\.reuse \}\}/); + // Маркер читается только по расписанию и только для своего tree и workflow. + const restore = material.slice(material.indexOf('actions/cache/restore@'), material.indexOf('- name: Нужен ли прогон')); + assert.match(material.slice(material.lastIndexOf('- name:', material.indexOf('actions/cache/restore@'))), + /^- name: [^\n]*\n\s+if: github\.event_name == 'schedule'\n/); + assert.match(restore, /restore-keys: \|\n\s+mutation-green-v1-\$\{\{ steps\.identity\.outputs\.tree \}\}-\$\{\{ github\.workflow_sha \}\}-\n/); + // Сбой решения — полный прогон. + assert.match(material, /else\n\s+echo "::warning::[^\n]*"\n\s+echo "reuse=false" >> "\$GITHUB_OUTPUT"/); + assert.match(material, /--decide/); + assert.match(mutants, /\n if: needs\.material\.outputs\.reuse != 'true'\n/); + assert.match(evidence, /\n if: always\(\) && needs\.material\.outputs\.reuse != 'true'\n/); + assert.match(marker, /\n if: needs\.material\.outputs\.reuse != 'true' && needs\.mutants\.result == 'success' && needs\.evidence\.result == 'success'\n/); + assert.match(marker, /--write-marker=artifacts\/mutation-green\/marker\.json/); + assert.match(marker, /key: mutation-green-v1-\$\{\{ needs\.material\.outputs\.tree \}\}-\$\{\{ github\.workflow_sha \}\}-\$\{\{ github\.run_id \}\}/); + // Отказ по-прежнему заводит issue; не заводит только принятое доказательство. + assert.match(report, /\n if: always\(\) && github\.event_name == 'schedule' && needs\.material\.outputs\.reuse != 'true' && \(needs\.mutants\.result != 'success' \|\| needs\.evidence\.result != 'success'\)\n/); +}); diff --git a/test/mutation-nightly-reuse.test.mjs b/test/mutation-nightly-reuse.test.mjs new file mode 100644 index 00000000..ce89ce97 --- /dev/null +++ b/test/mutation-nightly-reuse.test.mjs @@ -0,0 +1,98 @@ +// #620: ночной полный реестр не гоняется повторно на уже доказанном дереве. +// Решение — чистая функция; workflow-проводку держит test/mutation-gate.test.mjs. +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + MAX_REUSE_AGE_DAYS, REUSE_MARKER_SCHEMA, decideNightlyReuse, main, reuseMarker, +} from '../scripts/mutation-nightly-reuse.mjs'; + +const TREE = 'a'.repeat(40); +const OTHER_TREE = 'b'.repeat(40); +const SHA = 'c'.repeat(40); +const WORKFLOW = 'd'.repeat(40); +const NOW = Date.parse('2026-09-24T01:05:00Z'); +const DAY = 24 * 60 * 60 * 1000; + +const marker = (overrides = {}) => ({ + ...reuseMarker({ tree: TREE, sha: SHA, workflowSha: WORKFLOW, runId: 1234, runAttempt: 1, event: 'schedule', now: NOW - DAY }), + ...overrides, +}); +const decide = (overrides = {}) => decideNightlyReuse({ + event: 'schedule', tree: TREE, workflowSha: WORKFLOW, marker: marker(), now: NOW, ...overrides, +}); + +test('#620 AC1: неизменённое дерево с зелёным маркером — прогон переиспользуется с номером', () => { + assert.deepEqual(decide(), { reuse: true, reason: `tree ${TREE} already proved green`, runId: '1234' }); +}); + +test('#620: маркер другого дерева не переиспользуется', () => { + const result = decide({ marker: marker({ tree: OTHER_TREE }) }); + assert.equal(result.reuse, false); + assert.match(result.reason, /another tree/); + assert.equal(decide({ tree: OTHER_TREE }).reuse, false, 'дерево текущей ночи другое'); + assert.equal(decide({ tree: 'HEAD' }).reuse, false, 'tree не полный SHA'); +}); + +test('#620: маркер другого workflow не переиспользуется', () => { + assert.equal(decide({ workflowSha: 'e'.repeat(40) }).reuse, false); + assert.equal(decide({ workflowSha: '' }).reuse, false); +}); + +test('#620: ручной dispatch гоняет реестр всегда', () => { + const result = decide({ event: 'workflow_dispatch' }); + assert.equal(result.reuse, false); + assert.match(result.reason, /manual dispatch/); +}); + +test('#620: без маркера, с чужой схемой или без номера прогона — полный прогон', () => { + assert.equal(decide({ marker: null }).reuse, false); + assert.equal(decide({ marker: marker({ schema: 'houseplan-mutation-green/v0' }) }).reuse, false); + assert.equal(decide({ marker: marker({ runId: '' }) }).reuse, false); + assert.equal(decide({ marker: marker({ runId: '0' }) }).reuse, false); +}); + +test(`#620: маркер старше ${MAX_REUSE_AGE_DAYS} суток или из будущего не принимается`, () => { + const at = (ms) => marker({ provenAt: new Date(ms).toISOString() }); + assert.equal(decide({ marker: at(NOW - (MAX_REUSE_AGE_DAYS * DAY - 60_000)) }).reuse, true, 'в пределах недели'); + const stale = decide({ marker: at(NOW - (MAX_REUSE_AGE_DAYS * DAY + 60_000)) }); + assert.equal(stale.reuse, false); + assert.match(stale.reason, /older than/); + assert.equal(decide({ marker: at(NOW + DAY) }).reuse, false, 'из будущего'); + assert.equal(decide({ marker: marker({ provenAt: 'вчера' }) }).reuse, false, 'время не читается'); +}); + +test('#620: маркер не пишется с неполной identity', () => { + assert.equal(marker().schema, REUSE_MARKER_SCHEMA); + assert.throws(() => reuseMarker({ tree: TREE, sha: SHA, workflowSha: WORKFLOW, runId: 1, runAttempt: 1 }), /identity/); + assert.throws(() => reuseMarker({ tree: 'short', sha: SHA, workflowSha: WORKFLOW, runId: 1, runAttempt: 1, event: 'schedule' }), /identity/); +}); + +test('#620 AC1: CLI пишет маркер, решает по нему и оставляет «reused from run N» в сводке', () => { + const dir = mkdtempSync(join(tmpdir(), 'hp-620-')); + try { + const file = join(dir, 'green', 'marker.json'); + const summary = join(dir, 'summary.md'); + const quiet = () => {}; + assert.equal(main([`--write-marker=${file}`, `--tree=${TREE}`, `--sha=${SHA}`, `--workflow-sha=${WORKFLOW}`, + '--run-id=777', '--run-attempt=2', '--event=schedule'], { now: NOW - DAY, warn: quiet }), 0); + assert.equal(JSON.parse(readFileSync(file, 'utf8')).runId, '777'); + const out = []; + assert.equal(main(['--decide', '--event=schedule', `--tree=${TREE}`, `--workflow-sha=${WORKFLOW}`, + `--marker=${file}`, `--summary=${summary}`, '--run-url-base=https://example.test/runs'], + { now: NOW, log: (line) => out.push(line), warn: quiet }), 0); + assert.deepEqual(out, ['reuse=true', 'reused_run=777']); + assert.match(readFileSync(summary, 'utf8'), /reused from run 777 \(https:\/\/example\.test\/runs\/777\)/); + // Битый маркер — не доказательство. + writeFileSync(file, '{'); + const broken = []; + main(['--decide', '--event=schedule', `--tree=${TREE}`, `--workflow-sha=${WORKFLOW}`, `--marker=${file}`], + { now: NOW, log: (line) => broken.push(line), warn: quiet }); + assert.deepEqual(broken, ['reuse=false']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index d07f6d19..cc581cc2 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -482,6 +482,36 @@ test('#518: пустой план шарда не ставит окружени assert.match(save, /if: always\(\)/); }); +test('#620 AC2: Python и Chromium ставятся только шарду, чьим гардам они нужны', () => { + const workflow = read('validate.yml'); + const start = workflow.indexOf('\n changed_mutants:\n'); + const job = workflow.slice(start, workflow.indexOf('\n frontend:\n', start)); + const stepOf = (needle) => { + const at = job.indexOf(needle); + assert.ok(at > 0, `нет шага ${needle}`); + const from = job.lastIndexOf('\n - ', at); + const to = job.indexOf('\n - ', at + needle.length); + return job.slice(from, to < 0 ? job.length : to); + }; + // План называет окружение; при непустом плане непрочитанная строка — «ставить». + const plan = stepOf('name: План шарда'); + assert.match(plan, /echo "browser=\$\{browser:-true\}" >> "\$GITHUB_OUTPUT"/); + assert.match(plan, /echo "python=\$\{python:-true\}" >> "\$GITHUB_OUTPUT"/); + assert.match(plan, /sed -n 's\/\^plan-browser=\/\/p'/); + assert.match(plan, /sed -n 's\/\^plan-python=\/\/p'/); + for (const step of ['actions/setup-python@', 'pip install -r tests_backend/requirements.txt']) { + assert.match(stepOf(step), /if: steps\.plan\.outputs\.count != '0' && steps\.plan\.outputs\.python == 'true'\n/, step); + } + for (const step of ['name: Кэш браузеров Playwright', 'npx playwright install --with-deps chromium']) { + assert.match(stepOf(step), /if: steps\.plan\.outputs\.count != '0' && steps\.plan\.outputs\.browser == 'true'/, step); + } + // npm ci и сам прогон нужны любому непустому плану — без условия по окружению. + for (const step of ['run: npm ci', 'name: Затронутые мутанты ловятся']) { + assert.doesNotMatch(stepOf(step), /outputs\.(browser|python)/, step); + } + assert.match(stepOf('actions/setup-python@'), /cache: pip\n\s+cache-dependency-path: tests_backend\/requirements\.txt/); +}); + test('ручной/ночной полный прогон не делит concurrency с push (#479)', () => { const text = read('validate.yml'); assert.match(text, /group: validate-\$\{\{ github\.event_name == 'workflow_dispatch' && 'dispatch-' \|\| '' \}\}/);