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

1141 lines
77 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 } 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 } from './cli.mjs';
import { readLines } from './state.mjs';
import { razborDovodov, zamerDovody, vhodVorot, strokiOtkaza, odinVid } from './cli-parts.mjs';
// 🔴 Кусок 3, задача 6. `startGates` — НАСТОЯЩИЕ ворота: числа идут в них не рукой,
// а через `vhodVorot`. `odinVidPuti` — общая мелочь про пути (задача 2, `paths.mjs`),
// ввозится ради одной проверки: что она и `odinVid` куска 2 отвечают одинаково.
import { startGates } from './gates.mjs';
import { odinVidPuti } from './paths.mjs';
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([]);
});
});
// ============ блок 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: []` единственному пункту — обе половины останутся зелёными, а ночь молча
// вернётся к «ворота пересечения не держат никогда». Поэтому смотрим текстом.
const zovVorot = TEKST_CLI.match(/planRun\(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 по месту разъехались бы молча: поправят один, забудут другой.
expect((TEKST_CLI.match(/idushchieProgony\(\)/g) ?? []).length).toBe(2);
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('всё цело — обёртка отдаёт список как есть (не глухая)', () => {
const spisok = [{ run_id: 'r-1', status: 'running' }];
const r = idushchieProgony(() => spisok);
expect(r.ok).toBe(true);
expect(r.running).toBe(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('🔴 отказ по плану печатается ДО крошечного замера остатка', () => {
// 🔴🔴 Замер идёт программой `claude` из подписки, то есть стоит денег. План, который
// заведомо не пройдёт, не стоит ни одного платного зова. И только при этом порядке
// отказ можно посмотреть живьём глазами: запуск не рождает ни замера, ни угла,
// ни живого работника.
const iPlan = BLOK_RUN.indexOf('validatePlan(');
const iZamer = BLOK_RUN.indexOf('zamerSoSchyotom(');
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('zamerSoSchyotom(');
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(/planRun\(vhodVorot\(\{([\s\S]*?)\}\)\)/)[1]
.split('\n').filter(s => !s.trim().startsWith('//')).join('\n');
expect(zovVorot).not.toMatch(/planPath/);
// 🪤 И вторая половина: своего, второго чтения файла плана в `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);
});
});