Files
houseplan-card/scripts/toolchain-pins.mjs
T
Claude f0c8ae24ba infra: portable Windows gate, CI toolchain pins, task packet, deterministic wait, gate:small --smokes
Windows portability (the three red tests on the owner's machine at green CI):
- scripts/spawn-portable.mjs: isMainModule via pathToFileURL (the
  `file://${argv[1]}` form gives file:///C:/C:/... and the CLI stays silent);
  portableCommand — a shell only for npm/npx/.cmd, node and git run directly
  (spawn via shell dropped the quotes of `node -e "…"`). Applied to
  classify-changes, mutation-gate-report, review-doc-guard, check-inputs,
  merge-candidate, gate-small, rebase-on-dev.
- the rebase-on-dev test pins core.autocrlf=false / core.eol=lf through
  GIT_CONFIG_* for its temp repository instead of touching the user's git
  config; test/windows-portability.test.mjs forbids both anti-patterns.

Pins: scripts/toolchain-pins.mjs reads Node/Python from validate.yml, the HA
stack from tests_backend/requirements.txt, Playwright/Chromium from the
lockfile — no second dictionary; `npm run toolchain:check` compares the
machine; .nvmrc/.python-version are derived and tested equal;
scripts/wsl-setup.sh provisions WSL/Linux with those pins.

scripts/task-packet.mjs: one derived view of an issue (status, rights, owner
decisions, branch vs dev, Validate on the tip, previous verdict with recorded
tree, AC → evidence, unwitnessed). scripts/wait-verdict.mjs: polls labels,
pipeline comments and optionally Validate, prints only on state change, exit
0/3/4, writes nothing.

gate:small --smokes: after build the browser phase runs bundle-sync and then
the directly matched and registered smokes, two at a time; broad matches stay
with the reviewer. package.json changed, so the bundle is rebuilt here.

Issue: #496
User-Visible: no
2026-09-09 01:11:24 +03:00

123 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Пины toolchain — один источник, тот же, что у CI (#496).
//
// Локальный контур расходился с CI молча: Node 24 против 22, Python 3.13 в
// документах против 3.14 в workflow, Home Assistant «какой поставился». Здесь
// пины НЕ объявляются — они ЧИТАЮТСЯ из файлов, которыми живёт CI:
// Node — `node-version` в .github/workflows/validate.yml
// Python — `python-version` там же
// HA-стек — tests_backend/requirements.txt (homeassistant==, плагин pytest)
// Playwright — package-lock.json, Chromium — browsers.json самого Playwright
// Второго словаря версий не появляется; `.nvmrc` и `.python-version` обязаны
// совпадать с этим чтением (тест), чтобы nvm/uv/pyenv подхватывали их сами.
//
// node scripts/toolchain-pins.mjs # таблица пинов
// node scripts/toolchain-pins.mjs --json # для скриптов установки
// node scripts/toolchain-pins.mjs --check # локальное окружение против пинов
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isMainModule } from './spawn-portable.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const read = (rel) => readFileSync(resolve(ROOT, rel), 'utf8');
/** Единственное значение среди совпадений; разнобой — ошибка, а не «первое». */
function single(values, what) {
const unique = [...new Set(values)];
if (unique.length !== 1) throw new Error(`${what}: ожидался один пин, найдено ${unique.length} (${unique.join(', ') || 'ни одного'})`);
return unique[0];
}
export function pinsFromSources({
validateYml = read('.github/workflows/validate.yml'),
requirements = read('tests_backend/requirements.txt'),
packageLock = JSON.parse(read('package-lock.json')),
browsers = JSON.parse(read('node_modules/playwright-core/browsers.json')),
} = {}) {
const node = single([...validateYml.matchAll(/node-version:\s*['"]?(\d+)['"]?/g)].map((m) => m[1]), 'node-version');
const python = single([...validateYml.matchAll(/python-version:\s*['"]?([\d.]+)['"]?/g)].map((m) => m[1]), 'python-version');
const req = (name) => single([...requirements.matchAll(new RegExp(`^${name.replace(/-/g, '\\-')}==([^\\s#]+)`, 'gm'))].map((m) => m[1]), name);
const chromium = browsers.browsers.find((b) => b.name === 'chromium');
return {
node,
python,
homeassistant: req('homeassistant'),
pytestHomeAssistant: req('pytest-homeassistant-custom-component'),
playwright: packageLock.packages['node_modules/playwright'].version,
chromium: chromium ? { revision: chromium.revision, version: chromium.browserVersion } : null,
};
}
function run(cmd, args) {
const r = spawnSync(cmd, args, { encoding: 'utf8' });
if (r.error || r.status !== 0) return null;
return `${r.stdout || ''}${r.stderr || ''}`.trim();
}
/** Что установлено локально; `null` — не найдено. */
export function localToolchain({ exec = run } = {}) {
const node = process.versions.node;
const pyOut = exec('python', ['--version']) || exec('python3', ['--version']) || exec('py', ['-3', '--version']);
const python = pyOut ? (pyOut.match(/(\d+\.\d+(?:\.\d+)?)/) || [])[1] || null : null;
const pipShow = (name) => {
const out = exec('python', ['-m', 'pip', 'show', name]) || exec('python3', ['-m', 'pip', 'show', name]);
return out ? (out.match(/^Version:\s*(\S+)/m) || [])[1] || null : null;
};
let playwright = null;
try { playwright = JSON.parse(read('node_modules/playwright/package.json')).version; } catch { /* нет */ }
return {
node, python,
homeassistant: pipShow('homeassistant'),
pytestHomeAssistant: pipShow('pytest-homeassistant-custom-component'),
playwright,
};
}
/**
* Сравнение: `{ ok, lines, failures }`. Node — по мажору, Python — по minor,
* HA-стек — точно, но только если установлен (без него — предупреждение:
* харнесс HA на Windows и так невозможен, fcntl).
*/
export function compareToolchain(pins, local) {
const lines = []; const failures = [];
const row = (name, want, have, ok, note = '') => {
lines.push(`${ok ? 'ok ' : 'FAIL'} ${name.padEnd(14)} пин ${String(want).padEnd(12)} локально ${String(have ?? '—').padEnd(12)}${note}`);
if (!ok) failures.push(name);
};
row('node', pins.node, local.node, String(local.node).split('.')[0] === String(pins.node),
' (сравнение по мажору; .nvmrc)');
const pyMinor = (v) => (v ? v.split('.').slice(0, 2).join('.') : null);
row('python', pins.python, local.python, pyMinor(local.python) === pyMinor(pins.python),
' (сравнение по minor; .python-version)');
for (const [key, name] of [['homeassistant', 'homeassistant'], ['pytestHomeAssistant', 'pytest-ha-plugin']]) {
if (local[key] == null) {
lines.push(`warn ${name.padEnd(14)} пин ${String(pins[key]).padEnd(12)} локально — (не установлен: полный HA-харнесс — Linux/WSL)`);
continue;
}
row(name, pins[key], local[key], local[key] === pins[key]);
}
row('playwright', pins.playwright, local.playwright, local.playwright === pins.playwright, ' (npm ci ставит из lockfile)');
return { ok: failures.length === 0, lines, failures };
}
if (isMainModule(import.meta.url)) {
const argv = process.argv.slice(2);
const pins = pinsFromSources();
if (argv.includes('--json')) {
process.stdout.write(`${JSON.stringify(pins, null, 2)}\n`);
} else if (argv.includes('--check')) {
const result = compareToolchain(pins, localToolchain());
for (const line of result.lines) console.log(line);
console.log(result.ok
? 'toolchain совпадает с CI'
: `toolchain расходится с CI: ${result.failures.join(', ')} — установка: docs/DEVELOPMENT.md, WSL: scripts/wsl-setup.sh`);
process.exitCode = result.ok ? 0 : 1;
} else {
console.log(`Node ${pins.node} · Python ${pins.python} · homeassistant ${pins.homeassistant} · pytest-homeassistant-custom-component ${pins.pytestHomeAssistant} · Playwright ${pins.playwright} · Chromium ${pins.chromium?.version} (rev ${pins.chromium?.revision})`);
console.log('источники: .github/workflows/validate.yml, tests_backend/requirements.txt, package-lock.json, playwright-core/browsers.json');
}
}