mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
refactor: split mutation gate modules (#558)
Issue: #558 User-Visible: no
This commit is contained in:
@@ -221,7 +221,10 @@ export function referencesOf(file, rawText) {
|
||||
* сотни путей, но для того, кто его импортирует (тесты реестра), это данные,
|
||||
* а не зависимости — иначе одна правка любого теста отбирала бы весь реестр.
|
||||
*/
|
||||
export const LEAF_FILES = new Set(['scripts/mutation-gate.mjs']);
|
||||
// The mutation registry contains patch snippets that look like imports and
|
||||
// file paths but are data, not executable dependencies. Runner modules are
|
||||
// ordinary source and must participate in closure discovery.
|
||||
export const LEAF_FILES = new Set(['scripts/mutation-registry.mjs']);
|
||||
|
||||
export function closure(root, entries, { tracked = trackedFiles(root), stopAt = () => false, read, parents } = {}) {
|
||||
const trackedSet = new Set(tracked);
|
||||
@@ -299,7 +302,7 @@ export const CHECKS = {
|
||||
roots: [...BUILD_INPUTS, 'test/**', 'tsconfig*.json', ...WORKFLOW],
|
||||
},
|
||||
changed_mutants: {
|
||||
entries: ['scripts/mutation-gate.mjs', 'scripts/*-guard.mjs', 'test/*.test.mjs', 'demo/smoke_*.mjs', 'tests_backend/**/*.py'],
|
||||
entries: ['scripts/mutation-*.mjs', 'scripts/*-guard.mjs', 'test/*.test.mjs', 'demo/smoke_*.mjs', 'tests_backend/**/*.py'],
|
||||
roots: [...BUILD_INPUTS, 'custom_components/**/*.py', ...BROWSER_PROTOCOL, ...WORKFLOW],
|
||||
},
|
||||
integration: {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Fingerprints and the reusable caught-witness ledger.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { MUTATION_PROOF } from './mutation-guard-outcome.mjs';
|
||||
import { anchorRegion, guardInputs } from './mutation-selection.mjs';
|
||||
import { withoutProductVersion } from './source-fingerprint.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
/**
|
||||
* Отпечаток свидетеля (#481): содержимое файлов патча и гарда плюс само
|
||||
* объявление мутанта. Строка версии продукта нормализуется, как в
|
||||
* `visualFingerprint` (#245): релизный бамп трогает `houseplan-card.ts` и
|
||||
* `houseplan-editor-runtime.ts`, по которым отбираются десятки мутантов, но
|
||||
* ни одного свидетеля не меняет. Приближение то же, что у отбора по диффу:
|
||||
* непрямые зависимости гарда не учитываются — полный прогон остаётся
|
||||
* контрактом, журнал его не заменяет.
|
||||
*/
|
||||
export function witnessFingerprint(mutant, {
|
||||
root = repoRoot,
|
||||
read = (file) => (existsSync(join(root, file)) ? readFileSync(join(root, file), 'utf8') : ''),
|
||||
exists = (file) => existsSync(join(root, file)),
|
||||
normalize = withoutProductVersion(root),
|
||||
inputsOf = (guard) => guardInputs(guard, { exists, read }),
|
||||
} = {}) {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(JSON.stringify({
|
||||
id: mutant.id,
|
||||
guard: mutant.guard,
|
||||
oracle: mutant.oracle || MUTATION_PROOF.ASSERTION,
|
||||
patches: mutant.patches,
|
||||
}));
|
||||
hash.update('\0');
|
||||
const text = (file) => String(read(file)).replace(/\r\n?/g, '\n');
|
||||
// Сторона патча — только область якоря (#518); сторона гарда — файл целиком:
|
||||
// у гарда якоря нет, он судит поведение и меняется весь.
|
||||
const entries = [
|
||||
...mutant.patches.map((patch, index) => [
|
||||
`${patch.file}#якорь-${index}`,
|
||||
() => anchorRegion(text(patch.file), patch.find),
|
||||
]),
|
||||
...inputsOf(mutant.guard).map((file) => [file, () => text(file)]),
|
||||
].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
for (const [key, valueOf] of entries) {
|
||||
hash.update(key);
|
||||
hash.update('\0');
|
||||
hash.update(normalize(valueOf()));
|
||||
hash.update('\0');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export const LEDGER_SCHEMA = 2;
|
||||
|
||||
const emptyLedger = () => ({ schema: LEDGER_SCHEMA, caught: {} });
|
||||
const validProof = (proof) => Object.values(MUTATION_PROOF).includes(proof);
|
||||
|
||||
/**
|
||||
* Журнал доказанных свидетелей.
|
||||
*
|
||||
* Schema 2 намеренно не принимает старые строки fingerprint: до #550 они не
|
||||
* доказывали, что упал именно oracle, а не tsc/setup перед ним.
|
||||
*/
|
||||
export function readLedger(file) {
|
||||
if (!file || !existsSync(file)) return emptyLedger();
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (parsed?.schema !== LEDGER_SCHEMA || typeof parsed.caught !== 'object' || !parsed.caught) {
|
||||
return emptyLedger();
|
||||
}
|
||||
const caught = {};
|
||||
for (const [id, value] of Object.entries(parsed.caught)) {
|
||||
if (typeof value?.fingerprint === 'string' && validProof(value.proof)) caught[id] = { ...value };
|
||||
}
|
||||
return { schema: LEDGER_SCHEMA, caught };
|
||||
} catch {
|
||||
// Битый журнал — не отказ гейта: он лишь сужает работу, и пустой журнал
|
||||
// означает «гонять всё отобранное», то есть прежнее поведение.
|
||||
return emptyLedger();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Записать пойманного свидетеля — сразу, а не в конце прогона: отменённый
|
||||
* или упавший по таймауту шард обязан сохранить уже сделанное, иначе
|
||||
* следующий пуш начинает с нуля (снежный ком #481).
|
||||
*/
|
||||
export function recordCaught(file, ledger, mutant, fingerprint, proof = MUTATION_PROOF.ASSERTION) {
|
||||
if (!validProof(proof)) throw new Error(`нельзя записать недоказанный outcome в ledger: ${proof}`);
|
||||
ledger.caught[mutant.id] = { fingerprint, proof };
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, `${JSON.stringify({ schema: LEDGER_SCHEMA, caught: ledger.caught }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутанты, чей отпечаток совпадает с журналом, уже доказали, что ловятся на
|
||||
* этих же входах — их пропуск ничего не ослабляет. Сравнивается ОТПЕЧАТОК,
|
||||
* не факт присутствия id: запись от другого содержимого файлов не считается.
|
||||
*/
|
||||
export function splitByLedger(mutants, ledger, fingerprintOf = (m) => witnessFingerprint(m)) {
|
||||
const run = [];
|
||||
const skipped = [];
|
||||
for (const mutant of mutants) {
|
||||
const fingerprint = fingerprintOf(mutant);
|
||||
const proof = ledger.caught[mutant.id];
|
||||
const expectedProof = mutant.oracle || MUTATION_PROOF.ASSERTION;
|
||||
if (proof?.fingerprint === fingerprint && proof.proof === expectedProof
|
||||
&& validProof(proof.proof)) skipped.push(mutant);
|
||||
else run.push({ mutant, fingerprint });
|
||||
}
|
||||
return { run, skipped };
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Applying and executing mutants. This module deliberately knows nothing
|
||||
// about registry layout or diff selection.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
MUTATION_OUTCOME, MUTATION_PROOF, runGuardPhases, runMutationLifecycle,
|
||||
} from './mutation-guard-outcome.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
export function applyPatches(root, patches) {
|
||||
for (const patch of patches) {
|
||||
const path = join(root, patch.file);
|
||||
const source = readFileSync(path, 'utf8');
|
||||
const hits = source.split(patch.find).length - 1;
|
||||
if (hits !== 1) {
|
||||
throw new Error(`${patch.file}: якорь найден ${hits} раз(а), нужен ровно 1 — реестр отстал от кода`);
|
||||
}
|
||||
writeFileSync(path, source.replace(patch.find, patch.replace));
|
||||
}
|
||||
}
|
||||
|
||||
function sh(cmd, cwd, extraEnv = {}) {
|
||||
return spawnSync(cmd, {
|
||||
cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
timeout: Number(process.env.MUTATION_COMMAND_TIMEOUT_MS) || 180_000,
|
||||
killSignal: 'SIGTERM',
|
||||
env: { ...process.env, ...extraEnv },
|
||||
});
|
||||
}
|
||||
|
||||
export function makeWorktree() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-mutant-'));
|
||||
const added = spawnSync('git', ['-C', repoRoot, 'worktree', 'add', '--detach', dir, 'HEAD'],
|
||||
{ encoding: 'utf8' });
|
||||
if (added.status !== 0) throw new Error(`git worktree add: ${added.stderr}`);
|
||||
// node_modules не копируется — символическая ссылка на настоящий. Установка
|
||||
// зависимостей на каждого мутанта превратила бы вечерний гейт в суточный.
|
||||
symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'junction');
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function dropWorktree(dir) {
|
||||
// On Windows a directory junction can be traversed by recursive worktree
|
||||
// cleanup. Detach it first; otherwise removing the mutant may empty the real
|
||||
// repository's node_modules target instead of deleting only the junction.
|
||||
const modules = join(dir, 'node_modules');
|
||||
if (existsSync(modules)) unlinkSync(modules);
|
||||
spawnSync('git', ['-C', repoRoot, 'worktree', 'remove', '--force', dir], { encoding: 'utf8' });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Нужен ли гварду скомпилированный `test-build/` (#235).
|
||||
*
|
||||
* Гварды в реестре двух видов: длинные сами начинаются с
|
||||
* `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs`, короткие —
|
||||
* сразу с `node --test`. В свежем worktree каталога `test-build/` нет вообще,
|
||||
* поэтому короткий гвард падал с `ERR_MODULE_NOT_FOUND` — и это читалось как
|
||||
* «мутант поймал поломку», хотя означало «гвард не смог исполниться». Компиляция
|
||||
* стоит секунд, поэтому шаг ставится только там, где гвард не делает его сам.
|
||||
*/
|
||||
export function guardNeedsTestBuild(guard) {
|
||||
return /(^|[\s&|;])node --test\b/.test(guard) && !guard.includes('tsconfig.test.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Нужен ли гварду СОБРАННЫЙ бандл (#332).
|
||||
*
|
||||
* Бандл читают только браузерные проверки: смоки и golden-сцены грузят
|
||||
* дерево `demo/srv/assets/`, и мутант обязан попасть в entry и lazy chunks,
|
||||
* иначе
|
||||
* guard проверяет чистый код. Юнит- и бэкенд-гварды бандл не открывают ни в
|
||||
* каком виде (проверено по реестру и по исходникам тестов: dist/** читается
|
||||
* только как git-чекаут, который в worktree и так есть). Rollup-сборка —
|
||||
* самая дорогая часть прогона (255 мутантов × ~15-20 с), поэтому она
|
||||
* выполняется только там, где её результат кто-то откроет.
|
||||
*/
|
||||
export function guardNeedsBundle(guard) {
|
||||
// `bundle:sync` в гварде реестр больше не допускает (--check, #499), но
|
||||
// распознавание остаётся: чужой или старый гвард со сборкой всё равно
|
||||
// браузерный, и бандл ему нужен.
|
||||
return guard.includes('demo/') || guard.includes('bundle:sync');
|
||||
}
|
||||
|
||||
/**
|
||||
* Тёплый старт компиляции мутанта (#332): скопировать `test-build/` вместе с
|
||||
* `.tsbuildinfo` из основного дерева. Мутант меняет один-два файла, и
|
||||
* инкрементальный tsc пересобирает только их дельту вместо всего проекта;
|
||||
* `.tsbuildinfo` сверяет файлы по хэшу содержимого, поэтому свежие mtime
|
||||
* worktree его не сбивают, а мутированный файл гарантированно пересобирается.
|
||||
* Отсутствие каталога в основном дереве — не ошибка: холодная сборка просто
|
||||
* идёт прежним полным путём.
|
||||
*/
|
||||
function seedTestBuild(dir) {
|
||||
const warm = join(repoRoot, 'test-build');
|
||||
if (!existsSync(warm)) return;
|
||||
cpSync(warm, join(dir, 'test-build'), { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать `test-build/` из мутированного src в каталоге мутанта.
|
||||
*
|
||||
* `tsc` здесь — подготовка, а не заявленный оракул. Его отказ не может
|
||||
* считаться падением последующего теста (#550), даже если тёплый каталог от
|
||||
* прошлого дерева всё ещё существует.
|
||||
*/
|
||||
function buildTestBuild(dir) {
|
||||
seedTestBuild(dir);
|
||||
const built = sh('npx tsc -p tsconfig.test.json', dir);
|
||||
if (built.status !== 0) {
|
||||
throw new Error(`test-build не скомпилировался в мутанте:\n${(built.stderr || built.stdout || built.error?.message || '').slice(-2000)}`);
|
||||
}
|
||||
const fixed = sh('node scripts/fix-test-build.mjs', dir);
|
||||
if (fixed.status !== 0 || !existsSync(join(dir, 'test-build'))) {
|
||||
throw new Error(`test-build не собрался в мутанте:\n${(fixed.stderr || fixed.stdout).slice(-2000)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBundle(dir) {
|
||||
// Только rollup, без tsc --noEmit: мутант имеет право быть нестрогим по
|
||||
// типам — он воспроизводит поломку, а не образцовый код.
|
||||
const built = sh('npx rollup -c', dir);
|
||||
if (built.status !== 0) {
|
||||
throw new Error(`сборка мутанта упала:\n${String(built.stderr || built.stdout || built.error?.message || '').slice(-2000)}`);
|
||||
}
|
||||
const synced = sh('node scripts/bundle-sync.mjs', dir);
|
||||
if (synced.status !== 0) {
|
||||
throw new Error(`дерево бандла мутанта не синхронизировалось:\n${String(synced.stderr || synced.stdout || synced.error?.message || '').slice(-2000)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printMutantOutcome(mutant, outcome) {
|
||||
if (outcome.kind === MUTATION_OUTCOME.ASSERTION_KILLED) {
|
||||
console.log(`ok ${mutant.id}: заявленный тест покраснел на мутанте`);
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === MUTATION_OUTCOME.COMPILE_KILLED) {
|
||||
console.log(`ok ${mutant.id}: явный compile-time свидетель поймал мутант`);
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === MUTATION_OUTCOME.SURVIVED) {
|
||||
// Формат читает mutation-gate-report.mjs — менять синхронно.
|
||||
console.log(`FAIL ${mutant.id}: тест остался зелёным на сломанном коде`);
|
||||
console.log(` guard: ${mutant.guard}`);
|
||||
console.log(` ${mutant.because}`);
|
||||
return;
|
||||
}
|
||||
const labels = {
|
||||
[MUTATION_OUTCOME.INVALID]: 'неприменимый мутант',
|
||||
[MUTATION_OUTCOME.SETUP]: 'ошибка подготовки до заявленного теста',
|
||||
[MUTATION_OUTCOME.INTERRUPTED]: 'прерывание инфраструктуры',
|
||||
};
|
||||
console.log(`FAIL ${mutant.id}: ${labels[outcome.kind] || outcome.kind}`);
|
||||
if (outcome.command) console.log(` command: ${outcome.command}`);
|
||||
if (outcome.detail) console.log(` ${outcome.detail}`);
|
||||
}
|
||||
|
||||
export function runMutant(mutant) {
|
||||
let dir;
|
||||
try {
|
||||
dir = makeWorktree();
|
||||
const outcome = runMutationLifecycle({
|
||||
apply: () => applyPatches(dir, mutant.patches),
|
||||
prepare: () => {
|
||||
if (guardNeedsBundle(mutant.guard)) buildBundle(dir);
|
||||
if (guardNeedsTestBuild(mutant.guard)) buildTestBuild(dir);
|
||||
},
|
||||
guard: mutant.guard,
|
||||
proof: mutant.oracle || MUTATION_PROOF.ASSERTION,
|
||||
execute: (command) => sh(command, dir),
|
||||
});
|
||||
printMutantOutcome(mutant, outcome);
|
||||
return outcome;
|
||||
} catch (error) {
|
||||
const outcome = {
|
||||
kind: MUTATION_OUTCOME.INTERRUPTED,
|
||||
detail: error.message,
|
||||
command: '',
|
||||
};
|
||||
printMutantOutcome(mutant, outcome);
|
||||
return outcome;
|
||||
} finally {
|
||||
if (dir) dropWorktree(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Чистый прогон каждого guard ровно один раз: тест, красный и без мутанта,
|
||||
// «ловит» поломку тривиально и не доказывает ничего.
|
||||
export function runCleanGuards(mutants) {
|
||||
const guards = [...new Set(mutants.map((m) => m.guard))];
|
||||
const dir = makeWorktree();
|
||||
try {
|
||||
try {
|
||||
if (guards.some(guardNeedsBundle)) buildBundle(dir);
|
||||
// Один worktree на все чистые гварды — значит и компиляция одна.
|
||||
if (guards.some(guardNeedsTestBuild)) buildTestBuild(dir);
|
||||
} catch (error) {
|
||||
console.log(`FAIL чистая подготовка: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
for (const guard of guards) {
|
||||
const mutant = mutants.find((item) => item.guard === guard);
|
||||
const outcome = runGuardPhases(guard, {
|
||||
proof: mutant?.oracle || MUTATION_PROOF.ASSERTION,
|
||||
execute: (command) => sh(command, dir),
|
||||
});
|
||||
if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) {
|
||||
if (outcome.kind === MUTATION_OUTCOME.SETUP) {
|
||||
console.log(`FAIL чистая подготовка: ${outcome.command}`);
|
||||
} else if (outcome.kind === MUTATION_OUTCOME.INTERRUPTED) {
|
||||
console.log(`FAIL чистая инфраструктура: ${outcome.command || guard}`);
|
||||
} else {
|
||||
// Формат читает mutation-gate-report.mjs — менять синхронно.
|
||||
console.log(`FAIL чистый прогон: ${guard} красный без мутанта`);
|
||||
}
|
||||
if (outcome.detail) console.log(outcome.detail.slice(-1500));
|
||||
return false;
|
||||
}
|
||||
console.log(`ok чистый прогон: ${guard}`);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
dropWorktree(dir);
|
||||
}
|
||||
}
|
||||
+59
-10100
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@
|
||||
* command can prove the behavioural assertion. Treating every non-zero exit
|
||||
* as "caught" turns a missing dependency or a mutant that no longer compiles
|
||||
* into false test evidence. This module keeps that distinction pure and
|
||||
* fixture-testable; the worktree/build orchestration remains in
|
||||
* mutation-gate.mjs.
|
||||
* fixture-testable; the worktree/build orchestration lives in
|
||||
* mutation-execution.mjs.
|
||||
*/
|
||||
|
||||
export const MUTATION_OUTCOME = Object.freeze({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
||||
// Diff/guard input selection. Caches are caller-owned and therefore cannot
|
||||
// leak across source trees or separate CLI invocations.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { closure, trackedFiles } from './check-inputs.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
export const MUTATION_REGISTRY_FILES = ['scripts/mutation-registry.mjs'];
|
||||
export const LEGACY_MUTATION_REGISTRY_FILE = 'scripts/mutation-gate.mjs';
|
||||
|
||||
/**
|
||||
* Мутанты, чьи патч-файлы задеты диффом (#332). Дифф-режим — для локальной
|
||||
* проверки и ревью-циклов; полный набор идёт ночным расписанием (#513),
|
||||
* поэтому пустая выборка — честный успех с явным сообщением, а не ошибка.
|
||||
*/
|
||||
/**
|
||||
* Файлы, на которые ссылается команда гарда (#475).
|
||||
*
|
||||
* Без парсинга команды: берутся токены с суффиксом `.mjs`, `.test.mjs` или
|
||||
* `.py`, которые существуют в репозитории. Флаги (`--test-name-pattern=…`),
|
||||
* шаблоны и произвольные слова файлами не считаются. Фикстуры гардов
|
||||
* (`test/fixtures/*`, `tests_backend/fixtures/*`) по команде вывести нельзя —
|
||||
* это граница: их дрейф остаётся полному прогону.
|
||||
*/
|
||||
export function guardFiles(guard, exists = (file) => existsSync(join(repoRoot, file))) {
|
||||
const files = new Set();
|
||||
for (const token of String(guard || '').split(/\s+/)) {
|
||||
const bare = token.replace(/^["']|["']$/g, '');
|
||||
// Путь, а не обрывок шаблона: только [A-Za-z0-9_./-], без кавычек,
|
||||
// «|» и флагов. `--test-name-pattern="a|b.mjs"` даёт токен `b.mjs"` —
|
||||
// он не файл, даже если бы такой существовал.
|
||||
if (!/^[\w./-]+\.(mjs|py)$/.test(bare) || bare.startsWith('-')) continue;
|
||||
if (exists(bare)) files.add(bare);
|
||||
}
|
||||
return [...files];
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртки гардов объявляют, что запускают (#492 §6.1): `export const
|
||||
* GUARD_INPUTS = [...]` читается статически, без исполнения — обёртка при
|
||||
* импорте сразу бежит и падает на usage. Файл гарда без объявления не
|
||||
* считается обёрткой: его входы — только импорты и пути (§6.2).
|
||||
*/
|
||||
const GUARD_INPUTS_RE = /export const GUARD_INPUTS = \[([^\]]*)\]/;
|
||||
export function wrapperInputs(file, read = (f) => (existsSync(join(repoRoot, f)) ? readFileSync(join(repoRoot, f), 'utf8') : '')) {
|
||||
if (!/^scripts\/[\w-]+-guard\.mjs$/.test(file)) return [];
|
||||
const m = GUARD_INPUTS_RE.exec(String(read(file)));
|
||||
if (!m) return [];
|
||||
return [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]);
|
||||
}
|
||||
|
||||
/** Сторона патча остаётся точечной (#492 §6.4): замыкание в `src/**` не идёт. */
|
||||
const GUARD_CLOSURE_STOP = (file) => file.startsWith('src/') || file.startsWith('custom_components/houseplan/frontend/');
|
||||
|
||||
/**
|
||||
* Все входы гарда (#492 §6): файлы из строки команды ∪ объявленные входы
|
||||
* обёрток ∪ транзитивное замыкание по импортам и путям — смок тянет
|
||||
* `serve.mjs` и фикстуры, pytest-модуль — `conftest.py`, обёртка — свои
|
||||
* тесты. `src/**` исключён: это сторона патча, не гарда.
|
||||
*/
|
||||
export function guardInputs(guard, {
|
||||
exists = (file) => existsSync(join(repoRoot, file)),
|
||||
read,
|
||||
files = trackedFiles(repoRoot),
|
||||
} = {}) {
|
||||
const named = guardFiles(guard, exists);
|
||||
// Объявленное умолчание обёртки действует, когда гард не назвал файл сам:
|
||||
// третий аргумент backend-test-guard уже стоит в строке и отбирается оттуда.
|
||||
const isWrapper = (file) => /^scripts\/[\w-]+-guard\.mjs$/.test(file);
|
||||
const wrappers = named.filter(isWrapper);
|
||||
const explicit = named.some((file) => !isWrapper(file));
|
||||
const declared = explicit ? [] : wrappers.flatMap((file) => wrapperInputs(file, read));
|
||||
// Сама обёртка — вход (её текст в отпечатке), но её ссылки не читаются:
|
||||
// умолчание уже учтено выше, а перечитывать его из текста значило бы
|
||||
// отменять явный аргумент.
|
||||
const entries = [...new Set([...named, ...declared])].filter(exists);
|
||||
const reached = closure(repoRoot, entries.filter((file) => !wrappers.includes(file)),
|
||||
{ tracked: files, read, stopAt: GUARD_CLOSURE_STOP });
|
||||
return [...new Set([...entries, ...reached])]
|
||||
.filter((file) => !GUARD_CLOSURE_STOP(file))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invocation-scoped resolver for the expensive guard dependency closure.
|
||||
*
|
||||
* The resolver owns its Map; constructing a new resolver is the cache reset
|
||||
* when the source tree, tracked-file inventory or read strategy changes.
|
||||
*/
|
||||
export function createGuardInputResolver(options = {}) {
|
||||
const cache = new Map();
|
||||
// Take the tracked-file snapshot once per resolver. A new invocation (or a
|
||||
// caller examining another tree) constructs a new resolver and therefore a
|
||||
// fresh snapshot; no module-global result can leak across source material.
|
||||
const invocationOptions = {
|
||||
...options,
|
||||
files: options.files ?? trackedFiles(repoRoot),
|
||||
};
|
||||
let requests = 0;
|
||||
let computations = 0;
|
||||
const resolve = (guard) => {
|
||||
requests++;
|
||||
if (cache.has(guard)) return cache.get(guard);
|
||||
computations++;
|
||||
const inputs = guardInputs(guard, invocationOptions);
|
||||
cache.set(guard, inputs);
|
||||
return inputs;
|
||||
};
|
||||
resolve.stats = () => ({
|
||||
requests,
|
||||
computations,
|
||||
hits: requests - computations,
|
||||
uniqueGuards: cache.size,
|
||||
});
|
||||
return resolve;
|
||||
}
|
||||
|
||||
/**
|
||||
* Радиус области якоря (#518): сколько строк вокруг патча считается «его
|
||||
* кодом». Хост-файлы карты — тринадцать тысяч строк, и правка в одном их
|
||||
* конце перегоняла свидетелей из другого: на #500 двенадцать изменённых строк
|
||||
* `houseplan-editor-runtime.ts` тянули 53 мутанта из 75. Сорок строк — то
|
||||
* расстояние, на котором правка ещё почти всегда трогает тот же код; дальше
|
||||
* начинается чужой, и его перебирает ночной полный гейт (#513).
|
||||
*/
|
||||
export const ANCHOR_RADIUS_LINES = 40;
|
||||
|
||||
/**
|
||||
* Строки области якоря, 1-based включительно, или `null` — когда `find`
|
||||
* встречается в файле не ровно один раз. `null` значит «судить по файлу
|
||||
* целиком»: реестр, отставший от кода, обязан отвечать консервативно, а не
|
||||
* сужать проверку (эту же однократность требуют `--check` и `applyPatches`).
|
||||
*/
|
||||
export function anchorSpan(source, find, radius = ANCHOR_RADIUS_LINES) {
|
||||
const text = String(source ?? '');
|
||||
if (!find || text.split(find).length - 1 !== 1) return null;
|
||||
const start = text.indexOf(find);
|
||||
const before = text.slice(0, start).split('\n').length; // 1-based строка начала
|
||||
const inside = String(find).split('\n').length - 1;
|
||||
const lines = text.split('\n').length;
|
||||
return {
|
||||
from: Math.max(1, before - radius),
|
||||
to: Math.min(lines, before + inside + radius),
|
||||
};
|
||||
}
|
||||
|
||||
/** Текст области якоря (или весь файл, если якорь не однозначен). */
|
||||
export function anchorRegion(source, find, radius = ANCHOR_RADIUS_LINES) {
|
||||
const text = String(source ?? '');
|
||||
const span = anchorSpan(text, find, radius);
|
||||
if (!span) return text;
|
||||
return text.split('\n').slice(span.from - 1, span.to).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Изменённые области по файлам из `git diff --unified=0` — стороны ГОЛОВЫ
|
||||
* (`+`), потому что якоря ищутся в рабочем дереве. Чистое удаление даёт
|
||||
* нулевую длину `+c,0`: считаем задетыми строки вокруг стыка, иначе вырезанный
|
||||
* кусок кода не задел бы никого.
|
||||
*/
|
||||
export function parseDiffRanges(diffText) {
|
||||
const ranges = new Map();
|
||||
let file = null;
|
||||
for (const line of String(diffText ?? '').split('\n')) {
|
||||
const head = /^\+\+\+ (?:b\/)?(.+)$/.exec(line);
|
||||
if (head) { file = head[1] === '/dev/null' ? null : head[1]; continue; }
|
||||
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
||||
if (!hunk || !file) continue;
|
||||
const from = Number(hunk[1]);
|
||||
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
||||
const list = ranges.get(file) ?? [];
|
||||
list.push(count === 0 ? [from, from + 1] : [from, from + count - 1]);
|
||||
ranges.set(file, list);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
const spansOverlap = (span, [from, to]) => from <= span.to && to >= span.from;
|
||||
|
||||
/**
|
||||
* Задел ли дифф область якоря патча (#518). `ranges` — карта из
|
||||
* `parseDiffRanges`; её отсутствие означает прежний ответ по файлу целиком.
|
||||
*/
|
||||
export function patchTouched(patch, ranges, read) {
|
||||
if (!ranges) return true;
|
||||
const hunks = ranges.get(patch.file);
|
||||
if (!hunks || !hunks.length) return true;
|
||||
const span = anchorSpan(read(patch.file), patch.find);
|
||||
if (!span) return true; // якорь не однозначен — судим по файлу
|
||||
return hunks.some((hunk) => spansOverlap(span, hunk));
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутанты, затронутые диффом (#332, расширено в #475).
|
||||
*
|
||||
* Два способа свидетелю сгнить: изменился файл, который он патчит, — либо
|
||||
* изменился его гард (тест, смок, pytest-модуль), и тот перестал ходить по
|
||||
* мутированной ветке. Прежде отбор видел только первый; так после #302/#309
|
||||
* четыре мутанта пережили свои гарды и обнаружились лишь полным прогоном
|
||||
* перед v1.72.0 (#466, #467).
|
||||
*/
|
||||
export function selectChangedMutants(mutants, changedFiles, exists, options = {}) {
|
||||
const changed = new Set(changedFiles);
|
||||
if (!changed.size) return [];
|
||||
const inputsOf = options.guardInputs || createGuardInputResolver({
|
||||
...(exists ? { exists } : {}),
|
||||
...(options.read ? { read: options.read } : {}),
|
||||
...(options.files ? { files: options.files } : {}),
|
||||
});
|
||||
// #518: когда известны области диффа, файл патча отбирает свидетеля лишь
|
||||
// тем, что задел его якорь. Без областей — прежний ответ по файлу.
|
||||
const ranges = options.ranges || null;
|
||||
const read = options.read || ((file) => (existsSync(join(repoRoot, file)) ? readFileSync(join(repoRoot, file), 'utf8') : ''));
|
||||
return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file)
|
||||
&& patchTouched(patch, ranges, read))
|
||||
|| inputsOf(m.guard).some((file) => changed.has(file)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Задевает ли правка package.json хоть один гвард (#496, run 2795).
|
||||
*
|
||||
* package.json — вход почти каждого гварда (`npm run …`, `npx …`), поэтому любое
|
||||
* его касание отбирало ~195 мутантов из 590 и шард упирался в 30-минутный
|
||||
* лимит job. Но гвард зависит только от того, что он вызывает: изменённый или
|
||||
* удалённый существующий script, зависимости, engines/overrides. ДОБАВЛЕННЫЙ
|
||||
* script никакой прежний гвард не вызывает — он не вход. Любое другое поле или
|
||||
* непрочитанная база — считается задевающим: сторона ошибки — лишний прогон.
|
||||
*/
|
||||
export function packageJsonRelevance(baseText, headText) {
|
||||
let base; let head;
|
||||
try { base = JSON.parse(baseText); head = JSON.parse(headText); } catch { return { relevant: true, reason: 'package.json не разобран' }; }
|
||||
const baseScripts = base.scripts || {}; const headScripts = head.scripts || {};
|
||||
const changedScripts = Object.keys(baseScripts).filter((k) => headScripts[k] !== baseScripts[k]);
|
||||
if (changedScripts.length) return { relevant: true, reason: `изменены/удалены scripts: ${changedScripts.join(', ')}` };
|
||||
const rest = (pkg) => JSON.stringify({ ...pkg, scripts: undefined, description: undefined });
|
||||
if (rest(base) !== rest(head)) return { relevant: true, reason: 'изменены поля вне scripts (зависимости, engines, …)' };
|
||||
const added = Object.keys(headScripts).filter((k) => !(k in baseScripts));
|
||||
return { relevant: false, reason: `только добавлены scripts: ${added.join(', ') || '—'}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Определения реестра, добавленные или изменённые относительно базы (#492
|
||||
* §6.4): реестр базы читается через `git show` во временный модуль рядом с
|
||||
* этим файлом (относительные импорты обязаны разрешаться) и импортируется —
|
||||
* реестр данные, побочных эффектов при импорте нет (закреплено тестом).
|
||||
* Удалённые id возвращаются отдельно: гонять их нечем, но сказать стоит.
|
||||
*/
|
||||
export function registryDelta(current, base) {
|
||||
const shape = (m) => JSON.stringify({ guard: m.guard, patches: m.patches, because: m.because });
|
||||
const before = new Map(base.map((m) => [m.id, shape(m)]));
|
||||
const changed = current.filter((m) => before.get(m.id) !== shape(m)).map((m) => m.id);
|
||||
const removed = base.filter((m) => !current.some((c) => c.id === m.id)).map((m) => m.id);
|
||||
return { changed, removed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Отбор для диффа (#332, #475, #492): по файлам патчей и входам гардов плюс —
|
||||
* когда дифф трогает сам реестр — по добавленным/изменённым определениям
|
||||
* относительно реестра базы (`base`, null — база не прочитана).
|
||||
*/
|
||||
export function selectForDiff(mutants, files, base, options = {}) {
|
||||
const byFiles = selectChangedMutants(mutants, files, options.exists, options);
|
||||
let byRegistry = [];
|
||||
let removed = [];
|
||||
if (files.some((file) => MUTATION_REGISTRY_FILES.includes(file)) && base) {
|
||||
const delta = registryDelta(mutants, base);
|
||||
byRegistry = mutants.filter((m) => delta.changed.includes(m.id));
|
||||
removed = delta.removed;
|
||||
}
|
||||
const ids = new Set([...byFiles, ...byRegistry].map((m) => m.id));
|
||||
return { selected: mutants.filter((m) => ids.has(m.id)), byFiles, byRegistry, removed };
|
||||
}
|
||||
|
||||
export async function baseRegistry(baseRef) {
|
||||
let registryFile = MUTATION_REGISTRY_FILES[0];
|
||||
let shown = spawnSync('git', ['-C', repoRoot, 'show', `${baseRef}:${registryFile}`], { encoding: 'utf8' });
|
||||
if (shown.status !== 0) {
|
||||
// Compatibility across the one-time #558 split: a base before this
|
||||
// refactor still stores declarations inside mutation-gate.mjs.
|
||||
registryFile = LEGACY_MUTATION_REGISTRY_FILE;
|
||||
shown = spawnSync('git', ['-C', repoRoot, 'show', `${baseRef}:${registryFile}`], { encoding: 'utf8' });
|
||||
}
|
||||
if (shown.status !== 0) return null;
|
||||
const temp = join(repoRoot, 'scripts', `.mutation-gate.base-${process.pid}.mjs`);
|
||||
writeFileSync(temp, shown.stdout);
|
||||
try {
|
||||
const mod = await import(`${pathToFileURL(temp).href}?t=${Date.now()}`);
|
||||
return mod.MUTANTS;
|
||||
} finally {
|
||||
rmSync(temp, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Детерминированный шард `index/total` (#332): реестр сортируется по id и
|
||||
* режется чересполосно, чтобы дорогие смок-мутанты (соседи по алфавиту)
|
||||
* не скапливались в одном шарде. Объединение шардов равно реестру,
|
||||
* пересечений нет — закреплено юнитом.
|
||||
*/
|
||||
export function shardMutants(mutants, index, total) {
|
||||
const ordered = [...mutants].sort((a, b) => a.id.localeCompare(b.id));
|
||||
return ordered.filter((_, position) => position % total === index - 1);
|
||||
}
|
||||
Reference in New Issue
Block a user