mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
#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
115 lines
6.6 KiB
JavaScript
115 lines
6.6 KiB
JavaScript
// Окружение, которое нужно гардам плана (#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'}`];
|
|
}
|