test: prove AC5 by running the scanner, not the predicate (#399)

CODE-REVIEW-399-r1 High: the test named after AC5 called
installsPythonDeps on string literals and never executed the directory
walk it was supposed to protect. The reviewer showed what that costs:
restoring the old hardcoded pair of real names and dropping a third
workflow with unpinned installs into .github/workflows left all ten
checks green — the exact scenario AC5 describes went undetected.

The walk is now a function taking the directory, so the test can run it
for real: it builds a temporary directory with three files (a pinned
installer, a workflow that installs nothing, and a rogue one) and
asserts on what the scanner returns. Reverting the walk to a list of two
real names now reddens this test, verified by hand.

The mutant is sharpened accordingly: it substitutes the two-name list
instead of a one-name list. The old form failed on an unrelated
assertion about directory size, so it proved nothing about the scan
itself — while the two-name form is indistinguishable from correct code
on today's tree, which is what makes it the likely regression.

User-Visible: no
Issue: #399
This commit is contained in:
Codex
2026-08-31 04:48:41 +03:00
parent 8b2a146c6c
commit 5c4d8cba9e
2 changed files with 61 additions and 38 deletions
+5 -2
View File
@@ -753,8 +753,11 @@ const MUTANT_DEFINITIONS = [
+ 'unpinned dependencies unnoticed — the shape #399 removed',
patches: [{
file: 'test/validate-workflow.test.mjs',
find: " const workflows = readdirSync(WORKFLOWS).filter((name) => name.endsWith('.yml'));",
replace: " const workflows = ['validate.yml'];",
// Список из ДВУХ настоящих имён — самая правдоподобная форма регрессии:
// на сегодняшнем дереве она неотличима от корректного кода, и мутант с
// одним именем ловился бы по посторонней причине (замечание r1).
find: " const files = readdirSync(directory).filter((name) => name.endsWith('.yml'));",
replace: " const files = ['validate.yml', 'mutation-gate.yml'];",
}],
},
{
+56 -36
View File
@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
// #336. Воркфлоу — не текст, а контракт, и ломается он молча: висячая
@@ -216,47 +218,65 @@ test('гейты диапазона судят от доказанного пр
* установка python-пакетов — файл обязан ставить их из файла пинов. */
const installsPythonDeps = (workflow) => /(?:^|\s)(?:python -m )?pip\s+install\s/.test(workflow);
/** Нарушения пин-контракта в КАТАЛОГЕ workflow-файлов (#399).
*
* Каталог — параметр, а не константа: только так тест может исполнить ту же
* функцию на подставном каталоге и доказать, что она ловит третий файл.
* Проверка предиката на строковых литералах этого не доказывает — сам обход
* при этом не исполняется и может остаться списком имён (замечание r1).
*/
export function pinViolations(directory) {
const files = readdirSync(directory).filter((name) => name.endsWith('.yml'));
const problems = [];
let installers = 0;
for (const file of files) {
const workflow = readFileSync(new URL(file, `file://${directory}`), 'utf8');
if (!installsPythonDeps(workflow)) continue;
installers += 1;
if (!/pip install -r tests_backend\/requirements\.txt/.test(workflow)) {
problems.push(`${file}: ставит python-зависимости мимо файла пинов`);
}
if (/pip install pytest /.test(workflow)) {
problems.push(`${file}: остался установ без версий`);
}
}
return { problems, installers, scanned: files.length };
}
test('HA-харнесс ставится по точным версиям, а не по воле резолвера (#392, #399)', () => {
// Плавающие версии означают, что «зелёный backend» значит разное в разные
// дни: по SHA коммита нельзя сказать, чем его проверяли. Ровно так харнесс
// полгода тихо проверял интеграцию против февральского Home Assistant.
//
// Перебирается ВЕСЬ каталог, а не список имён: новый workflow с
// неверсионированной установкой обязан краснеть сам, без правки теста.
const workflows = readdirSync(WORKFLOWS).filter((name) => name.endsWith('.yml'));
assert.ok(workflows.length >= 2, 'каталог workflows обязан читаться');
const installers = [];
for (const file of workflows) {
const workflow = read(file);
if (!installsPythonDeps(workflow)) continue;
installers.push(file);
assert.match(workflow, /pip install -r tests_backend\/requirements\.txt/,
`${file}: ставит python-зависимости мимо файла пинов`);
assert.equal(/pip install pytest /.test(workflow), false,
`${file}: остался установ без версий`);
}
// Факт выводится проверкой, а не задаётся ей: список нужен для сообщения об
// ошибке, а не для решения, кого проверять.
assert.ok(installers.length > 0,
const { problems, installers, scanned } = pinViolations(WORKFLOWS);
assert.deepEqual(problems, []);
assert.ok(scanned >= 2, 'каталог workflows обязан читаться');
// Факт выводится проверкой, а не задаётся ей.
assert.ok(installers > 0,
'ни один workflow не ставит python-зависимости — либо каталог прочитан'
+ ' неверно, либо бэкенд-гейт исчез; и то и другое стоит увидеть');
});
test('#399 AC5: проверка ловит новый workflow, которого нет ни в каком списке', () => {
// Синтетический третий файл: при переборе по именам он бы не попал в
// проверку вовсе — именно так гейт и обходили бы, ничего не нарушая.
const rogue = [
'name: rogue',
'jobs:',
' backend:',
' steps:',
' - run: pip install pytest voluptuous homeassistant',
].join('\n');
assert.equal(installsPythonDeps(rogue), true,
'установка python-зависимостей обязана распознаваться по содержимому');
assert.equal(/pip install -r tests_backend\/requirements\.txt/.test(rogue), false,
'и такой файл обязан провалить проверку пинов');
// Обратный случай: файл без установки не должен требовать пинов.
const innocent = 'name: docs\njobs:\n build:\n steps:\n - run: npm ci\n';
assert.equal(installsPythonDeps(innocent), false);
test('#399 AC5: тот же код ловит третий workflow в подставном каталоге', () => {
// Доказательство исполнением, а не рассуждением: строится настоящий
// каталог из трёх файлов, и вызывается ТА ЖЕ функция. Если обход вернётся
// к списку имён, третий файл в него не попадёт и тест покраснеет — что и
// отличает эту проверку от прежней, гонявшей предикат на литералах.
const directory = mkdtempSync(join(tmpdir(), 'hp-workflows-'));
try {
writeFileSync(join(directory, 'validate.yml'),
'jobs:\n backend:\n steps:\n - run: pip install -r tests_backend/requirements.txt\n');
writeFileSync(join(directory, 'docs.yml'),
'jobs:\n build:\n steps:\n - run: npm ci\n');
writeFileSync(join(directory, 'zz-rogue.yml'),
'jobs:\n backend:\n steps:\n - run: pip install pytest voluptuous homeassistant\n');
const { problems, installers, scanned } = pinViolations(`${directory}/`);
assert.equal(scanned, 3, 'просмотрены все файлы каталога, а не два имени');
assert.equal(installers, 2, 'файл без установки python-пакетов не в счёте');
assert.deepEqual(problems, [
'zz-rogue.yml: ставит python-зависимости мимо файла пинов',
'zz-rogue.yml: остался установ без версий',
]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});