Files
houseplan-card/scripts/mutation-environment.mjs
T
Claudeandclaude[bot] 44ee23ee33 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
2026-09-24 05:48:42 +00:00

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'}`];
}