ci: мутанты по диффу на каждом пуше, отбор и по файлам гарда

User-Visible: no
Issue: #475
This commit is contained in:
Codex
2026-09-06 19:31:48 +03:00
parent d12b1f20b0
commit a6077ac922
4 changed files with 192 additions and 4 deletions
+58 -2
View File
@@ -2862,6 +2862,30 @@ const MUTANT_DEFINITIONS = [
replace: " if (asEscaped) { escaped.add(asEscaped[1]); continue; }\n if (/^FAIL (\\S+)/.test(line)) { escaped.add(line.split(' ')[1].replace(/:$/, '')); continue; }",
}],
},
{
id: 'changed-selection-ignores-guard-files',
guard: 'node --test --test-name-pattern="#475 AC2" test/mutation-gate.test.mjs',
because: 'a witness also rots when its guard changes and stops reaching the mutated branch; '
+ 'selecting by patch file alone lets that class survive until the weekly full run (#475)',
patches: [{
file: 'scripts/mutation-gate.mjs',
// Реестр живёт в том же файле, что и код: якорь собирается из двух
// частей, иначе --check найдёт его дважды — в коде и здесь.
find: ' || guardFiles(m.guard, exists)' + '.some((file) => changed.has(file)));',
replace: ' || false);',
}],
},
{
id: 'changed-selection-matches-any-token',
guard: 'node --test --test-name-pattern="#475 AC3" test/mutation-gate.test.mjs',
because: 'treating every token of the guard command as a file turns pattern fragments '
+ 'like `b.mjs"` into guard files and selects mutants for unrelated diffs (#475)',
patches: [{
file: 'scripts/mutation-gate.mjs',
find: " if (!/^[\\w./-]+\\.(mjs|py)$/.test(bare) || bare.startsWith('-')) continue;",
replace: " if (!/\\.(mjs|py)/.test(bare)) continue;",
}],
},
{
id: 'review-comment-source-ignores-issue-number',
guard: 'node --test --test-name-pattern="по документу ЭТОЙ задачи|чужой номер задачи" '
@@ -6975,9 +6999,41 @@ function runCleanGuards(mutants) {
* проверки и ревью-циклов; полный набор остаётся предрелизным контрактом,
* поэтому пустая выборка — честный успех с явным сообщением, а не ошибка.
*/
export function selectChangedMutants(mutants, changedFiles) {
/**
* Файлы, на которые ссылается команда гарда (#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];
}
/**
* Мутанты, затронутые диффом (#332, расширено в #475).
*
* Два способа свидетелю сгнить: изменился файл, который он патчит, — либо
* изменился его гард (тест, смок, pytest-модуль), и тот перестал ходить по
* мутированной ветке. Прежде отбор видел только первый; так после #302/#309
* четыре мутанта пережили свои гарды и обнаружились лишь полным прогоном
* перед v1.72.0 (#466, #467).
*/
export function selectChangedMutants(mutants, changedFiles, exists) {
const changed = new Set(changedFiles);
return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file)));
return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file))
|| guardFiles(m.guard, exists).some((file) => changed.has(file)));
}
/**