Files
portal/tools/night/plan-lock-hook.test.mjs
T

495 lines
32 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.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative, resolve } from 'node:path';
import { planLockVerdict, readRuns } from './plan-lock-hook.mjs';
import { assertPlanEditable, sealPlan, checkSeal } from './plan-fingerprint.mjs';
import { runsDir, runDir, samePath } from './paths.mjs';
import { VIDY, schitatZanyatym, sostoyanieProgona } from './zhiv-li-progon.mjs';
const PLAN = 'docs/superpowers/plans/2026-08-01-пример.md';
const OTHER = 'docs/superpowers/plans/2026-08-01-другой.md';
const running = { run_id: 'r-2026-08-01-2200', status: 'running', plan_path: PLAN };
let dir;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'night-planlock-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
function makeRunCard(runsRoot, runId, text) {
mkdirSync(join(runsRoot, runId), { recursive: true });
writeFileSync(join(runsRoot, runId, 'run.json'), text, 'utf8');
}
describe('samePath — один и тот же файл, записанный по-разному', () => {
it('обратные косые и прямые — это один файл', () => {
expect(samePath('docs\\superpowers\\plans\\п.md', 'docs/superpowers/plans/п.md')).toBe(true);
});
it('точка со слешем впереди ничего не меняет', () => {
expect(samePath('./' + PLAN, PLAN)).toBe(true);
});
it('разные файлы остаются разными', () => {
expect(samePath(PLAN, OTHER)).toBe(false);
});
it('пустой путь ни с чем не совпадает — иначе замок ловил бы всё подряд', () => {
expect(samePath(null, PLAN)).toBe(false);
expect(samePath(PLAN, '')).toBe(false);
});
it('ПОЛНЫЙ путь и путь от корня хранилища — это один файл', () => {
// 🔴 Дыра 2. Без `resolve` внутри `samePath` эти две записи не сходились, и замок
// молча пропускал правку идущего плана. Вырежи `resolve` — покраснеет здесь.
expect(samePath(resolve(PLAN), PLAN)).toBe(true);
expect(samePath(PLAN, resolve(PLAN))).toBe(true);
});
it('полный путь с обратными косыми (как его пишет владелец) — тоже тот же файл', () => {
expect(samePath(resolve(PLAN).replace(/\//g, '\\'), PLAN)).toBe(true);
});
it('полные пути РАЗНЫХ планов остаются разными — замок не начал ловить всё подряд', () => {
expect(samePath(resolve(PLAN), resolve(OTHER))).toBe(false);
expect(samePath(resolve(PLAN), OTHER)).toBe(false);
});
});
describe('замок и печать сравнивают путь ОДИНАКОВО (дыра 2)', () => {
it('что печать считает одним планом, то и замок считает одним планом', () => {
// 🔴 Сравнивателей было двое — `samePlanPath` у печати (plan-fingerprint.mjs) и
// `samePath` у замка, дословными копиями. Теперь он один и живёт в paths.mjs, но эта
// проверка остаётся: она сторожит не место жительства, а СОГЛАСИЕ двух сторон и идёт
// к печати через её единственный наружный вход `checkSeal`. Разведи поведение —
// сними `resolve` в `samePath` — и покраснеет здесь.
const planAbs = join(dir, 'план.md');
writeFileSync(planAbs, '# план\nтекст\n', 'utf8');
const planRel = relative(process.cwd(), planAbs);
const sealPath = join(dir, 'seal.json');
sealPlan(sealPath, planAbs, { approved_by: 'owner', at: 1 });
// Печать снята с ПОЛНОГО пути, а запускают по пути ОТ КОРНЯ — печать это принимает.
expect(checkSeal(sealPath, planRel).ok).toBe(true);
// Значит и замок обязан считать это одним и тем же файлом.
expect(samePath(planRel, planAbs)).toBe(true);
});
});
describe('planLockVerdict — правку идущего плана не пропускаем (проверка 43, часть)', () => {
it('идущих прогонов нет — правь сколько хочешь', () => {
const v = planLockVerdict({ staged: [PLAN], runs: [] });
expect(v.ok).toBe(true);
expect(v.blocked).toEqual([]);
});
it('прогон идёт, но правят ДРУГОЙ файл — проходит', () => {
const v = planLockVerdict({ staged: [OTHER], runs: [running] });
expect(v.ok).toBe(true);
});
it('прогон идёт, правят ЕГО план — ОТКАЗ, и назван номер прогона', () => {
const v = planLockVerdict({ staged: [PLAN], runs: [running] });
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([PLAN]);
expect(v.reason).toContain('r-2026-08-01-2200');
});
it('путь записан обратными косыми — всё равно ОТКАЗ', () => {
// 🪤 Владелец зовёт запуск с `docs\...`, а git отдаёт хуку `docs/...`.
// Сравни как есть — замок не поймал бы ничего, зеленея на проверках.
const v = planLockVerdict({
staged: ['docs/superpowers/plans/2026-08-01-пример.md'],
runs: [{ ...running, plan_path: 'docs\\superpowers\\plans\\2026-08-01-пример.md' }],
});
expect(v.ok).toBe(false);
});
it('причина взята у САМОГО правила, а не написана рядом своими словами', () => {
// 🔴 Два текста про одно правило разошлись бы молча: правило поправили, хук говорит старое.
const v = planLockVerdict({ staged: [PLAN], runs: [running] });
const rule = assertPlanEditable({ status: 'running', run_id: 'r-2026-08-01-2200' });
expect(v.reason).toContain(rule.reason);
});
it('в отказе сказано, ЧТО делать: остановить прогон командой «стоп всё»', () => {
const v = planLockVerdict({ staged: [PLAN], runs: [running] });
expect(v.reason).toMatch(/стоп вс[её]/i);
});
it('прогон кончился (finished) — замок снялся САМ, правка проходит', () => {
const v = planLockVerdict({ staged: [PLAN], runs: [{ ...running, status: 'finished' }] });
expect(v.ok).toBe(true);
});
it('прогон принят владельцем (accepted) — тоже проходит', () => {
const v = planLockVerdict({ staged: [PLAN], runs: [{ ...running, status: 'accepted' }] });
expect(v.ok).toBe(true);
});
it('идут двое — ловится план ЛЮБОГО из них, а не только первого', () => {
const second = { run_id: 'r-2026-08-01-2300', status: 'running', plan_path: OTHER };
const v = planLockVerdict({ staged: [OTHER], runs: [running, second] });
expect(v.ok).toBe(false);
expect(v.reason).toContain('r-2026-08-01-2300');
});
it('в коммите и план идущего прогона, и посторонний файл — отказ, и назван ровно план', () => {
const v = planLockVerdict({ staged: ['README.md', PLAN, 'tools/night/disk.mjs'], runs: [running] });
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([PLAN]);
});
it('идут двое по ОДНОМУ плану — файл назван один раз, а не дважды', () => {
// 🔴 Двенадцатая проверка замка — её не хватало, и число в шаге 4 не сходилось.
// Без защиты от повтора один и тот же файл лёг бы в `blocked` столько раз, сколько
// идёт прогонов по нему, и владелец прочёл бы «не принят план, не принят план».
// Номера обоих прогонов при этом назвать НАДО: остановить придётся оба.
const second = { run_id: 'r-2026-08-01-2300', status: 'running', plan_path: PLAN };
const v = planLockVerdict({ staged: [PLAN], runs: [running, second] });
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([PLAN]);
expect(v.reason).toContain('r-2026-08-01-2200');
expect(v.reason).toContain('r-2026-08-01-2300');
});
it('у идущего прогона плана не записано вовсе — замок не выдумывает и пропускает', () => {
// 🪤 Ноль почти всегда сбой, но здесь запирать нечего: не зная, какой план идёт,
// мы заперли бы владельцу ЛЮБУЮ правку любого плана — и он снёс бы замок целиком.
const v = planLockVerdict({ staged: [PLAN], runs: [{ run_id: 'r-9', status: 'running', plan_path: null }] });
expect(v.ok).toBe(true);
});
it('правят план ПОЛНЫМ путём, а в карточке он от корня — всё равно ОТКАЗ (дыра 2)', () => {
const v = planLockVerdict({ staged: [resolve(PLAN)], runs: [running] });
expect(v.ok).toBe(false);
expect(v.reason).toContain('r-2026-08-01-2200');
});
it('сторож подал путь от корня, а прогон запускали ПОЛНЫМ — тоже ОТКАЗ (дыра 2, боевой случай)', () => {
// 🔴 ЗАМЕРЕНО: lefthook 2.1.8 подставляет `{staged_files}` путём ОТ КОРНЯ хранилища
// (`docs/superpowers/plans/…`). А владелец запускает прогон полным путём, и полный путь
// ложится в карточку прогона. Именно эта пара и расходилась.
const v = planLockVerdict({
staged: [PLAN],
runs: [{ ...running, plan_path: resolve(PLAN).replace(/\//g, '\\') }],
});
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([PLAN]);
});
});
describe('ПОКОЙНИК НЕ ДЕРЖИТ ПЛАН ЗАЛОЖНИКОМ (дыра 3: слову «running» верили на слово)', () => {
// Подставной судья: отвечает заранее назначенным видом, как настоящий прибор.
const sudyaOtvechayet = vid => (run, o) => ({ vid, run_id: run.run_id, pochemu: `прогон ${run.run_id}: ${vid}`, dano: o });
it('карточка говорит «идёт», а прогона на деле НЕТ — правка ПРОХОДИТ', () => {
// 🔴 Это и есть сегодняшняя беда владельца: прогон брошен в час ночи, процесса давно нет,
// а план по нему нельзя было поправить НИКОГДА. Вырезание: вернуть строку 28 к виду
// `runs.filter(… status === 'running' …)` без спроса о живости — покраснеет здесь.
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet('mertv') });
expect(v.ok).toBe(true);
expect(v.blocked).toEqual([]);
});
it('прогон вправду ИДЁТ — правка по-прежнему НЕ проходит', () => {
// 🔴 Обратная половина, и она дороже первой: ошибись замок в сторону «мёртв» — правка
// легла бы под живого работника. Вырезание: пустить всех подряд — покраснеет здесь.
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet('idyot') });
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([PLAN]);
});
it('прогон заведён только что, надзиратель ещё не отметился — замок ЗАКРЫТ', () => {
// 🪤 Между записью карточки и подъёмом надзирателей прогон выглядит точь-в-точь как
// мертворождённый. Открыть замок в это окно — впустить правку под работника, который
// родится через секунду.
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet('tolko_zavedyon') });
expect(v.ok).toBe(false);
});
it('живость прочесть не удалось — замок ЗАКРЫТ, «не знаю» это не «мёртв»', () => {
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet('ne_prochest') });
expect(v.ok).toBe(false);
});
it('замок открыт РОВНО на тех видах, что и потолок задач, — правило одно на затею', () => {
// 🔴 Своего второго списка «что считать занятым» у замка нет и быть не должно: два
// списка про одно разъехались бы молча. Вырезание: заменить `schitatZanyatym` на
// свой `vid !== 'mertv' && vid !== 'ne_prochest'` — покраснеет здесь.
expect(VIDY.length).toBeGreaterThan(1);
for (const vid of VIDY) {
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet(vid) });
expect([vid, v.ok]).toEqual([vid, !schitatZanyatym(vid)]);
}
});
it('спрашивают про КАТАЛОГ ИЗ КАРТОЧКИ, а не про путь, собранный из номера прогона', () => {
// 🪤 Номер прогона живёт ВНУТРИ карточки и с именем папки совпадать не обязан.
// Собери замок путь из номера — он спросил бы про несуществующий каталог.
let sprosili = null;
planLockVerdict({
staged: [PLAN],
runs: [{ ...running, card_dir: 'X/папка-с-другим-именем' }],
sudit: (run, o) => { sprosili = o.dir; return { vid: 'mertv', run_id: run.run_id, pochemu: '' }; },
});
expect(sprosili).toBe('X/папка-с-другим-именем');
});
it('покойник НАЗВАН владельцу: карточку надо убрать рукой, сама затея её не тронет', () => {
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet('mertv') });
expect(v.pokoyniki).toHaveLength(1);
expect(v.pokoyniki[0].run_id).toBe('r-2026-08-01-2200');
});
it('покойник по ЧУЖОМУ плану владельцу не поминается — иначе строка при каждом коммите', () => {
const v = planLockVerdict({ staged: [OTHER], runs: [running], sudit: sudyaOtvechayet('mertv') });
expect(v.ok).toBe(true);
expect(v.pokoyniki).toEqual([]);
});
it('покойник рядом с живым: живой держит свой план, покойник свой не держит', () => {
const zhivoy = { run_id: 'r-живой', status: 'running', plan_path: OTHER };
const v = planLockVerdict({
staged: [PLAN, OTHER],
runs: [running, zhivoy],
sudit: (run) => ({ vid: run.run_id === 'r-живой' ? 'idyot' : 'mertv', run_id: run.run_id, pochemu: '' }),
});
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([OTHER]);
});
it('битую карточку у прибора живости НЕ спрашивают вовсе — она заперта раньше и сама по себе', () => {
// 🪤 У битой карточки не прочесть ни номера прогона, ни плана. Спроси мы прибор — он
// честно ответил бы «не прочесть», но замок и так закрыт, а лишний зов путал бы счёт.
let zvali = 0;
const v = planLockVerdict({
staged: [PLAN],
runs: [{ run_id: 'r-порван', broken: true, card_path: 'X/run.json' }],
sudit: () => { zvali += 1; return { vid: 'mertv', pochemu: '' }; },
});
expect(zvali).toBe(0);
expect(v.ok).toBe(false);
});
it('НАСТОЯЩИЙ прибор живости на карточке сегодняшнего вида: покойник план не держит', () => {
// 🔴 Здесь подставного судьи НЕТ — работает живой `sostoyanieProgona` по настоящему
// журналу на диске. Карточка сложена ровно как сегодняшняя брошенная:
// «running», журнал есть, а записи о надзирателе в нём нет, и с начала прошло 18 часов.
const staryy = Date.now() - 18 * 3600_000;
const card = { run_id: 'r-брошенный', status: 'running', plan_path: PLAN, started_at: staryy };
makeRunCard(dir, 'r-брошенный', JSON.stringify(card));
writeFileSync(join(dir, 'r-брошенный', 'events.jsonl'), '{"kind":"run_start"}\n', 'utf8');
const v = planLockVerdict({ staged: [PLAN], runs: readRuns(dir) });
expect(v.ok).toBe(true);
expect(v.pokoyniki.map(p => p.run_id)).toEqual(['r-брошенный']);
});
it('НАСТОЯЩИЙ прибор живости: прогон, который ведёт ЖИВОЙ процесс, план держит', () => {
// Живой номер процесса на этой машине заведомо есть — наш собственный.
const card = { run_id: 'r-живой', status: 'running', plan_path: PLAN, started_at: Date.now() - 3600_000 };
makeRunCard(dir, 'r-живой', JSON.stringify(card));
writeFileSync(
join(dir, 'r-живой', 'events.jsonl'),
`${JSON.stringify({ kind: 'supervisor_start', supervisor_pid: process.pid })}\n`,
'utf8',
);
const v = planLockVerdict({ staged: [PLAN], runs: readRuns(dir) });
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([PLAN]);
});
});
describe('ТРЕТЬЯ ДВЕРЬ: прогон-ПРОДОЛЖЕНИЕ судится тем же прибором и тем же правилом', () => {
// ━━━ ЧТО ЗДЕСЬ БЫЛО РАНЬШЕ И ПОЧЕМУ ПЕРЕПИСАНО (03.08.2026) ━━━
// 🔴 Эти сторожа стерегли ЗАПЛАТКУ: замок не отдавал продолжение прибору на суд вовсе,
// потому что прибор искал в журнале только `supervisor_start`, а продолжение объявляет
// надзирателя словом `resume_start` (run.mjs ~688) — и живой прогон получал ответ «мёртв».
// Первый из них прямо ЗАКРЕПЛЯЛ БАГ: он требовал от прибора ответа «мёртв» при заведомо
// живом номере процесса в журнале, то есть чинить прибор было нельзя, не покрасив его.
//
// Прибор починен (`VIDY_ZAPISEY_NADZIRATELYA` в zhiv-li-progon.mjs читает обе записи),
// заплатка снята, и сторожа перенацелены: теперь они стерегут ПОЧИНКУ, а не обход.
// Правило у продолжения ровно то же, что у всех: закрыто на всём, кроме доказанно мёртвого.
const prodolzhenie = { ...running, run_id: 'r-продолжение', resumed_from: 'r-вчерашний' };
it('🔴 НАСТОЯЩИЙ прибор на продолжении говорит ПРАВДУ: живой надзиратель — прогон идёт', () => {
// 🔴 Тот же опыт, что доказывал беду, теперь доказывает починку: номер в журнале — наш
// собственный, заведомо живой, а запись — та, которой пользуется только продолжение.
// Вырезание: вернуть в прибор отбор `row?.kind !== 'supervisor_start'` — сторож краснеет,
// и вместе с ним правка плана легла бы ПОД ЖИВОГО РАБОТНИКА.
const card = { run_id: 'r-п', status: 'running', plan_path: PLAN, started_at: Date.now() - 3600_000 };
makeRunCard(dir, 'r-п', JSON.stringify(card));
writeFileSync(
join(dir, 'r-п', 'events.jsonl'),
`${JSON.stringify({ kind: 'resume_start', worker: 'w1', supervisor_pid: process.pid })}\n`,
'utf8',
);
const sud = sostoyanieProgona(card, { dir: join(dir, 'r-п') });
expect(sud.vid).toBe('idyot');
expect(schitatZanyatym(sud.vid)).toBe(true);
});
it('🔴 ЖИВОЕ продолжение целиком, карточка с диска: замок ЗАКРЫТ', () => {
// Живой номер процесса на этой машине заведомо есть — наш собственный.
const card = { ...prodolzhenie, started_at: Date.now() - 3600_000 };
makeRunCard(dir, 'r-продолжение', JSON.stringify(card));
writeFileSync(
join(dir, 'r-продолжение', 'events.jsonl'),
`${JSON.stringify({ kind: 'resume_start', worker: 'w1', supervisor_pid: process.pid })}\n`,
'utf8',
);
const v = planLockVerdict({ staged: [PLAN], runs: readRuns(dir) });
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([PLAN]);
});
it('🔴 БРОШЕННОЕ продолжение план больше НЕ держит — заплатка держала бы его вечно', () => {
// 🔴 Ровно та беда, ради которой всё затевалось, и ровно то, что заплатка вернула бы:
// при ней замок на брошенном продолжении оставался бы закрытым НАВСЕГДА.
// Вырезание: вернуть в замок строку `if (run.resumed_from) { going.push(…); continue; }` —
// сторож краснеет.
const card = { ...prodolzhenie, started_at: Date.now() - 18 * 3600_000 };
makeRunCard(dir, 'r-продолжение', JSON.stringify(card));
writeFileSync(join(dir, 'r-продолжение', 'events.jsonl'), '{"kind":"resume_start","supervisor_pid":999999}\n', 'utf8');
const v = planLockVerdict({ staged: [PLAN], runs: readRuns(dir) });
expect(v.ok).toBe(true);
expect(v.pokoyniki.map(p => p.run_id)).toEqual(['r-продолжение']);
});
it('🔴 про продолжение прибор ТЕПЕРЬ СПРАШИВАЮТ — как про любой другой прогон', () => {
// Прежде замок не звал прибор вовсе (`zvali === 0`). Второе мнение о живости, живущее
// рядом с прибором, разъезжается с ним молча — поэтому мнение должно быть одно.
let zvali = 0;
planLockVerdict({ staged: [PLAN], runs: [prodolzhenie], sudit: () => { zvali += 1; return { vid: 'mertv' }; } });
expect(zvali).toBe(1);
});
it('🔴 владельцу больше НЕ говорят «затея пока не умеет» — эта фраза стала ложью', () => {
// 🔴 Фраза объявляла, что проверить живость продолжения нечем. Прибор умеет, и владелец
// (не программист) проверить такую строку не может — ложь в его строке дороже заплатки.
// Вырезание: вернуть в замок кусок `nesudim ? ' Отдельно: … не умеет …' : ''` — краснеет.
const v = planLockVerdict({ staged: [PLAN], runs: [prodolzhenie], sudit: () => ({ vid: 'idyot' }) });
expect(v.ok).toBe(false);
expect(v.reason).not.toMatch(/не умеет/i);
expect(v.reason).toMatch(/стоп всё/);
});
it('продолжение и обычный прогон судятся ОДИНАКОВО — отдельной поблажки нет ни у кого', () => {
// 🪤 Прежде разделение шло по полю `resumed_from`. Теперь его нет вовсе, и это проверяется
// прямо: на одинаковом ответе прибора оба прогона получают одинаковый вердикт.
const otvet = () => ({ vid: 'mertv', pochemu: '' });
expect(planLockVerdict({ staged: [PLAN], runs: [running], sudit: otvet }).ok).toBe(true);
expect(planLockVerdict({ staged: [PLAN], runs: [prodolzhenie], sudit: otvet }).ok).toBe(true);
});
});
describe('битая карточка прогона — замок ЗАКРЫТ, а не открыт (дыра 1)', () => {
const brokenRun = { run_id: 'r-2026-08-01-2200', status: null, plan_path: null, broken: true, card_path: 'X/run.json' };
it('карточку не прочли — правка НЕ принимается', () => {
// 🔴 Раньше порванная карточка молча исчезала из списка прогонов, и замок отвечал
// «идущих прогонов нет». Порвётся она вероятнее всего в ту самую ночь, когда кончилось
// место, — то есть замок открывался бы ровно тогда, когда он нужнее всего.
const v = planLockVerdict({ staged: [PLAN], runs: [brokenRun] });
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([PLAN]);
expect(v.reason).toContain('r-2026-08-01-2200');
});
it('в отказе названо, ЧТО делать, и назван сам нечитаемый файл', () => {
const v = planLockVerdict({ staged: [PLAN], runs: [brokenRun] });
expect(v.reason).toContain('X/run.json');
expect(v.reason).toMatch(/стоп вс[её]/i);
});
it('битая карточка запирает ВСЁ поданное: какой план по ней идёт — неизвестно', () => {
const v = planLockVerdict({ staged: [PLAN, OTHER], runs: [brokenRun] });
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([PLAN, OTHER]);
});
it('битая карточка есть, а правок нет — запирать нечего, отказа нет', () => {
const v = planLockVerdict({ staged: [], runs: [brokenRun] });
expect(v.ok).toBe(true);
expect(v.blocked).toEqual([]);
});
it('рядом с битой идёт целый прогон — оба названы, а файл в списке один раз', () => {
const v = planLockVerdict({ staged: [PLAN], runs: [brokenRun, { ...running, run_id: 'r-целый' }] });
expect(v.ok).toBe(false);
expect(v.blocked).toEqual([PLAN]);
expect(v.reason).toContain('r-2026-08-01-2200');
expect(v.reason).toContain('r-целый');
});
});
describe('readRuns — что лежит на диске (дыра 1: её не проверяла ни одна строка)', () => {
it('каталога прогонов нет вовсе — прогонов не было', () => {
expect(readRuns(join(dir, 'такого-каталога-нет'))).toEqual([]);
});
it('целая карточка читается как есть', () => {
makeRunCard(dir, 'r-1', JSON.stringify(running));
expect(readRuns(dir)).toEqual([{ ...running, card_dir: join(dir, 'r-1') }]);
});
it('к каждой карточке приложен КАТАЛОГ прогона — иначе живость спросить не у чего', () => {
// 🔴 Замок обязан уметь спросить «а идёт ли этот прогон на самом деле». Спрашивают
// по журналу `events.jsonl`, который лежит в каталоге прогона, а карточка своего
// каталога не знает. Убери `card_dir` — покраснеет здесь, и живость станет непроверяемой.
makeRunCard(dir, 'r-1', JSON.stringify(running));
makeRunCard(dir, 'r-порван', '{{{');
const runs = readRuns(dir);
// 🪤 Ищем НЕ по номеру прогона: имя папки (`r-1`) и номер внутри карточки
// (`r-2026-08-01-2200`) — разные вещи, и именно поэтому каталог нельзя вычислить из номера.
expect(runs.find(r => !r.broken).card_dir).toBe(join(dir, 'r-1'));
expect(runs.find(r => r.broken).card_dir).toBe(join(dir, 'r-порван'));
});
it('своё поле карточки замок не затирает: `dir` внутри run.json остаётся как был', () => {
// 🪤 Карточку пишет ЧУЖОЙ файл (run.mjs). Пиши мы каталог в поле `dir`, а заведись оно
// однажды у карточки — мы бы молча подменили чужое значение. Имя `card_dir` — наше.
makeRunCard(dir, 'r-1', JSON.stringify({ ...running, dir: 'что-то-своё' }));
expect(readRuns(dir)[0].dir).toBe('что-то-своё');
});
it('ПОРВАННАЯ карточка не исчезает: приходит с признаком broken и номером прогона', () => {
// 🔴 Дыра 1. Прежде здесь стоял `readJson` + `.filter(Boolean)`, и порванная карточка
// молча превращалась в «такого прогона нет». Верни `readJson` с отсевом — покраснеет здесь.
makeRunCard(dir, 'r-порван', '{"run_id": "r-порван", "status": "runn');
const runs = readRuns(dir);
expect(runs).toHaveLength(1);
expect(runs[0].broken).toBe(true);
expect(runs[0].run_id).toBe('r-порван');
expect(runs[0].card_path).toContain('run.json');
});
it('порванная карточка рядом с целой — целую видно, порванную тоже', () => {
makeRunCard(dir, 'r-1', JSON.stringify(running));
makeRunCard(dir, 'r-2', 'не json вовсе');
const runs = readRuns(dir);
expect(runs).toHaveLength(2);
expect(runs.filter(r => r.broken)).toHaveLength(1);
});
it('каталог прогона есть, а карточки в нём нет — это НЕ прогон и НЕ сбой', () => {
// «Файла нет» и «файл есть, но не читается» — разные вещи (общий прибор readJsonOrBroken).
mkdirSync(join(dir, 'r-пустой'), { recursive: true });
expect(readRuns(dir)).toEqual([]);
});
it('порванная карточка на диске доходит до отказа замка целиком, а не только по частям', () => {
makeRunCard(dir, 'r-порван', '{{{');
const v = planLockVerdict({ staged: [PLAN], runs: readRuns(dir) });
expect(v.ok).toBe(false);
expect(v.reason).toContain('r-порван');
});
it('раскладка каталогов здесь та же, что в paths.mjs — второй раскладки в затее нет', () => {
// readRuns строит путь сам (`каталог/номер/run.json`), чтобы проверки могли подать свой
// каталог. Разъедься эта сборка с paths.runDir — живой хук читал бы не тот каталог.
expect(runDir('r-1')).toBe(join(runsDir(), 'r-1'));
});
});