mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 21:58:56 +00:00
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
This commit is contained in:
@@ -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'}`];
|
||||
}
|
||||
Reference in New Issue
Block a user