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

845 lines
55 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: вид предела — НЕДЕЛЬНЫЙ; нет строки в настройках — всё равно недельный', () => {
// 🪤 Тихая беда: спросив пятичасовой предел, мы решали бы о недельных деньгах
// по чужому прибору — и число выглядело бы совершенно правдоподобно.
expect(zamerDovody({ ...CFG, rate_limit_type: 'five_hour' }).kind).toBe('five_hour');
expect(zamerDovody({ ...CFG, rate_limit_type: undefined }).kind).toBe('seven_day');
});
});
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');
const punkt = TEKST_CLI.match(/const items = \[\{([\s\S]*?)\}\];/)[1];
expect(punkt).toMatch(/files:\s*razbor\.files/);
expect(punkt).not.toMatch(/files:\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();
});
});