Files
portal/tools/night/cli.test.mjs
T

3229 lines
222 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.
// Кусок 1 — единственная точка входа затеи, и до этой правки у неё не было ни одной
// проверки. Главная причина, зачем этот файл вообще существует, названа в О-1: путь
// к письму (`cmd === 'mail'`) раньше падал МОЛЧА при сбое ДО самой отправки — сводка
// не строилась или транспорт не собирался, — и `mail-failures.jsonl` не получал строки.
// Владелец утром видел тишину, а тишину он не отличит от «всё хорошо».
//
// 🔴 Настоящих писем эти проверки не шлют, настоящих баз не заводят, платного помощника
// не рождают и в сеть не ходят — везде подставные `transport`/`sweep`/`readConfig` и
// временный каталог вместо `.night/`. В этом рабочем углу `.night/` уже живёт (второй
// правщик держит там свой прогон) — проверки его не трогают ни разу.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
main, runMailCommand, collectSummary, runningRuns, idushchieProgony, komandaProdolzheniya, kartochkiRabotnikov,
proverkaNastroekVladeltsa, vorotaPribora, vorotaZapasa,
progonPoTomuZhePlanu, vzyatZamokZapuska, otdatZamokZapuska, PREDEL_ZAMKA_MS,
besplatnyeVorotaDoDeneg, strokiOtkazaDoDeneg, VOROTA_DENEG, strokaProSvet,
} from './cli.mjs';
import { readLines } from './state.mjs';
import {
razborDovodov, zamerDovody, vhodVorot, strokiOtkaza, odinVid, vyborProdolzhaemogo, strokiPriborov,
} from './cli-parts.mjs';
// 🔴 Список продолжаемых в затее ОДИН и живёт в `resume.mjs`. Проверка ввозит его оттуда же,
// откуда его берёт правило: сверяйся я со своей копией слов — обе могли бы разъехаться
// с настоящей и остаться зелёными.
import { PRODOLZHAEMYE } from './resume.mjs';
// 🔴 Настоящие приборы запаса — для проверки шва между кусками 2 и 6. Подставка тут
// доказала бы только моё представление о том, что отдаёт `budgetState`.
import { recordMeasure as zapisatZamer, budgetState as sostoyanieZapasa } from './budget.mjs';
import { execFileSync } from 'node:child_process';
import { resolveBin } from './paths.mjs';
// 🔴 Настоящий откатчик и настоящий смотритель замка: подставки доказали бы только себя.
import { otkatDoKommita } from './supervisor.mjs';
import { mayTouch } from './file-claims.mjs';
// 🔴 ПРЕДЕЛ ВРЕМЕНИ ВСЕМУ ФАЙЛУ. Здесь вправду заводится `git`, а при пределе по умолчанию
// (5000 мс) это краснеет от СОСЕДЕЙ по машине, а не от поломки. Число не набирается своё —
// берётся общее; откуда оно взято, написано в самом `predel-zhivogo-zapuska.mjs`.
import { PREDEL_ZHIVOGO_ZAPUSKA } from './predel-zhivogo-zapuska.mjs';
vi.setConfig({ testTimeout: PREDEL_ZHIVOGO_ZAPUSKA });
const GIT = resolveBin('git');
// 🔴 Кусок 3, задача 6. `startGates` — НАСТОЯЩИЕ ворота: числа идут в них не рукой,
// а через `vhodVorot`. `odinVidPuti` — общая мелочь про пути (задача 2, `paths.mjs`),
// ввозится ради одной проверки: что она и `odinVid` куска 2 отвечают одинаково.
// 🔴 `GATE_ORDER` ввозится ОТТУДА ЖЕ, где он написан, а не переписывается сюда списком имён.
// Своя копия состава ворот в проверке — это ровно тот класс, которым уже расходились
// счётчик работников и разбор каталога прогонов: добавь кто-нибудь десятые ворота, и сторож
// остался бы зелёным, сторожа девятерых.
import { startGates, GATE_ORDER } from './gates.mjs';
import { odinVidPuti, runDir, workerDir, summaryLinesPath } from './paths.mjs';
// 🔴 Кусок 6, задача 7 — ПОДЪЁМ ПРОДОЛЖЕНИЯ. Живой `zavestiProdolzhenie` из `run.mjs`.
import { zavestiProdolzhenie } from './run.mjs';
import { readJson } from './state.mjs';
// 🔴 «Съеденного времени ноль» меряется ТЕМ ЖЕ прибором, что и ночью (`limits.mjs`),
// а не своим счётом рядом: два счёта одного и того же разъехались бы молча.
import { billableMs } from './limits.mjs';
import { randomUUID } from 'node:crypto';
let dir, failPath, sentPath;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'night-cli-'));
failPath = join(dir, 'mail-failures.jsonl');
sentPath = join(dir, 'last-summary.json');
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
const FAKE_CFG = { mail: { host: 'h', port: 587, user: 'u', pass: 'p', from: 'robot@liderra.ru', to: 'owner@example.com' } };
const OK_TRANSPORT = { sendMail: async () => ({ messageId: '1' }) };
const OK_SUMMARY = { subject: 'с', text: 'т' };
const noopSweep = () => [];
const okBuild = () => OK_SUMMARY;
describe('main — общий разбор команды', () => {
it('О-2: настроек нет — понятный отказ и код возврата, отличный от успеха', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const code = await main(['node', 'cli.mjs', 'summary'], { readConfig: () => null });
expect(code).not.toBe(0);
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/настроек нет/i));
errSpy.mockRestore();
});
it('О-2: неизвестная команда — понятный отказ и код возврата, отличный от успеха', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const code = await main(['node', 'cli.mjs', 'полетели-в-космос'], { readConfig: () => FAKE_CFG });
expect(code).not.toBe(0);
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/неизвестная команда/i));
errSpy.mockRestore();
});
it('команду не назвали вовсе — тот же честный отказ, а не падение', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const code = await main(['node', 'cli.mjs'], { readConfig: () => FAKE_CFG });
expect(code).not.toBe(0);
errSpy.mockRestore();
});
it('summary — печатает то, что отдала сводка, кодом успеха', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const code = await main(['node', 'cli.mjs', 'summary'], {
readConfig: () => FAKE_CFG,
summaryOf: () => ({ text: 'Сводка за сегодня' }),
sweep: () => [],
});
expect(code).toBe(0);
expect(logSpy).toHaveBeenCalledWith('Сводка за сегодня');
logSpy.mockRestore();
});
it('mail — успех передаётся наружу кодом 0 и словом об успехе', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const code = await main(['node', 'cli.mjs', 'mail'], {
readConfig: () => FAKE_CFG,
runMail: async () => ({ ok: true }),
});
expect(code).toBe(0);
expect(logSpy).toHaveBeenCalledWith('Сводка отправлена.');
logSpy.mockRestore();
});
it('mail — неудача передаётся наружу кодом, отличным от успеха, и словом о неудаче', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const code = await main(['node', 'cli.mjs', 'mail'], {
readConfig: () => FAKE_CFG,
runMail: async () => ({ ok: false, error: 'транспорт не собрался' }),
});
expect(code).not.toBe(0);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('транспорт не собрался'));
logSpy.mockRestore();
});
});
describe('collectSummary — О-2: сводка на пустом месте не молчит', () => {
it('прогона не было (нет last-run) — сводка всё равно есть и так и говорит', () => {
const s = collectSummary(FAKE_CFG, [], { readLastRun: () => null, readSummaryLines: () => [] });
expect(s.text).toMatch(/прогона не было/i);
});
it('строки сводки читаются, только когда есть run_id', () => {
const readSummaryLines = vi.fn(() => ['строка']);
collectSummary(FAKE_CFG, [], { readLastRun: () => null, readSummaryLines });
expect(readSummaryLines).not.toHaveBeenCalled();
});
});
describe('runMailCommand — О-1: любой сбой на пути к письму записывается', () => {
it('дошли до отправки, транспорт настоящий — письмо уходит, неудачи нет', async () => {
const r = await runMailCommand({
cfg: FAKE_CFG, failPath, sentPath,
sweep: noopSweep, buildSummaryLines: okBuild, makeTransport: async () => OK_TRANSPORT,
});
expect(r.ok).toBe(true);
expect(readLines(failPath)).toEqual([]);
});
it('О-1 главная: сборка сводки упала ДО отправки — неудача всё равно легла в mail-failures.jsonl', async () => {
const r = await runMailCommand({
cfg: FAKE_CFG, failPath, sentPath,
sweep: noopSweep,
buildSummaryLines: () => { throw new Error('сводка развалилась'); },
makeTransport: async () => OK_TRANSPORT,
});
expect(r.ok).toBe(false);
const rows = readLines(failPath);
expect(rows.length).toBe(1);
expect(rows[0].error).toContain('сводка развалилась');
});
it('О-1 главная: транспорт не собрался — неудача всё равно легла в mail-failures.jsonl', async () => {
const r = await runMailCommand({
cfg: FAKE_CFG, failPath, sentPath,
sweep: noopSweep, buildSummaryLines: okBuild,
makeTransport: async () => { throw new Error('SMTP не отвечает на соединение'); },
});
expect(r.ok).toBe(false);
const rows = readLines(failPath);
expect(rows.length).toBe(1);
expect(rows[0].error).toContain('SMTP не отвечает на соединение');
});
it('О-1 главная: сама уборка старых углов упала — неудача всё равно легла в mail-failures.jsonl', async () => {
const r = await runMailCommand({
cfg: FAKE_CFG, failPath, sentPath,
sweep: () => { throw new Error('угол не читается'); },
buildSummaryLines: okBuild, makeTransport: async () => OK_TRANSPORT,
});
expect(r.ok).toBe(false);
expect(readLines(failPath)[0].error).toContain('угол не читается');
});
it('функция не бросает наружу — она возвращает отказ, даже когда всё внутри рухнуло', async () => {
await expect(runMailCommand({
cfg: FAKE_CFG, failPath, sentPath,
sweep: noopSweep,
buildSummaryLines: () => { throw new Error('x'); },
makeTransport: async () => OK_TRANSPORT,
})).resolves.toBeTruthy();
});
it('писать свою неудачу тоже не вышло — всё равно не бросает и сказано об этом (unrecorded)', async () => {
const badPath = join(dir, 'занято-каталогом');
mkdirSync(badPath, { recursive: true });
const r = await runMailCommand({
cfg: FAKE_CFG, failPath: badPath, sentPath,
sweep: noopSweep,
buildSummaryLines: () => { throw new Error('сводка развалилась'); },
makeTransport: async () => OK_TRANSPORT,
});
expect(r.ok).toBe(false);
expect(r.error).toContain('сводка развалилась');
expect(r.unrecorded).toBeTruthy();
});
it('сама отправка (sendMail) упала — это по-прежнему записывает mailer.mjs, здесь ничего не задвоилось', async () => {
const r = await runMailCommand({
cfg: FAKE_CFG, failPath, sentPath,
sweep: noopSweep, buildSummaryLines: okBuild,
makeTransport: async () => ({ sendMail: async () => { throw new Error('SMTP лёг'); } }),
});
expect(r.ok).toBe(false);
const rows = readLines(failPath);
expect(rows.length).toBe(1); // ровно одна строка, а не две от двух ловушек подряд
expect(rows[0].error).toContain('SMTP лёг');
});
});
// 🪤 `sweepNow` (обёртка над `sweepStaleWorkspaces` из `workspace.mjs`) здесь НЕ проверяется
// напрямую: она зовёт настоящую уборку по настоящему REPO_ROOT этого рабочего угла — а там
// уже живёт реальный `.night/` второго правщика. Прогнать её вживую в проверке — риск
// тронуть чужие файлы. Проверки самой уборки — в workspace.test.mjs (чужой файл, не мой).
// В `main`/`runMailCommand` она всегда вызывается через внедряемый `sweep`, что и проверено выше.
// ============ кусок 2: три тихие части точки входа ============
//
// 🔴 Правило владельца: решая, писать ли проверку, спрашивать не «важно ли это», а «если
// сломается — заметим ли мы сами». Живой запуск ломается ГРОМКО (не запустится ничего) —
// он принимается глазами. А эти три вещи ломаются ТИХО и тихо делают не то:
// разбор доводов команды, раскладка ответов правил по кормушкам ворот и печать отказа.
//
// Чужие части подставляем, живьём не зовём: здесь проверяется НЕ то, что правила правы,
// а то, что их ответы дошли до ворот целыми и что владельцу сказали правду.
const ZAPAS = { may_start: true, renews_on: '2026-08-08', run_share_limit: 1 / 7 };
const MESTO = { ok: true, free_gb: 42.5 };
const PECHAT = { ok: true, seal: { fingerprint: 'aaa' } };
const BUMAGI = { ok: true };
const ZAMYSEL = { ok: true };
const CFG = {
base_branch: 'feat/night', probe_model: 'haiku', rate_limit_type: 'seven_day',
max_tasks_at_once: 2, max_workers_per_task: 7,
};
function argvFor(...rest) {
return ['node', 'tools/night/cli.mjs', ...rest];
}
describe('часть 1: доводы команды доходят до неё теми же, какими их набрали', () => {
it('run: три пути ложатся каждый в своё поле, а не путаются местами', () => {
const d = razborDovodov(argvFor('run', 'docs/plan.md', '--priyomka', 'docs/priyomka.md', '--spec', 'docs/spec.md'));
expect(d.planPath).toBe('docs/plan.md');
expect(d.priyomkaPath).toBe('docs/priyomka.md');
expect(d.specPath).toBe('docs/spec.md');
expect(d.error).toBeUndefined();
});
it('run: ключи набраны в другом порядке — поля всё равно свои', () => {
const d = razborDovodov(argvFor('run', 'docs/plan.md', '--spec', 'docs/spec.md', '--priyomka', 'docs/priyomka.md'));
expect(d.priyomkaPath).toBe('docs/priyomka.md');
expect(d.specPath).toBe('docs/spec.md');
});
it('run: обратные косые Windows приводятся к одному виду', () => {
// 🪤 Владелец копирует путь из окна Windows. Подай его как набран — хранилище ответит
// «нет такой бумаги», и владелец получит отказ по причине, которой нет.
const d = razborDovodov(argvFor('run', 'docs\\superpowers\\plans\\plan.md', '--priyomka', '.\\docs\\p.md', '--spec', 'docs\\s.md'));
expect(d.planPath).toBe('docs/superpowers/plans/plan.md');
expect(d.priyomkaPath).toBe('docs/p.md');
expect(d.specPath).toBe('docs/s.md');
});
it('run: не назван приёмочный лист — понятный отказ с образцом команды, а не молчание', () => {
const d = razborDovodov(argvFor('run', 'docs/plan.md', '--spec', 'docs/spec.md'));
expect(d.error).toContain('--priyomka');
expect(d.error).toContain('--spec');
expect(d.planPath).toBeUndefined();
});
it('run: не назван план — понятный отказ, и ключ --spec планом НЕ становится', () => {
const d = razborDovodov(argvFor('run', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md'));
expect(d.error).toContain('node tools/night/cli.mjs run');
});
it('seal: путь плана берётся из довода', () => {
expect(razborDovodov(argvFor('seal', 'docs/plan.md')).planPath).toBe('docs/plan.md');
});
it('seal: путь не назван — понятный отказ с образцом, а не молчание', () => {
const d = razborDovodov(argvFor('seal'));
expect(d.error).toContain('seal <путь-к-плану>');
expect(d.planPath).toBeUndefined();
});
it('approve-spec: бумага и ВСЕ названные рецензенты доходят до отметки', () => {
// 🔴 Рецензентов называют дважды. Возьми разбор только последнего — отметка выйдет
// с одним именем, ворота замысла откажут, а причина будет непонятной.
const d = razborDovodov(argvFor('approve-spec', 'docs/spec.md', '--reviewer', 'Пётр', '--reviewer', 'Анна'));
expect(d.specPath).toBe('docs/spec.md');
expect(d.reviewers).toEqual(['Пётр', 'Анна']);
});
it('approve-spec: бумага не названа — понятный отказ с образцом', () => {
expect(razborDovodov(argvFor('approve-spec', '--reviewer', 'Пётр')).error).toContain('--reviewer');
});
it('budget: голова замера берётся из настройки probe_model, зашитого слова нет', () => {
expect(zamerDovody({ ...CFG, probe_model: 'самая-дешёвая' }).model).toBe('самая-дешёвая');
});
it('budget: настройки probe_model нет — головы нет, и подставлять её нечем', () => {
// Умолчания нет намеренно: пусть замер откажется понятной строкой с именем настройки,
// а не сходит за деньги владельца чужой головой.
expect(zamerDovody({ ...CFG, probe_model: undefined }).model).toBeUndefined();
});
it('🔴 budget: вид предела берётся ИЗ НАСТРОЙКИ, а нет строки — отказ с её именем', () => {
// 🔴🔴 ЭТА ПРОВЕРКА СТОРОЖИЛА МИНУ. Прежде вторая её строка требовала обратного:
// «нет строки в настройках — ВСЁ РАВНО недельный». Замерено живьём 03.08.2026: этому
// счёту приходит `five_hour`, недельного события не приходило ни разу — то есть молчаливое
// умолчание заставляло ворота ждать запас, которого не бывает, и они не пропустили
// ни одного прогона за всю жизнь затеи. Владелец при этом читал «события от системы
// не пришло», хотя оно приходило каждый раз.
// Теперь умолчания нет нигде: потеряна настройка — отказ, и в нём её имя.
expect(zamerDovody({ ...CFG, rate_limit_type: 'five_hour' }).kind).toBe('five_hour');
expect(() => zamerDovody({ ...CFG, rate_limit_type: undefined })).toThrow(/rate_limit_type/);
});
});
describe('часть 2: воротам поданы ВСЕ девять ответов, а не часть', () => {
const running = [
{ run_id: 'r-1', status: 'running', workers: ['w-1', 'w-2'], items: [{ id: '1', files: ['app/A.php'] }] },
{ run_id: 'r-2', status: 'running', workers: ['w-3'], items: [{ id: '2', files: ['app/B.php'] }] },
];
const vhod = () => vhodVorot({
cfg: CFG, items: [{ id: '1' }], budget: ZAPAS, disk: MESTO,
planSeal: PECHAT, papers: BUMAGI, spec: ZAMYSEL, running,
});
it('во входе есть все девять кормушек ворот — поимённо', () => {
// 🔴 Порядок ворот: запас · отклик владельца · место · замысел · бумаги · отпечаток ·
// от прошлого прогона никого · потолки · пересечение файлов.
const v = vhod();
for (const pole of ['budget', 'owner', 'disk', 'spec', 'papers', 'planSeal',
'previousWorkers', 'capacity', 'newTaskFiles', 'runningTasksFiles']) {
expect(v).toHaveProperty(pole);
}
});
it('ответы правил доходят до ворот ТЕМИ ЖЕ, какими пришли', () => {
// 🔴 Тихая беда: положи ответ запаса в поле места — ворота места прочтут `may_start`,
// не найдут `ok`, откажут, и владелец пойдёт чистить диск вместо ожидания запаса.
const v = vhod();
expect(v.budget).toBe(ZAPAS);
expect(v.disk).toBe(MESTO);
expect(v.planSeal).toBe(PECHAT);
expect(v.papers).toBe(BUMAGI);
expect(v.spec).toBe(ZAMYSEL);
});
it('ветка отсчёта и пункты берутся из настроек и из разбора плана', () => {
const v = vhod();
expect(v.base_branch).toBe('feat/night');
expect(v.items).toEqual([{ id: '1' }]);
});
it('число идущих прогонов настоящее, а не ноль', () => {
expect(vhod().capacity.tasks_running).toBe(2);
});
it('число работников в работе берётся из карточек прогонов, а не выдумывается', () => {
// 🔴 Состав прогона пишет кусок 1 (`startAll` кладёт `workers` в `run.json`) — заводить
// это поле заново не надо, его надо сложить. Ноль здесь означал бы «никого нет»,
// и ворота потолков пропустили бы восьмого работника.
//
// 🔴 ПОПРАВЛЕНО КУСКОМ 3 (задача 6) И НАЗВАНО ВСЛУХ. Проверка не выброшена и число
// в ней то же — 3, — но вход стал честнее. Кусок 2 складывал работников ВСЕХ идущих
// прогонов и на этом входе получал 3 (2+1). Проверка 27 говорит «внутри ЗАДАЧИ»,
// поэтому счёт теперь идёт по своей задаче плюс те, кого просят завести: у задачи `r-1`
// своих двое, просят одного — трое. Не назови мы задачу, вышла бы единица, и это было
// бы верно: чужих работников в свой потолок не берут. Место, где кусок 3 имел право
// тронуть чужую проверку, план назвал только одно (`gates.test.mjs`) — вот второе,
// и его пропустили: старый ответ сторожил ровно то правило, которое кусок 3 заменяет.
const v = vhodVorot({
cfg: CFG, items: [{ id: '1' }], budget: ZAPAS, disk: MESTO,
planSeal: PECHAT, papers: BUMAGI, spec: ZAMYSEL, running, taskId: 'r-1',
});
expect(v.capacity.workers_in_task).toBe(3);
});
it('карточка прогона без состава — считается ноль работников, а не обвал', () => {
const v = vhodVorot({ cfg: CFG, budget: ZAPAS, disk: MESTO, running: [{ run_id: 'r-1' }] });
expect(v.capacity.workers_in_task).toBe(0);
expect(v.capacity.tasks_running).toBe(1);
});
it('потолки берутся из настроек владельца', () => {
const v = vhodVorot({ cfg: { ...CFG, max_tasks_at_once: 1, max_workers_per_task: 3 }, running: [] });
expect(v.capacity.max_tasks).toBe(1);
expect(v.capacity.max_workers).toBe(3);
});
it('занятые файлы идущих прогонов подаются воротам пересечения', () => {
expect(vhod().runningTasksFiles).toEqual([['app/A.php'], ['app/B.php']]);
});
it('четверо ненаписанных ворот получают ПУСТОЕ, а не выдуманное', () => {
// Их правила пишут куски 3 и 7. Пустое значение — это честное «сказать нечего»;
// выдуманное «всё хорошо» было бы враньём, которое никто потом не найдёт.
const v = vhod();
expect(v.owner).toEqual({ last_summary_at: null });
expect(v.previousWorkers).toEqual([]);
expect(v.newTaskFiles).toEqual([]);
});
});
describe('часть 3: в отказе владельцу названы ворота и человеческая причина', () => {
it('названы ИМЕНА непройденных ворот и причины словами', () => {
const stroki = strokiOtkaza({
ok: false, reason: 'Запас кончился.',
failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }],
});
expect(stroki.join('\n')).toContain('запас');
expect(stroki.join('\n')).toContain('Запас кончился.');
});
it('непройденных двое — названы ОБА, а не только первые', () => {
// Починив одно и упёршись во второе, владелец потерял бы ночь.
const stroki = strokiOtkaza({
ok: false, reason: 'Запас кончился.',
failed_gates: [
{ gate: 'запас', reason: 'Запас кончился.' },
{ gate: 'бумаги сохранены', reason: 'Не сохранено: docs/plan.md' },
],
});
const t = stroki.join('\n');
expect(t).toContain('запас');
expect(t).toContain('бумаги сохранены');
expect(t).toContain('docs/plan.md');
});
it('в отказе НЕТ месива: ни [object Object], ни склеенной строки all_reasons', () => {
// 🔴 Ровно на этом шве уже ломалось: `failed` — это ПАРЫ, а не строки. Разбери
// кто-нибудь `all_reasons` — владелец получит «запас: запас: Запас кончился.».
const t = strokiOtkaza({
ok: false, reason: 'Запас кончился.',
failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }],
all_reasons: ['запас: Запас кончился.'],
}).join('\n');
expect(t).not.toContain('[object Object]');
expect(t).not.toContain('запас: запас');
});
it('отказ есть, а имён ворот не дали — печатается общая причина, а не пустота', () => {
const t = strokiOtkaza({ ok: false, reason: 'Что-то пошло не так.', failed_gates: [] }).join('\n');
expect(t).toContain('Что-то пошло не так.');
});
it('запас кончился — сказано, КОГДА станет можно', () => {
const t = strokiOtkaza(
{ ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }] },
{ renews_on: '2026-08-08' },
).join('\n');
expect(t).toContain('2026-08-08');
});
it('🔴 дня обновления назвать не смогли — печатается ПРИЧИНА, а не пустое место', () => {
// Прежде строка про запас печаталась только при `renews_on`. Сбой прибора (день пуст,
// день в прошлом, число не в миллисекундах) не давал НИ ОДНОЙ строки, и владелец читал
// это как «ещё не считали». Слова сбоя готовит budget.mjs — здесь их обязаны донести.
const t = strokiOtkaza(
{ ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }] },
{ renews_on: null, renews_on_problem: 'прибор не назвал день обновления запаса вовсе (resets_at пуст)' },
).join('\n');
expect(t).toContain('сказать не удалось');
expect(t).toContain('resets_at пуст');
});
it('день назвали — печатается день, а причина сбоя не примешивается', () => {
const t = strokiOtkaza(
{ ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }] },
{ renews_on: '2026-08-08', renews_on_problem: null },
).join('\n');
expect(t).toContain('2026-08-08');
expect(t).not.toContain('сказать не удалось');
});
it('про запас не сказано ничего вовсе — лишней строки не появляется', () => {
// Отказ бывает не по запасу: тогда молчать про запас правильно.
const t = strokiOtkaza(
{ ok: false, reason: 'Место кончилось.', failed_gates: [{ gate: 'место', reason: 'Место кончилось.' }] },
{ renews_on: null, renews_on_problem: null },
).join('\n');
expect(t).not.toContain('недельный запас');
expect(t).not.toContain('Недельный запас');
});
it('ворота пройдены — печатать нечего вовсе', () => {
// Без этой проверки печать могла бы работать всегда, и владелец получал бы
// «Прогон НЕ запущен» в ночь, когда прогон как раз запустился.
expect(strokiOtkaza({ ok: true, failed_gates: [] })).toEqual([]);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 `strokiPriborov` — ЧТО ВЛАДЕЛЕЦ ПРОЧТЁТ ПРО ГОЛОВУ И МАСТЕРСКУЮ (03.08.2026)
//
// Класс беды: приговор построен и никуда не доходит. Датчики головы и мастерской работали
// правильно и молчали не они — их приговор выбрасывал слой выше. Здесь сторожатся слова,
// ниже (в блоке О-4 и в блоке продолжения) — то, что их вправду печатают.
// ═══════════════════════════════════════════════════════════════════════════════
describe('🔴 строки про два прибора: молчания не бывает ни в одном из состояний', () => {
const HOROSHA = { sovpalo: true, zakazana: 'haiku', otvetila: ['claude-haiku-4-5'], soobshchenie: null };
const SREZANA = { otsecheno: true, sostav: { tools: 0, mcp_servers: 0 }, soobshchenie: null };
it('на каждый платный замер — РОВНО две строки, по одной на прибор', () => {
const s = strokiPriborov({ golova: HOROSHA, masterskaya: SREZANA });
expect(s).toHaveLength(2);
expect(s[0]).toMatch(/^Голова:/);
expect(s[1]).toMatch(/^Мастерская/);
});
it('сошлось — говорится вслух, а не молчанием', () => {
// 🔴 Молчи мы при «сошлось» — владелец не отличил бы «проверили, всё хорошо»
// от «печать опять отвалилась». Ровно эта неразличимость и была бедой.
const t = strokiPriborov({ golova: HOROSHA, masterskaya: SREZANA }).join('\n');
expect(t).toContain('haiku');
expect(t).toMatch(/сошлось/);
expect(t).toMatch(/срезана/);
});
it('🔴 беда — печатаются СЛОВА ДАТЧИКА, не переписанные здесь заново', () => {
const t = strokiPriborov({
golova: { sovpalo: false, zakazana: 'haiku', otvetila: ['opus'], soobshchenie: 'ВОТ ЭТИ САМЫЕ СЛОВА ДАТЧИКА' },
masterskaya: { otsecheno: false, sostav: { tools: 50, mcp_servers: 51 }, soobshchenie: 'И ЭТИ ТОЖЕ' },
}).join('\n');
expect(t).toContain('ВОТ ЭТИ САМЫЕ СЛОВА ДАТЧИКА');
expect(t).toContain('И ЭТИ ТОЖЕ');
});
it('🪤 «сверить было нечем» — это НЕ «сошлось», и говорится своими словами', () => {
const t = strokiPriborov({
golova: { sovpalo: null, zakazana: 'haiku', otvetila: [], soobshchenie: 'в потоке нет ни одной головы' },
masterskaya: { otsecheno: null, sostav: { naydeno: false }, soobshchenie: 'события system/init не было' },
}).join('\n');
expect(t).toContain('в потоке нет ни одной головы');
expect(t).not.toMatch(/сошлось|срезана, как и заказывали/);
});
it('🪤 приговора нет вовсе (прибор промолчал) — «не сошлось» не выдумывается, но и «сошлось» тоже', () => {
const t = strokiPriborov({ golova: null, masterskaya: null }).join('\n');
expect(t).toMatch(/не пришло вовсе/);
expect(t).toContain('НЕ «сошлось»');
});
it('🪤 ТРЕТЬЕ: ответ пришёл, а разобрать нечем — считается НЕ проверенным, а не хорошим', () => {
// Незнакомый ответ зелёным не бывает — тем же приёмом, каким незнакомый светофор
// не считается зелёным в `budgetState`. Иначе поломка датчика читалась бы как «всё ладно».
const t = strokiPriborov({ golova: { zakazana: 'haiku' }, masterskaya: { sostav: {} } }).join('\n');
expect(t).toMatch(/не разобран/);
expect(t).toMatch(/НЕ проверенным/);
expect(t).not.toMatch(/сошлось/);
});
it('🪤 ответа замера нет вовсе — две строки всё равно есть, а не пустота', () => {
expect(strokiPriborov(null)).toHaveLength(2);
expect(strokiPriborov(undefined).join('\n')).toMatch(/не пришло вовсе/);
});
});
// ============ блок 4: точка входа не оставлена звать удалённые имена ============
// 🔴 Зачем он. `cli.mjs` в проверки ввозится только тремя своими функциями, а сам разбор
// команд ими не покрыт — это решение владельца: живой запуск ломается громко. Но один
// класс поломки здесь как раз тихий: правка убирает из файла объявление, а зов остаётся.
// Такой файл падает не при загрузке, а позже — внутри той команды, куда в тот вечер никто
// не заглянул. Сторож не запускает точку входа и не проверяет её поведение: он читает её
// как текст и смотрит, что каждое зовомое имя откуда-то взято.
//
// 🔴 Список известных имён намеренно ЩЕДРЫЙ — в него идут и доводы функций. Пусть сторож
// пропустит спорное, зато не покраснеет зря: сторож, который врёт красным, отключают первым.
// Совсем оторванное имя вроде `odinVid` он ловит всё равно — это доказано вырезанием.
const OT_SISTEMY = new Set([
'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof', 'else', 'do',
'await', 'async', 'new', 'delete', 'void', 'throw', 'case', 'try', 'in', 'of', 'instanceof',
'String', 'Number', 'Boolean', 'Object', 'Array', 'JSON', 'Date', 'Math', 'Set', 'Map',
'Promise', 'Error', 'RegExp', 'console', 'process', 'parseInt', 'parseFloat', 'isNaN',
'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'require', 'import', 'super',
]);
// Убрать примечания и тексты для владельца: слова из них — не зовы.
function tolkoKod(text) {
return text
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/\/\/[^\n]*/g, ' ')
.replace(/`(?:\\[\s\S]|[^`\\])*`/g, '``')
.replace(/'(?:\\[\s\S]|[^'\\])*'/g, "''")
.replace(/"(?:\\[\s\S]|[^"\\])*"/g, '""');
}
function imenaKotorykhFayluHvataet(kod) {
const est = new Set(OT_SISTEMY);
// 🔴 ПОПРАВЛЕНО ПРОТИВ ПЛАНА, вслух. В плане `dobav` стирал из куска ВСЕ не-буквы
// и добавлял остаток целиком. На куске `readConfig = () => readJson(configPath())`
// это давало слипшийся мусор `readConfigreadJsonconfigPath`, а само имя `readConfig`
// так и оставалось «ниоткуда» — сторож краснел бы на здоровом файле. Берём ПЕРВОЕ имя
// куска: оно и есть объявляемое. У ввоза с переименованием (`x as y`) имя даёт правая часть.
const dobav = s => {
const chistoe = String(s).replace(/^[\s{[(]+/, '').split(/\s+as\s+/).pop();
const m = String(chistoe).trim().match(/^[\w$]+/);
if (m) est.add(m[0]);
};
for (const m of kod.matchAll(/import\s*\{([^}]*)\}\s*from/g)) m[1].split(',').forEach(dobav);
for (const m of kod.matchAll(/import\s+([\w$]+)\s*(?:,|from)/g)) dobav(m[1]);
for (const m of kod.matchAll(/(?:function|class)\s+([\w$]+)/g)) dobav(m[1]);
for (const m of kod.matchAll(/(?:const|let|var)\s+([\w$]+)/g)) dobav(m[1]);
for (const m of kod.matchAll(/(?:const|let|var)\s*\{([^}]*)\}/g)) m[1].split(',').forEach(dobav);
// Доводы функций — тоже имена, которые у файла есть.
for (const m of kod.matchAll(/function\s*[\w$]*\s*\(([^)]*)\)/g)) m[1].split(',').forEach(dobav);
// 🔴 ВТОРАЯ ПОПРАВКА ПРОТИВ ПЛАНА. Доводы, разобранные прямо в скобках с пустым
// умолчанием — `function f({ a, b = c() } = {}) {` — прежней строкой не ловились вовсе:
// она обрывалась на первой же закрывающей скобке внутри (`mailFailuresPath()`), и семь
// доводов `runMailCommand` оставались «ниоткуда».
// 🪤 Один вложенный слой фигурных скобок внутри доводов разрешён намеренно: умолчание
// вида `x = async e => f(e, { disk: await g() })` иначе обрывает разбор на середине,
// и остаток доводов снова становится «ниоткуда». На этом сторож уже краснел живьём.
for (const m of kod.matchAll(/\{((?:[^{}]|\{[^{}]*\})*)\}\s*=\s*\{\s*\}\s*\)/g)) m[1].split(',').forEach(dobav);
for (const m of kod.matchAll(/\(([^()]*)\)\s*=>/g)) m[1].split(',').forEach(dobav);
for (const m of kod.matchAll(/([\w$]+)\s*=>/g)) dobav(m[1]);
return est;
}
describe('часть 4: точка входа не зовёт имён, которых у неё нет', () => {
it('каждое имя, которое зовёт `cli.mjs`, либо ввезено, либо объявлено в нём же', () => {
const kod = tolkoKod(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'cli.mjs'), 'utf8'));
const est = imenaKotorykhFayluHvataet(kod);
const bezymyannye = [];
for (const m of kod.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
if (!est.has(m[1]) && !bezymyannye.includes(m[1])) bezymyannye.push(m[1]);
}
// 🔴 Красное здесь читается прямо: вот имена, которые файл зовёт, а взять их неоткуда.
expect(bezymyannye).toEqual([]);
});
});
// ============ кусок 3: настоящие числа и списки для ворот ============
// 🔴 ПОПРАВЛЕНО ПРОТИВ ПЛАНА, вслух: план звал эти три блока «часть 4», «часть 5»
// и «часть 6», а «часть 4» в этом файле уже занята куском 2 («точка входа не зовёт имён,
// которых у неё нет»). Два блока с одним именем в одном файле — не поломка, но читать
// красное потом невозможно. Взяты следующие свободные номера: 5, 6, 7.
// 🔴 Ввозы (`startGates`, `odinVid`, `odinVidPuti`) дописаны в шапку файла, а не сюда;
// `readFileSync` там уже стоял с куска 1 — второго его имени не заводим.
const PUT_SYUDA = dirname(fileURLToPath(import.meta.url));
const TEKST_CLI = readFileSync(join(PUT_SYUDA, 'cli.mjs'), 'utf8');
const TEKST_VOROT = readFileSync(join(PUT_SYUDA, 'gates.mjs'), 'utf8');
// Две идущие задачи: у первой два работника и файл `app/A.php`, у второй один и `app/B.php`.
const IDUT = [
{ run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1', 'w-2'], items: [{ id: '1', files: ['app/A.php'] }] },
{ run_id: 'r-2', status: 'running', task_id: 'z-2', workers: ['w-3'], items: [{ id: '2', files: ['app/B.php'] }] },
];
const NASTROYKI = { ...CFG, max_tasks_at_once: 2, max_workers_per_task: 7 };
describe('часть 5: воротам ёмкости и пересечения поданы НАСТОЯЩИЕ числа и списки', () => {
it('newTaskFiles — настоящие файлы пунктов нового прогона, а не пустой список', () => {
// 🔴 Место, размеченное куском 2 словами «пусто, и это твоё». Пустой список означал бы,
// что ворота пересечения не держат никогда: правило без пищи пропускает всё.
const v = vhodVorot({
cfg: NASTROYKI, running: IDUT, taskId: null,
items: [{ id: '1', files: ['app/A.php'] }, { id: '2', files: ['app/C.php'] }],
});
expect(v.newTaskFiles).toEqual(['app/A.php', 'app/C.php']);
});
it('пунктов без файлов — список пустой, а не выдуманный', () => {
const v = vhodVorot({ cfg: NASTROYKI, running: [], items: [{ id: '1' }] });
expect(v.newTaskFiles).toEqual([]);
// 🔴 УСИЛЕНО ПРОТИВ ПЛАНА, вслух. В плане у этой проверки было только утверждение выше,
// и в красной поре она была ЗЕЛЁНОЙ: пустоты ждали, пустота там и стояла. Зелёная
// в красной поре не сторожит ничего. Вторая половина краснеет честно: пункт без файлов
// обязан пропасть из списка, а пункт с файлами — остаться. Свались они в одно
// (`items.map(i => i.files)`), в списке оказалось бы `undefined` — и ворота пересечения
// спотыкались бы о него на ровном месте.
const smeshanno = vhodVorot({
cfg: NASTROYKI, running: [], items: [{ id: '1' }, { id: '2', files: ['app/A.php'] }],
});
expect(smeshanno.newTaskFiles).toEqual(['app/A.php']);
});
it('workers_in_task — идущие работники СВОЕЙ задачи плюс те, кого просят завести', () => {
// 🔴 Считай мы только идущих — потолок стерёг бы уже начатое и пропустил бы восьмого.
// Своих у задачи `z-1` двое, просят завести ещё двоих — станет четверо, а не трое
// (трое — это ответ куска 2, который складывал работников ВСЕХ идущих прогонов).
const v = vhodVorot({
cfg: NASTROYKI, running: IDUT, taskId: 'z-1',
items: [{ id: '9', files: ['app/D.php'] }, { id: '10', files: ['app/E.php'] }],
});
expect(v.capacity.workers_in_task).toBe(4);
});
it('работники ЧУЖОЙ задачи в этот счёт не идут', () => {
// Иначе потолок «семь работников внутри задачи» превратился бы в «семь на весь сервер»,
// и вторая задача не запустилась бы никогда.
const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: 'z-2', items: [{ id: '9' }] });
expect(v.capacity.workers_in_task).toBe(2);
});
it('tasks_running считает ЗАДАЧИ, а не прогоны', () => {
const dvaProgonaOdnoyZadachi = [
{ run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1'], items: [] },
{ run_id: 'r-2', status: 'running', task_id: 'z-1', workers: ['w-2'], items: [] },
];
const v = vhodVorot({ cfg: NASTROYKI, running: dvaProgonaOdnoyZadachi, taskId: null, items: [] });
expect(v.capacity.tasks_running).toBe(1);
});
it('своя задача третьей не считается', () => {
// Продолжение уже идущей задачи новую задачу не заводит — иначе потолок «две задачи»
// запретил бы доводить до конца то, что уже начато.
const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: 'z-1', items: [] });
expect(v.capacity.tasks_running).toBe(1);
});
it('runningTasksFiles не включает файлы СВОЕЙ задачи', () => {
// Иначе задача пересекалась бы сама с собой и не запускалась бы никогда.
const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: 'z-1', items: [] });
expect(v.runningTasksFiles).toEqual([['app/B.php']]);
});
});
describe('часть 6: НАСТОЯЩИЕ ворота на этих числах отказывают и пропускают', () => {
const ostalnoe = {
budget: ZAPAS, disk: MESTO, planSeal: PECHAT, papers: BUMAGI, spec: ZAMYSEL,
};
it('восьмой работник внутри задачи — отказ (проверка 27)', () => {
const semero = [{ run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1', 'w-2', 'w-3', 'w-4', 'w-5', 'w-6', 'w-7'], items: [] }];
const r = startGates(vhodVorot({
cfg: NASTROYKI, ...ostalnoe, running: semero, taskId: 'z-1', items: [{ id: '8', files: ['app/Z.php'] }],
}));
expect(r.ok).toBe(false);
expect(r.failed.map(f => f.gate)).toContain('потолки задач и работников');
});
it('🔴 ГРАНИЦА: шестеро идут, просят седьмого — ПУСКАЮТ (проверка 46)', () => {
// 🔴🔴 Шов между задачей 2 и этой. `vhodVorot` подаёт «сколько СТАНЕТ»: шестеро идут
// плюс один просимый — семь. Правило задачи 2 сравнивает строго (`> 7`), поэтому
// семеро проходят. Стоял бы там `>=`, как в первом наброске, — эта проверка
// покраснела бы, а семеро одной задачи не собрались бы никогда, и проверку 46
// бумаги («семеро работников одной задачи шли разом») показать было бы нечем.
// Проверка ЖИВОГО пути: числа идут не рукой, а через `vhodVorot`.
const shestero = [{ run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1', 'w-2', 'w-3', 'w-4', 'w-5', 'w-6'], items: [] }];
const r = startGates(vhodVorot({
cfg: NASTROYKI, ...ostalnoe, running: shestero, taskId: 'z-1', items: [{ id: '7', files: ['app/Z.php'] }],
}));
expect(r.ok).toBe(true);
expect(r.failed).toEqual([]);
});
it('третья задача — отказ, а продолжение уже идущей — пропуск (проверка 26)', () => {
const r = startGates(vhodVorot({
cfg: NASTROYKI, ...ostalnoe, running: IDUT, taskId: 'z-3', items: [{ id: '1', files: ['app/Z.php'] }],
}));
expect(r.ok).toBe(false);
expect(r.failed.map(f => f.gate)).toContain('потолки задач и работников');
// 🔴 УСИЛЕНО ПРОТИВ ПЛАНА, вслух. План сам признал, что в красной поре эта проверка
// ЗЕЛЁНАЯ: кусок 2 складывал прогоны и на этом входе случайно давал тот же ответ.
// Зелёная в красной поре не сторожит ничего, поэтому здесь же стоит её зеркало —
// тот же вход, но задача СВОЯ. Отличить «третья» от «продолжение начатого» кусок 2
// не умел вовсе, и зеркало краснеет честно.
const svoya = startGates(vhodVorot({
cfg: NASTROYKI, ...ostalnoe, running: IDUT, taskId: 'z-1', items: [{ id: '1', files: ['app/Z.php'] }],
}));
expect(svoya.ok).toBe(true);
});
it('новая задача задевает файл идущей — отказ, и файл назван (проверка 50)', () => {
const r = startGates(vhodVorot({
cfg: { ...NASTROYKI, max_tasks_at_once: 9 }, ...ostalnoe,
running: IDUT, taskId: 'z-3', items: [{ id: '1', files: ['app/A.php'] }],
}));
expect(r.ok).toBe(false);
expect(r.failed.map(f => f.gate)).toContain('задачи не пересекаются по файлам');
expect(r.all_reasons.join(' ')).toContain('app/A.php');
});
it('вторая задача своими файлами — ворота пропускают: они не глухие', () => {
// 🔴 Страховка. Ворота, которые не пропускают НИКОГДА, зеленят три проверки выше
// и убивают затею целиком: ночь не запустится ни разу, а причина будет выглядеть верной.
const r = startGates(vhodVorot({
cfg: NASTROYKI, ...ostalnoe,
running: [IDUT[0]], taskId: 'z-3', items: [{ id: '1', files: ['app/Z.php'] }],
}));
expect(r.ok).toBe(true);
expect(r.failed).toEqual([]);
});
});
describe('часть 7: два новых ключа команды run и правда про несторожащие ворота', () => {
it('--task и --files доходят до разбора доводов', () => {
const d = razborDovodov(argvFor(
'run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md',
'--task', 'z-1', '--files', 'app/A.php, app\\B.php',
));
expect(d.taskId).toBe('z-1');
expect(d.files).toEqual(['app/A.php', 'app/B.php']);
});
it('🔴 `odinVid` куска 2 и `odinVidPuti` куска 3 отвечают ОДИНАКОВО', () => {
// 🔴🔴 Две мелочи делают одну работу — сведение имени файла к одному виду. Разойдись
// они хоть в одном знаке, и ключ `--files` дал бы одно имя, а замок и ворота ждали бы
// другое: пересечения не находились бы, и все проверки остались бы зелёными.
// Свести их в одну совсем нельзя: `odinVid` куска 2 зовут и другие его места, а кусок 3
// чужое не переписывает. Значит их СВЕРЯЕМ — вот здесь.
for (const obrazets of ['app\\A.php', './app/A.php', 'app//A.php', ' app/A.php ', 'docs\\a\\b.md', '']) {
expect(odinVid(obrazets)).toBe(odinVidPuti(obrazets));
}
});
it('ключей не назвали — файлов нет и задача новая, а не выдуманные значения', () => {
const d = razborDovodov(argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md'));
expect(d.files).toEqual([]);
expect(d.taskId).toBeNull();
});
it('строка «эти ворота пока НЕ сторожат» потеряла двоих', () => {
// 🪤 Тихая поломка: ворота начали сторожить, а владельцу в сводке по-прежнему пишут,
// что они пропускают всё. Он читает неправду и не узнает об этом никогда.
const spisok = TEKST_CLI.match(/VOROTA_POKA_NE_STOROZHAT = \[([\s\S]*?)\]/)[1];
expect(spisok).toContain('отклик владельца');
expect(spisok).toContain('от прошлого прогона никого');
expect(spisok).not.toContain('потолки задач и работников');
expect(spisok).not.toContain('задачи не пересекаются по файлам');
});
it('🔴 СВЕРХ ПЛАНА: несторожащих ворот названо владельцу РОВНО столько, сколько пустышек в самих воротах', () => {
// 🔴 Сторож на КЛАСС, а не на место: «ворота начали сторожить, а владельцу про них
// по-прежнему пишут „пропускают всё"». Проверка выше сторожит ровно сегодняшних двоих
// поимённо — то есть ровно сегодняшний день. Эта переживёт кусок 7: подставит он
// `ownerResponded` вместо пустышки и забудет вычеркнуть строку у владельца —
// покраснеет здесь.
const pustyshek = (TEKST_VOROT.match(/notYetWritten\(\),/g) ?? []).length;
const nazvano = TEKST_CLI.match(/VOROTA_POKA_NE_STOROZHAT = \[([\s\S]*?)\]/)[1]
.split('\n').filter(s => /['«][а-яё]/i.test(s)).length;
expect(nazvano).toBe(pustyshek);
});
it('🔴 СВЕРХ ПЛАНА: точка входа вправду ПОДАЁТ воротам номер задачи и настоящие файлы', () => {
// 🔴 Сторож на КЛАСС «правило написано, а зовущего нет» — ровно тот, ради которого
// весь этот кусок и затеян. Разбор доводов и `vhodVorot` проверены выше поодиночке,
// но между ними стоит `cli.mjs`, чьи команды проверками не покрыты намеренно (живой
// запуск ломается громко). Убери кто-нибудь `taskId` из зова ворот или верни
// `files: []` единственному пункту — обе половины останутся зелёными, а ночь молча
// вернётся к «ворота пересечения не держат никогда». Поэтому смотрим текстом.
// 🔄 ПЕРЕНАЦЕЛЕНО КРУГОМ Б 04.08.2026, называю вслух. Прежде здесь искалось
// `planRun(vhodVorot({...}))` одной строкой. Такой строки больше нет: вход ворот
// собирается ОДИН раз (`const vhod = vhodVorot({...})`) и служит обоим проходам —
// бесплатному до денег и полному после. Сторожимое свойство не ослаблено: смотрим
// ровно тот же зов `vhodVorot`, только по его новому имени.
const zovVorot = TEKST_CLI.match(/const vhod = vhodVorot\(\{([\s\S]*?)\}\);/)[1];
expect(zovVorot).toContain('taskId');
// 🔴 ПЕРЕНАЦЕЛЕНО КУСКОМ 4, называю вслух. Прежде здесь стояло `files: razbor.files`
// у единственного слепленного пункта «весь-план»: до разбора плана файлы называл
// владелец рукой ключом `--files`. Куска этого больше нет — файлы каждого пункта
// берутся из самого плана. Сторожимое СВОЙСТВО не ослаблено, а усилено: раньше
// сторожили «список файлов не пуст», теперь — что воротам идут вправду разобранные
// пункты плана. Верни кто-нибудь сюда выдуманный пункт — покраснеет.
expect(zovVorot).toMatch(/items:\s*punkty/);
expect(TEKST_CLI).toMatch(/punkty\s*=\s*vhodVorot\(\{\s*planPath\s*\}\)\.items/);
expect(TEKST_CLI).not.toMatch(/id:\s*'весь-план'/);
});
});
// ============ беда 2: порванная карточка прогона снимала ОБА замка разом ============
//
// 🔴 Найдено воротами ЖИВЬЁМ: одну запись порвали — и тут же завелась третья задача
// при потолке в две, а следом задача на файл, который порванный прогон держит. Оба отказа
// исчезли МОЛЧА: список идущих прогонов читался плоским `readJson`, а тот на порванном файле
// отдаёт «ничего», и прогон просто выпадал из счёта.
//
// 🔴 Правило на этот класс в хозяйстве УЖЕ ЕСТЬ и уже применено дважды: `file-claims.mjs`
// (замок на файлы) и `plan-lock-hook.mjs` (замок на правку плана) читают через общий прибор
// `readJsonOrBroken` и прямо пишут почему — испорченную запись нельзя считать за «никого нет».
// Здесь закрывается третья дверь тем же способом, а не выдумывается четвёртый.
describe('часть 8: порванная карточка прогона не превращается в «прогона нет»', () => {
function razlozhit(kartochki) {
const koren = join(dir, 'runs');
for (const [id, tekst] of Object.entries(kartochki)) {
mkdirSync(join(koren, id), { recursive: true });
writeFileSync(join(koren, id, 'run.json'), tekst, 'utf8');
}
return koren;
}
const idet = id => JSON.stringify({ run_id: id, status: 'running', task_id: id, workers: ['w-1'], items: [{ id: '1', files: ['app/A.php'] }] });
it('🔴 карточка порвана счёт идущих прогонов падает ГРОМКО, а не теряет прогон молча', () => {
const koren = razlozhit({ 'r-1': idet('r-1'), 'r-2': '{"run_id": "r-2", "status": "runn' });
expect(() => runningRuns(koren)).toThrow(/испорчен|прочесть не удалось|не читается/i);
});
it('🔴 в отказе назван ФАЙЛ, который надо посмотреть глазами', () => {
const koren = razlozhit({ 'r-2': 'это не JSON вовсе' });
expect(() => runningRuns(koren)).toThrow(/r-2/);
});
it('всё цело — идущие считаются, а кончившиеся не считаются (правило не глухое)', () => {
const koren = razlozhit({
'r-1': idet('r-1'),
'r-3': JSON.stringify({ run_id: 'r-3', status: 'finished', workers: ['w-1'] }),
});
const idut = runningRuns(koren);
expect(idut.map(r => r.run_id)).toEqual(['r-1']);
});
it('каталога прогонов нет вовсе — пусто, а не обвал', () => {
expect(runningRuns(join(dir, 'такого-каталога-нет'))).toEqual([]);
});
it('каталог прогона есть, а карточки в нём нет — это НЕ прогон, и не обвал', () => {
mkdirSync(join(dir, 'runs2', 'r-9'), { recursive: true });
expect(runningRuns(join(dir, 'runs2'))).toEqual([]);
});
it('🔴 счёт идущих прогонов читает карточки ОБЩИМ прибором, а не плоским readJson', () => {
// 🔴 Сторож на КЛАСС, а не на место: «одну дверь закрыли, вторую забыли». Плоский
// `readJson` на порванном файле отдаёт `null`, и прогон молча выпадает из счёта.
const telo = TEKST_CLI.match(/export function runningRuns\([\s\S]*?\n\}/)[0];
expect(telo).toContain('readRuns');
expect(telo).not.toMatch(/readJson\(/);
});
it('🔴 обёртка одна на ОБОИХ зовущих: и `seal`, и `run` спрашивают счёт через неё', () => {
// Два try/catch по месту разъехались бы молча: поправят один, забудут другой.
// 🔄 ПОПРАВЛЕНО КРУГОМ Б 04.08.2026 — и поправка сама по себе находка. Прежде здесь
// стояло `toBe(2)`: `seal` звал обёртку напрямую, и `run` звал её ВТОРОЙ раз напрямую,
// хотя чуть выше уже спросил тот же счёт через точку впрыска `idushchie`. То есть один
// запуск читал карточки прогонов ДВАЖДЫ — два ответа могли разойтись между собой
// (за секунды между ними прогон успевает завестись или умереть), а проверки, подставлявшие
// первый зов, второго не видели вовсе. Теперь у `run` спрос ОДИН, через впрыск.
// 🪤 Примечания вырезаются ПЕРЕД счётом. Поймано на себе тут же: я объяснил в коде,
// что прямого `idushchieProgony()` в `run` больше нет, — и сторож покраснел на этом
// самом объяснении. Сторож, красный от собственного примечания, толкает вычеркнуть
// объяснение, а не починить дело. Та же беда уже ловилась в этом файле у проверки
// «план читается ровно один раз», и лечение берётся оттуда же.
const KOD_CLI = TEKST_CLI.split('\n').filter(s => !s.trim().startsWith('//')).join('\n');
expect((KOD_CLI.match(/idushchieProgony\(\)/g) ?? []).length).toBe(1); // это `seal`
expect(TEKST_CLI).toMatch(/idushchie = idushchieProgony/); // это `run`, через впрыск
expect((BLOK_RUN.match(/idushchie\(\)/g) ?? []).length).toBe(1); // и ровно один раз
expect((TEKST_CLI.match(/runningRuns\(\)/g) ?? []).length).toBe(0);
});
it('🔴 обёртка отдаёт владельцу СТРОКУ, а не роняет программу следом стека', () => {
const r = idushchieProgony(() => { throw new Error('Карточка прогона r-2 испорчена.'); });
expect(r.ok).toBe(false);
expect(r.reason).toContain('r-2');
expect(r.running).toEqual([]);
});
it('всё цело — обёртка отдаёт список как есть (не глухая)', () => {
// 🔄 ИЗМЕНЕНО 03.08.2026 вместе с проверкой живости. Прежде здесь стояло `toBe(spisok)` —
// тот же самый список тем же предметом. Теперь обёртка каждый прогон СУДИТ (идёт он
// вправду или только помечен), поэтому предмет собирается заново. Смысл сторожа не
// изменился и проверяется по-прежнему: правило не глухое, целый прогон из счёта не
// пропадает. Каталога у выдуманного `r-1` нет, значит он «не прочесть», а «не прочесть»
// место за собой держит — потому и остаётся в списке.
const spisok = [{ run_id: 'r-1', status: 'running' }];
const r = idushchieProgony(() => spisok);
expect(r.ok).toBe(true);
expect(r.running).toEqual(spisok);
expect(r.reason).toBeNull();
});
});
// ============ кусок 4: план разбирается на пункты, отказы печатаются до замера ============
//
// 🔴 ТРИ ОТСТУПЛЕНИЯ ОТ ПЛАНА ЗАДАЧИ 6, НАЗВАННЫЕ ВСЛУХ. План описывал `cli.mjs` таким,
// каким тот был два куска назад, и списанные с него проверки сторожили бы пустоту:
// 1. План искал в тексте `probeUtilization(` — этого имени в `cli.mjs` НЕТ вовсе, замер
// давно идёт через `zamerSoSchyotom` (сторож `platnyy-zamer-tolko-cherez-schyot`).
// Проверка «отказ раньше замера» искала бы имя, которого нет, и была бы зелена всегда
// и при любом порядке — то есть не сторожила бы ровно того, ради чего написана.
// 2. План звал разобранные доводы `dovody`; в файле они зовутся `razbor`.
// 3. План велел сторожить «`const running = runningRuns()` объявлен один раз». Такой
// строки в `cli.mjs` нет: список идущих берётся через `idushchieProgony()` — обёртку,
// которая отличает «никого нет» от «карточка порвана». Переносить её вверх незачем:
// разбору плана она не нужна вовсе. Вместо этого сторожим то, что вправду опасно, —
// что план читается РОВНО ОДИН раз за запуск.
const PLAN_OBRAZETS = [
'# Пример плана',
'',
'### Task 1: Первый пункт',
'<!-- night: tests=app/tests/Feature/PervyyTest.php; files=app/A.php -->',
'Текст пункта.',
'',
'### Task 2: Второй пункт',
'<!-- night: tests=tools/night/vtoroy.test.mjs; files=app/B.php; long=yes -->',
'Текст пункта.',
].join('\n');
describe('часть 7: план разбирается на пункты на ВХОДЕ ворот', () => {
it('🔴 `vhodVorot` читает файл плана и отдаёт пункты с их проверками и файлами', () => {
// 🔴🔴 Здесь снимается долг куска 3, названный им вслух: через `cli.mjs run` больше
// одного работника за раз завести было нельзя — ключ `--files` клал все имена
// в один пункт. Теперь пункты берутся из плана, и их столько, сколько написал автор.
const v = vhodVorot({
cfg: NASTROYKI, running: [], taskId: null,
planPath: 'docs/пример.md',
readPlan: () => PLAN_OBRAZETS,
});
expect(v.items.map(i => i.id)).toEqual(['1', '2']);
expect(v.items[0].tests).toEqual(['app/tests/Feature/PervyyTest.php']);
expect(v.newTaskFiles).toEqual(['app/A.php', 'app/B.php']);
expect(v.items[1].long).toBe(true);
});
it('пути плана не дали — пункты берутся из довода, как прежде', () => {
// Умолчание честное: своих пунктов `vhodVorot` не выдумывает ни одного.
const v = vhodVorot({ cfg: NASTROYKI, running: [], items: [{ id: '9', files: ['app/Z.php'] }] });
expect(v.items.map(i => i.id)).toEqual(['9']);
});
it('🔴 пункты из плана идут и в счёт потолка работников, а не только в ответ', () => {
// 🪤 СВЕРХ ПЛАНА. В теле `vhodVorot` слово `items` стояло в ТРЁХ местах, и заменить
// надо все три. Пропусти третье — `workers_in_task` считал бы пункты, поданные доводом
// (то есть ноль), потолок в семь работников не сработал бы НИ РАЗУ, и проверка 27
// отменилась бы молча. Первые два места стерегут проверки выше, третье — эта.
const v = vhodVorot({
cfg: NASTROYKI, running: [], taskId: null,
planPath: 'docs/пример.md',
readPlan: () => PLAN_OBRAZETS,
});
expect(v.capacity.workers_in_task).toBe(2);
});
});
describe('часть 8: ключи «продолжай» и «список брака»', () => {
it('`--continue` разобран как слово владельца', () => {
const d = razborDovodov(argvFor(
'run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--continue',
));
expect(d.ownerSaidContinue).toBe(true);
});
it('`--defects <файл>` разобран в список брака по строкам', () => {
const d = razborDovodov(
argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--defects', 'docs/brak.md'),
{ readDefects: () => 'кнопка не там\n\nсумма не сходится\n' },
);
expect(d.defectList).toEqual(['кнопка не там', 'сумма не сходится']);
});
it('ключей не дали — «продолжай» нет и список брака пуст, а не выдуманы', () => {
const d = razborDovodov(argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md'));
expect(d.ownerSaidContinue).toBe(false);
expect(d.defectList).toEqual([]);
});
it('🔴 файла со списком брака нет — список ПУСТ, а не выдуман непустым', () => {
// 🪤 СВЕРХ ПЛАНА. Непустой список на нечитаемом файле открыл бы ворота задаче, брак
// которой никто не назвал: работник пошёл бы переделывать неизвестно что.
const d = razborDovodov(
argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--defects', 'нет-такого-файла.md'),
{ readDefects: () => { throw new Error('нет такого файла'); } },
);
expect(d.defectList).toEqual([]);
expect(d.defectsPath).toBe('нет-такого-файла.md');
});
it('🔴 `--continue` не съедает следующее слово командной строки', () => {
// 🪤 СВЕРХ ПЛАНА. Спроси мы `--continue` тем же помощником, что и ключи со значением,
// он забрал бы следующее слово — то есть чужой ключ, и `--task` потерялся бы молча.
const d = razborDovodov(argvFor(
'run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--continue', '--task', 'z-7',
));
expect(d.ownerSaidContinue).toBe(true);
expect(d.taskId).toBe('z-7');
});
});
// 🔴🔴 СМОТРИМ ТОЛЬКО ТЕЛО КОМАНДЫ `run`, И ЭТО НЕ ПРИДИРКА. Первая моя попытка искала
// имена по ВСЕМУ файлу — и сторож молча смотрел не туда: у команды `budget` есть СВОЙ зов
// замера, и он стоит в файле выше всей команды `run`. «Отказ раньше замера» сравнивал
// отказ из `run` с замером из `budget`, то есть был красным при любом верном порядке
// и позеленел бы от перестановки чужой команды. Замерено, а не предположено.
const BLOK_RUN = TEKST_CLI.slice(
TEKST_CLI.indexOf("if (cmd === 'run') {"),
TEKST_CLI.indexOf("if (cmd === 'smoke-longrun') {"),
);
describe('часть 9: отказы стоят РАНЬШЕ замера запаса и раньше ворот', () => {
it('🔴 кусок текста, по которому судят проверки ниже, вправду вырезан', () => {
// 🪤 Без этой строки все проверки порядка ниже стали бы зелёными на ПУСТОЙ строке:
// переименуй кто-нибудь команду — и `indexOf` дал бы −1, срез вышел бы пустым,
// а `indexOf` в пустой строке равен −1 у всех имён сразу, то есть «порядок сошёлся».
expect(BLOK_RUN.length).toBeGreaterThan(2000);
expect(BLOK_RUN).toContain("if (cmd === 'run') {");
});
it('🔴 отказ по плану печатается ДО крошечного замера остатка', () => {
// 🔴🔴 Замер идёт платной головой из подписки владельца, то есть стоит денег. План, который
// 🪤 Имя той программы здесь НЕ НАБИРАЕТСЯ словом нарочно: сторож живого запуска считает
// всякое её называние заявкой на платный зов и требует вписать её в разрешение — а этот
// файл её не заводит НИ РАЗУ и заводить не должен.
// заведомо не пройдёт, не стоит ни одного платного зова. И только при этом порядке
// отказ можно посмотреть живьём глазами: запуск не рождает ни замера, ни угла,
// ни живого работника.
// 🔴 Ищем `priborZamera(`, а не `zamerSoSchyotom(`: задача О-4 (03.08.2026) вынесла разбор
// ответа замера в общую дверь `vorotaPribora`, и `run` теперь зовёт её, а не прежний код
// на месте — самого имени `zamerSoSchyotom(` внутри тела `run` больше нет, оно живёт
// внутри умолчания `vorotaPribora` (см. описание функции), а `smoke-longrun` зовёт ТУ ЖЕ дверь.
const iPlan = BLOK_RUN.indexOf('validatePlan(');
const iZamer = BLOK_RUN.indexOf('priborZamera(');
expect(iPlan).toBeGreaterThan(-1);
expect(iZamer).toBeGreaterThan(-1);
expect(iPlan).toBeLessThan(iZamer);
});
it('🔴 ворота отложенной задачи стоят ДО замера и ДО `planRun`', () => {
const iSled = BLOK_RUN.indexOf('deferredTaskGate(');
const iZamer = BLOK_RUN.indexOf('priborZamera(');
const iPlanRun = BLOK_RUN.indexOf('planRun(');
expect(iSled).toBeGreaterThan(-1);
expect(iZamer).toBeGreaterThan(-1);
expect(iPlanRun).toBeGreaterThan(-1);
expect(iSled).toBeLessThan(iZamer);
expect(iSled).toBeLessThan(iPlanRun);
});
it('🔴 воротам отложенной задачи подаётся перепроверка плана ИЗ ЭТОГО запуска (проверка 30)', () => {
// 🪤 Тихая поломка: подай сюда `true` постоянной — правило зеленело бы, а план
// отложенной задачи не перепроверялся бы ни разу, и работник шёл бы по бумаге,
// которая за неделю разошлась с продуктом.
expect(TEKST_CLI).toMatch(/plan_revalidated:\s*planOk\.ok/);
});
it('🔴 `startAll` получает ВСЕ пункты плана, а не только пункты семерых', () => {
expect(TEKST_CLI).toMatch(/items:\s*punkty/);
});
it('🔴🔴 воротам подан путь плана ИЗ СЛЕДА и путь плана ЭТОГО запуска (проверка 29б)', () => {
// 🔴🔴 Правило «тот же план» живёт в воротах, но без обоих путей оно стоит и молчит:
// задачу можно было бы «доделать» по совсем другому плану, и заметить это нечем.
expect(TEKST_CLI).toMatch(/plan_path:\s*sled\.plan_path/);
expect(TEKST_CLI).toMatch(/plan_path_now:\s*razbor\.planPath/);
});
it('🔴 команда проверок работника строится ИЗ ЕГО пункта, а не общим набором', () => {
// 🔴🔴 Кусок 1 клал сюда `() => "npm run test:tools"` — общий набор. Оставь его —
// работник читал бы в поручении, что его пункт меряется всем прогоном продукта.
// 🪤 Сама надёжность проверки 38 держится НЕ на этой строке: замок команду из карточки
// не берёт вовсе, он считает её заново на каждом судимом пункте (задача 5).
// Здесь сторожится только то, что говорят РАБОТНИКУ.
expect(TEKST_CLI).toMatch(/testCommandFor:\s*w\s*=>\s*komandaProverokPunkta\(w\.item\)/);
});
it('🔴 план читается РОВНО ОДИН раз за запуск', () => {
// 🔴🔴 Читай мы файл дважды, между двумя чтениями он мог бы измениться, и ворота
// судили бы не тот план, который пошёл в работу. Поэтому `planPath` подан
// единственному зову — тому, который его разбирает.
const chteniya = BLOK_RUN.match(/vhodVorot\(\{\s*planPath\s*\}\)/g) ?? [];
expect(chteniya.length).toBe(1);
// 🪤 И полный зов ворот путь плана НЕ получает: получи он его — файл прочли бы второй раз.
// 🔴 Примечания из зова вырезаются. Найдено на себе: я написал в самом зове словами,
// что путь плана сюда НЕ подаётся, — и эта проверка покраснела на СЛОВЕ «planPath»
// в примечании, хотя довода там нет. Сторож, красный от собственного объяснения,
// толкает вычеркнуть объяснение, а не починить дело.
const zovVorot = BLOK_RUN.match(/const vhod = vhodVorot\(\{([\s\S]*?)\}\);/)[1]
.split('\n').filter(s => !s.trim().startsWith('//')).join('\n');
expect(zovVorot).not.toMatch(/planPath/);
// 🔴 И третья половина, добавленная кругом Б: полный проход ворот питается ТЕМ ЖЕ
// собранным входом, а не вторым зовом `vhodVorot`. Собери его дважды — бесплатный
// проход судил бы одно хозяйство, а платный другое, и владелец получил бы «до денег
// было можно, после денег нельзя» без единого слова о том, что изменилось.
expect(BLOK_RUN).toMatch(/planRun\(\{\s*\.\.\.vhod,\s*budget\s*\}\)/);
expect((BLOK_RUN.match(/vhodVorot\(/g) ?? []).length).toBe(2);
// 🪤 И вторая половина: своего, второго чтения файла плана в `cli.mjs` не осталось.
// Кусок 1 читал первую строку плана сам (`readFileSync(planPath`), чтобы слепить
// единственный пункт «весь-план». Этот путь заменён разбором, и возвращать его нельзя:
// два места, читающих один файл, — это два разных плана в одном запуске.
expect(TEKST_CLI).not.toMatch(/readFileSync\(planPath/);
});
it('🔴 отказ по плану вправду ОСТАНАВЛИВАЕТ запуск, а не только произносится', async () => {
// 🔴🔴🔴 НАЙДЕНО ВЫРЕЗАНИЕМ ВТОРОГО РОДА, планом не предусмотрено — дыра была настоящая.
// Все проверки выше смотрят ТЕКСТ и доказывают, что ворота плана ЗОВУТСЯ. Но зов —
// это ещё не защита: я обвёл их умно (`if (false && !planOk.ok)`), оставив зов на месте,
// и ВСЕ 139 проверок остались зелёными, а негодный план пошёл бы в ночь. Это ровно тот
// класс, ради которого весь кусок и затеян, только этажом выше: не «правило написано,
// а зовущего нет», а «зовущий есть, а ответа он не слушает».
//
// 🔴 ПЛАТНОГО ЗОВА ЗДЕСЬ НЕ РОДИТСЯ НИ ПРИ КАКОЙ ПОЛОМКЕ, и это не надежда. В настройках
// намеренно НЕТ строки `probe_model`, а замер спрашивает её ПЕРВЫМ делом и падает
// до единого запуска чего бы то ни было. Убери кто-нибудь отказ по плану — проверка
// покраснеет на СЛОВАХ, а не потратит деньги владельца.
const vremenno = mkdtempSync(join(tmpdir(), 'night-plan-gate-'));
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const planFile = join(vremenno, 'plan.md');
const priyomkaFile = join(vremenno, 'priyomka.md');
const specFile = join(vremenno, 'spec.md');
writeFileSync(planFile, '# Пробный план\n\nЗаголовков пунктов здесь нет ни одного.\n');
writeFileSync(priyomkaFile, '# приёмочный лист\n');
writeFileSync(specFile, '# замысел\n');
const code = await main(
argvFor('run', planFile, '--priyomka', priyomkaFile, '--spec', specFile),
{ readConfig: () => ({ base_branch: 'feat/night', max_tasks_at_once: 2, max_workers_per_task: 7 }) },
);
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(code).toBe(1);
// 🪤 Судим ПО НАПЕЧАТАННЫМ СЛОВАМ, а не по коду возврата: единица приходит и когда
// ворота честно отказали, и когда до них не дошло вовсе. Код возврата тут не улика.
expect(skazano).toMatch(/план проверку не прошёл/i);
expect(skazano).toMatch(/нет ни одного пункта/i);
} finally {
errSpy.mockRestore();
rmSync(vremenno, { recursive: true, force: true });
}
});
it('🔴 отказ по плану с повторяющимися номерами тоже ОСТАНАВЛИВАЕТ запуск', async () => {
// Второй вкус той же беды: номер пункта — ключ, по которому ведут очередь и запирают
// файлы. Двое под одним номером закроют друг друга, и оба отчитаются правдой.
const vremenno = mkdtempSync(join(tmpdir(), 'night-plan-gate-'));
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const planFile = join(vremenno, 'plan.md');
const priyomkaFile = join(vremenno, 'priyomka.md');
const specFile = join(vremenno, 'spec.md');
writeFileSync(planFile, [
'# Пробный план',
'',
'### Task 1: Первый',
'<!-- night: tests=app/tests/Feature/A.php; files=app/A.php -->',
'',
'### Task 1: Второй под тем же номером',
'<!-- night: tests=app/tests/Feature/B.php; files=app/B.php -->',
].join('\n'));
writeFileSync(priyomkaFile, '# приёмочный лист\n');
writeFileSync(specFile, '# замысел\n');
const code = await main(
argvFor('run', planFile, '--priyomka', priyomkaFile, '--spec', specFile),
{ readConfig: () => ({ base_branch: 'feat/night', max_tasks_at_once: 2, max_workers_per_task: 7 }) },
);
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(code).toBe(1);
expect(skazano).toMatch(/повторяются номера пунктов/i);
// 🪤 И виновник назван поимённо: без имени владелец не поймёт, который пункт чинить.
expect(skazano).toMatch(/Task 1/);
} finally {
errSpy.mockRestore();
rmSync(vremenno, { recursive: true, force: true });
}
});
it('🔴 след отложенной задачи снимается ПОСЛЕ удачного запуска, а не до ворот', () => {
// 🪤 СВЕРХ ПЛАНА. Сними след раньше отказа — вторая попытка запуска прошла бы уже
// без ворот вовсе: следа нет, значит «задача не откладывалась», и проверки 28–30
// отменились бы молча. Сними позже некуда: не сними вовсе — владельцу пришлось бы
// говорить «продолжай» каждую ночь заново.
const iVorota = BLOK_RUN.indexOf('deferredTaskGate(');
const iStartAll = BLOK_RUN.indexOf('const started = startAll(');
const iSnyatie = BLOK_RUN.indexOf('clearDeferred(');
expect(iSnyatie).toBeGreaterThan(-1);
expect(iSnyatie).toBeGreaterThan(iVorota);
expect(iSnyatie).toBeGreaterThan(iStartAll);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// О-3 — ВОРОТА СВЕРКИ НАСТРОЕК С РЕШЕНИЯМИ ВЛАДЕЛЬЦА (sverka-nastroek-s-resheniyami.mjs)
// ПОЛУЧАЮТ ПЕРВОГО ЗОВУЩЕГО.
//
// 🔴 Датчик зовущих (schyot-zovushchih.mjs) нашёл этого сторожа без единого зова в рабочих
// файлах: построен, проверен 23 своими проверками, зелёный — и ночь его не спрашивала
// НИ РАЗУ. Правило метода 2: защита, которую никто не зовёт, не бывает проверенной; затея
// заплатила за это шесть раз.
//
// 🔴 ДВЕ ДОРОГИ, ОДИН ЗОВ. `komandaProdolzheniya` (продолжение, --resume) идёт МИМО девяти
// ворот обычного запуска нарочно (решение 8) — значит защита девяти ворот её не касается.
// Обе дороги команды `run` расходятся у проверки `razbor.resumeRunId` ВНУТРИ
// `if (cmd === 'run')`, а зов сверки стоит ДО этой развилки — там, где дорога у обеих ещё
// общая. Проверь я только один рукав — второй утёк бы мимо сторожа, и это была бы та же
// беда, что чинится этой правкой.
//
// 🔴 СВЕРКА ЧИТАЕТ НАСТОЯЩИЙ ФАЙЛ ЗАНОВО (через `configPath()`), А НЕ ДОВОД `cfg`, КОТОРЫМ
// ЖИВЁТ ОСТАЛЬНОЙ `main()`. Причина не лень: `cfg` в проверках команды `run` выше по файлу —
// подставной у ДЕСЯТКОВ проверок, и ни одна из них не про решения владельца из протокола
// 02.08 (Р9а и соседние) — им хватает своих нескольких полей. Требовать спавн- и проб-бюджет
// от КАЖДОЙ из них означало бы менять правку, которая испытывает совсем другое. В бою
// разницы нет: `readConfig()` по умолчанию читает тот же `configPath()`, что и сверка.
describe('О-3: ворота сверки настроек с решениями владельца — первый зовущий', () => {
describe('proverkaNastroekVladeltsa — сама обёртка', () => {
it('читает настройки доводом и подаёт их сверке как есть', () => {
const sverka = vi.fn(() => []);
proverkaNastroekVladeltsa({ sverka, chitatNastoyashchiyFayl: () => ({ a: 1 }) });
expect(sverka).toHaveBeenCalledWith({ a: 1 });
});
it('файла нет (или он порван) — readJson отдал бы null, а сверке уходит {}, не null', () => {
// 🔴 Пустой объект — это НЕ «пропускаем»: сверка сама увидит все решения владельца
// отсутствующими и упадёт вслух по каждому. «Сторож сам сломался» здесь не молчит.
const sverka = vi.fn(() => []);
proverkaNastroekVladeltsa({ sverka, chitatNastoyashchiyFayl: () => null });
expect(sverka).toHaveBeenCalledWith({});
});
it('исход сверки уходит наружу как есть', () => {
const spisok = [{ tekst: 'слово' }];
const r = proverkaNastroekVladeltsa({ sverka: () => spisok, chitatNastoyashchiyFayl: () => ({}) });
expect(r).toBe(spisok);
});
it('падение сверки не глотается — уходит наружу тем же текстом', () => {
expect(() => proverkaNastroekVladeltsa({
sverka: () => { throw new Error('решение владельца 9а'); },
chitatNastoyashchiyFayl: () => ({}),
})).toThrow(/решение владельца 9а/);
});
it('🔴 живьём настоящей sverkaIliBrosok: дорогое расхождение падает с её текстом', () => {
// Настоящий прибор, не подставка: доказывает, что связка работает, а не моё
// представление о том, что она отдаёт.
const dorogo = {
spawn_budget_per_shift: 999, probe_budget_per_shift: 30,
max_workers_per_task: 7, max_tasks_at_once: 2, base_branch: 'feat/noch-proba-1',
};
let poyman = null;
try {
proverkaNastroekVladeltsa({ chitatNastoyashchiyFayl: () => dorogo });
} catch (err) { poyman = err; }
expect(poyman, 'дорогое расхождение обязано падать').not.toBeNull();
expect(poyman.message).toContain('spawn_budget_per_shift');
expect(poyman.message).toContain('999');
expect(poyman.message).toContain('решение владельца 9а');
expect(poyman.message).toMatch(/БОЛЬШЕ траты/);
});
// 🆕 ПОПРАВЛЕНО 05.08.2026, круг F. Замысел проверки не тронут («дешёвое расхождение
// не падает, а отдаёт слова») — поправлен только образец настройки под него. Причина:
// сверка перестала быть списком из пяти полей. Прежний образец теперь падает по-настоящему
// и по делу — в нём `max_tasks_at_once: 2` при семерых работниках даёт ЧЕТЫРНАДЦАТЬ
// работников разом против решения владельца 24 («семеро ВСЕГО»), а трёх решений владельца
// (голова, порог запаса, тумблер доли) в нём нет вовсе. Дешёвым такое расхождение не было
// никогда — его просто не видел никто.
// 🪤 Число слов больше не пересчитывается: сверка спрашивает у хранилища живую ветку,
// и число слов законно зависит от того, двинута ли она. Проверяется то, что и проверялось:
// отступление в дешёвую сторону названо словами и работу не остановило.
it('🔴 живьём: дешёвое расхождение не падает, а отдаёт слова', () => {
const deshevo = {
spawn_budget_per_shift: 20, probe_budget_per_shift: 30,
max_workers_per_task: 2, max_tasks_at_once: 1, base_branch: 'feat/noch-proba-1',
worker_model: 'opus', start_floor_share: 0.99, bez_doli_ne_zapuskat: false,
};
const slova = proverkaNastroekVladeltsa({ chitatNastoyashchiyFayl: () => deshevo });
const svoy = slova.find(s => s.klyuch === 'max_workers_per_task');
expect(svoy, 'дешёвое отступление обязано быть названо словами').toBeTruthy();
expect(svoy.tekst).toContain('max_workers_per_task');
});
});
describe('main() — зов стоит ДО развилки, на обеих дорогах команды run', () => {
it('🔴 обычный запуск: дорогое расхождение останавливает ночь ДО «Плана нет»', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const code = await main(argvFor('run', join(dir, 'net-takogo-plana.md'), '--priyomka', 'x', '--spec', 'y'), {
readConfig: () => CFG,
proverkaVladeltsa: () => { throw new Error('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ'); },
});
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(code).toBe(1);
expect(skazano).toContain('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ');
expect(skazano).not.toMatch(/Плана нет/);
errSpy.mockRestore();
});
it('🔴 дорога продолжения (--resume): та же остановка, ДО существования плана', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const code = await main(argvFor(
'run', join(dir, 'net-takogo-plana.md'), '--priyomka', 'x', '--spec', 'y', '--resume', 'r-1',
), {
readConfig: () => CFG,
proverkaVladeltsa: () => { throw new Error('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ'); },
});
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(code).toBe(1);
expect(skazano).toContain('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ');
expect(skazano).not.toMatch(/Плана нет/);
errSpy.mockRestore();
});
it('дешёвое расхождение (слова) не останавливает обычный запуск — печатается, дорога идёт дальше', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const code = await main(argvFor('run', join(dir, 'net-takogo-plana.md'), '--priyomka', 'x', '--spec', 'y'), {
readConfig: () => CFG,
proverkaVladeltsa: () => [{ tekst: 'МЯГКОЕ СЛОВО ВЛАДЕЛЬЦУ' }],
});
expect(logSpy.mock.calls.map(c => String(c[0]))).toContain('МЯГКОЕ СЛОВО ВЛАДЕЛЬЦУ');
// Дорога пошла дальше — упёрлась в следующую (честную) причину, план не существует.
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(code).toBe(2);
expect(skazano).toMatch(/Плана нет/);
logSpy.mockRestore();
errSpy.mockRestore();
});
it('дешёвое расхождение и на дороге продолжения — тоже идёт дальше', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const code = await main(argvFor(
'run', join(dir, 'net-takogo-plana.md'), '--priyomka', 'x', '--spec', 'y', '--resume', 'r-1',
), {
readConfig: () => CFG,
proverkaVladeltsa: () => [{ tekst: 'МЯГКОЕ СЛОВО ВЛАДЕЛЬЦУ' }],
});
expect(logSpy.mock.calls.map(c => String(c[0]))).toContain('МЯГКОЕ СЛОВО ВЛАДЕЛЬЦУ');
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(code).toBe(2);
expect(skazano).toMatch(/Плана нет/);
logSpy.mockRestore();
errSpy.mockRestore();
});
it('🔴 сторож сам сломался (нет файла настроек) не сходит за «сходится» — ворота падают', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const code = await main(argvFor('run', join(dir, 'net-takogo-plana.md'), '--priyomka', 'x', '--spec', 'y'), {
readConfig: () => CFG,
proverkaVladeltsa: () => proverkaNastroekVladeltsa({ chitatNastoyashchiyFayl: () => null }),
});
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(code).toBe(1);
expect(skazano).toMatch(/отсутствует в night\.config\.json/);
errSpy.mockRestore();
});
});
it('🔴 датчик зовущих: `main()` вправду зовёт `proverkaVladeltsa()` до развилки продолжения', () => {
// 🪤 Текстовый сторож — вторая нога рядом с поведенческими проверками выше: те доказывают
// ПОВЕДЕНИЕ через подставку, эта доказывает, что подставка вправду встаёт НА МЕСТО
// настоящего зова, а не осталась неиспользуемым доводом главной программы.
// 🪤 Найдено живьём: искать голое `razbor.resumeRunId` нельзя — эти же слова стоят
// и в объяснении ЭТОГО зова чуть выше (примечание над воротами), и находились раньше
// настоящего кода. Ищем КОД развилки — `if (razbor.resumeRunId)`, а не любое упоминание.
const iRun = TEKST_CLI.indexOf("if (cmd === 'run') {");
const iZov = TEKST_CLI.indexOf('proverkaVladeltsa()', iRun);
const iRazvilka = TEKST_CLI.indexOf('if (razbor.resumeRunId)', iRun);
expect(iRun).toBeGreaterThan(-1);
expect(iZov).toBeGreaterThan(iRun);
expect(iRazvilka).toBeGreaterThan(-1);
expect(iZov).toBeLessThan(iRazvilka);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// О-4 (03.08.2026) — ТРЕТЬЯ ДОРОГА БЕЗ ДЕНЕГ, НАЙДЕННАЯ ТРЕТЬИМ ПОМОЩНИКОМ СО СТОРОНЫ.
//
// `smoke-longrun` заводит ТОГО ЖЕ платного работника, что и `run`/`run --resume`, но стоит
// ОТДЕЛЬНОЙ веткой `if (cmd === 'smoke-longrun')` — развилку куска `run` (там, где сверка
// настроек и денежные ворота уже стоят) она не проходит вовсе, потому что `cmd` сравнивается
// заново, а не входит внутрь ветки `run`. Лечение: `smoke-longrun` зовёт ТЕ ЖЕ функции,
// что и `run` — `proverkaVladeltsa()` (тот же довод главной программы, без своей копии)
// и `vorotaPribora()` (кусок, вынесенный ИЗ `run` сюда же: раньше разбор ответа замера лежал
// ГОТОВЫМ КОДОМ только в теле `run`, и вторая копия для `smoke-longrun` разъехалась бы
// с первой молча — ровно та беда, что уже случалась у `readRuns`/`file-claims`, см. заметки
// в шапке `cli.mjs`). `run` теперь тоже зовёт `vorotaPribora()` — дверь одна на обоих, а не своя
// у каждого.
// ═══════════════════════════════════════════════════════════════════════════════
describe('О-4: vorotaPribora — ворота ПРИБОРА замера, вынесенные из `run`, теперь зовут оба зовущих', () => {
it('замер бросил — отказ словами владельцу, а не следом стека', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const g = await vorotaPribora({
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {},
zamer: async () => { throw new Error('прибор недоступен'); },
});
expect(g.ok).toBe(false);
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('замер остатка не сделан');
errSpy.mockRestore();
});
it('замеров за смену больше нет — отказ, причина сторожа названа', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const g = await vorotaPribora({
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {},
zamer: async () => ({ ok: false, reason: 'исчерпан лимит замеров за смену' }),
});
expect(g.ok).toBe(false);
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('исчерпан лимит замеров за смену');
errSpy.mockRestore();
});
it('замер сошёлся, а остатка нет (`measure` пуст) — отказ, ноль не подставляется', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const g = await vorotaPribora({
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {},
zamer: async () => ({ ok: true, measure: null, problem: 'вид предела не совпал' }),
});
expect(g.ok).toBe(false);
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('вид предела не совпал');
errSpy.mockRestore();
});
it('замер вправду посчитан — успех отдаёт измерение как есть', async () => {
const izm = { utilization: 0.1, status: 'ok', kind: 'seven_day', resets_at: 123 };
const g = await vorotaPribora({
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {}, zamer: async () => ({ ok: true, measure: izm }), log: () => {},
});
expect(g.ok).toBe(true);
expect(g.measure).toBe(izm);
});
// ━━━ 🔴🔴 ДВА ПРИГОВОРА ПРИБОРА — ДО ВЛАДЕЛЬЦА, А НЕ В КОРЗИНУ (03.08.2026) ━━━
// Беда: датчики головы и мастерской работали, а их приговор терялся слоем ворот счёта
// и до владельца не доходил ни одной дорогой. Здесь сторожится САМАЯ ДОРОГАЯ из трёх
// дорог — та, по которой идут `run` и `smoke-longrun`.
it('🔴 голова не та — владелец читает ЭТО В ОКНЕ при УДАЧНОМ (то есть платном) замере', async () => {
const skazano = [];
const g = await vorotaPribora({
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {}, log: s => skazano.push(String(s)), err: s => skazano.push(String(s)),
zamer: async () => ({
ok: true,
measure: { utilization: 0.1, status: 'ok', kind: 'seven_day' },
golova: { sovpalo: false, zakazana: 'haiku', otvetila: ['claude-opus-4-6'], soobshchenie: 'ЗАКАЗАННОЙ ГОЛОВЫ В СЧЁТЕ НЕТ ВОВСЕ' },
masterskaya: { otsecheno: true, sostav: { tools: 0, mcp_servers: 0 }, soobshchenie: null },
}),
});
expect(g.ok, 'решение владельца 15: чужая голова прогон НЕ останавливает').toBe(true);
expect(skazano.join('\n')).toContain('ЗАКАЗАННОЙ ГОЛОВЫ В СЧЁТЕ НЕТ ВОВСЕ');
});
it('🔴 замер НЕ удался — приговоры всё равно печатаются: деньги за него уже потрачены', async () => {
const skazano = [];
await vorotaPribora({
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {}, log: s => skazano.push(String(s)), err: s => skazano.push(String(s)),
zamer: async () => ({
ok: true, measure: null, problem: 'вид предела не совпал',
golova: { sovpalo: true, zakazana: 'haiku', otvetila: ['claude-haiku-4-5'], soobshchenie: null },
masterskaya: { otsecheno: false, sostav: { tools: 50, mcp_servers: 51 }, soobshchenie: 'ОБВЕС НЕ СРЕЗАН' },
}),
});
expect(skazano.join('\n')).toContain('ОБВЕС НЕ СРЕЗАН');
});
it('🪤 отказ СЧЁТА (замера не было) — про приборы не говорится ни слова: приговаривать нечего', async () => {
// Денег не потрачено, прибор не запускался. Скажи мы тут «сверить не удалось» —
// владелец читал бы тревогу там, где ничего не происходило, и перестал бы её замечать.
const skazano = [];
await vorotaPribora({
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {}, log: s => skazano.push(String(s)), err: s => skazano.push(String(s)),
zamer: async () => ({ ok: false, reason: 'замеров за смену больше нет' }),
});
expect(skazano.join('\n')).not.toMatch(/Голова:|Мастерская/);
});
it('🔴 ТРЕТЬЯ дорога — команда `budget` — тоже печатает приговоры (сторож по тексту)', () => {
// 🪤 Живьём эту дорогу проверить нечем: у команды `budget` замер не подставляется
// доводом, она зовёт настоящий `zamerSoSchyotom`, а он стоит денег владельца. Поэтому
// сторож читает текст точки входа: зов `strokiPriborov(` обязан стоять ВНУТРИ куска
// `budget` и ПОСЛЕ отказа по счёту (там прибор не запускался — приговаривать нечего).
const iCmd = TEKST_CLI.indexOf("if (cmd === 'budget') {");
const iSled = TEKST_CLI.indexOf("if (cmd === 'seal') {", iCmd);
const kusok = TEKST_CLI.slice(iCmd, iSled);
expect(iCmd, 'кусок `budget` пропал из точки входа').toBeGreaterThan(-1);
expect(iSled).toBeGreaterThan(iCmd);
expect(kusok, 'команда показа остатка перестала говорить о голове и мастерской').toContain('strokiPriborov(');
expect(kusok.indexOf('strokiPriborov('))
.toBeGreaterThan(kusok.indexOf('if (!z.ok)'));
});
it('🔴 ни одна из трёх платных дорог не осталась без приговоров (счёт зовущих по тексту)', () => {
// 🪤 Датчик зовущих врёт нулём, когда вещь передана значением, — потому имя считается
// здесь глазами и поимённо, а не «сколько-то раз встретилось». Три зова: `vorotaPribora`
// (за ней `run` и `smoke-longrun`), `komandaProdolzheniya` и кусок `budget`.
// 🪤 Ввоз имени в шапке скобки НЕ получает (`strokiPriborov,`) и сюда не попадает —
// счёт со скобкой считает именно зовы. Ровно на этом слепом пятне уже попадался
// датчик зовущих (`schyot-zovushchih.mjs`), потому число здесь названо и объяснено.
const zovy = TEKST_CLI.split('strokiPriborov(').length - 1;
expect(zovy, 'платных дорог три — зовов приговора должно быть три').toBe(3);
});
it('слова владельцу меняются доводом `otkaz` («Прогон»/«Проба»), а не выдуманы заново на месте', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await vorotaPribora({
cfg: CFG, komanda: 'smoke-longrun', phase: 'before-smoke', zapisatZamer: () => {},
otkaz: 'Проба НЕ запущена',
zamer: async () => { throw new Error('бух'); },
});
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('Проба НЕ запущена: замер остатка не сделан');
errSpy.mockRestore();
});
it('довод передаётся замеру как есть: та же `cfg`, тот же путь попыток, то же имя команды', async () => {
const zamer = vi.fn(async () => ({ ok: true, measure: { utilization: 0 } }));
await vorotaPribora({
cfg: CFG, komanda: 'smoke-longrun', phase: 'before-smoke', zapisatZamer: () => {},
zamer, attemptsPath: '/тут/попытки.jsonl',
});
// 🪤 `phase` замеру НЕ передаётся и передаваться не должен: это имя для журнала, а не
// довод прибора. Приборы от него не зависят, и подмешай мы его сюда — сторож перестал бы
// ловить настоящую беду («двери подсунули не ту cfg»).
expect(zamer).toHaveBeenCalledWith({ cfg: CFG, attemptsPath: '/тут/попытки.jsonl', komanda: 'smoke-longrun' });
});
});
describe('О-4: `main()` — smoke-longrun идёт через ТЕ ЖЕ ворота, что и `run`', () => {
it('🔴 дорогое расхождение настроек останавливает пробу ДО денег: денежные ворота не зовутся вовсе', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const dengiSpy = vi.fn(async () => ({ ok: true, measure: { utilization: 0 } }));
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
readConfig: () => CFG,
proverkaVladeltsa: () => { throw new Error('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ ПРО ПРОБУ'); },
priborZamera: dengiSpy,
});
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(code).toBe(1);
expect(skazano).toContain('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ ПРО ПРОБУ');
expect(dengiSpy).not.toHaveBeenCalled();
errSpy.mockRestore();
});
it('дешёвое расхождение настроек — слова печатаются, дорога идёт дальше, к деньгам', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const dengiSpy = vi.fn(async () => ({ ok: false }));
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
readConfig: () => CFG,
proverkaVladeltsa: () => [{ tekst: 'МЯГКОЕ СЛОВО ПРО ПРОБУ' }],
priborZamera: dengiSpy,
});
expect(logSpy.mock.calls.map(c => String(c[0]))).toContain('МЯГКОЕ СЛОВО ПРО ПРОБУ');
expect(dengiSpy).toHaveBeenCalledWith(expect.objectContaining({ cfg: CFG, komanda: 'smoke-longrun' }));
expect(code).not.toBe(0);
logSpy.mockRestore();
});
it('🔴 денежные ворота отказали (`ok: false`) — проба НЕ заведена, код отличен от успеха', async () => {
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
readConfig: () => CFG,
proverkaVladeltsa: () => [],
priborZamera: async () => ({ ok: false }),
});
expect(code).not.toBe(0);
});
it('🔴 датчик зовущих: тело `smoke-longrun` зовёт `proverkaVladeltsa()` РАНЬШЕ `priborZamera(`', () => {
// 🪤 Та же вторая нога, что и у куска `run` (проверка выше, часть О-3): поведенческие
// проверки доказывают ПОВЕДЕНИЕ через подставку, эта доказывает, что подставка вправду
// встала НА МЕСТО настоящего зова, в настоящем порядке, а не осталась неиспользуемым доводом.
const iCmd = TEKST_CLI.indexOf("if (cmd === 'smoke-longrun') {");
const iSverka = TEKST_CLI.indexOf('proverkaVladeltsa()', iCmd);
const iDengi = TEKST_CLI.indexOf('priborZamera(', iCmd);
expect(iCmd).toBeGreaterThan(-1);
expect(iSverka).toBeGreaterThan(iCmd);
expect(iDengi).toBeGreaterThan(iSverka);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// О-4, ДАТЧИК КЛАССА — не «у smoke-longrun нет ворот», а «у КАКОЙ УГОДНО команды, заводящей
// платного работника, нет ворот». Сегодня дорог оказалось три, а помнили про две — второй
// такой случай за день (первый — сверка настроек с нулём зовущих). Ручное перечисление команд
// глазами эту беду не лечит: четвёртая дорога послезавтра снова пройдёт мимо, и снова
// придётся, чтобы кто-то вспомнил и проверил руками. Здесь — датчик, который САМ находит
// в `cli.mjs` все команды и сам решает, кто из них платный.
// ═══════════════════════════════════════════════════════════════════════════════
// Вырезает тело каждого `if (cmd === '...') { … }` балансом фигурных скобок — а не до
// следующего `if (cmd ===`, чтобы вложенные `if`/функции внутри не обрывали вырезку раньше
// времени (внутри `run`, например, лежит целый вложенный `if (razbor.resumeRunId)`).
function komandnyeBloki(tekst) {
const bloki = [];
const re = /if \(cmd === '([\w-]+)'\) \{/g;
let m;
while ((m = re.exec(tekst))) {
const otkrytie = tekst.indexOf('{', m.index);
let depth = 0;
let j = otkrytie;
for (; j < tekst.length; j++) {
if (tekst[j] === '{') depth++;
else if (tekst[j] === '}') { depth--; if (depth === 0) break; }
}
bloki.push({ cmd: m[1], telo: tekst.slice(otkrytie, j + 1) });
}
return bloki;
}
// Имена, по которым датчик узнаёт «эта команда заводит живого ПЛАТНОГО работника» —
// а не просто мерит остаток (`budget`) или печатает бумагу (`seal`/`approve-spec`).
// 🪤 `probaProgon(` — то же самое `smokeLongRun`, только под именем точки впрыска. Без него
// датчик перестал бы видеть пробу платной в ту же минуту, когда точку впрыска завели: имя
// в тексте поменялось, трата — нет. Ровно так же тут уже живёт `podnyat(` (это
// `zavestiProdolzhenie` под именем довода). Псевдоним обязан попадать в список ВМЕСТЕ
// с настоящим именем, а не вместо него.
const ZAVODYAT_PLATNOGO_RABOTNIKA = ['startAll(', 'smokeLongRun(', 'probaProgon(', 'komandaProdolzheniya(', 'zavestiProdolzhenie(', 'podnyat('];
// Обе двери, обязательные у КАЖДОЙ такой команды.
const VOROTA_OBYAZATELNYE = [
{ imya: 'сверка настроек с решениями владельца', marker: 'proverkaVladeltsa(' },
// 🪤 Имя двери здесь ЧЕСТНОЕ, и это не мелочь: пока она звалась «денежные ворота»,
// зелёная строка «каждая платная команда зовёт денежные ворота» читалась как «деньги
// владельца сторожатся» — а сторожилась исправность прибора. Ворота ДЕНЕГ — в О-5 ниже.
{ imya: 'ворота прибора замера (НЕ деньги — деньги в О-5)', marker: 'priborZamera(' },
];
describe('О-4: датчик класса сам по себе — на синтетическом тексте, не на настоящем cli.mjs', () => {
it('поддельная команда со спавном и без единой двери — датчик её находит', () => {
const poddelnyj = "if (cmd === 'fake-fourth') {\n const r = startAll({ runId: 'x' });\n return 0;\n}\n";
const bloki = komandnyeBloki(poddelnyj);
expect(bloki.map(b => b.cmd)).toEqual(['fake-fourth']);
expect(bloki[0].telo).toContain('startAll(');
expect(bloki[0].telo).not.toContain('proverkaVladeltsa(');
expect(bloki[0].telo).not.toContain('priborZamera(');
});
it('та же поддельная команда, но с обеими дверями — датчик видит обе', () => {
const horoshaya = "if (cmd === 'fake-fourth') {\n proverkaVladeltsa();\n await priborZamera({ cfg, komanda: 'fake-fourth' });\n const r = startAll({ runId: 'x' });\n return 0;\n}\n";
const bloki = komandnyeBloki(horoshaya);
expect(bloki[0].telo).toContain('proverkaVladeltsa(');
expect(bloki[0].telo).toContain('priborZamera(');
});
it('вложенный `if` внутри команды не обрывает вырезку раньше закрывающей скобки', () => {
const vlozhennyj = "if (cmd === 'x') {\n if (a) {\n b();\n }\n startAll({});\n}\nif (cmd === 'y') {\n z();\n}\n";
const bloki = komandnyeBloki(vlozhennyj);
expect(bloki.map(b => b.cmd)).toEqual(['x', 'y']);
expect(bloki[0].telo).toContain('startAll(');
expect(bloki[0].telo).toContain('if (a)');
});
});
describe('О-4: датчик класса на НАСТОЯЩЕМ cli.mjs — каждая платная команда зовёт ОБЕ двери', () => {
const bloki = komandnyeBloki(TEKST_CLI);
const platnye = bloki.filter(b => ZAVODYAT_PLATNOGO_RABOTNIKA.some(marker => b.telo.includes(marker)));
it('датчик вправду нашёл все семь команд cli.mjs — не меньше и не какие-то другие', () => {
expect(bloki.map(b => b.cmd).sort()).toEqual(
['approve-spec', 'budget', 'mail', 'run', 'seal', 'smoke-longrun', 'summary'],
);
});
it('🔴 датчик вправду нашёл ОБЕ платных дороги — это класс, а не разбор одного места', () => {
expect(platnye.map(b => b.cmd).sort()).toEqual(['run', 'smoke-longrun']);
});
for (const vorota of VOROTA_OBYAZATELNYE) {
it(`каждая платная команда зовёт «${vorota.imya}»`, () => {
for (const b of platnye) {
expect(b.telo, `команда «${b.cmd}» заводит платного работника, но не зовёт ${vorota.imya}`).toContain(vorota.marker);
}
});
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// О-5, 03.08.2026 — ВОРОТА ЗАПАСА. ВТОРАЯ ПОЛОВИНА ТОГО ЖЕ КЛАССА.
//
// 🔴🔴 ЧЕМ ЭТО ОТЛИЧАЕТСЯ ОТ О-4 ВЫШЕ, И ПОЧЕМУ О-4 БЕДУ НЕ ПОЙМАЛ. Датчик О-4 требует
// у каждой платной дороги «денежные ворота» — а «денежные ворота» (`vorotaPribora`) спрашивают
// ПРИБОР: сделан ли замер, пришло ли число. ХВАТАЕТ ЛИ ЗАПАСА они не спрашивают вовсе.
// Замерено живьём 03.08.2026: доля израсходованного 0,76 при светофоре `allowed_warning`
// (правило владельца — «пока меньше пяти седьмых», это 0,714), денежные ворота ответили
// «можно», и проба ЗАВЕЛА платного работника. Обе двери зовутся «денежными» — в этом
// и была беда: врезали ту, что про исправность прибора, и посчитали, что врезали ту,
// что про деньги.
//
// 🪤 ГЛАВНОЕ ДОПУЩЕНИЕ ЭТОГО ДАТЧИКА, НАЗВАННОЕ ВСЛУХ: он читает ТЕКСТ и доказывает
// «правило владельца у этой дороги СПРОШЕНО». Он НЕ доказывает «ответ УСЛЫШАН» — дорога
// могла бы спросить и выбросить приговор в никуда, и текст выглядел бы точно так же.
// Вторая половина доказывается только поведением, по одной проверке на дорогу; они ниже.
// Датчик без них — половина, ровно как О-4 оказался половиной.
//
// 🪤 ВТОРОЕ ДОПУЩЕНИЕ. Датчик режет дорогу ЦЕЛИКОМ и не различает путей ВНУТРИ неё.
// У куска `run` путей вправду два — обычный и продолжение (`--resume`), и второй уходит
// из куска РАНЬШЕ, чем доходит до ворот. Поэтому продолжение режется отдельной дорогой:
// `komandaProdolzheniya` — своя область, со своим счётом ворот. Кусок `run` без этого
// выглядел бы сторожимым за обе свои дороги, а сторожил бы одну.
// ═══════════════════════════════════════════════════════════════════════════════
// Тело функции по её имени — тем же балансом скобок, что и у команд.
//
// 🪤 ЗДЕСЬ БЫЛА МОЯ ОШИБКА, И ОНА ПОКРАСНЕЛА САМА. Сперва тело искалось «первой `{` после
// имени» — а почти у каждой функции затеи доводы разобраны в скобках (`function f({ a, b })`),
// и первой `{` оказывался СПИСОК ДОВОДОВ, а не тело. Датчик тогда объявлял невиновными
// и `komandaProdolzheniya`, и `zapisatZamerIVzyatOstatok` — то есть врал ЗЕЛЁНЫМ на настоящем
// файле. Поэтому сначала честно закрывается список доводов балансом КРУГЛЫХ скобок,
// и только потом ищется `{` тела.
function teloFunkcii(tekst, imya) {
const re = new RegExp(`(?:export\\s+)?(?:async\\s+)?function ${imya}\\s*\\(`);
const m = re.exec(tekst);
if (!m) return null;
const skobka = tekst.indexOf('(', m.index);
let kruglye = 0;
let posleDovodov = -1;
for (let j = skobka; j < tekst.length; j++) {
if (tekst[j] === '(') kruglye++;
else if (tekst[j] === ')') { kruglye--; if (kruglye === 0) { posleDovodov = j; break; } }
}
if (posleDovodov < 0) return null;
const otkrytie = tekst.indexOf('{', posleDovodov);
if (otkrytie < 0) return null;
let depth = 0;
for (let j = otkrytie; j < tekst.length; j++) {
if (tekst[j] === '{') depth++;
else if (tekst[j] === '}') { depth--; if (depth === 0) return tekst.slice(otkrytie, j + 1); }
}
return null;
}
function imenaFunkciy(tekst) {
return [...tekst.matchAll(/(?:export\s+)?(?:async\s+)?function (\w+)\s*\(/g)].map(m => m[1]);
}
// Псевдонимы точек впрыска: строки вида `имя = НастоящееИмя,` в списке доводов.
// Без них датчик слеп ровно там, где затея прячет настоящий зов за доводом, — а прячет она
// его почти везде (`podnyat = zavestiProdolzhenie`, `zapisatIZapas = zapisatZamerIVzyatOstatok`).
function psevdonimy(tekst) {
return [...tekst.matchAll(/^\s*(\w+) = (\w+),\s*$/gm)].map(m => ({ imya: m[1], nastoyashchee: m[2] }));
}
// 🔴 ЗАМЫКАНИЕ, А НЕ ОДНА СТУПЕНЬ. Имя «спрашивает правило владельца о запасе», если оно
// само `budgetState`, либо его тело зовёт кого-то из уже набранных, либо оно псевдоним
// кого-то из уже набранных. Считается до тех пор, пока набор растёт: одной ступени мало —
// у пробы дорога до правила идёт через ДВЕ (`zapisatIZapas` → `zapisatZamerIVzyatOstatok`
// → `budgetState`), и датчик на одну ступень объявил бы пробу несторожимой ложно.
function imenaSprashivayushchieZapas(tekst) {
const nabor = new Set(['budgetState']);
const tela = new Map(imenaFunkciy(tekst).map(imya => [imya, teloFunkcii(tekst, imya) ?? '']));
const psev = psevdonimy(tekst);
for (let krug = 0; krug < 20; krug++) {
const bylo = nabor.size;
for (const [imya, telo] of tela) {
if (nabor.has(imya)) continue;
if ([...nabor].some(s => telo.includes(`${s}(`))) nabor.add(imya);
}
for (const p of psev) {
if (!nabor.has(p.imya) && nabor.has(p.nastoyashchee)) nabor.add(p.imya);
}
if (nabor.size === bylo) break;
}
return nabor;
}
function sprosheniPravilaZapasa(telo, nabor) {
return [...nabor].some(imya => telo.includes(`${imya}(`));
}
describe('О-5: датчик «спрошено ли правило запаса» сам по себе — на синтетическом тексте', () => {
it('дорога зовёт счёт запаса напрямую — датчик видит', () => {
const t = 'function doroga() {\n const b = budgetState(p, cfg, now);\n}\n';
expect(sprosheniPravilaZapasa(teloFunkcii(t, 'doroga'), imenaSprashivayushchieZapas(t))).toBe(true);
});
it('🔴 дорога не спрашивает правило вовсе — датчик её ловит', () => {
const t = 'function doroga() {\n const b = vorotaPribora({});\n startAll({});\n}\n';
expect(sprosheniPravilaZapasa(teloFunkcii(t, 'doroga'), imenaSprashivayushchieZapas(t))).toBe(false);
});
it('🔴 правило спрошено через ДВЕ ступени псевдонимов — датчик доходит, а не сдаётся на первой', () => {
const t = [
'function pravilo() {\n return budgetState(a, b, c);\n}',
'function sredniy({ x }) {\n return pravilo(x);\n}',
'function doroga({\n chuzhoe = sredniy,\n}) {\n const b = chuzhoe({});\n}',
'',
].join('\n');
const nabor = imenaSprashivayushchieZapas(t);
expect(nabor.has('sredniy')).toBe(true);
expect(nabor.has('chuzhoe')).toBe(true);
expect(sprosheniPravilaZapasa(teloFunkcii(t, 'doroga'), nabor)).toBe(true);
});
it('🪤 похожее имя не считается за зов: `budgetStateHelper` — не `budgetState`', () => {
const t = 'function doroga() {\n const b = budgetStateHelper;\n}\n';
expect(sprosheniPravilaZapasa(teloFunkcii(t, 'doroga'), imenaSprashivayushchieZapas(t))).toBe(false);
});
});
describe('О-5: датчик класса на НАСТОЯЩЕМ cli.mjs — у каждой платной дороги спрошено правило запаса', () => {
// Дороги — это НЕ только куски `if (cmd === …)`. Продолжение живёт отдельной функцией,
// и кусок `run` уходит в неё раньше своих ворот.
const NABOR = imenaSprashivayushchieZapas(TEKST_CLI);
const dorogi = [
...komandnyeBloki(TEKST_CLI).map(b => ({ imya: `команда «${b.cmd}»`, telo: b.telo })),
...imenaFunkciy(TEKST_CLI).map(imya => ({ imya: `функция «${imya}»`, telo: teloFunkcii(TEKST_CLI, imya) ?? '' })),
];
const platnyeDorogi = dorogi.filter(d => ZAVODYAT_PLATNOGO_RABOTNIKA.some(mk => d.telo.includes(mk)));
it('🔴 датчик нашёл РОВНО те платные дороги, что известны — новая дорога роняет эту проверку', () => {
// 🔴 Если она покраснела — в затее завелась дорога к платному работнику, которой раньше
// не было. Мало дописать её сюда: у каждой дороги обязана быть ПОВЕДЕНЧЕСКАЯ проверка
// «запаса не хватает — работник не заведён», иначе текстовый датчик снова окажется
// половиной, как О-4.
expect(platnyeDorogi.map(d => d.imya).sort()).toEqual([
'команда «run»',
'команда «smoke-longrun»',
'функция «komandaProdolzheniya»',
'функция «main»',
]);
});
// 🔴 ОБХОД ИДЁТ ПО НАЙДЕННЫМ ДОРОГАМ, А НЕ ПО СПИСКУ ИМЁН. Списком имён это было сперва —
// и вырезанием вскрылось, что так новая дорога получает ТОЛЬКО красноту «список изменился»,
// без прямых слов о том, чего у неё не хватает. Ровно этой близорукостью и жил О-4: он
// сторожил названное, а не найденное.
for (const d of platnyeDorogi) {
it(`у дороги ${d.imya} спрошено правило владельца о запасе`, () => {
expect(
sprosheniPravilaZapasa(d.telo, NABOR),
`${d.imya} заводит платного работника, но правило владельца о запасе у неё не спрошено ни разу`,
).toBe(true);
});
}
});
describe('О-5: `vorotaZapasa` — приговор правила владельца превращается в отказ', () => {
const CFG_Z = { worker_model: 'г', disk_free_min_gb: 5 };
it('🔴 запаса не хватает — отказ, и словами САМОГО правила, а не выдуманными на месте', () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const g = vorotaZapasa({
budget: { may_start: false, reason: 'РОВНО ЭТИ СЛОВА ПРАВИЛА' },
otkaz: 'Проба НЕ запущена',
});
expect(g.ok).toBe(false);
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('Проба НЕ запущена: РОВНО ЭТИ СЛОВА ПРАВИЛА');
errSpy.mockRestore();
});
it('🪤 отказ БЕЗ причины — всё равно отказ, а не «раз причины нет, значит можно»', () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const g = vorotaZapasa({ budget: { may_start: false, reason: null } });
expect(g.ok).toBe(false);
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('причина не названа');
errSpy.mockRestore();
});
it('🪤 ТРЕТИЙ ответ — ответа нет вовсе: это «неизвестно», а не «можно»', () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(vorotaZapasa({ budget: null }).ok).toBe(false);
expect(vorotaZapasa({ budget: undefined }).ok).toBe(false);
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('запас владельца не посчитан вовсе');
errSpy.mockRestore();
});
it('запаса хватает — пускает', () => {
expect(vorotaZapasa({ budget: { may_start: true } }).ok).toBe(true);
});
it('🔴 сужение защиты (доля не пришла) говорится владельцу вслух, а не молчком', () => {
const log = vi.fn();
const g = vorotaZapasa({
budget: { may_start: true, dolya_ne_prishla: true, slova_vladeltsu: 'ВАША РАМКА СЕЙЧАС НЕ МЕРИТСЯ' },
log,
});
expect(g.ok).toBe(true);
expect(log).toHaveBeenCalledWith('ВАША РАМКА СЕЙЧАС НЕ МЕРИТСЯ');
});
it('🪤 доля пришла — лишних слов о сужении нет', () => {
const log = vi.fn();
vorotaZapasa({ budget: { may_start: true, dolya_ne_prishla: false, slova_vladeltsu: 'НЕ ДОЛЖНО ПРОЗВУЧАТЬ' } , log });
expect(log).not.toHaveBeenCalled();
});
});
describe('О-5: ДОРОГА ЦЕЛИКОМ — `main(smoke-longrun)` на НАСТОЯЩЕМ правиле владельца', () => {
// 🔴 Правило здесь НЕ подставное: журнал замеров временный, а считают его настоящие
// `recordMeasure` и `budgetState` из budget.mjs. Подставлен только ПУТЬ журнала — иначе
// проверка писала бы в общий `.night/budget-measures.jsonl` и сдвигала бы чужой прирост
// (эта беда в затее уже случалась, см. `zapisatZamerIVzyatOstatok`).
//
// 🔴 И заведение пробы подставлено спаем — не ради удобства, а чтобы вырезание было
// безопасным: сломав ворота нарочно, проверка НЕ заведёт живого платного работника.
let dir;
let zhurnal;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'o5-zapas-'));
zhurnal = join(dir, 'budget-measures.jsonl');
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
// 🔄 ПЕРЕСОБРАНО КРУГОМ Б. Прежде запись и вопрос о запасе шли одной подставной парой
// (`zapisatIZapas`). Теперь запись делает САМА дверь прибора, сразу как деньги ушли, —
// поэтому и здесь она стоит внутри подставного прибора, а вопрос о запасе отдельным
// доводом. Правило по-прежнему НЕ подставное: и писарь, и счёт запаса настоящие,
// подставлен только ПУТЬ журнала.
const priborChtoPishet = measure => async ({ phase }) => {
zapisatZamer(zhurnal, {
utilization: measure.utilization, status: measure.status, kind: measure.kind,
resets_at: measure.resets_at, at: Date.now(), phase,
});
return { ok: true, measure };
};
const proba = async (measure) => {
const probaSpy = vi.fn(async () => ({
worker_id: 'w-1', supervisor_pid: 1, heartbeat_path: '/тут/жив', notes_path: '/тут/след',
}));
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
readConfig: () => CFG,
proverkaVladeltsa: () => [],
priborZamera: priborChtoPishet(measure),
sostoyanieZapasa: (_put, cfg, at) => sostoyanieZapasa(zhurnal, cfg, at),
probaProgon: probaSpy,
});
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
errSpy.mockRestore();
logSpy.mockRestore();
return { code, probaSpy, skazano };
};
it('🔴🔴 ТА САМАЯ БЕДА: израсходовано 0,76 при тревоге — проба НЕ заведена ни разу', async () => {
// Замер снят живьём 03.08.2026: доля 0,76, светофор `allowed_warning`. Правило владельца —
// пять седьмых (0,7142857…). До этой правки на этих же числах проба запускалась.
const { code, probaSpy, skazano } = await proba({
utilization: 0.76, status: 'allowed_warning', kind: 'seven_day', resets_at: null,
});
expect(probaSpy).not.toHaveBeenCalled();
expect(code).toBe(1);
expect(skazano).toContain('Проба НЕ запущена');
});
it('🪤 ровно на пороге (0,714285…) — тоже отказ: правило «МЕНЬШЕ пяти седьмых», не «не больше»', async () => {
const { code, probaSpy } = await proba({
utilization: 5 / 7, status: 'allowed', kind: 'seven_day', resets_at: null,
});
expect(probaSpy).not.toHaveBeenCalled();
expect(code).toBe(1);
});
it('🔴 система САМА отказала (`rejected`) при крошечном расходе — проба всё равно не заведена', async () => {
const { code, probaSpy } = await proba({
utilization: 0.03, status: 'rejected', kind: 'seven_day', resets_at: null,
});
expect(probaSpy).not.toHaveBeenCalled();
expect(code).toBe(1);
});
it('🪤 незнакомый светофор — не зелёный: проба не заводится по слову, которого затея не знает', async () => {
const { code, probaSpy } = await proba({
utilization: 0.03, status: 'какое-то-новое-слово', kind: 'seven_day', resets_at: null,
});
expect(probaSpy).not.toHaveBeenCalled();
expect(code).toBe(1);
});
it('🟢 запаса вправду хватает (0,10) — проба заводится РОВНО один раз', async () => {
const { code, probaSpy } = await proba({
utilization: 0.10, status: 'allowed', kind: 'seven_day', resets_at: null,
});
expect(probaSpy).toHaveBeenCalledTimes(1);
expect(code).toBe(0);
});
it('🔴 замер вправду лёг во ВРЕМЕННЫЙ журнал под своим именем — а не в общий', () => {
// Датчик того, что подставлен только путь: после отказного прогона строка обязана
// быть здесь, с фазой пробы. Если она уйдёт в общий журнал — тут будет пусто.
return proba({ utilization: 0.76, status: 'allowed_warning', kind: 'seven_day', resets_at: null })
.then(() => {
const stroki = readLines(zhurnal);
expect(stroki.length).toBe(1);
expect(stroki[0].phase).toBe('before-smoke');
expect(stroki[0].utilization).toBe(0.76);
});
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// КУСОК 6, ЗАДАЧА 7 — ПОДЪЁМ ПРОДОЛЖЕНИЯ.
//
// 🔴 Зачем эти проверки вообще есть. Датчик зовущих (`schyot-zovushchih.mjs`) показал:
// у СЕМИ из семи правил куска 6 зовущих НОЛЬ. Построено, проверено, зелено — и не подключено
// ни к чему. Правило метода номер 2: защита, которую никто не зовёт, не бывает проверенной;
// затея заплатила за это шесть раз. Здесь строится тот, кто зовёт.
//
// 🪤 Карточки пишутся в НАСТОЯЩИЙ `.night/runs/`, потому что `runDir` считает от постоянной
// `NIGHT_ROOT` и подмены не принимает. Поэтому номер прогона у каждой проверки СВОЙ
// (случайный) и каталог сносится в `finally` — тот же приём, что у проверок `startAll`.
// В этом углу `.night/` живой, там лежит чужой прогон: своими номерами мы его не задеваем.
// ═══════════════════════════════════════════════════════════════════════════════
describe('🔴 кусок 6, задача 7 — подъём продолжения (zavestiProdolzhenie)', () => {
// Восемь отрезков и три пометки «в расчёте на ответ» — ровно то, что П-25 велит подать.
const VOSEM_OTREZKOV = Array.from({ length: 8 }, (_, i) => ({ kind: 'working', ms: 3600_000, n: i }));
function staryj(over = {}) {
return {
worker_id: 'w-1',
run_id: `вчера-${randomUUID().slice(0, 8)}`,
item: { id: '3', title: 'третий', tests: ['npm run t'], files: ['app/A.php'] },
branch: 'night/вчера/w-1',
base_branch: 'feat/night-dispatcher-part1',
workspace: join(tmpdir(), 'угол-вчера-w-1'),
settings_path: join(tmpdir(), 'угол-вчера-w-1', '.night-worker', 'settings.json'),
db_name: 'night_w1',
model: 'старая-голова',
session_id: 'старая-сессия',
status: 'extinguished',
finish_reason: 'упёрся в восемь часов',
spans: VOSEM_OTREZKOV,
started_at: 1000,
resumed_at: null,
resumed_into: null,
...over,
};
}
// Кладём карточку старого работника на диск — её `zavestiProdolzhenie` обязан пометить.
function polozhitStarogo(w) {
const d = workerDir(w.run_id, w.worker_id);
mkdirSync(d, { recursive: true });
writeFileSync(join(d, 'worker.json'), JSON.stringify(w), 'utf8');
return d;
}
function ubrat(...runIds) {
for (const r of runIds) rmSync(runDir(r), { recursive: true, force: true });
}
it('🔴 П-13: у продолжения СВОЙ прогон, и обе карточки помнят, кого продолжают', () => {
const w = staryj();
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
polozhitStarogo(w);
try {
const r = zavestiProdolzhenie({
runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова-из-настроек',
spawnSupervisorFn: () => ({ pid: 4242 }),
});
expect(r.ok).toBe(true);
expect(r.run_id).toBe(novyj);
expect(r.run_id).not.toBe(w.run_id); // свой номер, не вчерашний
expect(r.supervisor_pid).toBe(4242);
// Карточка НОВОГО прогона помнит, какой прогон и какого работника продолжаем.
const runCard = readJson(join(runDir(novyj), 'run.json'));
expect(runCard.resumed_from).toBe(w.run_id);
expect(runCard.resumed_worker).toBe('w-1');
expect(runCard.status).toBe('running');
// Карточка СТАРОГО работника получила обе отметки.
const staraya = readJson(join(workerDir(w.run_id, 'w-1'), 'worker.json'));
expect(Number.isFinite(staraya.resumed_at)).toBe(true);
expect(staraya.resumed_into).toBe(novyj);
} finally {
ubrat(novyj, w.run_id);
}
});
it('🔴 П-25: у продолжающего учётчик отрезков ПУСТ — восемь вчерашних не переносятся', () => {
const w = staryj();
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
polozhitStarogo(w);
try {
const r = zavestiProdolzhenie({
runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова',
spawnSupervisorFn: () => ({ pid: 1 }),
});
const nov = readJson(join(workerDir(novyj, r.worker_id), 'worker.json'));
expect(nov.spans).toEqual([]);
expect(nov.spans).not.toHaveLength(8);
} finally {
ubrat(novyj, w.run_id);
}
});
it('🔴 П-26: съеденного времени ноль — восемь часов пошли ЗАНОВО', () => {
const w = staryj();
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
polozhitStarogo(w);
try {
const r = zavestiProdolzhenie({
runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова',
spawnSupervisorFn: () => ({ pid: 1 }), now: () => 9_000_000,
});
const nov = readJson(join(workerDir(novyj, r.worker_id), 'worker.json'));
expect(billableMs(nov.spans ?? [])).toBe(0);
expect(nov.started_at).toBe(9_000_000); // отсчёт от подъёма, а не от вчерашней 1000
expect(nov.started_at).not.toBe(w.started_at);
} finally {
ubrat(novyj, w.run_id);
}
});
it('угол, ветка, точка ветвления, база и настройки — ВЗЯТЫ У СТАРОГО, сессия НОВАЯ', () => {
const w = staryj();
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
polozhitStarogo(w);
try {
const r = zavestiProdolzhenie({
runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова',
spawnSupervisorFn: () => ({ pid: 1 }),
});
const nov = readJson(join(workerDir(novyj, r.worker_id), 'worker.json'));
expect(nov.workspace).toBe(w.workspace);
expect(nov.branch).toBe(w.branch);
expect(nov.base_branch).toBe(w.base_branch);
expect(nov.db_name).toBe(w.db_name);
expect(nov.settings_path).toBe(w.settings_path);
// 🔴 Сессия обязана быть НОВОЙ: та же вернула бы работника в оборванный разговор.
expect(nov.session_id).not.toBe(w.session_id);
expect(nov.session_id).toBeTruthy();
// Голова — из настроек владельца, а не вчерашняя из карточки.
expect(nov.model).toBe('голова');
expect(nov.model).not.toBe(w.model);
} finally {
ubrat(novyj, w.run_id);
}
});
it('🔴 П-45: порождатель надзирателя УПАЛ — карточка прогона не осталась идущей', () => {
// 🔴 Кусок про «пережить падение» обязан пережить и своё. Упади он между заведением
// карточки и подъёмом — прогон навсегда числился бы идущим, и закрыть его было бы некому.
const w = staryj();
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
polozhitStarogo(w);
try {
const r = zavestiProdolzhenie({
runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова',
spawnSupervisorFn: () => { throw new Error('надзиратель не поднялся'); },
});
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/надзиратель не поднялся/);
const runCard = readJson(join(runDir(novyj), 'run.json'));
expect(runCard.status).not.toBe('running');
expect(runCard.status).toBe('finished');
expect(runCard.finish_reason).toBeTruthy();
} finally {
ubrat(novyj, w.run_id);
}
});
it('🔴 головы не назвали — падаем ДО порождения надзирателя, а не после', () => {
const w = staryj();
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
let zovov = 0;
try {
expect(() => zavestiProdolzhenie({
runId: novyj, worker: w, planPath: 'план.md', workerModel: null,
spawnSupervisorFn: () => { zovov += 1; return { pid: 1 }; },
})).toThrow(/worker_model/);
// 🔴 Ноль — не украшение: надзиратель родил бы живого работника, то есть ДЕНЬГИ.
expect(zovov).toBe(0);
} finally {
ubrat(novyj, w.run_id);
}
});
it('🔴 угол НЕ заводится заново: `createWorkspace` из этой дороги не зовётся ни разу', () => {
// 🔴 Решение 1: продолжение идёт в ТОМ ЖЕ углу. Заведись угол заново — работник
// потерял бы всё наработанное, а `composer install` съел бы десять минут ночи.
// 🪤 Сторож смотрит на КОД, а не на поведение: подать сюда подставной `createWorkspace`
// нечем — его в подписи нет вовсе, и это ровно то, что проверяется.
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'run.mjs'), 'utf8');
const nachalo = src.indexOf('export function zavestiProdolzhenie');
expect(nachalo).toBeGreaterThan(-1);
const telo = src.slice(nachalo, src.indexOf('\n}', nachalo));
expect(telo).not.toMatch(/createWorkspace\w*\s*\(/);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// КУСОК 6, ЗАДАЧА 6 — ТИХИЕ ЧАСТИ ТОЧКИ ВХОДА. Снятие запора шагов 1 и 2.
//
// 🔴 Это те самые два шага, которые в прошлом круге заперли всю дорогу продолжения:
// у `razborDovodov` не было полей `--resume`/`--worker`, а `vyborProdolzhaemogo`
// не существовало вовсе. Тихое живёт здесь; в `cli.mjs` — только громкое.
// ═══════════════════════════════════════════════════════════════════════════════
describe('🔴 кусок 6, задача 6 — разбор доводов продолжения', () => {
const bazovye = ['node', 'cli.mjs', 'run', 'план.md', '--priyomka', 'п.md', '--spec', 'з.md'];
it('ключи `--resume` и `--worker` доезжают до команды теми же, какими их набрали', () => {
const d = razborDovodov([...bazovye, '--resume', 'прогон-7', '--worker', 'w-3']);
expect(d.resumeRunId).toBe('прогон-7');
expect(d.resumeWorkerId).toBe('w-3');
});
it('🔴 не набрали — НЕ выдумываем: оба поля пусты, а не «первый попавшийся»', () => {
const d = razborDovodov([...bazovye]);
expect(d.resumeRunId).toBeNull();
expect(d.resumeWorkerId).toBeNull();
});
it('🔴 `--resume` без значения не съедает следующий ключ', () => {
// 🪤 Ровно та беда, от которой в этом файле уже стоит `estLiKlyuch`: ключ, спрошенный
// как ключ-со-значением, забирает следующее слово командной строки — то есть ЧУЖОЙ ключ.
const d = razborDovodov(['node', 'cli.mjs', 'run', 'план.md', '--priyomka', 'п.md', '--spec', 'з.md', '--resume']);
expect(d.resumeRunId).toBeNull();
const e = razborDovodov([...bazovye, '--resume', '--worker', 'w-3']);
expect(e.resumeRunId).not.toBe('--worker');
expect(e.resumeRunId).toBeNull();
});
it('прежний разбор не тронут: план, приёмка, замысел и `--files` на месте', () => {
const d = razborDovodov([...bazovye, '--resume', 'r-1', '--files', 'a.php,b.php']);
expect(d.planPath).toBe('план.md');
expect(d.priyomkaPath).toBe('п.md');
expect(d.specPath).toBe('з.md');
expect(d.files).toEqual(['a.php', 'b.php']);
});
});
describe('🔴 кусок 6, задача 6 — выбор продолжаемого работника', () => {
const rab = (over = {}) => ({
worker_id: 'w-1', run_id: 'вчера', status: 'extinguished', resumed_into: null, ...over,
});
it('🔴 П-36: продолжаемых нет вовсе — никого не берём и говорим почему', () => {
const r = vyborProdolzhaemogo({ workers: [rab({ status: 'working' }), rab({ worker_id: 'w-2', status: 'paused' })] });
expect(r.worker).toBeNull();
expect(r.reason).toBeTruthy();
expect(r.candidates).toEqual([]);
});
it('🔴 П-36: продолжаемых несколько, имени не назвали — НЕ берём первого попавшегося', () => {
const r = vyborProdolzhaemogo({
workers: [rab({ worker_id: 'w-1' }), rab({ worker_id: 'w-2', status: 'stalled' })],
});
expect(r.worker).toBeNull();
expect(r.candidates).toEqual(['w-1', 'w-2']);
expect(r.reason).toMatch(/w-1/);
expect(r.reason).toMatch(/w-2/);
});
it('продолжаемый ровно один и имени не назвали — берём его', () => {
const r = vyborProdolzhaemogo({ workers: [rab({ worker_id: 'w-9' }), rab({ worker_id: 'w-2', status: 'working' })] });
expect(r.worker?.worker_id).toBe('w-9');
expect(r.reason).toBeNull();
});
it('назвали имя — берём именно его, даже когда продолжаемых несколько', () => {
const r = vyborProdolzhaemogo({
workers: [rab({ worker_id: 'w-1' }), rab({ worker_id: 'w-2', status: 'stalled' })],
workerId: 'w-2',
});
expect(r.worker?.worker_id).toBe('w-2');
});
it('🔴 П-39: назвали ЖИВОГО — отказ, и про паузу сказано, чем она снимается', () => {
for (const zhivoy of ['working', 'paused', 'waiting_service', 'waiting_answer_idle', 'stopped']) {
const r = vyborProdolzhaemogo({ workers: [rab({ status: zhivoy })], workerId: 'w-1' });
expect(r.worker).toBeNull();
expect(r.reason).toMatch(/жив/i);
}
// Про паузу — своё слово: владелец обязан узнать, ЧЕМ она снимается.
const p = vyborProdolzhaemogo({ workers: [rab({ status: 'paused' })], workerId: 'w-1' });
expect(p.reason).toMatch(/resume/);
});
it('🔴 П-42: назвали УЖЕ ПРОДОЛЖЕННОГО — отказ, и назван прогон, куда его продолжили', () => {
const r = vyborProdolzhaemogo({
workers: [rab({ resumed_into: 'прогон-завтра' })], workerId: 'w-1',
});
expect(r.worker).toBeNull();
expect(r.reason).toMatch(/прогон-завтра/);
});
it('🔴 уже продолженный не считается продолжаемым и БЕЗ имени — иначе поднимут второго', () => {
// 🪤 Второй живой работник в том же углу и той же базе поверх первого — худшее,
// что эта кнопка может сделать.
const r = vyborProdolzhaemogo({ workers: [rab({ resumed_into: 'прогон-завтра' })] });
expect(r.worker).toBeNull();
expect(r.candidates).toEqual([]);
});
it('назвали того, кого в прогоне нет вовсе — своя причина, а не «он жив»', () => {
const r = vyborProdolzhaemogo({ workers: [rab()], workerId: 'w-404' });
expect(r.worker).toBeNull();
expect(r.reason).toMatch(/w-404/);
expect(r.reason).not.toMatch(/жив/i);
});
it('🔴🔴 ТРЕТЬЕ: у карточки НЕТ слова состояния — это не «жив» и не «продолжаем»', () => {
// 🔴 ВЫРЕЗАНИЕ ВТОРОГО РОДА. Допущение правила: «состояние — это одно из известных слов».
// Годный по форме случай, в него не попавший: карточка, у которой слова нет вовсе
// (запись оборвалась, файл неполон). Плоское «не продолжаемый ⇒ жив» сказало бы владельцу
// «он жив, пауза снимается командой resume» про работника, которого, может быть, нет.
// Замерено: живых слов у работника ВОСЕМЬ (working · paused · stalled · extinguished ·
// waiting_service · waiting_answer_idle · waiting_answer_busy · stopped), а состояний
// у этого входа девять — девятое и есть «слова нет».
for (const net of [undefined, null, '', 7]) {
const r = vyborProdolzhaemogo({ workers: [rab({ status: net })], workerId: 'w-1' });
expect(r.worker).toBeNull();
expect(r.reason).toMatch(/состояни/i);
expect(r.reason).not.toMatch(/жив/i);
}
});
it('карточек не дали вовсе — отказ, а не обвал', () => {
for (const pusto of [undefined, null, []]) {
const r = vyborProdolzhaemogo({ workers: pusto });
expect(r.worker).toBeNull();
expect(r.reason).toBeTruthy();
}
expect(() => vyborProdolzhaemogo()).not.toThrow();
});
it('🛡️ СТОРОЖ: список продолжаемых ВВЕЗЁН из resume.mjs, а не набран здесь второй раз', () => {
// 🔴 Два списка слов на один вопрос разъехались бы молча: `resumePlan` судил бы по одному,
// кнопка — по другому, и обе стороны остались бы зелёными.
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'cli-parts.mjs'), 'utf8');
expect(src).toMatch(/PRODOLZHAEMYE/);
expect(src).toMatch(/from '\.\/resume\.mjs'/);
// Своей копии слов в файле нет.
expect(src).not.toMatch(/\[\s*'stalled'\s*,\s*'extinguished'\s*\]/);
// И правило вправду судит по ввезённому списку, а не по своему представлению:
// оба слова из него — продолжаемые.
for (const slovo of PRODOLZHAEMYE) {
expect(vyborProdolzhaemogo({ workers: [rab({ status: slovo })] }).worker?.worker_id).toBe('w-1');
}
});
it('🛡️ СТОРОЖ: сборщик карточек даёт ТРЕТИЙ ответ, а не пустоту', () => {
// 🔴 Долг М-181, который я же и назвал. Плоский `readJson` + `.filter(Boolean)` выбросил
// бы порванную карточку МОЛЧА — и продолжаемым оказался бы другой работник, а отказы
// «его уже продолжили» и «продолжаемых несколько» промолчали бы, оставаясь зелёными.
const d = mkdtempSync(join(tmpdir(), 'night-kart-'));
try {
const wdir = join(d, 'workers');
mkdirSync(join(wdir, 'w-1'), { recursive: true });
mkdirSync(join(wdir, 'w-2'), { recursive: true });
writeFileSync(join(wdir, 'w-1', 'worker.json'), JSON.stringify({ worker_id: 'w-1', status: 'stalled' }), 'utf8');
// 🪤 Порванная карточка: файл ЕСТЬ, разобрать нельзя. Это не «работника нет».
writeFileSync(join(wdir, 'w-2', 'worker.json'), '{ порвано', 'utf8');
const r = kartochkiRabotnikov('всё-равно', d);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/w-2/);
expect(r.reason).toMatch(/прочесть не удалось|разобрать/i);
} finally { rmSync(d, { recursive: true, force: true }); }
});
it('сборщик: карточек нет вовсе — отказ с номером прогона, а не обвал', () => {
const d = mkdtempSync(join(tmpdir(), 'night-kart2-'));
try {
const r = kartochkiRabotnikov('прогон-777', d);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/прогон-777/);
} finally { rmSync(d, { recursive: true, force: true }); }
});
it('🛡️ СТОРОЖ решения 11: в `vhodVorot` кусок 6 не встаёт ни одним доводом', () => {
// 🔴 Развилка «если продолжение — подавай пустышки» была снята диспетчером 01.08:
// это вторая дорога к правилу, открывающаяся молча при первой ошибке в признаке.
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'cli-parts.mjs'), 'utf8');
const i = src.indexOf('export function vhodVorot');
const telo = src.slice(i, src.indexOf('\n}', i));
expect(telo).not.toMatch(/resume/i);
expect(telo).not.toMatch(/prodolzh/i);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 ЖИВАЯ ДОРОГА ПРОДОЛЖЕНИЯ — ТОТ САМЫЙ ЗОВУЩИЙ.
// До неё у ДЕСЯТИ правил куска 6 зовущих было ноль.
// ═══════════════════════════════════════════════════════════════════════════════
describe('🔴 кнопка «продолжить» — живая дорога из двенадцати шагов', () => {
let ugol; let vcheraId; let kommitZachtennogo; let rechi; let novyjId;
// 🔴 Угол — НАСТОЯЩЕЕ хранилище, и прогон — настоящий каталог: дорога зовёт настоящие
// читалки (`closedItemsOfRun`, `kommitZachtennogoPunkta`, `rabotaPosleZachtennogo`),
// и подставить их нечем. Подставка доказала бы только моё представление о них — ровно
// тот класс, на котором в этой затее уже обжигались.
beforeEach(() => {
rechi = [];
novyjId = null;
ugol = mkdtempSync(join(tmpdir(), 'night-ugol-'));
execFileSync(GIT, ['init', '-q', '-b', 'baza'], { cwd: ugol });
execFileSync(GIT, ['config', 'user.email', 'r@liderra.local'], { cwd: ugol });
execFileSync(GIT, ['config', 'user.name', 'работник'], { cwd: ugol });
writeFileSync(join(ugol, 'A.php'), 'засчитанное\n', 'utf8');
execFileSync(GIT, ['add', '.'], { cwd: ugol });
execFileSync(GIT, ['commit', '-qm', 'пункт 1 засчитан'], { cwd: ugol });
kommitZachtennogo = String(execFileSync(GIT, ['rev-parse', 'HEAD'], { cwd: ugol, encoding: 'utf8' })).trim();
// Поверх засчитанного — работа, которую продолжение обязано снять откатом.
writeFileSync(join(ugol, 'B.php'), 'работа после засчитанного\n', 'utf8');
execFileSync(GIT, ['add', '.'], { cwd: ugol });
execFileSync(GIT, ['commit', '-qm', 'после засчитанного'], { cwd: ugol });
// Настоящий каталог поднимаемого прогона с приговором по пункту 1.
vcheraId = `вчера-${randomUUID().slice(0, 8)}`;
const wdir = workerDir(vcheraId, 'w-1');
mkdirSync(wdir, { recursive: true });
writeFileSync(join(runDir(vcheraId), 'run.json'), JSON.stringify({ run_id: vcheraId, items: [{ id: '1' }, { id: '2' }] }), 'utf8');
writeFileSync(join(wdir, 'items-verified.jsonl'),
`${JSON.stringify({ item: '1', at: 1000, commit_sha: kommitZachtennogo, tests_green: true, line: 'пункт 1 закрыт' })}\n`, 'utf8');
});
afterEach(() => {
rmSync(ugol, { recursive: true, force: true });
rmSync(runDir(vcheraId), { recursive: true, force: true });
// Новый прогон дорога заводит сама — за собой убираем тоже, чтобы не копить в хозяйстве.
if (novyjId) rmSync(runDir(novyjId), { recursive: true, force: true });
});
const CFG = { worker_model: 'голова-из-настроек', disk_free_min_gb: 5 };
const HOROSHO = { ok: true, reason: null };
const rabotnik = (over = {}) => ({
worker_id: 'w-1', run_id: vcheraId, status: 'extinguished', resumed_into: null,
workspace: ugol, branch: 'night/вчера/w-1', base_branch: 'baza',
db_name: 'night_w1', settings_path: 'нет.json', session_id: 'старая', spans: [], ...over,
});
// Всё вокруг дороги — подставное; предмет проверки сама дорога.
function dovody(over = {}) {
return {
cfg: CFG,
planPath: 'план.md',
resumeRunId: vcheraId,
readCards: () => ({ ok: true, cards: [rabotnik()], reason: null }),
pechat: () => HOROSHO,
measureDisk: async () => HOROSHO,
zamer: async () => ({ ok: true, measure: { utilization: 0.1, status: 'allowed', kind: 'five_hour', resets_at: Date.now() + 3600_000 }, problem: null }),
// 🪤 Настоящий остаток писал бы в ОБЩИЙ журнал замеров `.night/` — своя строка там
// сдвинула бы чужой прирост и погасила бы соседний прогон ни за что.
// 🪤 Заодно ловим номер НОВОГО прогона: он случайный, а замки живут в его каталоге.
// Шаг замера — самое раннее место, где номер уже известен.
ostatok: ({ runId }) => { novyjId = runId; return { may_start: true, reason: null, dolya_ne_prishla: false, slova_vladeltsu: null }; },
podnyat: () => ({ ok: true, reason: null, run_id: 'новый', worker_id: 'w-1', supervisor_pid: 42 }),
// 🔴 Откатчик — НАСТОЯЩИЙ. Подставной, отвечающий «получилось» без коммита, дорога
// законно отвергает («на слово не верим») — и это поймано живьём при первом прогоне:
// моя удобная подставка провалила happy-path, потому что защита работает.
// Угол здесь настоящее хранилище, так что настоящему откатчику есть с чем работать.
gryaz: () => false,
// Всё сказанное владельцу копится: по нему и судим, что он вправду прочтёт.
out: s => rechi.push(String(s)), err: s => rechi.push(String(s)),
...over,
};
}
it('🔴 П-24: `planRun` при продолжении не зовётся НИ РАЗУ', async () => {
// 🔴 Сторож решения 8. Приведи кнопку обратно на общую дорогу — краснеет.
// 🪤 Считаем зовы настоящего модуля: заглушка внутри чужой функции доказала бы себя.
vi.resetModules();
let zovov = 0;
vi.doMock('./run.mjs', async () => {
const nast = await vi.importActual('./run.mjs');
return { ...nast, planRun: (...a) => { zovov += 1; return nast.planRun(...a); } };
});
const { komandaProdolzheniya: kp } = await import('./cli.mjs');
await kp(dovody());
expect(zovov).toBe(0);
vi.doUnmock('./run.mjs');
vi.resetModules();
});
it('🔴 П-41: отпечаток плана негоден — ПЛАТНЫЙ ЗАМЕР НЕ ЗВАЛСЯ НИ РАЗУ', async () => {
// 🔴 Замер — платная программа, деньги владельца. Жечь его на попытку, которая заведомо
// провалится по дешёвым воротам, нельзя. Переставь замер вперёд — краснеет.
let zamerov = 0;
const d = mkdtempSync(join(tmpdir(), 'night-pechat-'));
try {
const kod = await komandaProdolzheniya(dovody({
// Отпечаток негоден — дешёвые ворота не сошлись.
pechat: () => ({ ok: false, reason: 'отпечаток плана не сошёлся' }),
zamer: async () => { zamerov += 1; return { ok: true, measure: null, problem: null }; },
}));
expect(kod).toBe(1);
expect(zamerov).toBe(0);
} finally { rmSync(d, { recursive: true, force: true }); }
});
it('🔴 приговор о чужой голове доходит до владельца и на дороге «продолжить»', async () => {
// 🔴 Третья платная дорога затеи. Её замер шёл мимо `vorotaPribora` своим куском кода —
// и молчал бы даже после починки двух других дорог. Слова идут через `skazat`,
// то есть и в окно, и в сводку поднимаемого прогона.
const kod = await komandaProdolzheniya(dovody({
zamer: async () => ({
ok: true,
measure: { utilization: 0.1, status: 'allowed', kind: 'five_hour', resets_at: Date.now() + 3600_000 },
problem: null,
golova: { sovpalo: false, zakazana: 'haiku', otvetila: ['opus'], soobshchenie: 'ЧУЖАЯ ГОЛОВА В СЧЁТЕ ПРОДОЛЖЕНИЯ' },
masterskaya: { otsecheno: true, sostav: { tools: 0, mcp_servers: 0 }, soobshchenie: null },
}),
}));
expect(rechi.join('\n')).toContain('ЧУЖАЯ ГОЛОВА В СЧЁТЕ ПРОДОЛЖЕНИЯ');
// 🔴 Решение владельца 15: чужая голова говорится вслух и НЕ останавливает работу.
expect(kod).toBe(0);
});
it('🔴 П-17: живой ЧУЖОЙ работник и молчание владельца кнопку НЕ держат', async () => {
// 🔴 Проверка 9 дословно: «больше кнопка не проверяет ничего… иначе она не сработала бы
// никогда». Подать это в `resumeChecks` нечем — у неё таких кормушек нет и не будет.
const kod = await komandaProdolzheniya(dovody({
readCards: () => ({
ok: true,
cards: [rabotnik(), rabotnik({ worker_id: 'w-9', status: 'working', workspace: ugol })],
reason: null,
}),
resumeWorkerId: 'w-1',
}));
expect(rechi.join(' | ')).toMatch(/продолжен прогоном/); // видно, ЧТО именно сказали
expect(kod).toBe(0); // дошли до подъёма
});
it('🔴 П-48: угла работника нет вовсе — понятный отказ, а не обвал глубже', async () => {
const kod = await komandaProdolzheniya(dovody({
readCards: () => ({ ok: true, cards: [rabotnik({ workspace: join(tmpdir(), 'такого-угла-нет-совсем') })], reason: null }),
}));
expect(kod).toBe(1);
});
it('🔴 П-47: КАЖДАЯ причина отказа печатается владельцу В ОКНО', async () => {
// 🔴 Решение владельца 125: окно — единственная дорога до него, письма нет.
// Убери печать — краснеет; появись зов отправлялки — краснеет тоже.
const skazano = [];
const kod = await komandaProdolzheniya(dovody({
readCards: () => ({ ok: true, cards: [], reason: 'продолжать некого' }),
err: s => skazano.push(String(s)),
out: s => skazano.push(String(s)),
}));
expect(kod).toBe(1);
// 🔴 Предмет — сам факт печати владельцу, а не точные слова: причину сочиняет правило
// `vyborProdolzhaemogo`, и приколачивать её текст здесь значило бы сторожить не то.
expect(skazano.length).toBeGreaterThan(0);
expect(skazano.join(' ')).toMatch(/Продолжение не начато/);
expect(skazano.join(' ')).toMatch(/карточки работника|продолжать/i);
});
it('🔴 П-29: встал, не закрыв НИ ОДНОГО пункта — не поднят, наработки показаны', async () => {
// 🔴 Настоящее условие проверки 117: продолжать не с чего. Убираем приговор по пункту —
// засчитанных не остаётся вовсе, и `resumePlan` отказывает САМ, в правиле.
rmSync(join(workerDir(vcheraId, 'w-1'), 'items-verified.jsonl'), { force: true });
let podnyatoRaz = 0;
const kod = await komandaProdolzheniya(dovody({
podnyat: () => { podnyatoRaz += 1; return { ok: true, run_id: 'н', worker_id: 'w-1', supervisor_pid: 1 }; },
}));
expect(kod).toBe(1);
// 🔴 Работник НЕ поднят — владелец решает сам, что делать с наработками.
expect(podnyatoRaz).toBe(0);
expect(rechi.join(' ')).toMatch(/Отложено/);
});
it('🔴 грязный угол — отказа хватает, до отката и подъёма не доходим', async () => {
let otkatov = 0; let podnyatoRaz = 0;
const kod = await komandaProdolzheniya(dovody({
gryaz: () => true,
otkatchik: () => { otkatov += 1; return { ok: true }; },
podnyat: () => { podnyatoRaz += 1; return { ok: true, run_id: 'н', worker_id: 'w-1', supervisor_pid: 1 }; },
}));
expect(kod).toBe(1);
expect(otkatov).toBe(0);
expect(podnyatoRaz).toBe(0);
});
it('🔴 «спросить про грязь не удалось» — тоже отказ: вслепую не откатываем', async () => {
// 🔴 ТРЕТЬЕ состояние прибора грязи. Слить его с «чисто» значило бы откатывать вслепую.
const kod = await komandaProdolzheniya(dovody({ gryaz: () => null }));
expect(kod).toBe(1);
});
it('🔴🔴 ШОВ: НАСТОЯЩИЙ ответ budgetState проходит ворота продолжения', async () => {
// 🔴 ЗАМЕРЕНО ЖИВЬЁМ: `budgetState` отвечает полем `may_start`, а `otvetVorot` внутри
// `resumeChecks` смотрит только на `ok` — и на заведомо хорошем запасе (израсходовано 5%)
// ворота отвечали «не мерили — остаток неизвестен». Кнопка не сработала бы НИ РАЗУ,
// а оба куска зелены, потому что ни один не видит другого.
// 🪤 Проверка нарочно подаёт НАСТОЯЩИЙ ответ настоящего `budgetState`, а не удобную
// выдумку: удобная выдумка и была причиной, по которой шов не находился.
const d = mkdtempSync(join(tmpdir(), 'night-shov-'));
try {
const p = join(d, 'm.jsonl');
zapisatZamer(p, { utilization: 0.05, status: 'allowed', kind: 'five_hour', resets_at: Date.now() + 3600_000, at: Date.now() });
const nastoyashchiy = sostoyanieZapasa(p, { rate_limit_type: 'five_hour' }, Date.now());
expect(nastoyashchiy.may_start).toBe(true);
const kod = await komandaProdolzheniya(dovody({ ostatok: () => nastoyashchiy }));
expect(kod).toBe(0); // дошли до подъёма, а не упёрлись в «не мерили»
} finally { rmSync(d, { recursive: true, force: true }); }
});
// ═════════ ЗАМКИ НА ФАЙЛЫ: самое опасное место затеи ═════════
// 🔴 Продолжение не просто пишет — оно ОТКАТЫВАЕТ (`read-tree --reset -u` переписывает
// дерево целиком). Без замка откат сносит правку соседа в общей ветке и общей базе,
// МОЛЧА. Владелец обнаружит это не утром, а когда чего-то недосчитается.
it('🔴🔴 файл держит ДРУГОЙ идущий прогон — не откатываем и не поднимаем', async () => {
let otkatov = 0; let podnyatoRaz = 0;
const kod = await komandaProdolzheniya(dovody({
// Соседний прогон занял тот же файл, что задел наш работник после засчитанного пункта.
idushchie: () => ({ ok: true, reason: null, running: [
{ run_id: 'сосед', status: 'running', items: [{ id: '9', files: ['B.php'] }] },
] }),
otkatchik: () => { otkatov += 1; return { ok: true }; },
podnyat: () => { podnyatoRaz += 1; return { ok: true, run_id: 'н', worker_id: 'w-1', supervisor_pid: 1 }; },
}));
expect(kod).toBe(1);
// 🔴 Главное: до отката НЕ дошли. Дойди — чужая правка была бы снесена.
expect(otkatov).toBe(0);
expect(podnyatoRaz).toBe(0);
// И владельцу сказано, КАКОЙ файл и что откат снёс бы чужое.
expect(rechi.join(' ')).toMatch(/B\.php/);
expect(rechi.join(' ')).toMatch(/чуж/i);
});
it('🔴 счёт идущих прогонов не сошёлся — тоже не откатываем (порванная карточка)', async () => {
// 🪤 «Сколько идёт сейчас — неизвестно» нельзя считать за «никого нет»: именно так
// замок открылся бы всем ровно в ту ночь, когда запись порвалась.
let otkatov = 0;
const kod = await komandaProdolzheniya(dovody({
idushchie: () => ({ ok: false, running: [], reason: 'карточку прогона прочесть не удалось' }),
otkatchik: () => { otkatov += 1; return { ok: true }; },
}));
expect(kod).toBe(1);
expect(otkatov).toBe(0);
});
it('🔴 поднимаемый прогон САМ СЕБЕ не соперник — иначе продолжение невозможно никогда', async () => {
// 🪤 Вырезание второго рода: «файл занят идущим прогоном» — а поднимаемый прогон
// тоже есть в списке, если его карточку ещё не закрыли. Считай мы его соперником,
// кнопка отказывала бы ВСЕГДА и по причине, которой нет.
const kod = await komandaProdolzheniya(dovody({
idushchie: () => ({ ok: true, reason: null, running: [
{ run_id: vcheraId, status: 'running', items: [{ id: '1', files: ['A.php', 'B.php'] }] },
] }),
}));
expect(kod).toBe(0);
});
it('🔴 файлы ВПРАВДУ заняты за продолжающим — и заняты ДО отката', async () => {
let zanyatyVMomentOtkata = null;
const kod = await komandaProdolzheniya(dovody({
otkatchik: (a) => {
// Смотрим замок ИМЕННО в минуту отката: занять после было бы поздно.
// 🔴🔴 СПРАШИВАЕМ ЗА ЧУЖОГО, А НЕ ЗА СВОЕГО — и это не придирка, это вырезание
// второго рода, поймавшее мою же слабую проверку. `mayTouch` за СВОЕГО отвечает
// «можно» и когда файл занят им, и когда не занят НИКЕМ: пустой список занятий
// читается как «всё свободно». То есть прежняя форма этой проверки оставалась
// зелёной даже с полностью вырезанным занятием. Отказ ЧУЖОМУ — единственное,
// что доказывает, что замок вправду взят.
zanyatyVMomentOtkata = mayTouch(runDir(novyjId), 'кто-то-другой', 'B.php');
return otkatDoKommita(a);
},
}));
expect(kod).toBe(0);
expect(zanyatyVMomentOtkata?.ok).toBe(false);
});
it('🔴 отказ ПОСЛЕ занятия освобождает замок — мертвец файлы не держит', async () => {
// 🔴 Замок, переживший неудачную попытку, держал бы файлы за работником, которого нет,
// и следующая попытка упёрлась бы в него же.
const kod = await komandaProdolzheniya(dovody({
podnyat: () => ({ ok: false, reason: 'надзиратель не поднялся' }),
}));
expect(kod).toBe(1);
// Файл снова свободен для КОГО УГОДНО.
expect(mayTouch(runDir(novyjId), 'кто-то-другой', 'B.php').ok).toBe(true);
});
it('🔴 голову не назвали — подъём отказывает, и это видно владельцу', async () => {
const skazano = [];
const kod = await komandaProdolzheniya(dovody({
cfg: { disk_free_min_gb: 5 },
podnyat: () => { throw new Error('в night.config.json нет worker_model'); },
err: s => skazano.push(String(s)), out: s => skazano.push(String(s)),
}));
expect(kod).toBe(1);
expect(skazano.join(' ')).toMatch(/worker_model/);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 КРУГ Б (04.08.2026): ВТОРОЙ ЗАПУСК ПЛАТИЛ ДЕНЬГАМИ ВЛАДЕЛЬЦА
//
// ━━━ ЗАМЕРЕНО В ЖИВОМ ЖУРНАЛЕ, А НЕ ПРЕДПОЛОЖЕНО ━━━
// `.night/budget-measures.jsonl`, время московское:
// before-run 21:54:07 · before-run 21:55:09 · start 21:55:10
// before-run 22:53:59 · before-run 22:55:28 · start 22:55:29 · before-run 22:55:37
// before-run 00:29:34 · start 00:29:35 · before-run 00:29:40
// Дважды замер «перед прогоном» лёг ПОСЛЕ старта — на 8 и на 5 секунд позже. Одному
// процессу это недоступно: он этот замер уже сделал и пошёл дальше. Значит запусков было
// несколько, и опоздавший ДОХОДИЛ ДО ПЛАТНОГО ЗАМЕРА, а до старта не доходил.
//
// ━━━ ПОЧЕМУ ЭТО НЕ ЛЕЧИТСЯ ПРЕДУПРЕЖДЕНИЕМ ━━━
// Порядок важнее самого замка. В `run` платный замер стоит ДЕСЯТЫМ шагом, а все ворота,
// которые могли бы узнать про идущий прогон (потолок задач, пересечение по файлам), —
// ПОСЛЕ него. Защита, стоящая после платного шага, не защита, а отчёт о потере.
// Поэтому обе двери ниже зовутся ДО `priborZamera`.
describe('🔴 второй запуск не платит: двери стоят ДО платного замера', () => {
let zamokDir;
beforeEach(() => { zamokDir = mkdtempSync(join(tmpdir(), 'night-zamok-')); });
afterEach(() => { rmSync(zamokDir, { recursive: true, force: true }); });
const zamok = () => join(zamokDir, 'zapusk.json');
// ── ДВЕРЬ ПЕРВАЯ: по этому же плану уже кто-то идёт ────────────────────────────
describe('живой прогон по тому же плану', () => {
it('идёт прогон по ЭТОМУ плану — второй запуск отбит', () => {
const nayden = progonPoTomuZhePlanu({
running: [{ run_id: 'r-1', plan_path: 'docs/plans/proba5-odin.md' }],
planPath: 'docs/plans/proba5-odin.md',
});
expect(nayden?.run_id).toBe('r-1');
});
it('идёт прогон по ДРУГОМУ плану — вторая задача законна и НЕ отбивается', () => {
// 🔴 Обратная сторона обязательна: владелец разрешил две задачи разом
// (`max_tasks_at_once: 2`). Замок, отбивающий любой второй запуск, отнял бы у него
// половину ночи — это была бы починка ценой работы.
expect(progonPoTomuZhePlanu({
running: [{ run_id: 'r-1', plan_path: 'docs/plans/drugoy.md' }],
planPath: 'docs/plans/proba5-odin.md',
})).toBeNull();
});
it('тот же план, записанный ИНАЧЕ (косые черты, регистр), — всё равно тот же', () => {
// 🪤 Иначе замок обходится опечаткой: `docs\\plans\\p.md` и `docs/plans/p.md` —
// один файл, а строки разные. Сравнение идёт тем же прибором путей, что и везде.
expect(progonPoTomuZhePlanu({
running: [{ run_id: 'r-1', plan_path: 'docs\\plans\\Proba5-Odin.md' }],
planPath: 'docs/plans/proba5-odin.md',
})?.run_id).toBe('r-1');
});
it('идущих нет вовсе — дверь молчит', () => {
expect(progonPoTomuZhePlanu({ running: [], planPath: 'docs/plans/p.md' })).toBeNull();
});
});
// ── ДВЕРЬ ВТОРАЯ: замок на сам запуск ─────────────────────────────────────────
// Первая дверь бессильна в самый опасный миг: пока НИ ОДИН из двух запусков ещё не
// написал карточку прогона, оба видят пустое хозяйство и оба идут платить.
describe('атомарный замок — на случай «а что, если этих же самых будет ДВА одновременно»', () => {
it('🔴 ГОЛОВНАЯ: двое разом — ровно один берёт замок, второй отбит', () => {
const zhiv = () => true;
const a = vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv });
const b = vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1000, zhiv });
expect(a.vzyal).toBe(true);
expect(b.vzyal).toBe(false);
expect(b.reason).toContain('1001'); // владельцу назван тот, кто держит
});
it('замок отдан — следующий запуск проходит', () => {
const zhiv = () => true;
expect(vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv }).vzyal).toBe(true);
otdatZamokZapuska({ path: zamok(), pid: 1001 });
expect(vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 2000, zhiv }).vzyal).toBe(true);
});
it('🪤 чужой замок не отдаётся: отдающий не смеет открыть дверь тому, кто её держит', () => {
const zhiv = () => true;
vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv });
otdatZamokZapuska({ path: zamok(), pid: 1002 }); // не мой — не трогаю
expect(vzyatZamokZapuska({ path: zamok(), pid: 1003, now: 1000, zhiv }).vzyal).toBe(false);
});
// ━━━ 🔴 «ЗАМОК, ПЕРЕЖИВШИЙ ПАДЕНИЕ ХОЗЯИНА, ХУЖЕ ОТСУТСТВИЯ ЗАМКА» ━━━
// Ночь просто не начнётся, и владелец узнает об этом утром. Все четыре дороги,
// которыми замок мог бы запереть хозяйство навсегда, обязаны самолечиться.
it('хозяин замка МЁРТВ — замок перехватывается, ночь идёт', () => {
vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv: () => true });
const b = vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 2000, zhiv: () => false });
expect(b.vzyal).toBe(true);
expect(b.perehvachen).toBe(true);
expect(b.reason).toMatch(/мёрт|не жив/i);
});
it('замок ПРОСРОЧЕН (хозяин жив по номеру, но номер мог достаться другому) — перехват', () => {
// 🪤 Номера процессов система выдаёт по кругу. Живой номер в замке — не доказательство,
// что жив ИМЕННО наш хозяин. Один только признак жизни запер бы ночь навсегда,
// достанься номер чему-нибудь долгоживущему. Поэтому есть и срок.
vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv: () => true });
const b = vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1000 + PREDEL_ZAMKA_MS + 1, zhiv: () => true });
expect(b.vzyal).toBe(true);
expect(b.perehvachen).toBe(true);
expect(b.reason).toMatch(/срок|устар|давн/i);
});
it('замок ПОРВАН (не читается) — перехват, и сказано вслух', () => {
writeFileSync(zamok(), '{это не json', 'utf8');
const b = vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1000, zhiv: () => true });
expect(b.vzyal).toBe(true);
expect(b.perehvachen).toBe(true);
expect(b.reason).toMatch(/проч|порван|разобрать/i);
});
it('в замке МОЙ ЖЕ номер — это не я себя запер, а старый след: перехват', () => {
// 🪤 Номер достался мне после смерти прежнего хозяина. Признак жизни ответит «жив»
// (это же я!), и без этой ветки запуск отказал бы САМ СЕБЕ, навсегда.
writeFileSync(zamok(), JSON.stringify({ pid: 1002, at: 1000 }), 'utf8');
const b = vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1500, zhiv: () => true });
expect(b.vzyal).toBe(true);
expect(b.perehvachen).toBe(true);
});
it('🪤 в замке номер, которого не бывает, — перехват, а не «жив навсегда»', () => {
// Ноль как номер процесса означает у системы «вся моя группа», то есть «жив всегда».
writeFileSync(zamok(), JSON.stringify({ pid: 0, at: 1000 }), 'utf8');
expect(vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1500, zhiv: () => true }).vzyal).toBe(true);
});
it('🔴 замок, который не заводится вовсе (нет каталога/прав), НЕ останавливает ночь', () => {
// 🪤 Четвёртая дорога к «ночь не началась». Замок — защита от лишней копейки; остановить
// из-за него всю работу значило бы обменять копейку на ночь. Пускаем и говорим вслух.
// 🪤 ПЕРВАЯ ПОПЫТКА ЭТОЙ ПРОВЕРКИ БЫЛА ПУСТОЙ: я взял путь «каталог/файл.json/ещё.json»
// и решил, что он негоден. А он годен — такого каталога просто не было, и он создался.
// Замок брался успешно, `reason` приходил пустым, и проверка падала не на том. Чтобы
// путь вправду стал негодным, на месте будущего каталога должен лежать ФАЙЛ.
const pomeha = join(zamokDir, 'pomeha');
writeFileSync(pomeha, 'я файл, а не каталог\n', 'utf8');
const r = vzyatZamokZapuska({ path: join(pomeha, 'nelzya.json'), pid: 1001, now: 1000, zhiv: () => true });
expect(r.vzyal).toBe(true);
expect(String(r.reason)).toMatch(/не вышло|БЕЗ него/i);
});
it('🔴 ответ двери НЕ берётся из того, кого она не пускает', () => {
// Дверь смотрит на две вещи, и обе НЕ подаёт отбиваемый: сам факт существования
// файла (его создаёт система неделимо) и номер хозяина (его записал ПОБЕДИТЕЛЬ).
// Проигравший до отказа не пишет НИ БАЙТА — проверяем это размером файла.
vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv: () => true });
const bylo = readFileSync(zamok(), 'utf8');
vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1000, zhiv: () => true });
expect(readFileSync(zamok(), 'utf8')).toBe(bylo);
});
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 ШОВ: МЕРКА НА ДЕЛО, А НЕ НА ПРИБОР.
// Двери выше проверены поодиночке — но проверенная дверь, повешенная не в том месте, денег
// не бережёт. Здесь спрашивается ровно то, ради чего всё затеяно: ПОЗВАЛИ ЛИ ПЛАТНЫЙ ЗАМЕР.
// Судим по спаю на самом платном приборе, а не по коду возврата: единица приходит и когда
// дверь честно отбила, и когда до неё не дошло вовсе — код возврата тут не улика.
//
// 🪤 Живого платного зова здесь не родится ни при какой поломке: `priborZamera` подставной
// всегда. Вырезание дверей красит проверку СЧЁТОМ ЗОВОВ, а не тратит деньги владельца.
describe('🔴🔴 ШОВ: живая дорога `run` вторым запуском не доходит до денег', () => {
let ugol, zamokDir, planFile;
beforeEach(() => {
ugol = mkdtempSync(join(tmpdir(), 'night-shov-'));
zamokDir = mkdtempSync(join(tmpdir(), 'night-shov-zamok-'));
planFile = join(ugol, 'plan.md');
writeFileSync(planFile, [
'# Пробный план',
'',
'### Task 1: Единственный пункт',
'<!-- night: tests=app/tests/Feature/A.php; files=app/A.php -->',
].join('\n'), 'utf8');
writeFileSync(join(ugol, 'priyomka.md'), '# приёмочный лист\n', 'utf8');
writeFileSync(join(ugol, 'spec.md'), '# замысел\n', 'utf8');
});
afterEach(() => {
rmSync(ugol, { recursive: true, force: true });
rmSync(zamokDir, { recursive: true, force: true });
});
const putZamka = () => join(zamokDir, 'zapusk.json');
// Платный прибор — подставной и СЧИТАЮЩИЙ. Заодно смотрит, стоял ли замок В МИНУТУ ТРАТЫ:
// это и есть проверка ПОРЯДКА, а не только наличия замка.
function priborSoSchyotom() {
const zovy = [];
const f = async () => {
zovy.push({ zamokStoyal: existsSync(putZamka()) });
return { ok: false, measure: null };
};
f.zovy = zovy;
return f;
}
// 🔴 БЕСПЛАТНЫЙ ПРЕДПРОХОД ВОРОТ ПОДСТАВЛЕН ЗЕЛЁНЫМ, и это названо вслух. Здесь мерятся
// ДВЕ ДВЕРИ ПРОТИВ УДВОЕНИЯ (двойник и замок), а не ворота: план лежит во временном
// каталоге, он не опечатан, не закоммичен и замысел по нему не одобрен — настоящие ворота
// отказали бы всегда, и ни один из этих замеров не дошёл бы до места, которое он мерит.
// Свой сторож у предпрохода ниже, отдельным разделом, и там он НЕ подставной.
const VSE_PROSHLI = () => ({ ok: true, failed: [], unguarded: [], reason: null });
async function zapustit({ idushchie, zamer, besplatnyeVorota = VSE_PROSHLI }) {
const tiho = vi.spyOn(console, 'error').mockImplementation(() => {});
const tiho2 = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
return await main(
argvFor('run', planFile, '--priyomka', join(ugol, 'priyomka.md'), '--spec', join(ugol, 'spec.md')),
{
readConfig: () => ({ base_branch: 'feat/night', max_tasks_at_once: 2, max_workers_per_task: 7, probe_model: 'нет-такой-головы' }),
proverkaVladeltsa: () => [],
priborZamera: zamer,
idushchie,
zamokPut: putZamka,
besplatnyeVorota,
},
);
} finally { tiho.mockRestore(); tiho2.mockRestore(); }
}
it('🔴 ГОЛОВНАЯ МЕРКА: по этому плану уже идёт прогон — платного замера НЕ БЫЛО НИ РАЗУ', () => expect(
(async () => {
const zamer = priborSoSchyotom();
const kod = await zapustit({
zamer,
idushchie: () => ({ ok: true, running: [{ run_id: 'r-uzhe-idyot', plan_path: planFile }], mertvye: [], neyasnye: [], reason: null }),
});
return { kod, zovov: zamer.zovy.length };
})(),
).resolves.toEqual({ kod: 1, zovov: 0 }));
it('идёт прогон по ДРУГОМУ плану — вторая задача законна, замер делается', async () => {
// 🔴 Обратная сторона: починка, отбивающая всё подряд, отняла бы у владельца половину
// ночи (`max_tasks_at_once: 2`). Зелень одной стороны без другой ничего не доказывает.
const zamer = priborSoSchyotom();
await zapustit({
zamer,
idushchie: () => ({ ok: true, running: [{ run_id: 'r-drugoy', plan_path: join(ugol, 'sovsem-drugoy.md') }], mertvye: [], neyasnye: [], reason: null }),
});
expect(zamer.zovy.length).toBe(1);
});
it('🔴 ПОРЯДОК: в минуту траты замок УЖЕ стоял', async () => {
// Защита, стоящая ПОСЛЕ платного шага, — не защита, а отчёт о потере. Проверяем не
// «замок существует когда-нибудь», а «замок существовал в ту самую секунду, когда платили».
const zamer = priborSoSchyotom();
await zapustit({ zamer, idushchie: () => ({ ok: true, running: [], mertvye: [], neyasnye: [], reason: null }) });
expect(zamer.zovy[0].zamokStoyal).toBe(true);
});
it('на выходе замок ОТДАН — следующий честный запуск не упрётся в него', async () => {
await zapustit({ zamer: priborSoSchyotom(), idushchie: () => ({ ok: true, running: [], mertvye: [], neyasnye: [], reason: null }) });
expect(existsSync(putZamka())).toBe(false);
});
it('🔴 два запуска ПОДРЯД в одном процессе оба доходят до замера — замок не копится', () => expect(
(async () => {
const idushchie = () => ({ ok: true, running: [], mertvye: [], neyasnye: [], reason: null });
const a = priborSoSchyotom(); await zapustit({ zamer: a, idushchie });
const b = priborSoSchyotom(); await zapustit({ zamer: b, idushchie });
return a.zovy.length + b.zovy.length;
})(),
).resolves.toBe(2));
});
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 КРУГ Б, ГЛАВНАЯ ДЕНЕЖНАЯ ТРАТА ЗАТЕИ (04.08.2026): БЕСПЛАТНОЕ — ВЫШЕ ПЛАТНОГО.
//
// ━━━ ЗАМЕРЕНО ПО СЛЕДУ ОПЛАТЫ ━━━
// В `.night/probe-raw/` лежит по файлу на КАЖДЫЙ платный запуск прибора, и в каждом —
// строка `total_cost_usd`. Три замера «перед прогоном», после которых старта не случилось:
// 19:31:36 → 0,03505 $ · 21:54:07 → 0,03409 $ · 22:53:59 → 0,03768 $ ═ 0,10682 $
// Против 0,00767 $ на удвоении запуска (0,00408 + 0,00360), которое чинили выше. Вчетырнадцать
// раз дороже. 🪤 Средней ценой это считать нельзя: те же 15 запусков стоили от 0,0033 до
// 0,1483 $ за штуку — разброс в сорок пять раз, и «событий × среднюю» соврало бы втрое.
//
// ━━━ ЧТО СТОРОЖИМ — УСТРОЙСТВО, А НЕ ЧИСЛА ━━━
// 🪤 Проверка вида «поле A не равно полю B» зелена там, где равенство законно, и потому
// не сторожит ничего. Здесь спрашивается другое: КТО ВПРАВЕ СДЕЛАТЬ ПЛАТНЫЙ ШАГ И ПОСЛЕ ЧЕГО.
// Сторож обязан покраснеть от одной только перестановки, даже если код при этом рабочий.
describe('🔴🔴 бесплатные ворота стоят ВЫШЕ платного замера', () => {
// ── Отказ КАЖДЫХ бесплатных ворот, по одному входу на каждые ────────────────────
// 🔴 Список берётся из `GATE_ORDER` — единственного места, где записан состав ворот.
// Здесь только сказано, чем КОРМИТЬ каждые, чтобы они отказали.
const CHEM_LOMAT = {
'место на сервере': { disk: { ok: false, reason: 'на диске 2 ГБ, а надо 5' } },
'замысел проверен': { spec: { ok: false, reason: 'замысел никем не одобрен' } },
'бумаги сохранены': { papers: { ok: false, reason: 'план не закоммичен' } },
'отпечаток плана': { planSeal: { ok: false, reason: 'план правили после печати' } },
'потолки задач и работников': { capacity: { tasks_running: 9, max_tasks: 2, max_workers: 7, workers_in_task: 1 } },
'задачи не пересекаются по файлам': { newTaskFiles: ['app/A.php'], runningTasksFiles: [['app/A.php']] },
};
// Вход, при котором ВСЕ бесплатные ворота довольны. Денежных ответов в нём нет вовсе —
// их в эту минуту и не бывает.
const VSYO_HOROSHO = {
disk: { ok: true }, spec: { ok: true }, papers: { ok: true }, planSeal: { ok: true },
capacity: { tasks_running: 0, max_tasks: 2, max_workers: 7, workers_in_task: 1 },
newTaskFiles: ['app/A.php'], runningTasksFiles: [],
};
it('🔴 ГОЛОВНАЯ: без единого ответа про деньги предпроход всё равно ПРОПУСКАЕТ', () => {
// Иначе починка была бы хуже беды: ночь не начиналась бы никогда.
const r = besplatnyeVorotaDoDeneg({ vhod: VSYO_HOROSHO });
expect(r.ok).toBe(true);
expect(r.failed).toEqual([]);
});
it('🔴🔴 денежные ворота — ЕДИНСТВЕННЫЕ, чей приговор тут не считается', () => {
// 🪤 Сторож на подмену смысла: подставь кто-нибудь сюда выдуманный зелёный ответ про
// запас вместо «не спрашиваем» — и ночь пошла бы платить при кончившемся запасе.
// Проверяем не значение, а УСТРОЙСТВО: воротам запаса подан `null`, то есть их не
// спрашивали, и их отказ отброшен ИМЕНЕМ, а не потому, что они случайно смолчали.
const nastoyashchie = startGates({ ...VSYO_HOROSHO, budget: null });
expect(nastoyashchie.ok).toBe(false);
expect(nastoyashchie.failed.map(f => f.gate)).toEqual([VOROTA_DENEG]);
// А предпроход при том же входе — пропускает.
expect(besplatnyeVorotaDoDeneg({ vhod: VSYO_HOROSHO }).ok).toBe(true);
});
it('🔴 имя денежных ворот вправду есть в составе ворот', () => {
// 🪤 Переименуй кто-нибудь ворота в `gates.mjs` — отбрасывание по имени промолчало бы,
// и предпроход стал бы отказывать всегда (либо, что хуже, пропускать деньги).
expect(GATE_ORDER).toContain(VOROTA_DENEG);
});
it('🔴🔴 КАЖДЫЕ бесплатные ворота из состава вправду умеют отказать ДО денег', () => {
// 🔴 Ходим по `GATE_ORDER`, а не по своему списку. Добавят десятые ворота — этот сторож
// покраснеет и потребует сказать вслух, платные они или бесплатные. Молча проскочить
// мимо решения «а этим нужен замер?» больше нельзя.
const nestorozhat = besplatnyeVorotaDoDeneg({ vhod: VSYO_HOROSHO }).unguarded;
for (const vorota of GATE_ORDER) {
if (vorota === VOROTA_DENEG) continue;
if (nestorozhat.includes(vorota)) continue; // правило им пишет кусок 7, отказывать нечем
const chem = CHEM_LOMAT[vorota];
expect(chem, `для ворот «${vorota}» не сказано, чем их сломать: они новые и никто не решил, платные они или нет`).toBeTruthy();
const r = besplatnyeVorotaDoDeneg({ vhod: { ...VSYO_HOROSHO, ...chem } });
expect(r.ok, `ворота «${vorota}» не отказали до денег`).toBe(false);
expect(r.failed.map(f => f.gate)).toContain(vorota);
}
});
it('слова отказа говорят владельцу, что денег это не стоило', () => {
const slova = strokiOtkazaDoDeneg(besplatnyeVorotaDoDeneg({ vhod: { ...VSYO_HOROSHO, ...CHEM_LOMAT['место на сервере'] } })).join(' ');
expect(slova).toMatch(/не запущен/i);
expect(slova).toMatch(/2 ГБ/);
expect(slova).toMatch(/не стоило|не запускался/i);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 ШОВ ВТОРОЙ: ЖИВАЯ ДОРОГА `run` ПРИ ОТКАЗЕ БЕСПЛАТНЫХ ВОРОТ НЕ ДОХОДИТ ДО ДЕНЕГ.
// Проверенная дверь, повешенная не в том месте, денег не бережёт. Судим спаем на платном
// приборе, а не кодом возврата: единица приходит и когда дверь честно отбила, и когда
// до неё не дошло вовсе.
// 🪤 Живого платного зова здесь не родится ни при какой поломке: прибор подставной всегда.
describe('🔴🔴 ШОВ: отказ бесплатных ворот НЕ стоит ни копейки', () => {
let ugol, zamokDir, planFile;
beforeEach(() => {
ugol = mkdtempSync(join(tmpdir(), 'night-shov2-'));
zamokDir = mkdtempSync(join(tmpdir(), 'night-shov2-zamok-'));
planFile = join(ugol, 'plan.md');
writeFileSync(planFile, [
'# Пробный план',
'',
'### Task 1: Единственный пункт',
'<!-- night: tests=app/tests/Feature/A.php; files=app/A.php -->',
].join('\n'), 'utf8');
writeFileSync(join(ugol, 'priyomka.md'), '# приёмочный лист\n', 'utf8');
writeFileSync(join(ugol, 'spec.md'), '# замысел\n', 'utf8');
});
afterEach(() => {
rmSync(ugol, { recursive: true, force: true });
rmSync(zamokDir, { recursive: true, force: true });
});
const putZamka = () => join(zamokDir, 'zapusk.json');
// Платный прибор — подставной и СЧИТАЮЩИЙ. Заодно смотрит, спросили ли к минуте траты
// бесплатные ворота: это и есть проверка ПОРЯДКА, а не только наличия предпрохода.
function priborSoSchyotom(poryadok) {
const zovy = [];
const f = async () => { zovy.push(poryadok.slice()); poryadok.push('деньги'); return { ok: false, measure: null }; };
f.zovy = zovy;
return f;
}
async function zapustit({ vorotaOtvet, poryadok = [] }) {
const zamer = priborSoSchyotom(poryadok);
const vidennoe = [];
const tiho = vi.spyOn(console, 'error').mockImplementation(() => {});
const tiho2 = vi.spyOn(console, 'log').mockImplementation(() => {});
let kod;
try {
kod = await main(
argvFor('run', planFile, '--priyomka', join(ugol, 'priyomka.md'), '--spec', join(ugol, 'spec.md')),
{
readConfig: () => ({ base_branch: 'feat/night', max_tasks_at_once: 2, max_workers_per_task: 7, probe_model: 'нет-такой-головы' }),
proverkaVladeltsa: () => [],
priborZamera: zamer,
idushchie: () => ({ ok: true, running: [], mertvye: [], neyasnye: [], reason: null }),
zamokPut: putZamka,
besplatnyeVorota: ({ vhod }) => { poryadok.push('бесплатные'); vidennoe.push(vhod); return vorotaOtvet; },
},
);
} finally { tiho.mockRestore(); tiho2.mockRestore(); }
return { kod, zovov: zamer.zovy.length, poryadok, vhod: vidennoe[0] };
}
it('🔴 ГОЛОВНАЯ МЕРКА: бесплатные ворота отказали — платного замера НЕ БЫЛО НИ РАЗУ', async () => {
const r = await zapustit({
vorotaOtvet: { ok: false, failed: [{ gate: 'место на сервере', reason: 'на диске 2 ГБ' }], unguarded: [] },
});
expect(r.zovov).toBe(0);
expect(r.kod).toBe(1);
});
it('🔴 ПОРЯДОК: бесплатные ворота спрошены РАНЬШЕ денег, а не «где-то тоже есть»', async () => {
const r = await zapustit({ vorotaOtvet: { ok: true, failed: [], unguarded: [] } });
expect(r.zovov).toBe(1);
expect(r.poryadok).toEqual(['бесплатные', 'деньги']);
});
it('🔴🔴 предпроходу поданы ВСЕ бесплатные ответы, а не половина', async () => {
// 🪤 Самая тихая поломка из возможных: предпроход стоит на месте и зелен, а `papers`
// или `spec` в него не положили — тогда эти ворота молча уезжают обратно ВНИЗ,
// за платный шаг, и их отказ снова начинает стоить денег. Смотрим сам поданный вход.
const r = await zapustit({ vorotaOtvet: { ok: true, failed: [], unguarded: [] } });
for (const pole of ['disk', 'planSeal', 'papers', 'spec', 'capacity', 'newTaskFiles', 'runningTasksFiles']) {
expect(r.vhod[pole], `бесплатным воротам не подан ответ «${pole}» — эти ворота уехали за платный шаг`).toBeDefined();
expect(r.vhod[pole]).not.toBeNull();
}
// 🔴 И обратная сторона: ответа про деньги в предпроходе быть НЕ должно — его в эту
// минуту неоткуда взять, а положенный сюда старый ответ был бы решением по вчерашнему числу.
expect(r.vhod.budget).toBeNull();
});
});