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
This commit is contained in:
Claude
2026-09-09 01:11:24 +03:00
parent 6e95c649e6
commit f0c8ae24ba
69 changed files with 1327 additions and 365 deletions
+3 -1
View File
@@ -24,6 +24,7 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, posix, relative, resolve } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
/** Корни, внутри которых файл считается исполняемым входом (§5.2). */
export const EXECUTABLE_ROOTS = ['scripts', 'demo', 'test', 'tests_backend', '.github', 'custom_components', 'src'];
@@ -56,6 +57,7 @@ export const NOT_AN_INPUT = [
['scripts/sh3d-convert/cli.mjs', 'CLI конвертера для человека'],
['scripts/sh3d-convert/make-fixtures.mjs', 'генератор фикстур конвертера, ручной'],
['scripts/support-relay/deploy/**', 'деплой relay на стенд'],
['scripts/wsl-setup.sh', 'установка локального Linux/WSL-контура с пинами CI (#496), ручной запуск'],
['.github/workflows/*.yml', 'другие workflow: у каждого свой запуск; validate.yml — вход toolchain всех проверок, объявлен явно'],
['.github/ISSUE_TEMPLATE/**', 'шаблоны issue GitHub, не исполняются'],
['.githooks/**', 'локальные хуки'],
@@ -378,7 +380,7 @@ export function coverage(root = process.cwd(), options = {}) {
// ---------------------------------------------------------------------------
// CLI: `node scripts/check-inputs.mjs --check=backend` печатает входы;
// `--coverage` — лист покрытия; `--affected` читает список файлов из stdin.
const invokedDirectly = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
const invokedDirectly = isMainModule(import.meta.url); // #496: переносимо для Windows
if (invokedDirectly) {
const argv = process.argv.slice(2);
const root = process.cwd();
+4 -2
View File
@@ -15,6 +15,7 @@
// недоказуема и проверять надо всё.
import { readFileSync } from 'node:fs';
import { isMainModule } from './spawn-portable.mjs';
import { checksAffectedBy } from './check-inputs.mjs';
@@ -105,8 +106,9 @@ export function formatOutputs(outputs) {
return lines.join('\n') + '\n';
}
const invokedDirectly = process.argv[1]
&& import.meta.url === new URL(`file://${process.argv[1]}`).href;
// #496: pathToFileURL, не `file://${argv}` — на Windows последнее давало
// `file:///C:/C:/...`, CLI считал себя импортированным и молчал.
const invokedDirectly = isMainModule(import.meta.url);
if (invokedDirectly) {
if (process.argv.includes('--heavy')) {
// Отдельный вызов: у `heavy` другие входы (событие, сообщение head-коммита),
+67 -10
View File
@@ -10,17 +10,50 @@
//
// npm run gate:small # база origin/dev
// npm run gate:small -- --base=origin/dev # явная база диапазона
// npm run gate:small -- --smokes # #496: после сборки — bundle-sync и
// # смоки, выбранные по диффу (прямые
// # и зарегистрированные), по два параллельно
//
// Фазы разделены зависимостями (#496): read-only проверки идут параллельно сразу;
// browser-consumers (смоки) — только после подготовки артефактов (build →
// bundle-sync), потому что читают собранное дерево demo/srv/assets.
import { spawn } from 'node:child_process';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { portableCommand } from './spawn-portable.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const npm = 'npm';
export function parseArgs(argv) {
const base = argv.find((a) => a.startsWith('--base='))?.slice('--base='.length) || 'origin/dev';
return { base };
const smokes = argv.includes('--smokes');
const jobs = Number(argv.find((a) => a.startsWith('--jobs='))?.slice('--jobs='.length) || 2);
return { base, smokes, jobs: Number.isInteger(jobs) && jobs > 0 ? jobs : 2 };
}
/** Смоки для прогона из JSON smoke-select: прямые + зарегистрированные, без «широких». */
export function smokesToRun(selection) {
if (!selection || selection.noExecutableDiff) return [];
const names = [...(selection.direct || []), ...(selection.registered || [])]
.map((entry) => (typeof entry === 'string' ? entry : entry.smoke))
.filter(Boolean);
return [...new Set(names)].sort();
}
/** Ограниченный параллелизм: browser-consumers тяжёлые, по умолчанию два разом. */
export async function runLimited(items, limit, worker) {
const results = new Array(items.length);
let next = 0;
const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const index = next++;
results[index] = await worker(items[index], index);
}
});
await Promise.all(lanes);
return results;
}
/** Шаги параллельной фазы: имя → команда. `base` — начало диапазона диффа. */
@@ -29,7 +62,7 @@ export function parallelSteps(base) {
{ name: 'юниты (npm test)', cmd: npm, args: ['test'] },
{ name: 'сборка + typecheck (npm run build)', cmd: npm, args: ['run', 'build'] },
{ name: 'новый код не добавляет any', cmd: process.execPath, args: ['scripts/no-new-any.mjs', '--base', base, '--head', 'HEAD'] },
{ name: 'смоки по диффу (smoke-select)', cmd: process.execPath, args: ['scripts/smoke-select.mjs', '--base', base, '--head', 'HEAD'], informational: true },
{ name: 'смоки по диффу (smoke-select)', cmd: process.execPath, args: ['scripts/smoke-select.mjs', '--base', base, '--head', 'HEAD', '--json'], informational: true },
];
}
@@ -44,7 +77,9 @@ export function serialSteps() {
function runStep(step, cwd) {
return new Promise((done) => {
const started = Date.now();
const child = spawn(step.cmd, step.args, { cwd, shell: process.platform === 'win32', env: process.env });
// Оболочка только для npm.cmd на Windows (#496): node/git — напрямую.
const { cmd, shell } = portableCommand(step.cmd);
const child = spawn(cmd, step.args, { cwd, shell, env: process.env });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.stderr.on('data', (chunk) => { out += chunk; });
@@ -64,13 +99,27 @@ export function summarize(results) {
return { lines, failed };
}
export async function gateSmall({ cwd = ROOT, base = 'origin/dev', log = console.log } = {}) {
export async function gateSmall({ cwd = ROOT, base = 'origin/dev', smokes = false, jobs = 2, log = console.log } = {}) {
const started = Date.now();
log(`gate:small — база диапазона ${base}; параллельно: юниты, сборка, no-new-any, smoke-select`);
log(`gate:small — база диапазона ${base}; параллельно: юниты, сборка, no-new-any, smoke-select${smokes ? `; затем bundle-sync и смоки по диффу (×${jobs})` : ''}`);
const parallel = await Promise.all(parallelSteps(base).map((step) => runStep(step, cwd)));
const buildOk = parallel.find((r) => r.args.includes('build'))?.code === 0;
const serial = [];
if (buildOk) for (const step of serialSteps()) serial.push(await runStep(step, cwd));
// Browser-consumers — после подготовки артефактов (#496): bundle-sync раскладывает
// собранное дерево в demo/srv/assets, смоки читают его и отказываются на несвежем.
let selected = [];
if (smokes && buildOk) {
const select = parallel.find((r) => r.args.includes('scripts/smoke-select.mjs'));
try { selected = smokesToRun(JSON.parse(select?.out || 'null')); } catch { selected = []; }
if (selected.length) {
serial.push(await runStep({ name: 'подготовка артефактов (bundle-sync)', cmd: process.execPath, args: ['scripts/bundle-sync.mjs'] }, cwd));
if (serial.at(-1).code === 0) {
const runs = await runLimited(selected, jobs, (smoke) => runStep({ name: `смок ${smoke}`, cmd: process.execPath, args: [`demo/${smoke}`] }, cwd));
serial.push(...runs);
}
}
}
const results = [...parallel, ...serial];
const { lines, failed } = summarize(results);
log('');
@@ -78,8 +127,16 @@ export async function gateSmall({ cwd = ROOT, base = 'origin/dev', log = console
const select = parallel.find((r) => r.args.includes('scripts/smoke-select.mjs'));
if (select) {
log('');
log('смоки, относящиеся к диффу (гоняются автором отдельно, решение по каждой строке — в ревью):');
log(select.out.trim() || ' (smoke-select ничего не напечатал)');
let selection = null;
try { selection = JSON.parse(select.out || 'null'); } catch { /* ниже — сырой вывод */ }
if (!selection) log(select.out.trim() || ' (smoke-select ничего не напечатал)');
else if (selection.noExecutableDiff) log('smoke-select: исполняемого frontend-диффа нет — смоки этим диффом не выбираются');
else {
const names = smokesToRun(selection);
log(`smoke-select: прямые и зарегистрированные (${names.length})${smokes ? ' — прогнаны выше' : ' — гоняются автором (или `--smokes`), решение по каждой строке в ревью'}:`);
for (const name of names) log(` demo/${name}`);
if ((selection.broad || []).length) log(` «широких» символов: ${selection.broad.length} — решает ревьюер, автоматически не гоняются`);
}
}
for (const r of results) {
if (r.code !== 0 && !r.informational) { log(''); log(`--- ${r.name}`); log(r.out.trim()); }
@@ -92,6 +149,6 @@ export async function gateSmall({ cwd = ROOT, base = 'origin/dev', log = console
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const { base } = parseArgs(process.argv.slice(2));
gateSmall({ base }).then(({ failed }) => { process.exitCode = failed ? 1 : 0; });
const { base, smokes, jobs } = parseArgs(process.argv.slice(2));
gateSmall({ base, smokes, jobs }).then(({ failed }) => { process.exitCode = failed ? 1 : 0; });
}
+2 -1
View File
@@ -21,6 +21,7 @@
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { isMainModule } from './spawn-portable.mjs';
export const MAX_ATTEMPTS = 3;
export const VALIDATE_APPEAR_MS = 3 * 60 * 1000;
@@ -216,7 +217,7 @@ const safe = (fn) => { try { return fn(); } catch { return null; } };
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
if (isMainModule(import.meta.url)) { // #496: переносимо для Windows
const branch = arg('branch');
const material = arg('material');
const issue = arg('issue');
+4 -2
View File
@@ -24,6 +24,7 @@
* но и выдумывать из неё сущность тоже.
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { isMainModule } from './spawn-portable.mjs';
export const REPORT_TITLE_MARKER = '[mutation-gate] отказ прогона по расписанию';
@@ -143,8 +144,9 @@ export function telegramSummary(report, issueUrl) {
return `${head}\n${escaped}\nшарды: ${shards || '—'}\n${issueUrl}`;
}
const invokedDirectly = process.argv[1]
&& import.meta.url === new URL(`file://${process.argv[1]}`).href;
// #496: pathToFileURL, не `file://${argv}` — на Windows последнее давало
// `file:///C:/C:/...`, CLI считал себя импортированным и молчал.
const invokedDirectly = isMainModule(import.meta.url);
if (invokedDirectly) {
const argv = process.argv.slice(2);
const value = (name, fallback = '') => {
+2
View File
@@ -59,6 +59,8 @@ const CLASS_B = [
/^test\//, /^tests_backend\//, /^demo\//, /^scripts\//,
/^\.github\//, /^\.githooks\//, /^rollup\.config\.mjs$/, /^tsconfig.*\.json$/,
/^package(-lock)?\.json$/, /^pytest\.ini$/, /^\.gitignore$/, /^\.gitattributes$/,
// Пины toolchain — производные от validate.yml (#496), конфиг сборки.
/^\.nvmrc$/, /^\.python-version$/,
];
const CLASS_C = [
/^docs\//, /^README/, /^CHANGELOG/, /^AGENTS\.md$/, /^LICENSE$/,
+5 -1
View File
@@ -19,6 +19,7 @@ import { spawnSync } from 'node:child_process';
import { existsSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { portableCommand } from './spawn-portable.mjs';
export const GENERATED_ROOTS = ['dist/', 'custom_components/houseplan/frontend/'];
export const isGenerated = (path) => GENERATED_ROOTS.some((root) => path.startsWith(root));
@@ -114,7 +115,10 @@ export function rebaseOnDev({
// Пересборка: версия dev в бандле — не версия этой ветки. Собираем и, если
// бандл отличается, амендим последний коммит ветки.
const [cmd, ...args] = syncCommand;
const sync = spawnSync(cmd, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
// Оболочка только для npm.cmd на Windows (#496): `node -e "…"` из теста и
// любая команда с кавычками через shell разваливаются.
const portable = portableCommand(cmd);
const sync = spawnSync(portable.cmd, args, { cwd, stdio: 'inherit', shell: portable.shell });
if (sync.status !== 0) throw new Error(`${syncCommand.join(' ')} завершился с кодом ${sync.status}; ребейз сделан, бандл не закоммичен`);
git(['add', '-A', '--', ...GENERATED_ROOTS.filter((root) => existsSync(resolve(cwd, root)))]);
const staged = git(['diff', '--cached', '--name-only']).stdout;
+4 -2
View File
@@ -25,6 +25,7 @@
*/
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { isMainModule } from './spawn-portable.mjs';
export const REVIEW_DOC_ALLOWLIST = ['docs/reviews/'];
@@ -581,8 +582,9 @@ export function reviewCounters({ rounds = [], docs = [], comments = {} } = {}) {
};
}
const invokedDirectly = process.argv[1]
&& import.meta.url === new URL(`file://${process.argv[1]}`).href;
// #496: pathToFileURL, не `file://${argv}` — на Windows последнее давало
// `file:///C:/C:/...`, CLI считал себя импортированным и молчал.
const invokedDirectly = isMainModule(import.meta.url);
if (invokedDirectly) {
const argv = process.argv.slice(2);
// Режим счёта раундов (#454): на входе список имён и тела документов,
+28
View File
@@ -0,0 +1,28 @@
// Переносимый запуск команд (#496).
//
// Две Windows-ловушки, из-за которых тесты инфраструктуры падали на машине
// владельца при зелёном Linux CI:
//
// 1. `new URL(\`file://${process.argv[1]}\`)` для `C:\...\x.mjs` даёт
// `file:///C:/C:/...` — сравнение с `import.meta.url` ложно, CLI молчит.
// Правильно — `pathToFileURL`, он знает про буквы дисков.
// 2. `spawn(cmd, args, { shell: true })` склеивает аргументы в строку без
// экранирования: `node -e "…"` с кавычками и пробелами разваливается. Оболочка
// нужна ТОЛЬКО для `.cmd`/`.bat` (Node ≥ 18.20 иначе бросает EINVAL), то есть
// для `npm` на Windows; `node` и `git` запускаются напрямую.
import { pathToFileURL } from 'node:url';
/** Скрипт запущен как CLI, а не импортирован (переносимая форма). */
export function isMainModule(metaUrl, argv1 = process.argv[1]) {
if (!argv1) return false;
try { return pathToFileURL(argv1).href === metaUrl; } catch { return false; }
}
/** Что и как запускать: `{ cmd, shell }` для `spawn`/`spawnSync`. */
export function portableCommand(cmd, platform = process.platform) {
if (platform !== 'win32') return { cmd, shell: false };
if (cmd === 'npm' || cmd === 'npx') return { cmd: `${cmd}.cmd`, shell: true };
if (/\.(cmd|bat)$/i.test(cmd)) return { cmd, shell: true };
return { cmd, shell: false };
}
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env node
// Пакет задачи — производное представление, а не новый источник статуса (#496).
//
// Агент, берущий задачу, сегодня собирает одно и то же руками из четырёх мест:
// метки и комментарии issue, ветку и её положение относительно dev, документы
// ревью с их якорями, состояние Validate на вершине. Каждый раз — свои ходы,
// свои ошибки («метка встала раньше push'а», «ревьюер читал не тот SHA»).
// Скрипт делает эту сборку детерминированно и печатает один markdown-пакет:
//
// issue · статус и трек · что можно делать в этом статусе · решения владельца ·
// материал (ветка, SHA, база, Validate) · предыдущий вердикт · AC → свидетель ·
// непроверенное / следующий шаг
//
// Источник правды остаётся GitHub и git: пакет ничего не пишет и ничего не
// решает. Все чтения инъектируемы — `buildPacket(inputs)` чист и покрыт тестами.
//
// node scripts/task-packet.mjs --issue 437 [--repo Matysh/houseplan-card] [--json]
import { spawnSync } from 'node:child_process';
import { isMainModule } from './spawn-portable.mjs';
import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs';
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
/** Что разрешено в статусе — по PROCESS.md, без домыслов. */
export function rightsFor(status, labels = []) {
const blocked = labels.includes('blocked');
const exhausted = labels.includes('review-4');
const code = ['S5-ready', 'S6-in-progress', 'S7-code-review'].includes(status);
const lines = [];
if (exhausted) lines.push('review-4: лимит циклов исчерпан — решение владельца (разделить, отклонить, арбитраж); дальше не двигать');
if (blocked) lines.push('blocked: работа стоит, ждём внешнего решения — коммиты по задаче гейт не пропустит');
lines.push(code ? 'продуктовый код трогать МОЖНО (правило №1)' : 'продуктовый код трогать НЕЛЬЗЯ: статус не S5/S6/S7 (правило №1)');
switch (status) {
case 'S1-new': lines.push('следующий шаг: аналитика (S2) — оценки метками, критерий лёгкого трека, затем ТЗ'); break;
case 'S2-analysis': lines.push('следующий шаг: ТЗ (S3); трек по умолчанию small — отказ от него обосновать названным критерием §5'); break;
case 'S3-spec': lines.push('следующий шаг: ТЗ готово → push ветки → метка S4-spec-review (метку после push)'); break;
case 'S4-spec-review': lines.push('идёт ревью ТЗ: ждать вердикт (scripts/wait-verdict.mjs), не править материал'); break;
case 'S5-ready': lines.push('следующий шаг: ветка issue/NN-slug от dev, код по ТЗ, метка S6-in-progress'); break;
case 'S6-in-progress': lines.push('следующий шаг: gate:small + смоки по AC → push ветки → метка S7-code-review (метку после push)'); break;
case 'S7-code-review': lines.push('идёт код-ревью: ждать вердикт, ничего не пушить в ветку — вердикт привязан к SHA (#312)'); break;
case 'S8-merged': lines.push('код в dev, ждёт беты; ничего не делать; issue закроет владелец при выпуске'); break;
default: lines.push('статусной метки нет — задача вне процесса; вход в процесс — присвоение первой S*-метки владельцем');
}
return lines;
}
/** AC из текста ТЗ или тела issue: строки таблицы `| ACn |` и маркеры `ACn` в начале. */
export function extractAcceptanceCriteria(text) {
const out = new Map();
for (const raw of String(text ?? '').split('\n')) {
const line = raw.trim();
let m = line.match(/^\|\s*\**(AC\d+)\**\s*\|\s*([^|]*)\|/);
if (!m) m = line.match(/^(?:[-*]\s*)?\**(AC\d+)\**[.:)\s-]+(.*)$/);
if (!m) continue;
if (!out.has(m[1])) out.set(m[1], m[2].trim().slice(0, 160));
}
return [...out.entries()].map(([id, text]) => ({ id, text }));
}
/** Где в документе ревью упомянут AC: «проверен» или «без записи». */
export function evidenceFor(acs, reviewDocText) {
const text = String(reviewDocText ?? '');
return acs.map((ac) => {
const re = new RegExp(`(^|[^A-Z0-9])(${ac.id})(?![0-9])`, 'm');
const m = re.exec(text);
if (!m) return { ...ac, evidence: 'без записи в последнем документе ревью' };
const start = m.index + m[1].length;
const line = text.slice(start).split('\n')[0].replace(/^\W+/, '').slice(0, 140);
return { ...ac, evidence: line };
});
}
/** Решения владельца: его комментарии с заголовками/словами решения, свежие первыми. */
export function ownerDecisions(comments, owner) {
return (comments || [])
.filter((c) => c.author === owner && /решени|ответ|принима|утвержда|делаем|не делаем|owner|decision/i.test(String(c.body)))
.slice(-5)
.map((c) => ({ at: c.createdAt, head: String(c.body).trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 140), url: c.url }));
}
/** Последний вердикт этапа по комментариям (страховка) и по документу (запись конвейера). */
export function lastVerdict(comments, docs, stage) {
const marker = stage === 'spec' ? 'SPEC-REVIEW' : 'CODE-REVIEW';
const fromComments = (comments || []).map((c) => ({ ...c, line: verdictDeclaration(c.body) }))
.filter((c) => c.line && new RegExp(`${marker}|заход r\\d+`).test(c.body));
const latestComment = fromComments.at(-1) || null;
const numbered = (docs || [])
.map((d) => ({ ...d, round: Number((d.name.match(/-r(\d+)\.md$/) || [])[1]) }))
.filter((d) => d.name.startsWith(`${marker}-`) && Number.isFinite(d.round))
.sort((a, b) => b.round - a.round);
const latestDoc = numbered[0] || null;
return {
comment: latestComment ? { at: latestComment.createdAt, line: latestComment.line.trim(), url: latestComment.url } : null,
doc: latestDoc ? {
name: latestDoc.name, round: latestDoc.round,
tree: anchorTreeFrom(latestDoc.text), recorded: anchorVerdictFrom(latestDoc.text),
} : null,
};
}
export function buildPacket(inputs) {
const {
issue, labels = [], comments = [], owner = 'Matysh', branch = null, specs = [], reviewDocs = [], validate = null,
} = inputs;
const status = STATUS_LABELS.find((l) => labels.includes(l)) || null;
const track = labels.includes('trivial') ? 'trivial' : labels.includes('small') ? 'small' : 'полный';
const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code';
const verdict = lastVerdict(comments, reviewDocs, stage);
const acSource = specs.length ? specs.map((s) => s.text).join('\n') : issue.body;
const acs = evidenceFor(extractAcceptanceCriteria(acSource), reviewDocs.length ? reviewDocs.at(-1).text : '');
const unverified = acs.filter((a) => a.evidence.startsWith('без записи'));
const packet = {
issue: { number: issue.number, title: issue.title, state: issue.state, url: issue.url },
status, track, labels, rights: rightsFor(status, labels),
decisions: ownerDecisions(comments, owner),
material: branch ? {
branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind,
treeMatchesVerdict: verdict.doc?.tree ? branch.treeWithoutReviews === verdict.doc.tree : null,
validate: validate || { status: 'неизвестно' },
} : null,
verdict,
acceptance: acs,
unverified: unverified.map((a) => a.id),
};
return packet;
}
export function renderPacket(p) {
const L = [];
L.push(`# Пакет задачи #${p.issue.number} — ${p.issue.title}`);
L.push('');
L.push(`Статус: **${p.status || 'без S-метки'}** · трек: ${p.track} · метки: ${p.labels.join(', ') || '—'} · issue ${p.issue.state}`);
L.push('');
L.push('## Права и следующий шаг');
for (const r of p.rights) L.push(`- ${r}`);
L.push('');
L.push('## Решения владельца (свежие)');
if (!p.decisions.length) L.push('- не найдены (комментарии владельца со словами решения отсутствуют)');
for (const d of p.decisions) L.push(`- ${d.at?.slice(0, 16) || ''} — ${d.head}${d.url ? ` (${d.url})` : ''}`);
L.push('');
L.push('## Материал');
if (!p.material) L.push('- ветки issue/NN-* на origin нет — материал не запушен');
else {
const m = p.material;
L.push(`- ветка \`${m.branch}\`, вершина \`${m.tip.slice(0, 12)}\`, база dev \`${m.base.slice(0, 12)}\`: впереди ${m.ahead}, позади ${m.behind}${m.behind ? ' — перед S7 ребейз (rebase-on-dev.mjs при конфликте в бандле)' : ''}`);
L.push(`- Validate на вершине: ${m.validate.status}${m.validate.url ? ` (${m.validate.url})` : ''}`);
if (m.treeMatchesVerdict === true) L.push('- дерево вне docs/reviews совпадает с материалом последнего вердикта — повторный S7 применит его без модели (#499)');
if (m.treeMatchesVerdict === false) L.push('- дерево изменилось с последнего вердикта — будет полный разбор');
}
L.push('');
L.push('## Предыдущий вердикт');
if (p.verdict.comment) L.push(`- комментарий: ${p.verdict.comment.line}${p.verdict.comment.url ? ` (${p.verdict.comment.url})` : ''}`);
if (p.verdict.doc) L.push(`- документ: \`${p.verdict.doc.name}\`, дерево \`${p.verdict.doc.tree?.slice(0, 12) || '—'}\`, запись конвейера: ${p.verdict.doc.recorded ? `${p.verdict.doc.recorded.verdict} · High ${p.verdict.doc.recorded.high}` : 'нет (документ до #499)'}`);
if (!p.verdict.comment && !p.verdict.doc) L.push('- вердиктов этапа ещё не было');
L.push('');
L.push('## AC → свидетель (по последнему документу ревью)');
if (!p.acceptance.length) L.push('- AC не распознаны: ни таблицы `| ACn |`, ни строк `ACn:` в ТЗ/теле issue');
for (const a of p.acceptance) L.push(`- **${a.id}** ${a.text} → ${a.evidence}`);
L.push('');
L.push('## Непроверенное');
L.push(p.unverified.length ? `- ${p.unverified.join(', ')}: нет записи в последнем документе ревью — свидетель нужен до S7` : '- по документу ревью все распознанные AC имеют запись');
return `${L.join('\n')}\n`;
}
// ---- сбор входов: gh + git -------------------------------------------------
function sh(cmd, args, { cwd } = {}) {
const r = spawnSync(cmd, args, { encoding: 'utf8', cwd, maxBuffer: 64 * 1024 * 1024 });
if (r.error) throw r.error;
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} → ${(r.stderr || '').trim()}`);
return (r.stdout || '').trim();
}
export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = process.cwd() }) {
const view = JSON.parse(sh('gh', ['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,state,url,labels,comments']));
const labels = (view.labels || []).map((l) => l.name);
const comments = (view.comments || []).map((c) => ({ author: c.author?.login, body: c.body, createdAt: c.createdAt, url: c.url }));
const owner = repo.split('/')[0];
try { sh('git', ['fetch', '-q', 'origin', 'dev', `+refs/heads/issue/${number}-*:refs/remotes/origin/issue/${number}-*`], { cwd }); } catch { /* офлайн — читаем что есть */ }
const refs = sh('git', ['for-each-ref', '--sort=-committerdate', '--format=%(refname:lstrip=3)', `refs/remotes/origin/issue/${number}-*`], { cwd }).split('\n').filter(Boolean);
let branch = null; let reviewDocs = []; let specs = [];
if (refs[0]) {
const name = refs[0]; const ref = `origin/${name}`;
const tip = sh('git', ['rev-parse', ref], { cwd });
const base = sh('git', ['merge-base', 'origin/dev', ref], { cwd });
const ahead = Number(sh('git', ['rev-list', '--count', `origin/dev..${ref}`], { cwd }));
const behind = Number(sh('git', ['rev-list', '--count', `${ref}..origin/dev`], { cwd }));
// Дерево без docs/reviews — для сравнения с якорем вердикта: git сам его не даёт,
// поэтому сравнение делается diff'ом при известном якоре (см. ниже).
const names = sh('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { cwd }).split('\n').filter((n) => new RegExp(`-${number}-r\\d+\\.md$`).test(n));
reviewDocs = names.sort().map((n) => ({ name: n, text: sh('git', ['show', `${ref}:docs/reviews/${n}`], { cwd }) }));
const specNames = sh('git', ['ls-tree', '--name-only', `${ref}:docs/specs`], { cwd }).split('\n').filter((n) => new RegExp(`^${number}-.*\\.md$`).test(n));
specs = specNames.map((n) => ({ name: n, text: sh('git', ['show', `${ref}:docs/specs/${n}`], { cwd }) }));
const anchorTree = reviewDocs.length ? anchorTreeFrom(reviewDocs.at(-1).text) : null;
let treeWithoutReviews = null;
if (anchorTree) {
const same = spawnSync('git', ['diff', '--quiet', anchorTree, tip, '--', '.', ':!docs/reviews'], { cwd });
treeWithoutReviews = same.status === 0 ? anchorTree : `differs-from-${anchorTree}`;
}
branch = { name, tip, base, ahead, behind, treeWithoutReviews };
}
let validate = null;
if (branch) {
try {
const runs = JSON.parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', 'validate.yml', '--commit', branch.tip, '--limit', '5', '--json', 'status,conclusion,url']));
const green = runs.find((r) => r.status === 'completed' && r.conclusion === 'success');
const running = runs.find((r) => r.status !== 'completed');
validate = green ? { status: 'зелёный', url: green.url } : running ? { status: 'идёт', url: running.url }
: runs[0] ? { status: `красный (${runs[0].conclusion})`, url: runs[0].url } : { status: 'прогона нет' };
} catch { validate = { status: 'неизвестно (gh run list недоступен)' }; }
}
return { issue: view, labels, comments, owner, branch, specs, reviewDocs, validate };
}
if (isMainModule(import.meta.url)) {
const argv = process.argv.slice(2);
const value = (name) => {
const eq = argv.find((a) => a.startsWith(`--${name}=`));
if (eq) return eq.slice(name.length + 3);
const at = argv.indexOf(`--${name}`);
return at >= 0 ? (argv[at + 1] || '') : '';
};
const number = Number(value('issue'));
if (!Number.isInteger(number) || number <= 0) { console.error('нужен --issue <номер>'); process.exit(2); }
const repo = value('repo') || 'Matysh/houseplan-card';
const packet = buildPacket(collectInputs({ number, repo }));
process.stdout.write(argv.includes('--json') ? `${JSON.stringify(packet, null, 2)}\n` : renderPacket(packet));
}
+122
View File
@@ -0,0 +1,122 @@
#!/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');
}
}
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
// Детерминированное ожидание вердикта / CI (#496).
//
// Runbook велит автору ждать вердикт опросом: раз в 90 секунд, не более 30 раз,
// смотреть на метку. В исполнении LLM каждый тик — это ход модели с чтением JSON
// и рассуждением «ничего не изменилось» — десятки пустых ходов на одно ревью.
// Этот скрипт делает опрос сам и ГОВОРИТ только при смене состояния: одинаковое
// состояние не будит никого. Что доставляется: смена статусной метки (вердикт),
// отказ конвейера (комментарий «Ревью не запускалось» / «Слияние отменено» /
// «Автоматическое ревью не отработало»), `blocked`, `review-4`, а при `--sha` —
// исход Validate на этом SHA.
//
// Скрипт НИЧЕГО не пишет: ни меток, ни комментариев, ни запусков. Новое ревью
// или релиз начинаются только по текущей авторизации человека.
//
// node scripts/wait-verdict.mjs --issue 437 [--sha <tip>] [--interval 90] [--max 30]
//
// Коды выхода: 0 — статус сменился (вердикт есть, читать метку и комментарий);
// 3 — доставлено событие, требующее действия (отказ конвейера, конфликт,
// blocked, review-4, красный Validate); 4 — лимит ожидания, состояние прежнее;
// 2 — ошибка вызова.
import { spawnSync } from 'node:child_process';
import { isMainModule } from './spawn-portable.mjs';
export const REVIEW_LABELS = ['S4-spec-review', 'S7-code-review'];
const STATUS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
/** Комментарии конвейера, которые требуют действия автора или владельца. */
export const PIPELINE_EVENTS = [
{ re: /^\*\*Ревью не запускалось:\*\*/m, kind: 'conflict', text: 'конвейер: ветка не ребейзится на dev — конфликт разрешает автор' },
{ re: /^\*\*Слияние отменено/m, kind: 'stale', text: 'конвейер: слияние отменено — вершина ветки ушла от проверенного SHA (#312)' },
{ re: /^\*\*Код-ревью зелёное — вердикт выше в силе/m, kind: 'merge-conflict', text: 'конвейер: вердикт зелёный, слияние конфликтует — ребейз (rebase-on-dev.mjs) и снова S7' },
{ re: /^Автоматическое ревью не отработало/m, kind: 'failure', text: 'конвейер: прогон ревью упал — метка не менялась, смотреть логи, сообщить владельцу' },
{ re: /^Лимит циклов ревью исчерпан/m, kind: 'exhausted', text: 'конвейер: лимит циклов исчерпан — решение владельца' },
{ re: /^Конвейер ревью не запущен:/m, kind: 'refused', text: 'конвейер отказал (blocked/review-4) — читать комментарий' },
];
/** Снимок → нормализованное состояние. */
export function stateOf(snapshot) {
const labels = snapshot.labels || [];
const status = STATUS.find((l) => labels.includes(l)) || null;
const events = (snapshot.comments || [])
.map((c) => ({ id: c.id, at: c.createdAt, event: PIPELINE_EVENTS.find((e) => e.re.test(String(c.body || ''))) }))
.filter((c) => c.event);
const last = events.at(-1) || null;
return {
status,
blocked: labels.includes('blocked'),
exhausted: labels.includes('review-4'),
lastEventId: last ? String(last.id) : null,
lastEvent: last ? last.event : null,
validate: snapshot.validate || null,
};
}
/**
* Решение по паре состояний: что сказать и заканчивать ли.
* `prev` — предыдущее состояние (null на первом тике).
*/
export function decide(prev, next) {
const lines = [];
let code = null;
if (prev && prev.status !== next.status) {
lines.push(`метка: ${prev.status || '—'} → ${next.status || '—'}`);
code = 0;
}
if (next.lastEventId && (!prev || prev.lastEventId !== next.lastEventId)) {
lines.push(next.lastEvent.text);
if (code === null) code = 3;
}
if (next.exhausted && (!prev || !prev.exhausted)) { lines.push('review-4: лимит циклов — решение владельца'); code = 3; }
if (next.blocked && (!prev || !prev.blocked)) { lines.push('blocked: задача ждёт владельца, ждать вердикт бессмысленно'); code = 3; }
if (next.validate && (!prev || !prev.validate || prev.validate.conclusion !== next.validate.conclusion || prev.validate.status !== next.validate.status)) {
if (next.validate.status === 'completed' && next.validate.conclusion !== 'success') {
lines.push(`Validate на SHA красный: ${next.validate.conclusion}${next.validate.url ? ` ${next.validate.url}` : ''}`);
if (code === null) code = 3;
} else if (next.validate.status === 'completed') {
lines.push(`Validate на SHA зелёный${next.validate.url ? ` ${next.validate.url}` : ''}`);
}
}
// Первый тик: состояние печатается один раз как точка отсчёта, без завершения.
if (!prev) {
lines.unshift(`ожидание: статус ${next.status || '—'}${next.blocked ? ' +blocked' : ''}${next.exhausted ? ' +review-4' : ''}`);
if (!REVIEW_LABELS.includes(next.status) && code === null) {
lines.push('статус не ревьюшный — ждать нечего');
code = 0;
}
}
return { lines, done: code !== null, code };
}
export async function waitForVerdict({
readSnapshot, intervalMs = 90_000, maxTicks = 30, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), log = console.log,
}) {
let prev = null;
for (let tick = 1; tick <= maxTicks; tick++) {
const next = stateOf(await readSnapshot());
const { lines, done, code } = decide(prev, next);
for (const line of lines) log(`[${new Date().toISOString().slice(11, 19)}] ${line}`);
if (done) return code;
prev = next;
if (tick < maxTicks) await sleep(intervalMs);
}
log(`лимит ожидания (${maxTicks} × ${Math.round(intervalMs / 1000)} с): состояние не менялось — прогон мог упасть, смотреть Actions`);
return 4;
}
function gh(args) {
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
if (r.error || r.status !== 0) throw new Error(`gh ${args.join(' ')} → ${(r.stderr || r.error?.message || '').trim()}`);
return JSON.parse(r.stdout || 'null');
}
export function ghSnapshotReader({ number, repo, sha }) {
return async () => {
const view = gh(['issue', 'view', String(number), '--repo', repo, '--json', 'labels,comments']);
const snapshot = {
labels: (view.labels || []).map((l) => l.name),
comments: (view.comments || []).map((c) => ({ id: c.id || c.url || c.createdAt, createdAt: c.createdAt, body: c.body })),
};
if (sha) {
try {
const runs = gh(['run', 'list', '--repo', repo, '--workflow', 'validate.yml', '--commit', sha, '--limit', '5', '--json', 'status,conclusion,url']);
const done = runs.find((r) => r.status === 'completed') || runs[0];
if (done) snapshot.validate = { status: done.status, conclusion: done.conclusion, url: done.url };
} catch { /* Validate — дополнение, не условие */ }
}
return snapshot;
};
}
if (isMainModule(import.meta.url)) {
const argv = process.argv.slice(2);
const value = (name, fallback = '') => {
const eq = argv.find((a) => a.startsWith(`--${name}=`));
if (eq) return eq.slice(name.length + 3);
const at = argv.indexOf(`--${name}`);
return at >= 0 ? (argv[at + 1] || '') : fallback;
};
const number = Number(value('issue'));
if (!Number.isInteger(number) || number <= 0) { console.error('нужен --issue <номер>'); process.exit(2); }
const repo = value('repo', 'Matysh/houseplan-card');
const sha = value('sha') || null;
const intervalMs = Number(value('interval', '90')) * 1000;
const maxTicks = Number(value('max', '30'));
waitForVerdict({ readSnapshot: ghSnapshotReader({ number, repo, sha }), intervalMs, maxTicks })
.then((code) => { process.exitCode = code; })
.catch((error) => { console.error(`wait-verdict: ${error.message}`); process.exitCode = 2; });
}
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Воспроизводимый локальный Linux-контур с пинами CI (#496): WSL2 или любой Linux.
#
# Пины не задаются здесь — читаются `node scripts/toolchain-pins.mjs --json`
# (тот же источник, что у CI). Скрипт идемпотентен: повторный запуск ничего не
# ломает. Полный HA-харнесс и каноническое релизное доказательство остаются за
# Linux CI на точном SHA; этот контур — ранняя обратная связь.
#
# bash scripts/wsl-setup.sh # установить/обновить окружение
# bash scripts/wsl-setup.sh --check # только сверить с пинами
#
# Требует: git, curl. Node ставится через nvm (в ~/.nvm), Python — через uv
# (в .venv репозитория), Chromium — Playwright той версии, что в lockfile.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ "${1:-}" = "--check" ]; then
exec node scripts/toolchain-pins.mjs --check
fi
# Node нужен уже для чтения пинов; bootstrap — из .nvmrc (тест держит его равным CI).
NODE_PIN=$(tr -d '[:space:]' < .nvmrc)
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
echo "nvm не найден — устанавливаю в $NVM_DIR"
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
fi
# shellcheck disable=SC1091
. "$NVM_DIR/nvm.sh"
nvm install "$NODE_PIN" >/dev/null
nvm use "$NODE_PIN" >/dev/null
echo "node $(node --version) (пин $NODE_PIN)"
npm ci --no-audit --no-fund
PY_PIN=$(node scripts/toolchain-pins.mjs --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).python))')
if ! command -v uv >/dev/null 2>&1; then
echo "uv не найден — устанавливаю"
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi
uv python install "$PY_PIN" >/dev/null
[ -d .venv ] || uv venv --python "$PY_PIN" .venv >/dev/null
uv pip install --python .venv/bin/python -r tests_backend/requirements.txt >/dev/null
echo "python $(.venv/bin/python --version) + HA-стек из tests_backend/requirements.txt"
npx playwright install chromium >/dev/null
echo "chromium: $(node -e 'console.log(require("playwright").chromium.executablePath())')"
# Сверка тем же скриптом, что и на Windows; python берётся из .venv.
PATH="$PWD/.venv/bin:$PATH" node scripts/toolchain-pins.mjs --check