прибор «ворота без денег»: показывает, что ночь видит в плане, не тратя ни копейки

This commit is contained in:
Дмитрий
2026-08-05 15:19:41 +03:00
parent 99706b70d6
commit 39b3d41596
2 changed files with 425 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env node
// ━━━ ПРИБОР «ВОРОТА БЕЗ ДЕНЕГ» ━━━
//
// Показывает, ЧТО НОЧЬ УВИДИТ В ПЛАНЕ и какие ворота она пройдёт, — и не тратит ни копейки.
// Ничего не заводит, ничего не пишет: только читает и печатает простым русским.
//
// 🔴 ЗАЧЕМ ОН ЕСТЬ. Обычный запуск (`cli.mjs run`) отвечает на тот же вопрос, но по дороге
// платит: перед воротами запаса он зовёт платный замер остатка. Замерено по следу оплаты
// в `.night/probe-raw/`: три замера «перед прогоном», после которых старта не случилось,
// стоили 0,10682 $. Владельцу, который просто хочет посмотреть, годен ли его план, платить
// за это незачем. Здесь тот же вопрос задаётся даром.
//
// 🔴🔴 ПРАВИЛА НЕ ПЕРЕПИСАНЫ И НЕ СКОПИРОВАНЫ НИ ОДНО. Зовутся ТЕ ЖЕ самые:
// · `vhodVorot` (cli-parts.mjs) — читает план и раскладывает ответы по кормушкам ворот;
// · `validatePlan`(plan-model.mjs) — ворота самого плана;
// · `besplatnyeVorotaDoDeneg` + `VOROTA_DENEG` (cli.mjs) — тот же бесплатный предпроход,
// который стоит в живом запуске ПЕРЕД деньгами;
// · `GATE_ORDER` (gates.mjs) — единственное место, где записан состав и порядок ворот.
// Вторая копия любого из них разъехалась бы с первой МОЛЧА, и прибор показывал бы не ту ночь,
// которая вправду запустится.
//
// 🔴🔴 ВОРОТА «ЗАПАС» ЗДЕСЬ НЕ СПРАШИВАЮТСЯ ВОВСЕ, и это сказано владельцу прямым текстом.
// Подставить им зелёный ответ нельзя — это был бы выдуманный замер. Назвать их пройденными
// нельзя тем более: человек прочёл бы «денег хватает» там, где никто не спрашивал. Ровно
// от этой неразличимости в затее завели метку `unguarded` у пропускающих заглушек.
//
// Как звать:
// node tools/night/vorota-bez-deneg.mjs <путь-к-плану> [--priyomka <лист>] [--spec <бумага>]
//
// 🟡 Приёмочный лист и бумага замысла — необязательны. Не назвали — двое ворот («бумаги
// сохранены», «замысел проверен») честно отвечают «не спросили, значит не проверили», и прибор
// говорит об этом словами, а не выдаёт молчание за успех.
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readJson } from './state.mjs';
import { REPO_ROOT, configPath, planSealPath, specApprovalPath } from './paths.mjs';
import { checkSeal } from './plan-fingerprint.mjs';
import { freeGb, diskState, porogMesta } from './disk.mjs';
import { specApproved, papersCommitted, paperPathsFor } from './paper-gates.mjs';
import { vhodVorot } from './cli-parts.mjs';
import { validatePlan, komandaProverokPunkta } from './plan-model.mjs';
import { GATE_ORDER } from './gates.mjs';
import { besplatnyeVorotaDoDeneg, VOROTA_DENEG, idushchieProgony, strokiProProgony } from './cli.mjs';
// 🔴 Слова про платные ворота — ОДНА строка на весь прибор. Второй текст того же смысла
// разошёлся бы с первым молча, а читает их человек, который по ним решает, проверено или нет.
export const SLOVA_ZA_DENGI = 'эти ворота проверяются только за деньги, здесь не проверены';
// Метки состояния. Слово «пройдены» стоит РОВНО У ОДНОЙ из них — у той, где ворота вправду
// прошли. Ни платные, ни ненаписанные его не получают ни при каком исходе.
const METKA = {
da: ' [да] ',
net: ' [НЕТ] ',
dengi: ' [деньги] ',
net_pravila: ' [нет правила] ',
};
// ━━━ ЧТО НОЧЬ ВИДИТ В ПЛАНЕ ━━━
// 🔴 Печатается ВСЁ, чем ночь пользуется: номер, название, проверки (ими пункт закрывается),
// файлы (их работник правит и ими же пункт запирается), помета «длинный» и команда, которой
// проверки этого пункта вправду гоняются. Ничего из этого не сочиняется здесь — берётся
// из разбора плана и из `komandaProverokPunkta`.
function strokiProPunkty(punkty = []) {
const out = [];
for (const i of punkty ?? []) {
out.push(` · Task ${i?.id ?? '?'}: ${i?.title ?? '(без названия)'}${i?.long ? ' [помечен длинным]' : ''}`);
const proverki = (i?.tests ?? []).filter(Boolean);
out.push(proverki.length
? ` чем закрывается: ${proverki.join(', ')}`
: ' чем закрывается: НИЧЕМ — проверок не названо, такой пункт закрыть нечем.');
const fajly = (i?.files ?? []).filter(Boolean);
out.push(fajly.length
? ` что правит: ${fajly.join(', ')}`
: ' что правит: НЕ НАЗВАНО — запереть пункт нечем, файлы за ним держать не будут.');
const komanda = komandaProverokPunkta(i);
out.push(komanda
? ` команда проверок: ${komanda}`
: ' команда проверок: собрать не из чего.');
}
return out;
}
// ━━━ СОСТОЯНИЕ КАЖДЫХ ВОРОТ ━━━
// 🔴 Состав и порядок берутся из `GATE_ORDER` — единственного места, где они записаны.
// Свой список имён здесь не заводится ни в каком виде: вторая копия разъезжается молча
// ровно в ту ночь, когда воротам добавят десятые.
export function sostoyanieVorot(prigovor) {
const failed = prigovor?.failed ?? [];
const unguarded = prigovor?.unguarded ?? [];
return GATE_ORDER.map(gate => {
if (gate === VOROTA_DENEG) return { gate, vid: 'dengi', reason: null };
if (unguarded.includes(gate)) return { gate, vid: 'net_pravila', reason: null };
const beda = failed.find(f => f?.gate === gate);
if (beda) return { gate, vid: 'net', reason: beda.reason ?? 'Причина не названа.' };
return { gate, vid: 'da', reason: null };
});
}
// ━━━ ВЕСЬ ОТЧЁТ СТРОКАМИ ━━━
// 🔴 Отдельной функцией, а не печатью на месте: слова, которые читает владелец, обязаны
// проверяться прямо, а не через живой запуск.
export function stroki(o = {}) {
const out = [];
out.push('Прибор «ворота без денег»: показывает, что ночь увидит в плане.');
out.push('Ничего не заводит и НЕ ТРАТИТ НИ КОПЕЙКИ — платный замер остатка здесь не запускается.');
out.push(`План: ${o.planPath ?? '(не назван)'}`);
out.push('');
out.push('━━ ЧТО НОЧЬ ВИДИТ В ЭТОМ ПЛАНЕ ━━');
if (o.oshibkaPlana) {
out.push('Прочитать план не удалось — ночь по нему тоже не пойдёт.');
out.push(` · ${o.oshibkaPlana}`);
} else {
const p = o.planOk ?? {};
out.push(`Пунктов: ${p.items_count ?? (o.punkty ?? []).length}. Из них помечено длинными: ${p.long_count ?? 0}.`);
out.push(...strokiProPunkty(o.punkty));
if (p.ok === true) {
out.push('Ворота плана: план годен — по такому ночь запускают.');
} else if (p.ok === false) {
out.push('Ворота плана: план НЕ годен. Почему:');
out.push(` · ${p.reason ?? 'причина не названа'}`);
out.push(' · это правится в самом плане, а не ночью.');
}
}
out.push('');
out.push('━━ ВОРОТА ЗАПУСКА ━━');
for (const s of sostoyanieVorot(o.prigovor)) {
if (s.vid === 'dengi') {
out.push(`${METKA.dengi}${s.gate}${SLOVA_ZA_DENGI}.`);
continue;
}
if (s.vid === 'net_pravila') {
out.push(`${METKA.net_pravila}${s.gate} — правила у этих ворот ещё нет, они пропускают всё. Сторожа за ними НЕТ.`);
continue;
}
if (s.vid === 'net') {
out.push(`${METKA.net}${s.gate} — не пройдены. Почему:`);
out.push(` ${s.reason}`);
continue;
}
out.push(`${METKA.da}${s.gate} — пройдены.`);
}
if (!o.bumagiNazvany) {
out.push('');
out.push('Приёмочный лист и бумагу замысла вы не назвали, поэтому двое ворот выше отвечают');
out.push('«не спросили — значит не проверили». Хотите проверить и их — добавьте');
out.push('ключи --priyomka <приёмочный лист> и --spec <бумага замысла>.');
}
out.push('');
out.push('━━ ЧТО ЭТО ЗНАЧИТ ━━');
const netVorot = sostoyanieVorot(o.prigovor).filter(s => s.vid === 'net');
const planPloh = o.oshibkaPlana || o.planOk?.ok === false;
if (planPloh) {
out.push('Ночь по этому плану сейчас НЕ запустится: не годится сам план.');
} else if (netVorot.length) {
out.push(`Ночь по этому плану сейчас НЕ запустится: не пройдено ворот — ${netVorot.length}.`);
} else {
out.push('Всё, что можно проверить даром, — пройдено. Остаются ворота «запас»: хватает ли');
out.push('денег, здесь не спрашивали, и узнать это можно только платным замером при запуске.');
}
out.push('Ни один работник не заведён, ничего не потрачено.');
return out;
}
// ━━━ СБОР ОТЧЁТА ━━━
// 🔴 Каждый замер ниже стоит НОЛЬ: чтение файлов, счёт свободного места и вызовы `git`.
// Платного здесь нет ничего, и появиться ему тут нельзя — на это стоит сторож в проверках,
// смотрящий на сам исходник.
//
// 🪤 Все живые замеры вынесены в подставляемые доводы: без этого проверки правила ходили бы
// на настоящий диск и в настоящее хранилище и краснели бы от соседей, а не от поломки.
export async function sobratOtchyot({
planPath,
priyomkaPath = null,
specPath = null,
cfg = null,
chitatNastroyki = () => readJson(configPath()) ?? {},
// Свободное место — тем же правилом и тем же порогом, каким его меряет живой запуск.
mesto = async nastroyki => diskState(await freeGb(REPO_ROOT), porogMesta({
rabotnikov: nastroyki.max_workers_per_task,
zapasGb: nastroyki.disk_free_min_gb,
})),
otpechatok = put => checkSeal(planSealPath(put), put),
idushchie = () => idushchieProgony(),
bumagi = puti => papersCommitted(REPO_ROOT, puti),
otmetkaZamysla = () => readJson(specApprovalPath()),
} = {}) {
const nastroyki = cfg ?? chitatNastroyki();
// Разбор плана — ЭТО ВХОД ворот, а не ворота. Падает он только на настоящей беде плана
// (незакрытая рамка-образец) либо когда файла нет вовсе; в обоих случаях говорим словами.
let punkty = [];
let oshibkaPlana = null;
try {
punkty = vhodVorot({ planPath }).items;
} catch (err) {
oshibkaPlana = err?.message ?? String(err);
}
const planOk = oshibkaPlana ? null : validatePlan(punkty);
const schyot = idushchie();
const running = schyot?.running ?? [];
// 🟡 Бумаги спрашиваются, только когда владелец их назвал. Не назвал — сюда идёт `null`,
// и ворота честно отвечают «ответа не дали вовсе»: это отказ, а не пропуск.
let papers = null;
let spec = null;
const bumagiNazvany = Boolean(priyomkaPath && specPath);
if (bumagiNazvany) {
papers = bumagi(paperPathsFor({ planPath, priyomkaPath, specPath }));
spec = specApproved(otmetkaZamysla(), specPath);
}
const vhod = vhodVorot({
cfg: nastroyki,
items: punkty,
budget: null,
disk: await mesto(nastroyki),
planSeal: otpechatok(planPath),
papers,
spec,
running,
taskId: null,
});
// 🔴 Тот же бесплатный предпроход, что стоит в живом запуске перед деньгами. Ворота
// «запас» он из приговора выкидывает сам — их отказ здесь означал бы не «нельзя»,
// а «ещё не мерили».
const prigovor = besplatnyeVorotaDoDeneg({ vhod });
return {
planPath,
punkty,
planOk,
oshibkaPlana,
prigovor,
bumagiNazvany,
schyot,
ok: Boolean(!oshibkaPlana && planOk?.ok && prigovor.ok),
};
}
export async function main(argv = process.argv) {
const put = argv[2] && !String(argv[2]).startsWith('--') ? argv[2] : null;
if (!put) {
console.error('Надо так: node tools/night/vorota-bez-deneg.mjs <путь-к-плану> [--priyomka <лист>] [--spec <бумага замысла>]');
return 2;
}
const klyuch = name => {
const i = argv.indexOf(name);
const v = i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
return v && !String(v).startsWith('--') ? v : null;
};
const o = await sobratOtchyot({
planPath: put,
priyomkaPath: klyuch('--priyomka'),
specPath: klyuch('--spec'),
});
for (const s of stroki(o)) console.log(s);
// Разбор хозяйства — теми же словами, какими его говорит живой запуск. Мёртвые карточки
// владелец обязан увидеть и здесь: они занимают место в потолке задач.
const proProgony = strokiProProgony(o.schyot);
if (proProgony.length) {
console.log('');
console.log('━━ ЧТО СЕЙЧАС В ХОЗЯЙСТВЕ ━━');
for (const s of proProgony) console.log(s);
}
return o.ok ? 0 : 1;
}
// Точка входа звонит только когда файл запущен САМ, а не подключён проверкой через import —
// иначе проверка сама себе позвала бы `process.exit` при подключении файла. Приём тот же,
// что и у `cli.mjs`, и заведён он там ровно от этой беды.
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main().then(code => process.exit(code));
}
+144
View File
@@ -0,0 +1,144 @@
// Проверки прибора «ворота без денег» (`vorota-bez-deneg.mjs`).
//
// 🔴 ГЛАВНОЕ, ЧТО ЗДЕСЬ СТЕРЕЖЁТСЯ, — не красота слов, а ДВЕ вещи, которые ломаются молча:
// 1) ворота «запас» НИКОГДА не объявляются пройденными. Они платные, прибор их не зовёт,
// и назови он их пройденными — владелец прочёл бы «денег хватает» там, где никто
// не спрашивал. Это ровно тот класс, из-за которого в затее уже заводили метку
// `unguarded`: пропускающая заглушка была неотличима от честно пройденной двери;
// 2) в самом приборе нет ни одного платного зова. Сторож смотрит НА ИСХОДНИК, потому что
// зов, добавленный завтра, ни одной проверке поведения не виден: он просто спишет
// деньги владельца при запуске.
//
// 🪤 Проверки пишут только во ВРЕМЕННЫЙ каталог: живое рабочее дерево трогать нельзя
// (сторож `storozh-zhivogo-dereva.mjs` краснеет на любую запись отсюда).
import { describe, it, expect } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { stroki, sobratOtchyot, SLOVA_ZA_DENGI } from './vorota-bez-deneg.mjs';
const ISHODNIK = fileURLToPath(new URL('./vorota-bez-deneg.mjs', import.meta.url));
const PUNKT = {
id: '1',
title: 'Сторож смотрит на каждую команду',
tests: ['tools/a.test.mjs'],
files: ['tools/a.mjs'],
long: false,
};
function otchyotObrazets(pravki = {}) {
return {
planPath: 'docs/superpowers/plans/plan.md',
punkty: [PUNKT],
planOk: { ok: true, reason: null, items_count: 1, long_count: 0 },
oshibkaPlana: null,
prigovor: {
ok: false,
failed: [{ gate: 'бумаги сохранены', reason: 'Эти бумаги не сохранены в хранилище — в хранилище их нет вовсе: docs/plan.md.' }],
unguarded: ['отклик владельца', 'от прошлого прогона никого'],
},
bumagiNazvany: false,
...pravki,
};
}
const vTekst = o => stroki(o).join('\n');
describe('прибор «ворота без денег» — что он говорит владельцу', () => {
it('🔴 ворота «запас» НЕ объявляются пройденными: они платные и здесь не проверялись', () => {
const t = vTekst(otchyotObrazets());
expect(t).toContain('запас');
expect(t).toContain(SLOVA_ZA_DENGI);
// Ни одной строки, где «запас» назван пройденным.
const proZapas = stroki(otchyotObrazets()).filter(s => s.includes('запас'));
expect(proZapas.length).toBeGreaterThan(0);
for (const s of proZapas) expect(s).not.toMatch(/пройден/i);
});
it('🔴 в приборе нет ни одного платного зова — сторож смотрит на сам исходник', () => {
const src = readFileSync(ISHODNIK, 'utf8');
// Строки кода, а не примечания: примечание про деньги здесь как раз обязано быть.
const kod = src.split('\n').filter(s => !s.trim().startsWith('//')).join('\n');
for (const platnoe of ['probeUtilization', 'zamerSoSchyotom', 'vorotaPribora', 'priborZamera', 'ANTHROPIC_API_KEY']) {
expect(kod, `в приборе появился платный зов «${platnoe}»`).not.toContain(platnoe);
}
});
it('говорит, что ночь видит в плане: сколько пунктов, их проверки и файлы', () => {
const t = vTekst(otchyotObrazets());
expect(t).toContain('Пунктов: 1');
expect(t).toContain('Task 1');
expect(t).toContain(PUNKT.title);
expect(t).toContain('tools/a.test.mjs');
expect(t).toContain('tools/a.mjs');
});
it('по каждым непройденным воротам называет причину целиком', () => {
const t = vTekst(otchyotObrazets());
expect(t).toContain('бумаги сохранены');
expect(t).toContain('в хранилище их нет вовсе: docs/plan.md');
});
it('ворота без правила названы поимённо и не выданы за пройденные', () => {
const nezapertye = stroki(otchyotObrazets()).filter(s => s.includes('отклик владельца'));
expect(nezapertye.length).toBeGreaterThan(0);
for (const s of nezapertye) expect(s).not.toMatch(/пройден/i);
expect(vTekst(otchyotObrazets())).toContain('от прошлого прогона никого');
});
it('отказ ворот плана печатается своими словами, а список ворот не выдумывается', () => {
const t = vTekst(otchyotObrazets({
planOk: { ok: false, reason: 'В плане нет ни одного пункта — запускать нечего.', items_count: 0, long_count: 0 },
punkty: [],
}));
expect(t).toContain('В плане нет ни одного пункта');
});
it('не названы приёмочный лист и бумага замысла — говорится прямо, а не молчком', () => {
expect(vTekst(otchyotObrazets({ bumagiNazvany: false }))).toMatch(/--priyomka/);
expect(vTekst(otchyotObrazets({ bumagiNazvany: true }))).not.toMatch(/--priyomka/);
});
});
describe('сбор отчёта — читает настоящий план, ни к чему платному не ходит', () => {
it('берёт пункты из файла плана и судит их воротами плана', async () => {
const dir = mkdtempSync(join(tmpdir(), 'vorota-bez-deneg-'));
const put = join(dir, 'plan.md');
try {
writeFileSync(put, '# План\n\n### Task 1: Первый\n<!-- night: tests=tools/a.test.mjs; files=tools/a.mjs -->\n');
const o = await sobratOtchyot({
planPath: put,
cfg: { base_branch: 'x', max_tasks_at_once: 1, max_workers_per_task: 7 },
// Живые замеры подменены: проверка не ходит ни на диск, ни в хранилище.
mesto: () => ({ ok: true, reason: null }),
otpechatok: () => ({ ok: true, reason: null }),
idushchie: () => ({ ok: true, running: [], mertvye: [], neyasnye: [], reason: null }),
bumagi: () => ({ ok: true, reason: null }),
otmetkaZamysla: () => null,
});
expect(o.punkty.map(i => i.id)).toEqual(['1']);
expect(o.punkty[0].files).toEqual(['tools/a.mjs']);
expect(o.planOk.ok).toBe(true);
// Ворота «запас» в приговоре бесплатного прохода не судятся вовсе.
expect((o.prigovor.failed ?? []).map(f => f.gate)).not.toContain('запас');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('план не читается — прибор говорит это словами, а не падает молча', async () => {
const o = await sobratOtchyot({
planPath: join(tmpdir(), 'takogo-plana-net-vovse-1234.md'),
cfg: { base_branch: 'x', max_tasks_at_once: 1, max_workers_per_task: 7 },
mesto: () => ({ ok: true, reason: null }),
otpechatok: () => ({ ok: true, reason: null }),
idushchie: () => ({ ok: true, running: [], mertvye: [], neyasnye: [], reason: null }),
bumagi: () => ({ ok: true, reason: null }),
otmetkaZamysla: () => null,
});
expect(o.oshibkaPlana).toBeTruthy();
expect(vTekst(o)).toMatch(/план/i);
});
});