diff --git a/pyproject.toml b/pyproject.toml index 745ddc87..22b075a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,16 @@ [tool.ruff] target-version = "py313" -include = ["custom_components/houseplan/**/*.py", "scripts/*.py", "tests_backend/**/*.py"] +# Скоуп ОБЪЯВЛЕННЫЙ равен проверяемому (#399). Раньше здесь стояли три +# дерева, а CI линтил одно — читающий конфиг делал неверный вывод, и в +# tests_backend уже проехали F401/F811, которых на v1.69.0 не было. +# +# Сужение, а не расширение: разбор долга в scripts/ и tests_backend/ (56 +# находок, в основном E402 и I001 в тестах, плюс 7 B023 и 5 B017) — работа +# со своей ценой и своими решениями, её место в отдельной задаче, а не в +# правке видимости. Расширять этот список можно только вместе со шагом +# «Линт бэкенда» в validate.yml — за этим следит test/lint-scope.test.mjs. +include = ["custom_components/houseplan/**/*.py"] [tool.ruff.lint] # Narrow on purpose (#42: no mass rewrite): errors, pyflakes, bugbear, imports. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 2998126c..9b84dc20 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -746,6 +746,39 @@ const MUTANT_DEFINITIONS = [ replace: " this._persistDecorStyle();\n }, 0);", }], }, + { + id: 'workflow-scan-hardcodes-the-list', + guard: 'node --test test/validate-workflow.test.mjs', + because: 'scanning a fixed pair of names is how a third workflow installs ' + + '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'];", + }], + }, + { + id: 'lint-scope-drifts', + guard: 'node --test test/lint-scope.test.mjs', + because: 'a declared lint scope wider than the checked one is what let ' + + 'F401/F811 into tests_backend unnoticed (#399)', + patches: [{ + file: 'pyproject.toml', + find: 'include = ["custom_components/houseplan/**/*.py"]', + replace: 'include = ["custom_components/houseplan/**/*.py", "tests_backend/**/*.py"]', + }], + }, + { + id: 'frontend-pin-drifts-from-ha', + guard: 'node --test test/backend-pins.test.mjs', + because: 'a frontend pin nobody derives from the pinned HA is how the ' + + 'harness ends up testing a combination that does not exist (#399)', + patches: [{ + file: 'tests_backend/requirements.txt', + find: 'home-assistant-frontend==20260729.7', + replace: 'home-assistant-frontend==20260826.1', + }], + }, { id: 'sysmodules-guard-blind-to-variable', guard: 'node --test test/backend-test-hygiene.test.mjs', diff --git a/test/backend-pins.test.mjs b/test/backend-pins.test.mjs new file mode 100644 index 00000000..f0ee1daf --- /dev/null +++ b/test/backend-pins.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +// #399: пин фронтенда не выводился ниоткуда — его однажды назначили руками, и +// получился набор, которого нет ни в одном релизе Home Assistant. Отказа это +// не вызывало (пакет — статика), но обещание #392 «по SHA видно, чем +// проверяли» переставало быть правдой ровно там, где его труднее всего +// заметить. + +const read = (name) => readFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8'); + +// Ожидание живёт рядом с пином, а не в сети: тест обязан работать без +// GitHub. Источник — homeassistant/package_constraints.txt тега 2026.8.3. +// Поднимая HA, обновлять обе строки из того же файла. +const EXPECTED = { homeassistant: '2026.8.3', frontend: '20260729.7' }; + +const pinned = (name) => { + const line = read('tests_backend/requirements.txt') + .split('\n') + .find((row) => row.trimStart().startsWith(`${name}==`)); + assert.ok(line, `${name} обязан быть закреплён в tests_backend/requirements.txt`); + return line.trim().split('==')[1].split('#')[0].trim(); +}; + +test('#399 AC1: фронтенд закреплён так, как требует закреплённый Home Assistant', () => { + assert.equal(pinned('homeassistant'), EXPECTED.homeassistant, + 'версия HA изменилась — ожидание фронтенда ниже больше не действует,' + + ' возьмите новое из package_constraints.txt соответствующего тега'); + assert.equal(pinned('home-assistant-frontend'), EXPECTED.frontend, + 'пин фронтенда разошёлся с констрейнтами закреплённого HA:' + + ' проверяется набор, которого не существует'); +}); + +test('#399 AC1: источник версии назван в самом файле', () => { + const requirements = read('tests_backend/requirements.txt'); + assert.match(requirements, /package_constraints\.txt/, + 'следующий подъём HA не должен превращаться в угадывание: откуда взялась' + + ' версия фронтенда, сказано рядом с ней'); +}); diff --git a/test/lint-scope.test.mjs b/test/lint-scope.test.mjs new file mode 100644 index 00000000..4c085e72 --- /dev/null +++ b/test/lint-scope.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +// #399: конфиг обещал линт по трём деревьям, CI проверял одно. Расхождение +// само по себе не ломает сборку — оно ломает доверие к зелёному значку, а +// следом и к выводу «раз зелено, значит проверено». Ровно так в tests_backend +// проехали F401/F811, которых на v1.69.0 не было. + +const root = (name) => fileURLToPath(new URL(`../${name}`, import.meta.url)); +const read = (name) => readFileSync(root(name), 'utf8'); + +/** Деревья из `[tool.ruff] include`, приведённые к каталогу. */ +const declaredTrees = () => { + const pyproject = read('pyproject.toml'); + const section = pyproject.slice(pyproject.indexOf('[tool.ruff]')); + const include = /include\s*=\s*\[([^\]]*)\]/.exec(section); + assert.ok(include, '[tool.ruff] include обязан существовать'); + return [...include[1].matchAll(/"([^"]+)"/g)] + .map((match) => match[1].replace(/\/\*\*\/\*\.py$|\/\*\.py$/, '')) + .sort(); +}; + +/** Деревья, которые реально проверяет шаг линта в CI. */ +const lintedTrees = () => { + const workflow = read('.github/workflows/validate.yml'); + const step = /python -m ruff check ([^\n]*)/.exec(workflow); + assert.ok(step, 'шаг «Линт бэкенда» обязан существовать в validate.yml'); + return step[1].trim().split(/\s+/).filter(Boolean).sort(); +}; + +test('#399 AC2: объявленный скоуп линта равен проверяемому', () => { + assert.deepEqual(declaredTrees(), lintedTrees(), + 'include в pyproject.toml и аргументы ruff в validate.yml разошлись:' + + ' конфиг обещает одно, CI проверяет другое. Расширять список можно' + + ' только вместе — иначе объявленное покрытие снова станет фикцией.'); +}); + +test('#399 AC2: расхождение действительно ловится', () => { + // Проверка, которая не умеет краснеть, ничего не гарантирует. + const declared = ['custom_components/houseplan', 'tests_backend']; + const linted = ['custom_components/houseplan']; + assert.throws(() => assert.deepEqual(declared, linted)); +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index b2a8aabf..d7540367 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -207,16 +207,56 @@ test('гейты диапазона судят от доказанного пр assert.match(frontend, /git merge-base origin\/dev "\$HEAD_SHA"/); }); -test('HA-харнесс ставится по точным версиям, а не по воле резолвера (#392)', () => { +/** Ставит ли workflow python-зависимости — по собственному содержимому. + * + * #399: раньше проверка перебирала два имени и молча пропускала файл, где + * не нашлось ни имени пакета, ни пути к пинам. Обе зацепки исчезают разом + * при возврате к `pip install pytest …`, то есть гейт выключался ровно тем + * изменением, ради которого заведён. Признак теперь положительный: есть + * установка python-пакетов — файл обязан ставить их из файла пинов. */ +const installsPythonDeps = (workflow) => /(?:^|\s)(?:python -m )?pip\s+install\s/.test(workflow); + +test('HA-харнесс ставится по точным версиям, а не по воле резолвера (#392, #399)', () => { // Плавающие версии означают, что «зелёный backend» значит разное в разные // дни: по SHA коммита нельзя сказать, чем его проверяли. Ровно так харнесс // полгода тихо проверял интеграцию против февральского Home Assistant. - for (const file of ['validate.yml', 'mutation-gate.yml']) { + // + // Перебирается ВЕСЬ каталог, а не список имён: новый 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 (!/pytest-homeassistant-custom-component|tests_backend\/requirements\.txt/.test(workflow)) continue; + if (!installsPythonDeps(workflow)) continue; + installers.push(file); assert.match(workflow, /pip install -r tests_backend\/requirements\.txt/, - `${file}: зависимости харнесса ставятся мимо файла пинов`); + `${file}: ставит python-зависимости мимо файла пинов`); assert.equal(/pip install pytest /.test(workflow), false, `${file}: остался установ без версий`); } + // Факт выводится проверкой, а не задаётся ей: список нужен для сообщения об + // ошибке, а не для решения, кого проверять. + assert.ok(installers.length > 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); }); diff --git a/tests_backend/requirements.txt b/tests_backend/requirements.txt index 76458cc3..67de981d 100644 --- a/tests_backend/requirements.txt +++ b/tests_backend/requirements.txt @@ -24,9 +24,14 @@ # # Менять версии здесь можно, но только вместе с прогоном полного набора: это не # косметика, а смена того, что означает зелёный backend. +# Версия фронтенда НЕ выбирается, а берётся из констрейнтов закреплённого +# Home Assistant: homeassistant/package_constraints.txt тега 2026.8.3 требует +# ровно 20260729.7 (#399). До этого здесь стояла 20260826.1 — набор, которого +# нет ни в одном релизе HA: phcc фронтенд не объявляет, и версию однажды +# назначили руками. Поднимая HA, обновлять эту строку из того же файла. voluptuous==0.15.2 pytest-homeassistant-custom-component==0.13.357 -home-assistant-frontend==20260826.1 +home-assistant-frontend==20260729.7 # Ставится транзитивно из phcc; закреплён явно, чтобы версия была видна тому, # кто читает этот файл, а не только тому, кто читает лог. homeassistant==2026.8.3