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

269 lines
16 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';
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('битая карточка прогона — замок ЗАКРЫТ, а не открыт (дыра 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]);
});
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'));
});
});