сторож настроек: сверял поля, а решения владельца про смысл — три новых разряда, образец и ветка

This commit is contained in:
Дмитрий
2026-08-04 20:38:20 +03:00
parent a9e3946587
commit 1874b866cc
7 changed files with 1270 additions and 199 deletions
+6 -6
View File
@@ -4,19 +4,19 @@
"supervisor_tick_seconds": 30,
"stall_minutes": 30,
"pause_wait_minutes": 20,
"max_tasks_at_once": 2,
"max_tasks_at_once": 1,
"max_workers_per_task": 7,
"workspace_max_age_days": 7,
"base_branch": "feat/night-dispatcher-part1",
"worker_model": "sonnet",
"rate_limit_type": "five_hour",
"base_branch": "feat/noch-proba-1",
"worker_model": "opus",
"rate_limit_type": "seven_day",
"bez_doli_ne_zapuskat": false,
"run_share": 0.14285714285714285,
"start_floor_share": 0.7142857142857143,
"start_floor_share": 0.99,
"probe_model": "haiku",
"probe_budget_per_shift": 30,
"probe_budget_window_hours": 24,
"spawn_budget_per_shift": 40,
"spawn_budget_per_shift": 20,
"item_tests_timeout_minutes": 10,
"disk_free_min_gb": 5,
"disk_bad_probes": 2,
+15 -3
View File
@@ -1350,14 +1350,26 @@ describe('О-3: ворота сверки настроек с решениями
expect(poyman.message).toMatch(/БОЛЬШЕ траты/);
});
// 🆕 ПОПРАВЛЕНО 05.08.2026, круг F. Замысел проверки не тронут («дешёвое расхождение
// не падает, а отдаёт слова») — поправлен только образец настройки под него. Причина:
// сверка перестала быть списком из пяти полей. Прежний образец теперь падает по-настоящему
// и по делу — в нём `max_tasks_at_once: 2` при семерых работниках даёт ЧЕТЫРНАДЦАТЬ
// работников разом против решения владельца 24 («семеро ВСЕГО»), а трёх решений владельца
// (голова, порог запаса, тумблер доли) в нём нет вовсе. Дешёвым такое расхождение не было
// никогда — его просто не видел никто.
// 🪤 Число слов больше не пересчитывается: сверка спрашивает у хранилища живую ветку,
// и число слов законно зависит от того, двинута ли она. Проверяется то, что и проверялось:
// отступление в дешёвую сторону названо словами и работу не остановило.
it('🔴 живьём: дешёвое расхождение не падает, а отдаёт слова', () => {
const deshevo = {
spawn_budget_per_shift: 20, probe_budget_per_shift: 30,
max_workers_per_task: 7, max_tasks_at_once: 2, base_branch: 'другая-ветка',
max_workers_per_task: 2, max_tasks_at_once: 1, base_branch: 'feat/noch-proba-1',
worker_model: 'opus', start_floor_share: 0.99, bez_doli_ne_zapuskat: false,
};
const slova = proverkaNastroekVladeltsa({ chitatNastoyashchiyFayl: () => deshevo });
expect(slova.length).toBe(1);
expect(slova[0].tekst).toContain('base_branch');
const svoy = slova.find(s => s.klyuch === 'max_workers_per_task');
expect(svoy, 'дешёвое отступление обязано быть названо словами').toBeTruthy();
expect(svoy.tekst).toContain('max_workers_per_task');
});
});
+7 -1
View File
@@ -119,8 +119,14 @@ describe('🔴 приговор для владельца без читател
// 🔴 Имена названы нарочно: переименуй или унеси любую — сторож скажет, КОГО потерял,
// а не просто «стало меньше».
const imena = prigovorshchicy().map(p => p.imya).sort();
// 🆕 05.08.2026, круг F: у сверки настроек с решениями владельца прибавилось три
// приговорщицы — `sverkaOdnoyVelichiny` (решение владельца про ВЕЛИЧИНУ, собранную
// из нескольких полей: «работников разом» = задач × работников), `razborVetkiNochi`
// (ветка, от которой ветвятся работники, не должна отставать от ветки стройки) и
// `razyezdObraztsaSZhivoy` (образец настройки против живой — по всем полям сразу).
expect(imena).toEqual([
'budgetState', 'masterskayaOtsechena', 'sverkaGolovy', 'sverkaOdnoyNastroyki',
'budgetState', 'masterskayaOtsechena', 'razborVetkiNochi', 'razyezdObraztsaSZhivoy',
'sverkaGolovy', 'sverkaOdnoyNastroyki', 'sverkaOdnoyVelichiny', 'sverkaVyvedennogo',
]);
});
+513 -82
View File
@@ -4,117 +4,370 @@
// `spawn_budget_per_shift: 40`, хотя решение 9а протокола (строка 246) называет 20 —
// предел рождений работников оказался вдвое слабее того, что обещано владельцу на бумаге.
// Ночью программа читает НАСТРОЙКУ, а не протокол, и обещание владельцу становилось пустым
// молча: ни одна строка кода не сверяла файл с бумагой. У настроек ночи не было НИ ОДНОГО
// сторожа этого класса — бумага и рабочая настройка жили порознь, и разъезд был невидим.
// молча: ни одна строка кода не сверяла файл с бумагой.
//
// ━━━ 🔴🔴🔴 ПЕРЕСТРОЙКА 05.08.2026: СТОРОЖ БЫЛ ЗЕЛЁНЫЙ И ПРОСПАЛ ДВА РАСХОЖДЕНИЯ ━━━
// Двадцать три проверки, все зелёные — и два прямых расхождения с решениями владельца нашли
// не они, а два приёмщика вслепую (решения владельца 24 и 25). Разбор причины дал не две
// дырки, а ЧЕТЫРЕ МЕХАНИЗМА СЛЕПОТЫ, и все четыре чинятся здесь:
//
// 1. 🔴 СТОРОЖ ПРОВЕРЯЛ, ЧТО СОГЛАСЕН САМ С СОБОЙ. Список ожидаемых значений — это перевод
// решений владельца в поля, сделанный рукой; перевод и есть место ошибки. А эталон
// в проверках строился ИЗ ЭТОГО ЖЕ СПИСКА (`idealnyyCfg`). Значит ни одна из 23 проверок
// не могла поймать ошибку перевода в принципе: обе стороны сравнения брались из одного
// источника. Зелёное означало «сторож не противоречит себе», а не «настройка сходится
// с бумагой». Лечение — не «ещё одна проверка», а ПЕРЕБОР ВСЕГО ПРОТОКОЛА ПОИМЁННО
// и три явных списка ниже: что сверяем, чего не сверяем нарочно (с доводом), и какие
// решения владельца настройки не имеют вовсе.
//
// 2. 🔴 РЕШЕНИЕ ВЛАДЕЛЬЦА БЫВАЕТ ПРО ВЕЛИЧИНУ, А НЕ ПРО ПОЛЕ. «Работников разом — семеро»
// (решение 3) — это `max_tasks_at_once` × `max_workers_per_task`. В списке стояли 7 и 2
// ОТДЕЛЬНЫМИ СТРОКАМИ, каждая сходилась, а произведение давало ЧЕТЫРНАДЦАТЬ. Двенадцать
// смен считали деньги и память вдвое меньше настоящего. ⇒ Заведён второй разряд проверок —
// ВЕЛИЧИНЫ (`VELICHINY`): смысл, собранный из нескольких полей.
//
// 3. 🔴 СТОРОЖ СВЕРЯЛ ТО, ЧТО ЕМУ УДОБНО, А НЕ ТО, ЧТО ВАЖНО ВЛАДЕЛЬЦУ. Он умел ровно два
// рода значений — целое число и строку. Поэтому самое денежное решение владельца из всех
// (18: «ночи разрешено съесть 99 % недельного запаса», `start_floor_share: 0.99`) в список
// попасть НЕ МОГЛО: дробь он объявил бы мусором. Умение инструмента молча определило
// границу его обязанностей. ⇒ Родов значений теперь четыре: целое, доля, да/нет, строка.
//
// 4. 🔴 ОТГОВОРКА «ЭТО НЕ РЕШЕНИЕ ВЛАДЕЛЬЦА» БЫЛА ВПИСАНА В КОД, ЗАКРЕПЛЕНА ПРОВЕРКОЙ —
// И ПРОТУХЛА. Здесь стояло дословно: «`bez_doli_ne_zapuskat` — решение самой затеи,
// а не владельца; протокол этого ключа не касается вовсе», и проверка цементировала
// его отсутствие. Решение владельца 19 от 04.08 — прямой дословный ответ ИМЕННО про этот
// ключ. Довод был верен в день, когда его писали, и стал ложью через сутки, а проверка
// сторожила ложь. ⇒ Всякое «нарочно не сверяем» теперь лежит ДАННЫМИ (`NE_STOROZHIM`)
// с датой и доводом, и проверка требует, чтобы довод был назван — чтобы такой отговорке
// было где протухнуть заметно.
//
// ━━━ 🔴 ПРАВИЛО ИСХОДА — ТРИ ОТВЕТА НА КАЖДУЮ НАСТРОЙКУ, А НЕ ДВА ━━━
// Падать на ЛЮБОМ расхождении нельзя: это запретило бы намеренные отступления на время
// проверок (рабочая ветка вместо `feat/noch-proba-1` стоит в этом угле осознанно, пока идёт
// стройка). А не падать НИКОГДА не защитило бы обещание владельцу вовсе. Поэтому:
// 1. сходится — молчит;
// 2. расходится в сторону БОЛЬШЕЙ траты или СЛАБЕЕ защиты — падает вслух;
// 3. расходится иначе (меньше траты, или вопрос вовсе не про деньги) — говорит словами,
// но не падает.
// Падаем только там, где отступление дороже для владельца. Это решение уже принято
// (см. постановку задачи), здесь оно не переизобретается — только исполняется.
// Падаем только там, где отступление дороже для владельца.
//
// Отдельно от этой тройки — ДВА своих исхода, потому что оба они опаснее любого известного
// расхождения: настройки может не быть в файле вовсе (тогда ночь тихо возьмёт СВОЁ запасное
// число, никак не сверенное с бумагой), и в настройке может лежать мусор (строка вместо
// числа, `null`, отрицательное). Ни один из них не «сходится», и молчать про них нельзя.
// 🔴 ЧТО В ЭТОМ ПРАВИЛЕ ИЗМЕНЕНО 05.08.2026 И ПОЧЕМУ. Прежде здесь было записано, что
// `base_branch` — «не про деньги», а рабочая ветка вместо ветки ночи «стоит в этом углу
// ОСОЗНАННО, пока идёт стройка», поэтому падать нечему. Решение владельца 25 показало, что
// это была выдумка смены: «Передача смене 13 назвала это расхождением осознанным — но решения
// владельца об этом нет ни одного». И цена оказалась прямо денежной: от этой ветки ночью
// отпочкуются семеро платных работников. ⇒ `base_branch` теперь ПАДАЕТ на любом отступлении,
// а рядом стоит второй сторож (`vetka-nochi-ne-otstayot.mjs`), спрашивающий у хранилища, годно
// ли то, что на ветке с этим именем лежит.
//
// Отдельно от тройки — ДВА своих исхода, потому что оба опаснее любого известного расхождения:
// настройки может не быть в файле вовсе (тогда ночь тихо возьмёт СВОЁ запасное число, никак
// не сверенное с бумагой), и в настройке может лежать мусор.
import { existsSync, readFileSync } from 'node:fs';
import { razborVetkiNochi, sprosItHranilishcheRazVZahod } from './vetka-nochi-ne-otstayot.mjs';
// Путь к протоколу — печатается владельцу в каждом отказе, поэтому имя вынесено в постоянную
// тем же приёмом, что `KLYUCH_VIDA` / `KLYUCH_BEZ_DOLI` в `budget.mjs`: набери его строкой
// по месту — и переименование протокола (или перенос файла) разъехалось бы со сторожем молча.
// Путь к протоколу — печатается владельцу в каждом отказе, поэтому имя вынесено в постоянную:
// набери его строкой по месту — и переименование протокола разъехалось бы со сторожем молча.
export const PROTOKOL_PUT = 'docs/grilling/2026-08-02-nastroyki-nochi-i-ramka-deneg.md';
// ━━━ 🔴🔴 ОДИН ЯВНЫЙ СПИСОК РЕШЕНИЙ ВЛАДЕЛЬЦА — А НЕ ВЫВЕДЕННЫЙ ИЗ ПРОТОКОЛА ━━━
// Читать протокол автоматом и угадывать из текста число намеренно НЕ делается: развилка
// та же, что у ключа предела в budget.mjs (Р1) — «зашитого умолчания нет нигде, а есть
// явная строка, которую видно глазами». Здесь то же самое, только источник умолчания —
// не код, а бумага: если протокол переформатируют, эта строка не подхватит перемену сама,
// а честно останется опираться на число, записанное здесь, — его придётся поправить руками,
// и это дешевле, чем гадать, какую фразу протокола разбирать программой.
// Читать протокол автоматом и угадывать из текста число намеренно НЕ делается: если протокол
// переформатируют, эта строка не подхватит перемену сама, а честно останется опираться
// на число, записанное здесь, — его придётся поправить руками.
//
// Каждая запись — одна настройка: её ключ в `night.config.json`, решённое владельцем
// значение, номер решения и строка протокола, где оно записано его словами, и направление
// опасности при расхождении.
// Каждая запись: ключ в `night.config.json`, решённое владельцем значение, род значения,
// номер решения и строка протокола, направление опасности при расхождении.
//
// • napravlenie: 'potolok' — это верхний предел денег/защиты: больше числа в файле,
// чем решил владелец, значит МОЖНО БОЛЬШЕ ТРАТИТЬ или МЕНЬШЕ ОСТОРОЖНОСТИ — падает.
// Меньше числа, чем решил владелец, — работа станет ОСТОРОЖНЕЕ, чем обещано, то есть
// дешевле для владельца, — не падает, только слова.
// • napravlenie: 'ne-pro-dengi' — расхождение вообще не про деньги и не про защиту
// (например, какую ветку взять за основу); падать здесь нечему в принципе, поэтому
// любое несовпадение — только слова, независимо от направления.
//
// 🔴 Что НАМЕРЕННО НЕ в этом списке — и почему. Список — только про решения ВЛАДЕЛЬЦА
// из протокола; решения самой затеи в нём не место, иначе владельцу подсунули бы за его
// решение то, чего он не принимал:
// • `bez_doli_ne_zapuskat` — это решение Р5 самой затеи, а не владельца (см. довод
// прямо у настройки, `budget.mjs:57`). Протокол этого ключа не касается вовсе.
// • `probe_budget_window_hours` — толкование слова «смена» выбрал НАДЗИРАТЕЛЬ, а не
// владелец: протокол, решение 9 (строка ~235), «Число предела владелец НЕ называл —
// его выберет надзиратель». Сверять с бумагой, которая прямо говорит «решает не
// владелец», — придумывать ему решение, которого он не принимал.
// • vid: 'celoe' | 'dolya' | 'da-net' | 'stroka' — чем значение обязано быть. Всё, что этому
// роду не отвечает, — мусор: толковать его молча нельзя ни в одну сторону.
// • napravlenie: 'potolok' — числовое; больше решённого = БОЛЬШЕ трат / СЛАБЕЕ
// защита ⇒ падает. Меньше = осторожнее обещанного ⇒ только слова.
// • napravlenie: 'deshevle-lyuboe' — любое отступление уводит в дешёвую или строгую сторону
// (умную голову заменить менее умной, тумблер защиты включить) ⇒ только слова.
// • napravlenie: 'padaet-lyuboe' — сравнивать нечего (имя ветки), но любое отступление
// дорого ⇒ падает.
export const RESHENIYA = [
{
klyuch: 'spawn_budget_per_shift',
ozhidaetsya: 20,
resheniye: '9а',
stroka: 246,
napravlenie: 'potolok',
},
{
klyuch: 'probe_budget_per_shift',
ozhidaetsya: 30,
vid: 'celoe',
resheniye: '1',
stroka: 18,
napravlenie: 'potolok',
},
{
klyuch: 'spawn_budget_per_shift',
ozhidaetsya: 20,
vid: 'celoe',
resheniye: '9а',
stroka: 246,
napravlenie: 'potolok',
},
{
klyuch: 'max_workers_per_task',
ozhidaetsya: 7,
vid: 'celoe',
resheniye: '3',
stroka: 40,
napravlenie: 'potolok',
},
// 🔴 БЫЛО 2 (решение 10), СТАЛО 1 — и это не описка. Решения владельца 3 и 10 оказались
// несовместимы арифметически: семь на два не делится. Владелец, выбирая 05.08, объявил
// старшинство СЕМЕРЫХ и переставил это поле на 1 (решение 24). Решение 10 не отменено
// по существу — отложено до первого замера цены ночи. Старая строка списка ссылалась
// на решение 10 и была зелёной ровно потому, что сверяла поле, а не смысл.
{
klyuch: 'max_tasks_at_once',
ozhidaetsya: 2,
resheniye: '10',
stroka: 268,
ozhidaetsya: 1,
vid: 'celoe',
resheniye: '24',
stroka: 691,
napravlenie: 'potolok',
},
{
klyuch: 'base_branch',
ozhidaetsya: 'feat/noch-proba-1',
resheniye: '4',
stroka: 186,
napravlenie: 'ne-pro-dengi',
vid: 'stroka',
resheniye: '25',
// 🪤 Строка перемерена 05.08.2026: была 717, стала 748 — протокол дорос за те часы,
// пока шла эта правка. Ровно то, ради чего заведён якорь в проверках: ссылка на решение
// съезжает молча, и владелец читал бы в отказе указание на чужое решение.
stroka: 748,
napravlenie: 'padaet-lyuboe',
},
// Решение 2: голова работника — самая умная. Не сверялось НИКЕМ до 05.08, хотя это прямой
// ответ владельца и притом самый дорогой род траты за ночь.
{
klyuch: 'worker_model',
ozhidaetsya: 'opus',
vid: 'stroka',
resheniye: '2',
stroka: 31,
napravlenie: 'deshevle-lyuboe',
},
// Решение 18: «В настройке порог запаса стоит 99 % так и оставляем!» — дословно, и оно
// переспрошено с верным объяснением (строка 486), то есть настоящее. Числом это значит:
// ночи разрешено съесть 99 % недельного запаса владельца. Больше 0,99 — падает.
{
klyuch: 'start_floor_share',
ozhidaetsya: 0.99,
vid: 'dolya',
resheniye: '18',
stroka: 436,
napravlenie: 'potolok',
},
// Решение 19: тумблер «без доли не запускать» — оставить ВЫКЛ, идти по светофору.
// Включить его = строже обещанного ⇒ слова.
{
klyuch: 'bez_doli_ne_zapuskat',
ozhidaetsya: false,
vid: 'da-net',
resheniye: '19',
stroka: 500,
napravlenie: 'deshevle-lyuboe',
},
];
// ━━━ 🔴🔴 ВТОРОЙ РАЗРЯД: ВЕЛИЧИНЫ — СМЫСЛ, СОБРАННЫЙ ИЗ НЕСКОЛЬКИХ ПОЛЕЙ ━━━
// Ровно то, чего не хватало 12 смен. Решение владельца звучит про величину («работников
// разом — семеро»), а в файле она не записана ни одной строкой: она СОБИРАЕТСЯ. Поля
// по отдельности сходились, величина расходилась вдвое, сторож молчал.
//
// 🔴 Правило заведения новой величины: если решение владельца названо словами, которых нет
// ни в одном ключе настройки, — это величина, а не поле. Ищи, из чего она собирается.
export const VELICHINY = [
{
imya: 'работников разом',
klyuchi: ['max_tasks_at_once', 'max_workers_per_task'],
schitaet: cfg => cfg.max_tasks_at_once * cfg.max_workers_per_task,
kak: 'задач разом × работников на задачу',
ozhidaetsya: 7,
resheniye: '24',
stroka: 676,
napravlenie: 'potolok',
},
];
// ━━━ 🔴🔴🔴 ТРЕТИЙ РАЗРЯД: ЧИСЛО, ВЫВЕДЕННОЕ ИЗ ДРУГОГО ЧИСЛА ━━━
// Находка судьи ночи 05.08.2026, и это НЕ «поле против поля» и не «поля против величины».
// Это третий, самый тихий род расхождения: **посылка умерла, а вывод из неё живёт**.
//
// Живой случай, ради которого разряд заведён. Предел рождений работников — 20 — выведен
// в самом протоколе, дословно (строка 248): «Ночь по вашим же решениям заводит две задачи
// разом по семеро работников — это уже 14 рождений на штатную ночь. Двадцать оставляет запас
// на 6 подъёмов и ПРИ ЭТОМ ОСТАНАВЛИВАЕТ РАЗГОН». То есть 20 — не круглое число и не рамка
// расхода, а СТРАХОВКА ОТ РАЗГОНА, посчитанная от ночи в ЧЕТЫРНАДЦАТЬ работников.
// 05.08 владелец сжал ночь вдвое, до семерых (решение 24). Посылка умерла — вывод остался:
// 20 рождений на ночь из семерых это почти ТРИ ПОЛНЫХ СОСТАВА, то есть тормозом это число
// быть перестало, оставаясь на вид тем же самым.
//
// 🔴 ПОЧЕМУ ЗДЕСЬ ТОЛЬКО СЛОВА, А НЕ ОТКАЗ, И ПОЧЕМУ ЧИСЛО НЕ ПРАВИТСЯ САМО.
// 20 — решение владельца 9а. Пересчитать его «по-честному» значило бы решить за него: тормоз
// это или рамка, шесть подъёмов оставлять или три. Сторож обязан не чинить, а НАЗВАТЬ, что
// основание уехало, — и оставить число владельцу. Падать тоже нельзя: настройка не разошлась
// ни с одной бумагой, разошлась ЖИЗНЬ с доводом, а за это ночь не останавливают.
export const VYVEDENNYE = [
{
klyuch: 'spawn_budget_per_shift',
resheniye: '9а',
stroka: 248,
zachem: 'страховка от разгона: если затея начнёт заводить работников по кругу из-за поломки, '
+ 'она упрётся в предел, а не в счёт владельца',
posylka: 'работников разом',
posylkaBylo: 14,
kakVyvodili: '14 рождений на штатную ночь + запас на 6 подъёмов = 20',
},
];
// Считает, жива ли посылка. Величина берётся из того же списка `VELICHINY` — второй копии
// правила «как считать работников разом» не заводим: две копии одного правила расходятся
// молча, и это в затее уже случалось.
export function sverkaVyvedennogo(v, cfg, { velichiny = VELICHINY } = {}) {
const opisanie = velichiny.find(x => x.imya === v.posylka);
const baza = { klyuch: v.klyuch, vyvedennoe: true, resheniye: v.resheniye, posylka: v.posylka };
if (!opisanie) {
return { ...baza, status: 'posylka-poteryana', padaet: false, tekst: null };
}
const hudye = opisanie.klyuchi.filter(k => !Number.isFinite(cfg?.[k]));
if (hudye.length) return { ...baza, status: 'ne-schitaetsya', padaet: false, tekst: null };
const seychas = opisanie.schitaet(cfg);
if (seychas === v.posylkaBylo) {
return { ...baza, status: 'sovpadaet', seychas, padaet: false, tekst: null };
}
return {
...baza,
status: 'posylka-umerla',
seychas,
padaet: false,
tekst: `🔴 Настройка «${v.klyuch}» = ${JSON.stringify(cfg?.[v.klyuch])} была ВЫВЕДЕНА из числа, `
+ `которого больше нет. ${otkuda(v)} считало её так: ${v.kakVyvodili} — при «${v.posylka}» `
+ `равном ${v.posylkaBylo}. Сейчас «${v.posylka}» равно ${seychas}. Само число никто не менял, `
+ `и с бумагой оно сходится — уехало ОСНОВАНИЕ. Зачем оно ставилось: ${v.zachem}; `
+ `на нынешней ночи это уже не тормоз. Работа не встаёт и число не правится само: `
+ 'оно решение владельца, и пересчитать его — тоже его слово, а не наше.',
};
}
// ━━━ 🔴 ТРЕТИЙ СПИСОК: ЧЕГО НЕ СВЕРЯЕМ НАРОЧНО — ДАННЫМИ, А НЕ ПРИМЕЧАНИЕМ ━━━
// Прежде это лежало примечанием в шапке, и одна из записей протухла за сутки, оставшись
// зелёной (механизм 4 выше). Теперь у каждой отговорки есть дата и довод, а проверка требует,
// чтобы довод был назван словами. Отговорка без довода — это не решение, а забывчивость.
export const NE_STOROZHIM = [
{
klyuch: 'probe_budget_window_hours',
dovod: 'толкование слова «смена» выбрал НАДЗИРАТЕЛЬ, а не владелец — протокол, решение 9 '
+ '(строка 235): «Число предела владелец НЕ называл». Сверять с бумагой, которая прямо '
+ 'говорит «решает не владелец», значит придумать владельцу решение, которого он не принимал.',
kogda: '03.08.2026',
},
{
klyuch: 'mail',
dovod: 'решение 5 («первая ночь без почты», строка 76) владелец объявил ВРЕМЕННЫМ и сам '
+ 'обещал вернуться к нему после первой ночи. Сторож на пустую почту покраснел бы ровно '
+ 'в тот миг, когда владелец сделает задуманное, — то есть наказывал бы за исполнение '
+ 'его же решения. Пароль в этом поле, кроме того, вне хранилища.',
kogda: '05.08.2026',
},
{
klyuch: 'rate_limit_type',
dovod: 'НЕ решение владельца — именованного решения про вид предела в протоколе нет ни одного, '
+ 'и вписать сюда своё значило бы подсунуть владельцу за его решение мою догадку. '
+ '🔴 НО ЭТО НЕ ЗНАЧИТ «НЕ ВАЖНО»: замерено сменой 13, что при несовпадении этого поля '
+ 'с видом пришедшего показания оплаченный замер выбрасывается МОЛЧА — самый дорогой '
+ 'замер суток (0,148 $) так и сгорел, попытка есть, следа в журнале нет. Поэтому поле '
+ 'сторожится вторым правилом — «образец в согласии с живой» (`razyezdObraztsaSZhivoy`), '
+ 'а не сверкой с бумагой.',
kogda: '05.08.2026',
},
];
// ━━━ 🔴🔴 ПОЛЯ, ГДЕ ОБРАЗЕЦ И ЖИВАЯ ОБЯЗАНЫ РАЗЛИЧАТЬСЯ ━━━
// Единственное законное расхождение — там, где в живой лежат тайны. В образце на их месте
// стоят заготовки, и требовать совпадения значило бы либо вписать пароль в хранилище, либо
// стереть заготовку и оставить того, кто заводит ночь заново, без подсказки.
export const OBRAZETS_VPRAVE_OTLICHATSYA = ['mail'];
// ━━━ 🔴 ЧЕТВЁРТЫЙ СПИСОК: РЕШЕНИЯ ВЛАДЕЛЬЦА, У КОТОРЫХ НАСТРОЙКИ НЕТ ВОВСЕ ━━━
// Приговор «все решения владельца покрыты» требует перебора ВСЕГО протокола, а не списка
// того, что уже сверяется. Перебор сделан поимённо 05.08.2026, и вот что он нашёл: решение
// есть, а сверять нечем — потому что и настраивать нечего. Сторожить это нельзя, ЗАБЫТЬ ТОЖЕ
// НЕЛЬЗЯ. Список печатается вслух, чтобы долг не растворился.
export const RESHENIYA_BEZ_NASTROYKI = [
{
resheniye: '16',
stroka: 421,
chto: 'потолок живых рождений без владельца — 5–6 попыток за смену',
gde: 'ни настройки, ни кода: замерено 05.08.2026 поиском по всему tools/ — ни одного ключа '
+ 'и ни одного счётчика. Решение владельца названо числом и не сторожит ничего.',
},
{
resheniye: '13',
stroka: 374,
chto: 'годная ночь = закрытые задачи из плана',
gde: 'мерка названа, но ЧИСЛО («сколько пунктов — годная ночь») владелец нарочно не назвал: '
+ 'оно ждёт первого живого замера. Настройки нет и заводить нельзя.',
},
{
resheniye: '12',
stroka: 353,
chto: 'у ночи будет свой судья, не реже раза за ночь',
gde: 'настройки нет; когда именно звать судью, решение прямо оставило надзирателю (строка 370).',
},
];
// ━━━ 🔴 ПЯТЫЙ СПИСОК: ТЕХНИЧЕСКИЕ УМОЛЧАНИЯ — НЕ РЕШЕНИЯ ВЛАДЕЛЬЦА ━━━
// Протокол, строка 166: «Одиннадцать настроек взяты из образца и решениями не являются» —
// они из замороженной бумаги-замысла либо технические умолчания. Список выписан поимённо
// не ради красоты: вместе с четырьмя списками выше он даёт ПОЛНЫЙ РАЗБОР каждого ключа
// живой настройки. Проверка требует, чтобы у КАЖДОГО ключа файла нашлось место в одном
// из списков — и тогда новый ключ, заведённый когда-нибудь молча, покраснеет сразу, а не
// проживёт двенадцать смен несверенным, как `worker_model` и `start_floor_share`.
export const TEHNICHESKIE = [
'worker_hours_limit', 'service_wait_hours', 'supervisor_tick_seconds', 'stall_minutes',
'pause_wait_minutes', 'workspace_max_age_days', 'run_share', 'probe_model',
'item_tests_timeout_minutes', 'disk_free_min_gb', 'disk_bad_probes',
];
// Куда отнесён ключ живой настройки. `null` — не отнесён никуда, и это беда.
export function kudaOtnesyon(klyuch) {
if (RESHENIYA.some(r => r.klyuch === klyuch)) return 'решение владельца';
if (NE_STOROZHIM.some(n => n.klyuch === klyuch)) return 'нарочно не сторожим';
if (TEHNICHESKIE.includes(klyuch)) return 'техническое умолчание';
return null;
}
// Откуда взялось ожидаемое значение — общая строка для всех текстов ниже.
function otkuda(r) {
return `решение владельца ${r.resheniye} (строка ${r.stroka} протокола ${PROTOKOL_PUT})`;
}
const IMYA_VIDA = {
celoe: 'целое число',
dolya: 'доля от нуля до единицы',
'da-net': 'да или нет',
stroka: 'строка',
};
function chegoZhdali(r) {
return typeof r.ozhidaetsya === 'number' ? 'целое число' : 'строка';
return IMYA_VIDA[r.vid] ?? 'значение';
}
// 🔴 Мусор — это НЕ «расхождение», а брак записи: значения, по которым сравнивать
// направление опасности нельзя вовсе (строка вместо числа, `null`, отрицательное,
// дробное там, где решение — целое число, пустая строка). Толковать его молча нельзя
// ни в одну сторону — так же, как `bedaNastroykiChisla` в `gates.mjs` не подставляет
// запасное число вместо описки владельца.
// 🔴 Мусор — это НЕ «расхождение», а брак записи: значения, по которым сравнивать направление
// опасности нельзя вовсе. Толковать его молча нельзя ни в одну сторону — так же, как
// `bedaNastroykiChisla` в `gates.mjs` не подставляет запасное число вместо описки владельца.
function estMusor(r, aktualno) {
if (typeof r.ozhidaetsya === 'number') {
return !Number.isInteger(aktualno) || aktualno < 0;
switch (r.vid) {
case 'celoe':
return !Number.isInteger(aktualno) || aktualno < 0;
// 🪤 Доля — единственный род, где дробное ПРАВИЛЬНО, а целое подозрительно. Границы взяты
// по смыслу: доля запаса меньше нуля бессмысленна, больше единицы — «съесть 120 % запаса».
case 'dolya':
return typeof aktualno !== 'number' || !Number.isFinite(aktualno) || aktualno < 0 || aktualno > 1;
case 'da-net':
return typeof aktualno !== 'boolean';
case 'stroka':
return typeof aktualno !== 'string' || aktualno.trim() === '';
default:
return true;
}
return typeof aktualno !== 'string' || aktualno.trim() === '';
}
function tekstMusora(r, aktualno) {
@@ -139,10 +392,18 @@ function tekstOtsutstviya(r) {
}
function slovoNapravleniya(r, aktualno) {
if (r.napravlenie !== 'potolok') return null; // не про деньги — направления нет вовсе
return aktualno > r.ozhidaetsya
? 'это БОЛЬШЕ траты и СЛАБЕЕ защита, чем решил владелец'
: 'это МЕНЬШЕ траты, чем решил владелец, — не опаснее, а осторожнее обещанного';
if (r.napravlenie === 'potolok') {
return aktualno > r.ozhidaetsya
? 'это БОЛЬШЕ траты и СЛАБЕЕ защита, чем решил владелец'
: 'это МЕНЬШЕ траты, чем решил владелец, — не опаснее, а осторожнее обещанного';
}
if (r.napravlenie === 'deshevle-lyuboe') {
return 'любое отступление отсюда уводит в дешёвую или более строгую сторону — не опаснее обещанного';
}
if (r.napravlenie === 'padaet-lyuboe') {
return 'сравнивать тут нечего — это имя, и всякое чужое имя дорого';
}
return null;
}
function tekstRaskhozhdeniya(r, aktualno, { padaet }) {
@@ -176,9 +437,9 @@ export function sverkaOdnoyNastroyki(reshenie, cfg) {
return { ...baza, status: 'sovpadaet', padaet: false, tekst: null };
}
// Расхождение ЕСТЬ — решаем, в какую оно сторону, только теперь, когда значение
// проверено и сравнимо с ожидаемым (не мусор, не пусто).
const padaet = reshenie.napravlenie === 'potolok' && aktualno > reshenie.ozhidaetsya;
// Расхождение ЕСТЬ — решаем, в какую оно сторону, только теперь, когда значение проверено.
const padaet = (reshenie.napravlenie === 'potolok' && aktualno > reshenie.ozhidaetsya)
|| reshenie.napravlenie === 'padaet-lyuboe';
return {
...baza,
status: padaet ? 'padaet' : 'slovami',
@@ -187,34 +448,187 @@ export function sverkaOdnoyNastroyki(reshenie, cfg) {
};
}
// Сверка ВСЕХ настроек списка разом. Порядок ответов совпадает с порядком RESHENIYA.
export function sverkaNastroek(cfg = {}, { resheniya = RESHENIYA } = {}) {
return resheniya.map(r => sverkaOdnoyNastroyki(r, cfg));
// ━━━ Сверка ОДНОЙ ВЕЛИЧИНЫ ━━━
// 🔴 Величина считается ТОЛЬКО из годных полей. Если хоть одно поле — мусор или его нет,
// произведение вышло бы выдумкой («7 × undefined»), и владельцу назвали бы число, которого
// нигде нет. В этом случае величина молчит: про сами поля уже сказано отдельными строками,
// и второй раз кричать о той же беде другими словами — только путать.
export function sverkaOdnoyVelichiny(velichina, cfg) {
const c = cfg ?? {};
const baza = {
klyuch: velichina.imya,
velichina: true,
klyuchi: velichina.klyuchi,
ozhidaetsya: velichina.ozhidaetsya,
resheniye: velichina.resheniye,
};
const hudye = velichina.klyuchi.filter(k => !Number.isFinite(c[k]));
if (hudye.length) {
return { ...baza, aktualno: null, status: 'ne-schitaetsya', padaet: false, tekst: null };
}
const aktualno = velichina.schitaet(c);
if (aktualno === velichina.ozhidaetsya) {
return { ...baza, aktualno, status: 'sovpadaet', padaet: false, tekst: null };
}
const padaet = velichina.napravlenie === 'potolok' && aktualno > velichina.ozhidaetsya;
const slova = velichina.klyuchi.map(k => `${k}=${JSON.stringify(c[k])}`).join(' и ');
const storona = aktualno > velichina.ozhidaetsya
? 'это БОЛЬШЕ траты и БОЛЬШЕ нагрузки на машину, чем решил владелец'
: 'это МЕНЬШЕ, чем решил владелец, — осторожнее обещанного';
return {
...baza,
aktualno,
status: padaet ? 'padaet' : 'slovami',
padaet,
tekst: `Величина «${velichina.imya}» выходит ${aktualno}, а ${otkuda(velichina)}${velichina.ozhidaetsya}. `
+ `${storona}. 🔴 В файле такой строки НЕТ ни одной: величина собирается (${velichina.kak}), `
+ `сейчас там ${slova} — и каждое поле по отдельности может быть верным, а вместе они дают `
+ `${aktualno}. Ровно так двенадцать смен подряд считали вдвое меньше настоящего. `
+ `Что делать: подберите ${velichina.klyuchi.join(' и ')} так, чтобы «${velichina.imya}» `
+ `равнялось ${velichina.ozhidaetsya}.`,
};
}
// То, что обязано остановить работу. `musor` и `net-v-fayle` падают тем же порядком,
// что и `padaet` (потолок в опасную сторону): молчать про брак записи или про пропавшую
// настройку нельзя — оба этих случая опаснее обычного мягкого расхождения.
// Сверка ВСЕХ настроек, ВСЕХ величин и живой ветки ночи. Порядок: сперва поля (в порядке
// RESHENIYA), затем величины, затем ветка.
//
// 🔴 Ветка спрашивается у ХРАНИЛИЩА, а не у настройки — и это третий род проверки, которого
// у сторожа не было вовсе: «настройка называет живое, и это живое годно». Имя ветки может
// сойтись с бумагой до знака, а на ветке при этом лежать код трёхдневной давности, который
// не запускается. Замерено 05.08.2026: отставание 346 коммитов при верном имени.
export function sverkaNastroek(cfg = {}, {
resheniya = RESHENIYA,
velichiny = VELICHINY,
vyvedennye = VYVEDENNYE,
sprosit = sprosItHranilishcheRazVZahod,
vetkuNeSprashivat = false,
// 🔴 «Сейчас будут заводить платных работников». Только при нём отставание ветки ПАДАЕТ —
// решение владельца 22, поток красным, лежащий долг числом. Довод ставит тот, кто вправду
// запускает ночь; сверка настроек сама по себе живых работников не заводит.
zhivoyZapusk = false,
} = {}) {
const polya = resheniya.map(r => sverkaOdnoyNastroyki(r, cfg));
const smysly = velichiny.map(v => sverkaOdnoyVelichiny(v, cfg));
// Выведенные числа идут ПОСЛЕ величин нарочно: их приговор ссылается на величину, и владелец
// читает сперва «работников разом семеро», а потом «а предел рождений считали от четырнадцати».
const osnovaniya = vyvedennye.map(v => sverkaVyvedennogo(v, cfg, { velichiny }));
// Ветку спрашиваем, только если имя ветки вообще годное: спрашивать хранилище про пустую
// строку или про число — значит получить обвал вместо ответа и назвать владельцу вторую
// беду вместо первой. Про сам мусор в поле уже сказано выше отдельной строкой.
const imya = cfg?.base_branch;
if (vetkuNeSprashivat || typeof imya !== 'string' || imya.trim() === '') {
return [...polya, ...smysly, ...osnovaniya];
}
let otvet = null;
try {
otvet = sprosit(imya);
} catch {
otvet = null; // разбор сам скажет «спросить не удалось» — своими словами, а не молчанием
}
return [...polya, ...smysly, ...osnovaniya, razborVetkiNochi(imya, otvet, { zhivoyZapusk })];
}
// То, что обязано остановить работу. `musor` и `net-v-fayle` падают тем же порядком, что
// и `padaet`: молчать про брак записи или про пропавшую настройку нельзя.
export function padeniya(rezultaty) {
return rezultaty.filter(r => r.padaet);
}
// ВОРОТА. Бросает ОДНУ ошибку сразу со всеми падающими причинами (по абзацу на каждую —
// не сливаем их в одну строку, иначе владелец не поймёт, из-за какой именно настройки
// встала работа), если такие есть. Иначе отдаёт список МЯГКИХ расхождений — не как отказ,
// а как слова: их можно напечатать в сводке, не останавливая ночь.
// не сливаем их в одну строку, иначе владелец не поймёт, из-за какой именно настройки встала
// работа). Иначе отдаёт список МЯГКИХ расхождений — не как отказ, а как слова.
export function sverkaIliBrosok(cfg = {}, opts = {}) {
const rezultaty = sverkaNastroek(cfg, opts);
const padshie = padeniya(rezultaty);
if (padshie.length) {
throw new Error(padshie.map(r => r.tekst).join('\n\n'));
}
return rezultaty.filter(r => r.status === 'slovami');
// 🔴 «Слова» — это ВСЁ, у чего есть текст владельцу и что при этом не останавливает работу.
// Прежде здесь стоял отбор по одному имени состояния (`slovami`), и всякий новый род
// мягкого приговора (умершая посылка выведенного числа, отставшая ветка) утёк бы молча —
// построен, зелен и невидим. Отбираем по СВОЙСТВУ, а не по имени.
return rezultaty.filter(r => r.tekst && !r.padaet);
}
// Запуск как отдельная программа — для человека, который хочет проверить настройку
// глазами прямо сейчас, без похода в код. Второго способа завести проверку намеренно
// не выдумано: тот же `sverkaIliBrosok`, только напечатан в окно и отдан кодом выхода.
// ━━━ 🔴🔴 ОБРАЗЕЦ НАСТРОЙКИ — ЕДИНСТВЕННЫЙ СЛЕД НАСТРОЙКИ В ХРАНИЛИЩЕ, И ЕГО НЕ СВЕРЯЛ НИКТО ━━━
// Живая `night.config.json` лежит в игноре (в ней поле под почтовый пароль) — значит в хранилище
// её нет вовсе, и `night.config.example.json` остаётся ЕДИНСТВЕННОЙ записью о том, как ночь
// настроена. Замерено надзирателем 05.08.2026: образец расходился с живой по ШЕСТИ полям и
// противоречил ЧЕТЫРЁМ решениям владельца разом (`spawn_budget_per_shift: 40` — та самая беда,
// ради которой этот файл и заведён; средняя голова работника вместо самой умной — против
// решения 2 (🪤 имя средней головы здесь НЕ набрано словом нарочно: сторож `spawn.test.mjs`
// запрещает его во всех рабочих файлах затеи, чтобы голова не оказалась зашитой мимо
// настроек владельца); `start_floor_share:
// 0.714` против решения 18; ветка стройки против решения 25). Тот, кто заведёт настройку из
// образца — на новой машине, после потери угла, — получит машину, противоречащую владельцу,
// и не заметит: всё запустится.
//
// 🔴 ДОЛЖНЫ ЛИ ОБРАЗЕЦ И ЖИВАЯ СОВПАДАТЬ ЦЕЛИКОМ — НЕТ, И ЭТО ВАЖНО СКАЗАТЬ ДО ПОЧИНКИ.
// У образца своя законная роль: показать ВСЕ ключи, включая те, которых в живой нет, и дать
// местам под пароль и адреса пустые заготовки. Требовать «образец = живая» значило бы либо
// тащить в живую настройку то, чего там быть не должно, либо вписывать пароли в хранилище.
// ⇒ Правило разреза: **по решениям владельца образец обязан совпадать с бумагой ТОЧНО, всё
// остальное в нём — свободно.** Ровно этот разрез и делает `sverkaObraztsa`.
//
// 🔴 И ОТДЕЛЬНО: ОБРАЗЕЦ НЕ ОСТАНАВЛИВАЕТ НОЧЬ. Ночь читает живой файл, а не образец, — падать
// ночью из-за бумаги, которую никто ночью не открывает, значит гасить работу владельца из-за
// того, что его денег в эту минуту не касается. Образец сторожат ПРОВЕРКИ (краснеют, и его
// нельзя сохранить в хранилище разъехавшимся), а не ворота ночного запуска.
export function sverkaObraztsa(cfgObraztsa, { resheniya = RESHENIYA, velichiny = VELICHINY } = {}) {
// 🪤 Выведенные числа из разбора образца ВЫРЕЗАНЫ нарочно: умершая посылка — беда живой
// ночи, а не бумаги-образца, и в двух местах об одном и том же владелец прочтёт дважды.
return sverkaNastroek(cfgObraztsa ?? {}, { resheniya, velichiny, vyvedennye: [], vetkuNeSprashivat: true })
.filter(r => r.status !== 'sovpadaet' && r.status !== 'ne-schitaetsya');
}
// ━━━ 🔴🔴 ВТОРОЕ ПРАВИЛО ОБРАЗЦА: СОГЛАСИЕ С ЖИВОЙ ПО ВСЕМ ПОЛЯМ СРАЗУ ━━━
// Сверки образца с решениями владельца НЕ ХВАТАЕТ, и это доказано деньгами. Поле
// `rate_limit_type` не названо ни одним решением владельца — значит первым правилом оно
// не сторожится вовсе. А расхождение по нему уже сожгло оплаченный замер: затея принимает
// показание, только если вид предела совпал с этим полем, иначе ЗАПИСЬ НЕ ДЕЛАЕТСЯ МОЛЧА.
// Деньги списаны, следа нет, выглядит как «в тот час не мерили». Три смены искали причину
// в коде — а изменилась не программа, а настройка, у которой нет истории: живой файл лежит
// в игноре, искать было не в чем.
//
// 🔴 ⇒ Правило нарочно НЕ по списку известных полей: список устареет на следующей же правке,
// и следующая мина будет опять в поле, которого в списке нет. Сверяются ВСЕ поля, какие есть
// в обоих файлах; исключения названы поимённо и с доводом (`OBRAZETS_VPRAVE_OTLICHATSYA`).
//
// 🪤 Поля, которых нет в одном из файлов, расхождением НЕ считаются: у образца законно шире
// набор ключей (он показывает всё, что бывает). Их список отдаётся отдельно — чтобы прибавка
// была видна глазами, но не красила прибор.
export function razyezdObraztsaSZhivoy(obrazets, zhivaya, { krome = OBRAZETS_VPRAVE_OTLICHATSYA } = {}) {
const o = obrazets ?? {};
const zh = zhivaya ?? {};
const raznye = [];
for (const klyuch of Object.keys(o)) {
if (krome.includes(klyuch) || !(klyuch in zh)) continue;
if (JSON.stringify(o[klyuch]) === JSON.stringify(zh[klyuch])) continue;
raznye.push({
klyuch,
vObraztse: o[klyuch],
vZhivoy: zh[klyuch],
tekst: `Поле «${klyuch}»: в образце ${JSON.stringify(o[klyuch])}, в живой настройке `
+ `${JSON.stringify(zh[klyuch])}. Образец — ЕДИНСТВЕННЫЙ след настройки в хранилище `
+ '(живой файл закрыт от него), поэтому тот, кто заведёт ночь заново, получит именно '
+ 'это значение. Расхождение здесь уже стоило владельцу оплаченного замера: при '
+ 'несовпадении вида предела показание выбрасывается молча.',
});
}
return {
raznye,
tolkoVObraztse: Object.keys(o).filter(k => !(k in zh)),
tolkoVZhivoy: Object.keys(zh).filter(k => !(k in o)),
};
}
// Запуск как отдельная программа — для человека, который хочет проверить настройку глазами
// прямо сейчас, без похода в код.
const isMain = (() => {
try {
return process.argv[1] && new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href
@@ -231,6 +645,23 @@ if (isMain) {
process.exit(2);
}
const cfg = JSON.parse(readFileSync(put, 'utf8'));
// Долг называется ВСЕГДА, а не только при беде: решение владельца, у которого нет настройки,
// не расходится с файлом — его просто не сторожит никто, и молчание об этом и есть беда.
for (const d of RESHENIYA_BEZ_NASTROYKI) {
console.log(`🟡 Решение владельца ${d.resheniye}${d.chto}») не сторожит никто: ${d.gde}`);
}
// 🔴 ОБРАЗЕЦ ПРОВЕРЯЕТСЯ ТУТ ЖЕ, ОБОИМИ ПРАВИЛАМИ. Человек, зовущий эту команду глазами,
// обязан увидеть и то, что образец разошёлся с бумагой, и то, что он разошёлся с живой:
// ночь читает живой файл, а заводят её заново — из образца.
const putObraztsa = new URL('../../night.config.example.json', import.meta.url);
if (existsSync(putObraztsa)) {
const obr = JSON.parse(readFileSync(putObraztsa, 'utf8'));
for (const b of sverkaObraztsa(obr)) console.log(`🟡 ОБРАЗЕЦ против бумаги: ${b.tekst}`);
for (const r of razyezdObraztsaSZhivoy(obr, cfg).raznye) {
console.log(`🟡 ОБРАЗЕЦ против живой настройки: ${r.tekst}`);
}
}
try {
const slova = sverkaIliBrosok(cfg);
for (const s of slova) console.log(s.tekst);
+434 -107
View File
@@ -1,214 +1,541 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
RESHENIYA, PROTOKOL_PUT,
sverkaNastroek, sverkaIliBrosok, padeniya,
RESHENIYA, VELICHINY, VYVEDENNYE, NE_STOROZHIM, RESHENIYA_BEZ_NASTROYKI, TEHNICHESKIE,
PROTOKOL_PUT, kudaOtnesyon,
sverkaNastroek, sverkaIliBrosok, sverkaObraztsa, razyezdObraztsaSZhivoy, padeniya,
} from './sverka-nastroek-s-resheniyami.mjs';
// Настройка «в файле полностью верна» — эталон для проверок «сходится молчит».
// Собрана из RESHENIYA, чтобы список решений и проверка не могли молча разъехаться:
// поменяй кто-нибудь ожидаемое число в RESHENIYA — этот эталон поменяется вместе с ним.
// 🔴 ВЕТКУ У ХРАНИЛИЩА ЗДЕСЬ НЕ СПРАШИВАЕМ НИ РАЗУ. Живой `git` в проверках правил запрещён
// (сторож `zhivoy-zapusk-v-proverkah.test.mjs`), и по существу он тут не нужен: правило
// про ветку проверяется своим файлом (`vetka-nochi-ne-otstayot.test.mjs`) подставным ответчиком.
const BEZ_VETKI = { vetkuNeSprashivat: true };
// Настройка «в файле полностью верна».
//
// 🔴🔴 ГЛАВНАЯ ЛОВУШКА ПРЕЖНЕГО НАБОРА ПРОВЕРОК, НАЗВАННАЯ ВСЛУХ. Этот эталон собирается
// ИЗ `RESHENIYA` — то есть обе стороны сравнения берутся из одного источника. Значит ни одна
// проверка, стоящая на нём, НЕ МОЖЕТ поймать ошибку в самом списке: поменяй кто-нибудь
// ожидаемое число — эталон поменяется вместе с ним и всё останется зелёным. Ровно так
// 23 зелёные проверки проспали два расхождения с решениями владельца.
// ⇒ Эталон оставлен (он удобен для правил «как сторож ведёт себя при таком-то отступлении»),
// но приговор «настройка сходится с бумагой» на нём НЕ строится. Для приговора есть два
// других рода проверок ниже: сверка ЖИВОГО ФАЙЛА и ПЕРЕБОР ВСЕХ КЛЮЧЕЙ поимённо.
function idealnyyCfg() {
const cfg = {};
for (const r of RESHENIYA) cfg[r.klyuch] = r.ozhidaetsya;
return cfg;
}
function zhivayaNastroyka() {
return JSON.parse(readFileSync(fileURLToPath(new URL('../../night.config.json', import.meta.url)), 'utf8'));
}
function obrazets() {
return JSON.parse(readFileSync(fileURLToPath(new URL('../../night.config.example.json', import.meta.url)), 'utf8'));
}
// ━━━━━━━━━━ 🔴🔴 ЯКОРЬ В ПРОТОКОЛ: ССЫЛКА НА РЕШЕНИЕ ОБЯЗАНА ВЕСТИ ТУДА, КУДА ОБЕЩАЕТ ━━━━━━━━━━
// Находка надзирателя смены 13, названная им как класс: «защита, повторяющая проверяемое,
// не проверяет ничего». Прежде сторож держал ожидаемое значение у себя и рядом писал «решение
// владельца 3 (строка 40 протокола)» — и НИКТО не проверял, что по этой строке вправду лежит
// решение 3. Протокол за трое суток вырос с 670 строк до 770 и перенумеровался; ссылки могли
// съехать молча, и владелец читал бы в отказе указание на чужое решение.
//
// 🔴 Почему при этом ожидаемое значение НЕ вычитывается из протокола автоматом (довод сохранён
// от прежней смены и усилен): разбирая бумагу программой, сторож начал бы брать обе стороны
// сравнения из одного источника — то самое, чем он уже один раз ослеп. Правильный разрез:
// ЧИСЛО набрано руками и видно глазами, а вот АДРЕС решения сверяется с бумагой машиной.
describe('🔴🔴 каждая ссылка на протокол ведёт в раздел именно того решения', () => {
function razdelyProtokola() {
const stroki = readFileSync(
fileURLToPath(new URL(`../../${PROTOKOL_PUT}`, import.meta.url)), 'utf8',
).split(/\r?\n/);
const zagolovki = [];
stroki.forEach((s, i) => {
const m = s.match(/^#{2,4}\s+(\d+[а-я]?)[.\s]/);
if (m) zagolovki.push({ nomer: m[1], ot: i + 1 });
});
return zagolovki.map((z, i) => ({
...z, do: i + 1 < zagolovki.length ? zagolovki[i + 1].ot - 1 : stroki.length,
}));
}
it('протокол вообще разобрался на разделы — иначе проверка сторожит пустоту', () => {
expect(razdelyProtokola().length).toBeGreaterThan(15);
});
it('🔴 у каждого решения из всех списков строка попадает В ЕГО СОБСТВЕННЫЙ раздел', () => {
const razdely = razdelyProtokola();
const bedy = [];
for (const r of [...RESHENIYA, ...VELICHINY, ...VYVEDENNYE, ...RESHENIYA_BEZ_NASTROYKI]) {
const imya = r.klyuch ?? r.imya ?? r.chto;
const svoi = razdely.filter(z => z.nomer === r.resheniye);
if (!svoi.length) { bedy.push(`${imya}: решения ${r.resheniye} в протоколе НЕТ вовсе`); continue; }
if (!svoi.some(z => r.stroka >= z.ot && r.stroka <= z.do)) {
bedy.push(`${imya}: строка ${r.stroka} лежит вне раздела решения ${r.resheniye} `
+ `(${svoi.map(z => `${z.ot}${z.do}`).join(', ')})`);
}
}
expect(bedy, bedy.join(' | ')).toEqual([]);
});
it('🔴 приёмка вырезанием: соври в строке — проверка обязана покраснеть', () => {
const razdely = razdelyProtokola();
const svoi = razdely.filter(z => z.nomer === '3');
expect(svoi.some(z => 999999 >= z.ot && 999999 <= z.do)).toBe(false);
});
});
describe('список решений владельца — сам по себе', () => {
it('в списке есть все четыре настройки, найденные расхождения по которым и завели эту сверку', () => {
it('у каждого решения назван номер и строка протокола — иначе владельцу неоткуда узнать, откуда взялось число', () => {
for (const r of [...RESHENIYA, ...VELICHINY]) {
expect(r.resheniye, `у ${r.klyuch ?? r.imya} нет номера решения`).toBeTruthy();
expect(Number.isInteger(r.stroka), `у ${r.klyuch ?? r.imya} нет строки протокола`).toBe(true);
}
});
it('у каждого решения назван род значения — иначе мусор не отличить от расхождения', () => {
for (const r of RESHENIYA) {
expect(['celoe', 'dolya', 'da-net', 'stroka'], `у ${r.klyuch} странный род`).toContain(r.vid);
}
});
it('в списке есть все четыре настройки, расхождения по которым завели эту сверку', () => {
const klyuchi = RESHENIYA.map(r => r.klyuch);
expect(klyuchi).toEqual(expect.arrayContaining([
'spawn_budget_per_shift', 'max_workers_per_task', 'base_branch', 'max_tasks_at_once',
]));
});
it('у каждого решения назван номер решения владельца и строка протокола — иначе владельцу неоткуда узнать, откуда взялось число', () => {
for (const r of RESHENIYA) {
expect(r.resheniye, `у настройки ${r.klyuch} нет номера решения`).toBeTruthy();
expect(Number.isInteger(r.stroka), `у настройки ${r.klyuch} нет строки протокола`).toBe(true);
// 🔴 Живая находка 05.08.2026: три решения владельца не сверялись НИКЕМ.
it('🔴 в списке есть три решения, которые сторож проспал целиком: голова, порог запаса, тумблер доли', () => {
const klyuchi = RESHENIYA.map(r => r.klyuch);
expect(klyuchi).toContain('worker_model'); // решение 2
expect(klyuchi).toContain('start_floor_share'); // решение 18 — самое денежное из всех
expect(klyuchi).toContain('bez_doli_ne_zapuskat'); // решение 19
});
});
// ━━━━━━━━━━ МЕХАНИЗМ 2: РЕШЕНИЕ ПРО ВЕЛИЧИНУ, А НЕ ПРО ПОЛЕ ━━━━━━━━━━
describe('🔴🔴 БОЛЬНОЙ ОБРАЗЕЦ ТОГО САМОГО РОДА: поля порознь верны, произведение вдвое больше', () => {
// Это ровно то, что 12 смен считали правильным и что нашли приёмщики вслепую.
const BOLNOY = { ...idealnyyCfg(), max_tasks_at_once: 2, max_workers_per_task: 7 };
it('поле «работников на задачу» сходится с решением 3 — по нему претензий нет', () => {
const r = sverkaNastroek(BOLNOY, BEZ_VETKI).find(x => x.klyuch === 'max_workers_per_task');
expect(r.status).toBe('sovpadaet');
});
it('🔴 и тем не менее сторож ПАДАЕТ — потому что ловит ПРОИЗВЕДЕНИЕ, а не поле', () => {
const velichina = sverkaNastroek(BOLNOY, BEZ_VETKI).find(x => x.velichina);
expect(velichina.aktualno).toBe(14);
expect(velichina.ozhidaetsya).toBe(7);
expect(velichina.padaet).toBe(true);
expect(() => sverkaIliBrosok(BOLNOY, BEZ_VETKI)).toThrow(/работников разом/);
});
it('текст называет 14, 7, оба поля и говорит, что такой строки в файле нет ни одной', () => {
let poyman = null;
try { sverkaIliBrosok(BOLNOY, BEZ_VETKI); } catch (e) { poyman = e; }
const t = poyman.message;
expect(t).toContain('14');
expect(t).toContain('max_tasks_at_once');
expect(t).toContain('max_workers_per_task');
expect(t).toMatch(/собирается/);
expect(t).toContain('24'); // номер решения владельца
});
it('🔴 доказательство, что ловится именно произведение: 7×1 и 1×7 — оба сходятся', () => {
for (const [zadach, rabotnikov] of [[1, 7], [7, 1]]) {
const cfg = { ...idealnyyCfg(), max_tasks_at_once: zadach, max_workers_per_task: rabotnikov };
const v = sverkaNastroek(cfg, BEZ_VETKI).find(x => x.velichina);
expect(v.aktualno, `${zadach}×${rabotnikov}`).toBe(7);
expect(v.padaet).toBe(false);
}
});
// Живой якорь: если протокол когда-нибудь переформатируют и решение сдвинется —
// этот список придётся поправить руками, а не оставить врать про несуществующую строку.
it('намеренно НЕ читает протокол — список в коде явный, а не выведенный', () => {
expect(RESHENIYA.length).toBeGreaterThan(0);
it('меньше семерых разом — осторожнее обещанного, только слова', () => {
const cfg = { ...idealnyyCfg(), max_workers_per_task: 3 };
const v = sverkaNastroek(cfg, BEZ_VETKI).find(x => x.velichina);
expect(v.aktualno).toBe(3);
expect(v.padaet).toBe(false);
expect(v.status).toBe('slovami');
});
it('поле — мусор: величина молчит, а не выдумывает число из «7 × ничего»', () => {
const cfg = { ...idealnyyCfg(), max_tasks_at_once: 'две' };
const v = sverkaNastroek(cfg, BEZ_VETKI).find(x => x.velichina);
expect(v.status).toBe('ne-schitaetsya');
expect(v.tekst).toBeNull();
// но про само поле сказано отдельной строкой, и она падает
expect(() => sverkaIliBrosok(cfg, BEZ_VETKI)).toThrow(/max_tasks_at_once/);
});
});
// ━━━━━━━━━━ 🔴🔴🔴 ТРЕТИЙ РАЗРЯД: ЧИСЛО ПЕРЕЖИЛО СВОЮ ПОСЫЛКУ ━━━━━━━━━━
// Находка судьи ночи. Не «поле против поля» и не «поля против величины»: настройка сходится
// с бумагой до знака, а довод, по которому её выбрали, умер.
describe('🔴🔴 предел рождений 20 выведен из ночи в 14 работников, а ночь стала семерыми', () => {
it('живая настройка: посылка умерла — сторож говорит вслух', () => {
const cfg = zhivayaNastroyka();
const v = sverkaNastroek(cfg, BEZ_VETKI).find(x => x.vyvedennoe);
expect(v.status).toBe('posylka-umerla');
expect(v.seychas).toBe(7);
});
it('🔴 и НЕ падает: настройка ни с одной бумагой не разошлась, уехала жизнь', () => {
const v = sverkaNastroek(zhivayaNastroyka(), BEZ_VETKI).find(x => x.vyvedennoe);
expect(v.padaet).toBe(false);
expect(() => sverkaIliBrosok(zhivayaNastroyka(), BEZ_VETKI)).not.toThrow();
});
it('🔴 слова доходят до владельца, а не тонут: они в списке слов, хоть состояние и новое', () => {
const slova = sverkaIliBrosok(zhivayaNastroyka(), BEZ_VETKI);
const svoy = slova.find(s => s.vyvedennoe);
expect(svoy, 'новый род мягкого приговора утёк мимо владельца').toBeTruthy();
expect(svoy.tekst).toContain('20');
expect(svoy.tekst).toContain('14');
expect(svoy.tekst).toMatch(/ОСНОВАНИЕ|выведен/);
expect(svoy.tekst).toMatch(/решение владельца/); // число правит только он
});
it('верни ночь в четырнадцать — и сторож замолкает: сторожит именно посылку', () => {
const cfg = { ...zhivayaNastroyka(), max_tasks_at_once: 2 };
const v = sverkaNastroek(cfg, BEZ_VETKI).find(x => x.vyvedennoe);
expect(v.status).toBe('sovpadaet');
expect(v.tekst).toBeNull();
});
it('🪤 число само не правится: сторож называет беду, но значение оставляет владельцу', () => {
expect(zhivayaNastroyka().spawn_budget_per_shift).toBe(20);
expect(RESHENIYA.find(r => r.klyuch === 'spawn_budget_per_shift').ozhidaetsya).toBe(20);
});
});
// ━━━━━━━━━━ МЕХАНИЗМ 3: РОДА ЗНАЧЕНИЙ, КОТОРЫХ СТОРОЖ НЕ УМЕЛ ━━━━━━━━━━
describe('🔴 доля — род, из-за неумения которого самое денежное решение владельца не сверялось', () => {
it('порог выше решённого (ночи разрешено съесть больше) — падает', () => {
const cfg = { ...idealnyyCfg(), start_floor_share: 1 };
expect(() => sverkaIliBrosok(cfg, BEZ_VETKI)).toThrow(/start_floor_share/);
});
it('порог ниже решённого (строже) — только слова', () => {
const cfg = { ...idealnyyCfg(), start_floor_share: 5 / 7 };
const slova = sverkaIliBrosok(cfg, BEZ_VETKI);
expect(slova.some(s => s.klyuch === 'start_floor_share')).toBe(true);
});
it('🔴 дробь НЕ считается мусором — прежний сторож объявил бы её браком записи и потому не мог её сверять', () => {
const r = sverkaNastroek(idealnyyCfg(), BEZ_VETKI).find(x => x.klyuch === 'start_floor_share');
expect(r.status).toBe('sovpadaet');
});
it('доля больше единицы («съесть 120 % запаса») — мусор, а не расхождение', () => {
const cfg = { ...idealnyyCfg(), start_floor_share: 1.2 };
expect(sverkaNastroek(cfg, BEZ_VETKI).find(x => x.klyuch === 'start_floor_share').status).toBe('musor');
});
it('строка вместо доли — мусор', () => {
const cfg = { ...idealnyyCfg(), start_floor_share: '0.99' };
expect(sverkaNastroek(cfg, BEZ_VETKI).find(x => x.klyuch === 'start_floor_share').status).toBe('musor');
});
});
describe('🔴 да/нет — род, из-за неумения которого решение 19 объявили «не владельцевым»', () => {
it('тумблер включён вместо выключенного — строже обещанного, только слова', () => {
const cfg = { ...idealnyyCfg(), bez_doli_ne_zapuskat: true };
const slova = sverkaIliBrosok(cfg, BEZ_VETKI);
expect(slova.some(s => s.klyuch === 'bez_doli_ne_zapuskat')).toBe(true);
});
it('число вместо да/нет — мусор', () => {
const cfg = { ...idealnyyCfg(), bez_doli_ne_zapuskat: 0 };
expect(sverkaNastroek(cfg, BEZ_VETKI).find(x => x.klyuch === 'bez_doli_ne_zapuskat').status).toBe('musor');
});
it('🔴 отговорка «протокол этого ключа не касается» была ложью — ключ в списке решений владельца', () => {
expect(RESHENIYA.some(r => r.klyuch === 'bez_doli_ne_zapuskat')).toBe(true);
expect(NE_STOROZHIM.some(n => n.klyuch === 'bez_doli_ne_zapuskat')).toBe(false);
});
});
describe('голова работника (решение 2) — любое отступление дешевле, но названо вслух', () => {
it('средняя голова вместо самой умной — слова, не падение', () => {
const cfg = { ...idealnyyCfg(), worker_model: 'sonnet' };
const slova = sverkaIliBrosok(cfg, BEZ_VETKI);
const svoy = slova.find(s => s.klyuch === 'worker_model');
expect(svoy).toBeTruthy();
expect(svoy.tekst).toContain('opus');
});
});
// ━━━━━━━━━━ ИСХОДЫ 1–3 ━━━━━━━━━━
describe('исход 1 — сходится, сторож молчит', () => {
it('когда все настройки в файле равны решениям владельца — падений и слов нет вовсе', () => {
it('все настройки равны решениям — падений нет ни одного', () => {
const cfg = idealnyyCfg();
const rezultaty = sverkaNastroek(cfg);
expect(rezultaty.every(r => r.status === 'sovpadaet')).toBe(true);
expect(padeniya(rezultaty)).toEqual([]);
expect(() => sverkaIliBrosok(cfg)).not.toThrow();
expect(sverkaIliBrosok(cfg)).toEqual([]);
expect(sverkaNastroek(cfg, BEZ_VETKI).filter(r => !r.vyvedennoe)
.every(r => r.status === 'sovpadaet')).toBe(true);
expect(padeniya(sverkaNastroek(cfg, BEZ_VETKI))).toEqual([]);
});
// 🔴 И ЭТО НЕ ПРИДИРКА, А ГЛАВНЫЙ УРОК ФАЙЛА. Настройка сходится с бумагой ДО ЗНАКА,
// а слово всё равно есть: предел рождений выведен из ночи вчетырнадцать, а ночь семеро.
// «Всё сходится» и «всё в порядке» — разные вещи, и сторож обязан их различать.
it('🔴 и всё-таки одно слово остаётся: выведенное число пережило свою посылку', () => {
const slova = sverkaIliBrosok(idealnyyCfg(), BEZ_VETKI);
expect(slova).toHaveLength(1);
expect(slova[0].vyvedennoe).toBe(true);
});
});
describe('исход 2 — расхождение в сторону БОЛЬШЕЙ траты или СЛАБЕЕ защиты: падает вслух', () => {
describe('исход 2 — больше траты или слабее защита: падает вслух', () => {
it('живая находка: spawn_budget_per_shift=40 вместо решённых 20 — падает', () => {
const cfg = { ...idealnyyCfg(), spawn_budget_per_shift: 40 };
const rezultaty = sverkaNastroek(cfg);
const svoy = rezultaty.find(r => r.klyuch === 'spawn_budget_per_shift');
expect(svoy.status).toBe('padaet');
expect(padeniya(rezultaty)).toHaveLength(1);
expect(() => sverkaIliBrosok(cfg)).toThrow(/spawn_budget_per_shift/);
expect(sverkaNastroek(cfg, BEZ_VETKI).find(r => r.klyuch === 'spawn_budget_per_shift').status).toBe('padaet');
expect(() => sverkaIliBrosok(cfg, BEZ_VETKI)).toThrow(/spawn_budget_per_shift/);
});
it('текст падения называет: имя настройки, что стоит, что обещано, номер решения, что делать', () => {
const cfg = { ...idealnyyCfg(), spawn_budget_per_shift: 40 };
let poyman = null;
try { sverkaIliBrosok(cfg); } catch (e) { poyman = e; }
expect(poyman).not.toBeNull();
try { sverkaIliBrosok(cfg, BEZ_VETKI); } catch (e) { poyman = e; }
const tekst = poyman.message;
expect(tekst).toContain('spawn_budget_per_shift'); // имя настройки
expect(tekst).toContain('40'); // что стоит
expect(tekst).toContain('20'); // что обещано
expect(tekst).toContain('9а'); // номер решения владельца
expect(tekst).toMatch(/night\.config\.json/); // что делать (где поправить)
expect(tekst).toContain(PROTOKOL_PUT); // где записано решение
expect(tekst).toContain('spawn_budget_per_shift');
expect(tekst).toContain('40');
expect(tekst).toContain('20');
expect(tekst).toContain('9а');
expect(tekst).toMatch(/night\.config\.json/);
expect(tekst).toContain(PROTOKOL_PUT);
});
it('превышение потолка задач тоже падает (та же сторона: больше трат)', () => {
const cfg = { ...idealnyyCfg(), max_tasks_at_once: 3 };
expect(() => sverkaIliBrosok(cfg)).toThrow(/max_tasks_at_once/);
it('🔴 решение 24 переставило потолок задач на 1: две задачи разом теперь падают сами по себе', () => {
const cfg = { ...idealnyyCfg(), max_tasks_at_once: 2 };
expect(() => sverkaIliBrosok(cfg, BEZ_VETKI)).toThrow(/max_tasks_at_once/);
});
});
describe('исход 3 — расхождение ИНАЧЕ: сторож говорит словами, но не падает', () => {
it('живая находка: max_workers_per_task=2 вместо решённых 7 — это МЕНЬШЕ работников, не падает', () => {
describe('исход 3 — расходится иначе: слова, но не падение', () => {
it('max_workers_per_task=2 вместо семерых — меньше работников, не падает', () => {
const cfg = { ...idealnyyCfg(), max_workers_per_task: 2 };
const rezultaty = sverkaNastroek(cfg);
const svoy = rezultaty.find(r => r.klyuch === 'max_workers_per_task');
const svoy = sverkaNastroek(cfg, BEZ_VETKI).find(r => r.klyuch === 'max_workers_per_task');
expect(svoy.status).toBe('slovami');
expect(padeniya(rezultaty)).toEqual([]);
expect(() => sverkaIliBrosok(cfg)).not.toThrow();
const slova = sverkaIliBrosok(cfg);
expect(slova.some(s => s.klyuch === 'max_workers_per_task')).toBe(true);
expect(slova.find(s => s.klyuch === 'max_workers_per_task').tekst).toContain('max_workers_per_task');
expect(sverkaIliBrosok(cfg, BEZ_VETKI).some(s => s.klyuch === 'max_workers_per_task')).toBe(true);
});
it('живая находка: base_branch — рабочая ветка вместо feat/noch-proba-1 — это НЕ про деньги, не падает', () => {
const cfg = { ...idealnyyCfg(), base_branch: 'feat/night-dispatcher-part1' };
const rezultaty = sverkaNastroek(cfg);
const svoy = rezultaty.find(r => r.klyuch === 'base_branch');
expect(svoy.status).toBe('slovami');
expect(padeniya(rezultaty)).toEqual([]);
expect(() => sverkaIliBrosok(cfg)).not.toThrow();
});
it('слово про мягкое расхождение тоже называет имя настройки, что стоит и что обещано', () => {
it('слово про мягкое расхождение называет имя настройки, что стоит и что обещано', () => {
const cfg = { ...idealnyyCfg(), max_workers_per_task: 2 };
const [slovo] = sverkaIliBrosok(cfg);
const slovo = sverkaIliBrosok(cfg, BEZ_VETKI).find(s => s.klyuch === 'max_workers_per_task');
expect(slovo.tekst).toContain('max_workers_per_task');
expect(slovo.tekst).toContain('2');
expect(slovo.tekst).toContain('7');
expect(slovo.tekst).toContain('3'); // номер решения
});
});
describe('отсутствие настройки в файле — НЕ «сходится», отдельный ответ, и он падает', () => {
it('настройки нет вовсе в cfg — падает, а не молчит как «сходится»', () => {
// 🔴 ПЕРЕМЕНА ПРАВИЛА, СДЕЛАННАЯ 05.08.2026 ПО РЕШЕНИЮ ВЛАДЕЛЬЦА 25.
describe('🔴 base_branch: чужая ветка теперь ПАДАЕТ, а не проходит словами', () => {
it('ветка стройки вместо ветки ночи — падение', () => {
const cfg = { ...idealnyyCfg(), base_branch: 'feat/night-dispatcher-part1' };
const svoy = sverkaNastroek(cfg, BEZ_VETKI).find(r => r.klyuch === 'base_branch');
expect(svoy.status).toBe('padaet');
expect(() => sverkaIliBrosok(cfg, BEZ_VETKI)).toThrow(/base_branch/);
});
it('прежнее поведение («не про деньги, только слова») ушло — приёмка перемены', () => {
const cfg = { ...idealnyyCfg(), base_branch: 'main' };
expect(padeniya(sverkaNastroek(cfg, BEZ_VETKI))).toHaveLength(1);
});
});
describe('отсутствие настройки — НЕ «сходится», отдельный ответ, и он падает', () => {
it('настройки нет вовсе — падает', () => {
const cfg = idealnyyCfg();
delete cfg.spawn_budget_per_shift;
const rezultaty = sverkaNastroek(cfg);
const svoy = rezultaty.find(r => r.klyuch === 'spawn_budget_per_shift');
expect(svoy.status).toBe('net-v-fayle');
expect(svoy.status).not.toBe('sovpadaet');
expect(() => sverkaIliBrosok(cfg)).toThrow(/spawn_budget_per_shift/);
expect(sverkaNastroek(cfg, BEZ_VETKI).find(r => r.klyuch === 'spawn_budget_per_shift').status).toBe('net-v-fayle');
expect(() => sverkaIliBrosok(cfg, BEZ_VETKI)).toThrow(/spawn_budget_per_shift/);
});
it('текст про отсутствие называет имя настройки и что обещано', () => {
const cfg = idealnyyCfg();
delete cfg.base_branch;
let poyman = null;
try { sverkaIliBrosok(cfg); } catch (e) { poyman = e; }
try { sverkaIliBrosok(cfg, BEZ_VETKI); } catch (e) { poyman = e; }
expect(poyman.message).toContain('base_branch');
expect(poyman.message).toContain('feat/noch-proba-1');
expect(poyman.message).toMatch(/отсутств|нет в файле|не сказан/i);
expect(poyman.message).toMatch(/отсутств/i);
});
it('пустой объект настроек — падает на каждой из отсутствующих', () => {
const rezultaty = sverkaNastroek({});
const rezultaty = sverkaNastroek({}, BEZ_VETKI).filter(r => !r.velichina && !r.vyvedennoe);
expect(rezultaty.every(r => r.status === 'net-v-fayle')).toBe(true);
expect(padeniya(rezultaty).length).toBe(RESHENIYA.length);
expect(rezultaty.length).toBe(RESHENIYA.length);
});
});
describe('мусор в настройке — не толкуется молча, падает вслух с именем настройки', () => {
it('строка вместо числа', () => {
const cfg = { ...idealnyyCfg(), spawn_budget_per_shift: 'двадцать' };
const rezultaty = sverkaNastroek(cfg);
expect(rezultaty.find(r => r.klyuch === 'spawn_budget_per_shift').status).toBe('musor');
expect(() => sverkaIliBrosok(cfg)).toThrow(/spawn_budget_per_shift/);
expect(sverkaNastroek(cfg, BEZ_VETKI).find(r => r.klyuch === 'spawn_budget_per_shift').status).toBe('musor');
});
it('null вместо числа', () => {
const cfg = { ...idealnyyCfg(), max_workers_per_task: null };
const rezultaty = sverkaNastroek(cfg);
expect(rezultaty.find(r => r.klyuch === 'max_workers_per_task').status).toBe('musor');
expect(() => sverkaIliBrosok(cfg)).toThrow(/max_workers_per_task/);
expect(sverkaNastroek(cfg, BEZ_VETKI).find(r => r.klyuch === 'max_workers_per_task').status).toBe('musor');
});
it('отрицательное число', () => {
const cfg = { ...idealnyyCfg(), max_tasks_at_once: -1 };
const rezultaty = sverkaNastroek(cfg);
expect(rezultaty.find(r => r.klyuch === 'max_tasks_at_once').status).toBe('musor');
expect(() => sverkaIliBrosok(cfg)).toThrow(/max_tasks_at_once/);
expect(sverkaNastroek(cfg, BEZ_VETKI).find(r => r.klyuch === 'max_tasks_at_once').status).toBe('musor');
});
it('дробное число там, где решение владельца — целое', () => {
it('дробное там, где решение владельца — целое', () => {
const cfg = { ...idealnyyCfg(), max_tasks_at_once: 2.5 };
const rezultaty = sverkaNastroek(cfg);
expect(rezultaty.find(r => r.klyuch === 'max_tasks_at_once').status).toBe('musor');
expect(sverkaNastroek(cfg, BEZ_VETKI).find(r => r.klyuch === 'max_tasks_at_once').status).toBe('musor');
});
it('пустая строка вместо имени ветки', () => {
const cfg = { ...idealnyyCfg(), base_branch: '' };
const rezultaty = sverkaNastroek(cfg);
expect(rezultaty.find(r => r.klyuch === 'base_branch').status).toBe('musor');
expect(() => sverkaIliBrosok(cfg)).toThrow(/base_branch/);
expect(sverkaNastroek(cfg, BEZ_VETKI).find(r => r.klyuch === 'base_branch').status).toBe('musor');
});
it('число там, где решение владельца — строка (перепутан тип)', () => {
it('число там, где решение владельца — строка', () => {
const cfg = { ...idealnyyCfg(), base_branch: 123 };
const rezultaty = sverkaNastroek(cfg);
expect(rezultaty.find(r => r.klyuch === 'base_branch').status).toBe('musor');
expect(sverkaNastroek(cfg, BEZ_VETKI).find(r => r.klyuch === 'base_branch').status).toBe('musor');
});
it('🔴 мусор в имени ветки не заставляет сторожа лезть в хранилище со всякой чушью', () => {
// Спросить `git` про пустую строку значило бы получить обвал и назвать владельцу вторую
// беду вместо первой. Живой ответчик здесь не зовётся — доказательство: подставной молчит.
let zvali = false;
sverkaNastroek({ ...idealnyyCfg(), base_branch: '' }, { sprosit: () => { zvali = true; return {}; } });
expect(zvali).toBe(false);
});
});
describe('смесь исходов сразу: падающее падает, слова остаются словами', () => {
it('одновременно живая находка (падает) и безопасное расхождение (слова) — бросок называет только падающее, а слова доступны отдельно', () => {
describe('смесь исходов: падающее падает, слова остаются словами', () => {
it('дорогое и безопасное расхождение разом', () => {
const cfg = { ...idealnyyCfg(), spawn_budget_per_shift: 40, max_workers_per_task: 2 };
let poyman = null;
try { sverkaIliBrosok(cfg); } catch (e) { poyman = e; }
expect(poyman).not.toBeNull();
try { sverkaIliBrosok(cfg, BEZ_VETKI); } catch (e) { poyman = e; }
expect(poyman.message).toContain('spawn_budget_per_shift');
const rezultaty = sverkaNastroek(cfg);
const slovami = rezultaty.filter(r => r.status === 'slovami');
const slovami = sverkaNastroek(cfg, BEZ_VETKI).filter(r => r.status === 'slovami');
expect(slovami.some(r => r.klyuch === 'max_workers_per_task')).toBe(true);
});
});
describe('приёмка вырезанием — текущий night.config.json угла (после починки)', () => {
it('живой файл угла проходит сверку без падения: 9а осталась 20, остальные расхождения — только словами', async () => {
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const put = fileURLToPath(new URL('../../night.config.json', import.meta.url));
const cfg = JSON.parse(readFileSync(put, 'utf8'));
expect(cfg.spawn_budget_per_shift).toBe(20);
expect(() => sverkaIliBrosok(cfg)).not.toThrow();
// ━━━━━━━━━━ МЕХАНИЗМ 1: ПРИГОВОР НА ЖИВОМ ФАЙЛЕ, А НЕ НА ЭТАЛОНЕ ИЗ СЕБЯ ━━━━━━━━━━
describe('🔴🔴 приёмка на ЖИВОМ файле — единственное место, где приговор чего-то стоит', () => {
it('живая night.config.json проходит сверку полей и величин без падения', () => {
expect(() => sverkaIliBrosok(zhivayaNastroyka(), BEZ_VETKI)).not.toThrow();
});
it('🔴 и разом ровно семеро, а не четырнадцать — величина, а не поле', () => {
const cfg = zhivayaNastroyka();
expect(cfg.max_tasks_at_once * cfg.max_workers_per_task).toBe(7);
});
it('решения 2, 18 и 19 в живом файле стоят так, как решил владелец', () => {
const cfg = zhivayaNastroyka();
expect(cfg.worker_model).toBe('opus');
expect(cfg.start_floor_share).toBe(0.99);
expect(cfg.bez_doli_ne_zapuskat).toBe(false);
});
});
describe('bez_doli_ne_zapuskat — намеренно вне этого списка', () => {
it('в RESHENIYA нет ключа bez_doli_ne_zapuskat: это решение Р5 самой затеи (budget.mjs), а не владельца из протокола', () => {
expect(RESHENIYA.some(r => r.klyuch === 'bez_doli_ne_zapuskat')).toBe(false);
describe('🔴🔴 ПЕРЕБОР ВСЕХ КЛЮЧЕЙ ПОИМЁННО — чтобы «все решения покрыты» перестало быть догадкой', () => {
it('у каждого ключа живой настройки есть своё место: решение, отговорка или техническое умолчание', () => {
const bezhoznye = Object.keys(zhivayaNastroyka()).filter(k => kudaOtnesyon(k) === null);
expect(bezhoznye, `ключи, которых не разобрал никто: ${bezhoznye.join(', ')}`).toEqual([]);
});
it('в RESHENIYA нет ключа probe_budget_window_hours: толкование слова «смена» выбрал надзиратель, а не владелец', () => {
expect(RESHENIYA.some(r => r.klyuch === 'probe_budget_window_hours')).toBe(false);
it('четыре списка не пересекаются — один ключ не может быть и решением, и отговоркой', () => {
const iz = [...RESHENIYA.map(r => r.klyuch), ...NE_STOROZHIM.map(n => n.klyuch), ...TEHNICHESKIE];
expect(new Set(iz).size).toBe(iz.length);
});
it('🔴 у каждой отговорки «нарочно не сверяем» назван довод и дата — иначе ей негде протухнуть заметно', () => {
for (const n of NE_STOROZHIM) {
expect(n.dovod?.length ?? 0, `у ${n.klyuch} нет довода`).toBeGreaterThan(40);
expect(n.kogda, `у ${n.klyuch} нет даты`).toBeTruthy();
}
});
it('🔴 решения владельца без настройки названы поимённо и не забыты', () => {
expect(RESHENIYA_BEZ_NASTROYKI.length).toBeGreaterThan(0);
expect(RESHENIYA_BEZ_NASTROYKI.map(d => d.resheniye)).toContain('16');
for (const d of RESHENIYA_BEZ_NASTROYKI) {
expect(d.chto, 'не сказано, о чём решение').toBeTruthy();
expect(d.gde, 'не сказано, почему сторожить нечем').toBeTruthy();
}
});
});
// ━━━━━━━━━━ ОБРАЗЕЦ В ХРАНИЛИЩЕ ━━━━━━━━━━
describe('🔴🔴 образец настройки — единственный след настройки в хранилище, и его не сверял никто', () => {
it('образец отвечает ВСЕМ решениям владельца — иначе новая машина заведётся против его слова', () => {
const bedy = sverkaObraztsa(obrazets());
expect(bedy.map(b => `${b.klyuch}: ${b.tekst}`), 'образец разошёлся с решениями владельца').toEqual([]);
});
it('образец и живая НЕ обязаны совпадать целиком — у образца своя роль', () => {
// Разрез назван вслух: по решениям владельца — точно, всё прочее свободно. Доказательство,
// что разрез именно такой: в образце есть ключи, которых в живой нет вовсе, и это законно.
const lishnie = Object.keys(obrazets()).filter(k => !(k in zhivayaNastroyka()));
expect(lishnie.length).toBeGreaterThan(0);
for (const k of lishnie) expect(RESHENIYA.some(r => r.klyuch === k)).toBe(false);
});
it('в образце нет ни пароля, ни настоящего адреса почты', () => {
const m = obrazets().mail ?? {};
expect(String(m.pass)).not.toMatch(/@|[A-Za-z0-9]{12,}/);
});
it('🔴 больной образец: спрячь в образец прежние 40 — сверка образца краснеет', () => {
const bolnoy = { ...obrazets(), spawn_budget_per_shift: 40 };
expect(sverkaObraztsa(bolnoy).some(b => b.klyuch === 'spawn_budget_per_shift')).toBe(true);
});
});
// ━━━━━━━━━━ 🔴🔴 ОБРАЗЕЦ ПРОТИВ ЖИВОЙ — ПО ВСЕМ ПОЛЯМ, А НЕ ПО СПИСКУ ИЗВЕСТНЫХ ━━━━━━━━━━
describe('🔴🔴 согласие образца с живой настройкой — правило, за отсутствие которого уже заплачено', () => {
it('🔴 расхождений нет ни по одному полю, какое есть в обоих файлах', () => {
const { raznye } = razyezdObraztsaSZhivoy(obrazets(), zhivayaNastroyka());
expect(
raznye.map(r => r.klyuch),
'Образец — единственный след настройки в хранилище. Разошлось: '
+ `${raznye.map(r => `${r.klyuch} (образец ${JSON.stringify(r.vObraztse)}, живая ${JSON.stringify(r.vZhivoy)})`).join('; ')}. `
+ '🔴 Особо про rate_limit_type: при несовпадении этого поля с видом пришедшего показания '
+ 'оплаченный замер выбрасывается МОЛЧА — так уже сгорел самый дорогой замер суток '
+ '(0,148 $). Не «поправьте обратно», а разберитесь, какое значение верное.',
).toEqual([]);
});
it('🔴 правило ловит поле, которого нет ни в одном списке известных — иначе следующая мина будет в нём', () => {
// Ровно тот случай: `rate_limit_type` не назван ни одним решением владельца, и первым
// правилом (сверка с бумагой) он не сторожится вовсе.
const { raznye } = razyezdObraztsaSZhivoy(
{ ...obrazets(), rate_limit_type: 'five_hour' }, zhivayaNastroyka(),
);
expect(raznye.map(r => r.klyuch)).toContain('rate_limit_type');
expect(RESHENIYA.some(r => r.klyuch === 'rate_limit_type')).toBe(false);
});
it('поле, выдуманное на ходу и не известное никому, тоже ловится', () => {
const { raznye } = razyezdObraztsaSZhivoy({ novoe_pole: 1 }, { novoe_pole: 2 });
expect(raznye.map(r => r.klyuch)).toEqual(['novoe_pole']);
});
it('почта вправе различаться: в живой пусто, в образце заготовка — это не расхождение', () => {
const { raznye } = razyezdObraztsaSZhivoy(
{ mail: { host: 'SMTP-хост' } }, { mail: { host: '' } },
);
expect(raznye).toEqual([]);
});
it('ключи, каких нет в другом файле, названы отдельно и прибор не красят', () => {
const r = razyezdObraztsaSZhivoy(obrazets(), zhivayaNastroyka());
expect(r.tolkoVObraztse.length).toBeGreaterThan(0);
expect(r.raznye).toEqual([]);
});
});
+164
View File
@@ -0,0 +1,164 @@
// СТОРОЖ: ВЕТКА, ОТ КОТОРОЙ ВЕТВЯТСЯ РАБОТНИКИ, НЕ ДОЛЖНА ОТСТАВАТЬ ОТ ВЕТКИ СТРОЙКИ.
//
// 🔴🔴 БЕДА, ЗАВЕДШАЯ ЭТОТ ФАЙЛ (решение владельца 25, 05.08.2026, строка 769 протокола:
// «Чтобы это не повторилось, заведён сторож»). В настройке стояло имя ветки — и сверка имени
// была зелёной, потому что имя-то верное. А замер показал: ветка `feat/noch-proba-1` отстаёт
// от ветки стройки на 346 коммитов, файлов затеи на ней 41 против 97, и починки точки входа
// в ней НЕТ вовсе. ⇒ Семеро платных работников, отпочковавшись от неё, получили бы код,
// который не запускается — ту самую поломку, что три дня держала ночь мёртвой. Деньги за
// ночь потрачены, сделано ноль.
//
// ━━━ 🔴 ЧЕМУ ЭТО УЧИТ И ЗАЧЕМ ОТДЕЛЬНЫЙ ФАЙЛ ━━━
// Сверка настроек умеет сравнивать ЗАПИСАННОЕ с ЗАПИСАННЫМ: строка в файле против строки
// в бумаге. Здесь этого мало по существу: имя ветки верное, а беда — в том, ЧТО ЛЕЖИТ
// на ветке с этим именем. Такое не сверишь чтением настройки, это надо СПРОСИТЬ У ХРАНИЛИЩА.
// Отсюда отдельный файл и отдельный род проверки: «настройка называет живое, и это живое
// годно», а не «в двух бумагах написано одно и то же».
//
// ━━━ ПОЧЕМУ ВЕТКА СТРОЙКИ НИГДЕ НЕ ЗАПИСАНА, А СПРАШИВАЕТСЯ У ХРАНИЛИЩА ━━━
// Соблазн был набрать `feat/night-dispatcher-part1` строкой. Отвергнуто: имя ветки стройки
// меняется (кусок кончится — заведут следующую), и набранное имя разъехалось бы молча ровно
// тем же способом, каким разъехалось всё остальное в этой затее. Ветка стройки — это ветка
// ТОГО УГЛА, ИЗ КОТОРОГО НОЧЬ ЗАПУСКАЮТ: код, который ночь исполняет прямо сейчас, лежит
// именно там. Спрашивается она у хранилища и не хранится нигде.
import { execFileSync } from 'node:child_process';
import { REPO_ROOT } from './paths.mjs';
// 🔴 ТОЧКА ВПРЫСКА. Проверки правил НЕ зовут настоящий `git` (сторож
// `zhivoy-zapusk-v-proverkah.test.mjs`) — они подставляют сюда свой ответчик. Живой ответчик
// живёт здесь, в рабочем файле, и зовётся только в бою.
export function sprosItHranilishche(vetkaNochi, { koren = REPO_ROOT } = {}) {
const git = (...dovody) => execFileSync('git', dovody, {
cwd: koren, encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const vetkaUgla = git('rev-parse', '--abbrev-ref', 'HEAD');
// «Есть ли такая ветка» спрашивается ОТДЕЛЬНО и ДО счёта: `rev-list` по несуществующему
// имени падает тем же обвалом, что и сломанный `git`, и различить их было бы нечем —
// а это два очень разных ответа владельцу.
let est = true;
try {
git('rev-parse', '--verify', '--quiet', `${vetkaNochi}^{commit}`);
} catch {
est = false;
}
if (!est) return { vetkaUgla, est: false, otstaet: null, svoih: null };
return {
vetkaUgla,
est: true,
otstaet: Number(git('rev-list', '--count', `${vetkaNochi}..${vetkaUgla}`)),
svoih: Number(git('rev-list', '--count', `${vetkaUgla}..${vetkaNochi}`)),
};
}
// ━━━ 🔴🔴 ДВА ИСХОДА ОДНОГО ПРАВИЛА — ПО РЕШЕНИЮ ВЛАДЕЛЬЦА 22 (строка 626 протокола) ━━━
// Первый заход этого сторожа падал ВСЕГДА. Замерено сразу: прибор проверок всей смены стал
// красным — не от поломки в коде, а оттого, что ветка вправду отстаёт СЕЙЧАС, и двинуть её
// вправе только надзиратель. Это ровно храповик, который владелец велел развести: «сломать
// может всякий, починить только владелец, и не тот, кто сломал», и у упёршейся смены остаётся
// один рычаг — ослабить самого сторожа.
//
// ⇒ Разведено так же, как велено там: **поток — красным, лежащий долг — числом без красноты.**
// · `zhivoyZapusk: true` — сейчас будут заводить платных работников. Отставание ПАДАЕТ:
// деньги вот-вот уйдут на код, который не запускается.
// · без него — то же самое говорится СЛОВАМИ и попадает в сводку владельцу числом.
// Работа не встаёт: закрыть этот долг одна смена не может, а держать её в вечном красном
// за чужой долг — это наказывать за то, чего не исправить.
export function razborVetkiNochi(vetkaNochi, otvet, { zhivoyZapusk = false } = {}) {
const baza = { klyuch: 'base_branch', vetka: vetkaNochi, resheniye: '25', zhivoyZapusk };
// Одно место, где долг превращается в отказ, — чтобы «падает» и «говорит» не разъехались
// по трём разным веткам кода и не начали означать разное.
const ishod = tekst => (zhivoyZapusk
? { padaet: true, tekst }
: {
padaet: false,
tekst: `${tekst} Работа сейчас не встаёт: живых работников не заводят, а закрыть этот долг `
+ 'может только надзиратель. Перед живым запуском тот же разбор ПАДАЕТ.',
});
// Спросить не удалось вовсе — это ПАДЕНИЕ, а не слова. Молчащий датчик и есть та беда,
// ради которой файл заведён: ночь целиком стоит на хранилище, и если оно не отвечает,
// «непроверено» надо назвать вслух, а не выдать за «сходится».
if (!otvet || typeof otvet !== 'object' || !otvet.vetkaUgla) {
return {
...baza,
status: 'ne-sprosili',
...ishod(`Спросить у хранилища про ветку «${vetkaNochi}» не удалось — значит НЕ ПРОВЕРЕНО, `
+ 'а не «сходится». От этой ветки ночью отпочкуются рабочие углы, и если она окажется '
+ 'старой, работники получат код, который не запускается, и потратят вашу ночь впустую. '
+ 'Что делать: проверьте, что хранилище отвечает (git), и запустите заново.'),
};
}
const { vetkaUgla, est, otstaet, svoih } = otvet;
// Ветка ночи и ветка угла — одна и та же. Отставать не от чего по устройству: работники
// возьмут ровно тот код, который ночь и исполняет.
if (vetkaNochi === vetkaUgla) {
return { ...baza, status: 'ta-zhe-vetka', padaet: false, tekst: null };
}
if (!est) {
return {
...baza,
status: 'vetki-net',
...ishod(`В настройке «base_branch» стоит ветка «${vetkaNochi}», а в хранилище такой ветки НЕТ. `
+ 'Отпочковать от неё рабочие углы нельзя — ночь не начнётся ни у одного работника. '
+ `Что делать: заведите ветку «${vetkaNochi}» от нынешней «${vetkaUgla}» либо поправьте `
+ 'имя в night.config.json.'),
};
}
if (otstaet > 0) {
return {
...baza,
status: 'otstaet',
otstaet,
...ishod(`Ветка «${vetkaNochi}», от которой ночью отпочкуются рабочие углы, ОТСТАЁТ от рабочей `
+ `ветки «${vetkaUgla}» на ${otstaet} коммитов. Имя в настройке при этом верное — расходится `
+ 'не имя, а то, что на этой ветке лежит: работники получат код старше нынешнего, вплоть '
+ 'до того, что он вовсе не запустится, и ночь будет оплачена впустую. '
+ `Что делать: двинуть «${vetkaNochi}» вперёд до вершины «${vetkaUgla}» — это делает `
+ 'надзиратель и только когда смена закончила коммитить.'),
};
}
// Ветка ночи ушла ВПЕРЁД угла. Не опасно (код там новее, а не старее), но сказать надо:
// значит на ней завелось своё, чего в стройке нет, и однажды это разойдётся.
if (svoih > 0) {
return {
...baza,
status: 'slovami',
padaet: false,
svoih,
tekst: `Ветка «${vetkaNochi}» не отстаёт, но на ней есть ${svoih} своих коммитов, которых нет `
+ `в рабочей «${vetkaUgla}». Работе это не мешает — код там не старее, а новее. Сказано, `
+ 'чтобы расхождение не приняли потом за потерянную работу.',
};
}
return { ...baza, status: 'sovpadaet', padaet: false, tekst: null };
}
// Ворота целиком: спросить и разобрать. Ответчик подставляется — по умолчанию живой.
export function vorotaVetkiNochi(vetkaNochi, { sprosit = sprosItHranilishche, zhivoyZapusk = false } = {}) {
let otvet = null;
try {
otvet = sprosit(vetkaNochi);
} catch {
otvet = null; // разбор сам скажет «не спросили» — тем же текстом, что и при пустом ответе
}
return razborVetkiNochi(vetkaNochi, otvet, { zhivoyZapusk });
}
// 🪤 ПАМЯТЬ НА ОДИН ЗАХОД. Ответ хранилища про ветку за время одного прогона не меняется,
// а спрашивают его на каждом заходе сверки — в проверках это десятки лишних запусков `git`
// и мигание прибора от занятой машины (та же беда, из-за которой заведён
// `predel-zhivogo-zapuska.mjs`). Память живёт ровно в одном процессе и наружу не течёт.
const PAMYAT = new Map();
export function sprosItHranilishcheRazVZahod(vetkaNochi, opts) {
if (!PAMYAT.has(vetkaNochi)) PAMYAT.set(vetkaNochi, sprosItHranilishche(vetkaNochi, opts));
return PAMYAT.get(vetkaNochi);
}
@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest';
import { razborVetkiNochi, vorotaVetkiNochi } from './vetka-nochi-ne-otstayot.mjs';
// 🔴 ЖИВОГО `git` ЗДЕСЬ НЕТ НИ ОДНОГО, И ЭТО НАРОЧНО. Предмет проверки — ПРАВИЛО («отстаёт —
// падаем»), а не поведение хранилища. Ответчик подставляется целиком; сторож
// `zhivoy-zapusk-v-proverkah.test.mjs` этот файл в списке не держит, и держать его там
// не за чем: настоящему `git` тут доказывать нечего.
const UGOL = 'feat/night-dispatcher-part1';
// «Сейчас заводят платных работников» — только в этом случае долг становится отказом
// (решение владельца 22: поток красным, лежащий долг числом).
const ZHIVOY = { zhivoyZapusk: true };
describe('ветка ночи и ветка стройки — одна и та же', () => {
it('отставать не от чего по устройству — сторож молчит', () => {
const r = razborVetkiNochi(UGOL, { vetkaUgla: UGOL, est: true, otstaet: 0, svoih: 0 });
expect(r.status).toBe('ta-zhe-vetka');
expect(r.padaet).toBe(false);
expect(r.tekst).toBeNull();
});
});
describe('🔴 живая находка 05.08.2026: ветка отстаёт — перед живым запуском ПАДАЕТ', () => {
it('отставание на 346 коммитов падает, хотя имя ветки в настройке верное', () => {
const r = razborVetkiNochi('feat/noch-proba-1', {
vetkaUgla: UGOL, est: true, otstaet: 346, svoih: 0,
}, ZHIVOY);
expect(r.status).toBe('otstaet');
expect(r.padaet).toBe(true);
});
it('текст называет обе ветки, число отставания и что делать', () => {
const r = razborVetkiNochi('feat/noch-proba-1', {
vetkaUgla: UGOL, est: true, otstaet: 346, svoih: 0,
}, ZHIVOY);
expect(r.tekst).toContain('feat/noch-proba-1');
expect(r.tekst).toContain(UGOL);
expect(r.tekst).toContain('346');
expect(r.tekst).toMatch(/двинуть/);
// Главное для владельца: почему это дорого, а не просто «числа разные».
expect(r.tekst).toMatch(/не запуст|впустую|старше/);
});
it('отставание даже на ОДИН коммит падает — порога «немножко можно» нет', () => {
const r = razborVetkiNochi('feat/noch-proba-1', {
vetkaUgla: UGOL, est: true, otstaet: 1, svoih: 0,
}, ZHIVOY);
expect(r.padaet).toBe(true);
});
});
// ━━━ 🔴🔴 РЕШЕНИЕ ВЛАДЕЛЬЦА 22: ПОТОК КРАСНЫМ, ЛЕЖАЧИЙ ДОЛГ ЧИСЛОМ ━━━
describe('🔴 то же отставание БЕЗ живого запуска — число без красноты', () => {
const OTSTAYOT = { vetkaUgla: UGOL, est: true, otstaet: 346, svoih: 0 };
it('работа не встаёт: закрыть долг может только надзиратель, двинув ветку', () => {
const r = razborVetkiNochi('feat/noch-proba-1', OTSTAYOT);
expect(r.status).toBe('otstaet');
expect(r.padaet).toBe(false);
});
it('🔴 но молчания нет: число названо, и сказано, что перед живым запуском это отказ', () => {
const r = razborVetkiNochi('feat/noch-proba-1', OTSTAYOT);
expect(r.tekst).toContain('346');
expect(r.tekst).toMatch(/ПАДАЕТ/);
});
it('🪤 приёмка вырезанием этого самого разведения: один и тот же ответ хранилища даёт разные исходы', () => {
expect(razborVetkiNochi('feat/noch-proba-1', OTSTAYOT).padaet).toBe(false);
expect(razborVetkiNochi('feat/noch-proba-1', OTSTAYOT, ZHIVOY).padaet).toBe(true);
});
});
describe('ветки нет в хранилище вовсе — падает отдельным ответом', () => {
it('это НЕ «отстаёт» и не «сходится»', () => {
const r = razborVetkiNochi('feat/vydumannaya',
{ vetkaUgla: UGOL, est: false, otstaet: null, svoih: null }, ZHIVOY);
expect(r.status).toBe('vetki-net');
expect(r.padaet).toBe(true);
expect(r.tekst).toContain('feat/vydumannaya');
});
});
describe('ветка ушла вперёд угла — слова, но не падение', () => {
it('код там новее, а не старее: работе не мешает', () => {
const r = razborVetkiNochi('feat/noch-proba-1', { vetkaUgla: UGOL, est: true, otstaet: 0, svoih: 3 });
expect(r.status).toBe('slovami');
expect(r.padaet).toBe(false);
expect(r.tekst).toContain('3');
});
});
describe('ровно вровень — молчит', () => {
it('другая ветка, но ни отставания, ни своего', () => {
const r = razborVetkiNochi('feat/noch-proba-1', { vetkaUgla: UGOL, est: true, otstaet: 0, svoih: 0 });
expect(r.status).toBe('sovpadaet');
expect(r.padaet).toBe(false);
});
});
describe('🔴 хранилище не ответило — это ПАДЕНИЕ, а не молчание', () => {
it('ответа нет вовсе', () => {
const r = razborVetkiNochi('feat/noch-proba-1', null, ZHIVOY);
expect(r.status).toBe('ne-sprosili');
expect(r.padaet).toBe(true);
expect(r.tekst).toMatch(/НЕ ПРОВЕРЕНО/);
});
it('ответ есть, но пустой — тот же исход: молчащий датчик не выдаётся за «сходится»', () => {
expect(razborVetkiNochi('feat/noch-proba-1', {}, ZHIVOY).padaet).toBe(true);
});
it('ответчик обвалился — ворота ловят обвал и говорят то же самое, а не роняют ночь молча', () => {
const r = vorotaVetkiNochi('feat/noch-proba-1', {
sprosit: () => { throw new Error('хранилище не отвечает'); },
zhivoyZapusk: true,
});
expect(r.status).toBe('ne-sprosili');
expect(r.padaet).toBe(true);
});
});
describe('ворота целиком — спросить и разобрать', () => {
it('подставной ответчик спрошен именно про ту ветку, что названа', () => {
let sprosili = null;
vorotaVetkiNochi('feat/noch-proba-1', {
sprosit: v => { sprosili = v; return { vetkaUgla: UGOL, est: true, otstaet: 0, svoih: 0 }; },
});
expect(sprosili).toBe('feat/noch-proba-1');
});
});