269 lines
16 KiB
JavaScript
269 lines
16 KiB
JavaScript
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'));
|
||
});
|
||
});
|