118 lines
8.3 KiB
JavaScript
118 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
||
// Замок на правку плана, по которому прямо сейчас идёт прогон (проверка 43, часть).
|
||
//
|
||
// 🔴 Зачем отдельный файл. Правило `assertPlanEditable` написано в plan-fingerprint.mjs,
|
||
// но правило без зовущего зеленеет на проверках и не срабатывает ни разу. Зовёт его
|
||
// ЭТОТ файл, а его — lefthook перед каждым коммитом.
|
||
//
|
||
// 🪤 В отдельном рабочем углу хуки не запускаются вовсе (проверено живьём 21.07.2026).
|
||
// Поэтому замок стоит в ГЛАВНОМ каталоге хозяйства, где план правит владелец или помощник.
|
||
// Работник свой план не правит — он его исполняет.
|
||
//
|
||
// Вторая половина проверки 43 — сама команда «стоп всё» — строится в куске 3. После неё
|
||
// последний погасший работник ставит прогону `finished`, и замок снимается сам.
|
||
import { existsSync, readdirSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { readJsonOrBroken } from './state.mjs';
|
||
// 🔴 Сравниватель пути — ОДИН на всю затею, и берётся он у paths.mjs. Здесь лежала его
|
||
// дословная копия (`samePath`), а вторая такая же — у печати (plan-fingerprint.mjs).
|
||
// Замок и печать ОБЯЗАНЫ отвечать одинаково: прежде здесь не было `resolve`, и печать
|
||
// считала `C:\…\docs\план.md` и `docs\план.md` одним файлом, а замок — разными; замок
|
||
// пропускал правку идущего плана тихо и зелено. Свести мешал круг по ввозу — печать
|
||
// ввозит сюда правило `assertPlanEditable`, обратный ввоз замкнул бы круг. paths.mjs
|
||
// не ввозит из затеи ничего, поэтому круга нет и копий больше не нужно.
|
||
import { runsDir, samePath } from './paths.mjs';
|
||
import { assertPlanEditable } from './plan-fingerprint.mjs';
|
||
|
||
export function planLockVerdict({ staged = [], runs = [] }) {
|
||
const going = runs.filter(r => r && !r.broken && r.status === 'running' && r.plan_path);
|
||
const blocked = [];
|
||
const reasons = [];
|
||
|
||
// 🔴 БИТАЯ КАРТОЧКА — НЕ «ПРОГОНА НЕТ». Прочесть `run.json` не удалось — значит неизвестно,
|
||
// идёт по нему прогон или нет. Молча выкинуть такой прогон из списка (так было раньше:
|
||
// `readJson` отдавал пустоту, а `.filter(Boolean)` её убирал) значит открыть замок ровно
|
||
// в тот момент, когда он нужнее всего: `run.json` вероятнее всего порвётся именно в ту ночь,
|
||
// когда на сервере кончилось место. Не смогли прочесть — замок ЗАКРЫТ.
|
||
//
|
||
// Какой именно план идёт по битой карточке, мы не знаем, поэтому не принимается ВСЁ, что
|
||
// подали. Сторож зовёт хук только на планы (`glob: docs/superpowers/plans/*.md`), так что
|
||
// под запрет попадают планы, а не весь коммит.
|
||
const broken = runs.filter(r => r && r.broken);
|
||
for (const run of broken) {
|
||
for (const file of staged) {
|
||
if (!blocked.includes(file)) blocked.push(file);
|
||
}
|
||
reasons.push(
|
||
`Карточку прогона ${run.run_id} прочесть не удалось: файл ${run.card_path ?? 'run.json'} на месте, `
|
||
+ 'но разобрать его нельзя. Идёт по нему прогон или он давно кончился — неизвестно, поэтому правка '
|
||
+ 'плана не принимается: открыть замок вслепую хуже, чем задержать правку. Так бывает, когда на '
|
||
+ 'сервере кончилось место посреди записи. Посмотрите этот файл: прогон кончился — поправьте или '
|
||
+ 'удалите карточку, прогон идёт — остановите его командой «стоп всё».',
|
||
);
|
||
}
|
||
|
||
for (const run of going) {
|
||
for (const file of staged) {
|
||
if (!samePath(file, run.plan_path)) continue;
|
||
// 🔴 Слова причины берутся у САМОГО правила. Напиши их здесь заново — два текста
|
||
// про одно правило разошлись бы молча.
|
||
const rule = assertPlanEditable({ status: 'running', run_id: run.run_id });
|
||
// 🔴 Один файл — одно имя в списке, сколько бы прогонов по нему ни шло. Иначе
|
||
// владелец читает одно и то же дважды. Номера прогонов при этом называются оба:
|
||
// остановить придётся каждый.
|
||
if (!blocked.includes(file)) blocked.push(file);
|
||
reasons.push(
|
||
`${rule.reason} Прогон: ${run.run_id}. План: ${run.plan_path}. `
|
||
+ 'Хотите править — сначала остановите прогон командой «стоп всё»: работники погаснут, '
|
||
+ 'последний погасший закроет прогон, и замок снимется сам.',
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!blocked.length) return { ok: true, reason: null, blocked: [] };
|
||
return { ok: false, reason: reasons.join('\n'), blocked };
|
||
}
|
||
|
||
// Все прогоны, какие есть на диске. Нет каталога вовсе — значит прогонов не было.
|
||
//
|
||
// 🔴 Три ответа, а не два (общий прибор затеи — `readJsonOrBroken` в state.mjs):
|
||
// — файла нет вовсе (каталог прогона есть, карточки нет) — это НЕ прогон, пропускаем;
|
||
// — карточка читается — отдаём как есть;
|
||
// — карточка есть, но не читается — отдаём заглушку с признаком `broken`, и замок по ней
|
||
// ЗАКРЫВАЕТСЯ. Раньше такой прогон молча исчезал из списка.
|
||
//
|
||
// Каталог берётся аргументом только ради проверок: живой хук зовёт без аргумента и работает
|
||
// по настоящему `runsDir()`. Раскладку «каталог прогонов / номер / run.json» знает paths.mjs;
|
||
// что она здесь та же самая — сторожит проверка «раскладка совпадает с paths.runDir».
|
||
export function readRuns(dir = runsDir()) {
|
||
if (!existsSync(dir)) return [];
|
||
const out = [];
|
||
for (const entry of readdirSync(dir)) {
|
||
const cardPath = join(dir, entry, 'run.json');
|
||
const got = readJsonOrBroken(cardPath);
|
||
if (!got.found) continue;
|
||
if (got.broken) {
|
||
out.push({ run_id: entry, status: null, plan_path: null, broken: true, card_path: cardPath });
|
||
continue;
|
||
}
|
||
out.push(got.value);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function main(argv = process.argv) {
|
||
const staged = argv.slice(2).filter(Boolean);
|
||
const verdict = planLockVerdict({ staged, runs: readRuns() });
|
||
if (verdict.ok) return 0;
|
||
console.error('Правка плана НЕ принята:');
|
||
console.error(verdict.reason);
|
||
return 1;
|
||
}
|
||
|
||
// Позвали файл напрямую (так его зовёт lefthook) — работаем. Позвали ввозом (так его зовут
|
||
// проверки) — молчим.
|
||
if (process.argv[1] && process.argv[1].endsWith('plan-lock-hook.mjs')) {
|
||
process.exit(main());
|
||
}
|