From 1874b866cc9dfa0ca849efff2eec9bf81bb0b44b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 4 Aug 2026 20:38:20 +0300 Subject: [PATCH] =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=B6=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA:=20=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D0=BB=20=D0=BF=D0=BE=D0=BB=D1=8F,=20=D0=B0?= =?UTF-8?q?=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=20=D1=81=D0=BC=D1=8B=D1=81=D0=BB=20=E2=80=94=20=D1=82=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D1=80=D1=8F=D0=B4=D0=B0,=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B5=D1=86=20=D0=B8=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- night.config.example.json | 12 +- tools/night/cli.test.mjs | 18 +- tools/night/nemye-prigovory.test.mjs | 8 +- tools/night/sverka-nastroek-s-resheniyami.mjs | 595 +++++++++++++++--- .../sverka-nastroek-s-resheniyami.test.mjs | 541 ++++++++++++---- tools/night/vetka-nochi-ne-otstayot.mjs | 164 +++++ tools/night/vetka-nochi-ne-otstayot.test.mjs | 131 ++++ 7 files changed, 1270 insertions(+), 199 deletions(-) create mode 100644 tools/night/vetka-nochi-ne-otstayot.mjs create mode 100644 tools/night/vetka-nochi-ne-otstayot.test.mjs diff --git a/night.config.example.json b/night.config.example.json index e9d40905..41560ca6 100644 --- a/night.config.example.json +++ b/night.config.example.json @@ -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, diff --git a/tools/night/cli.test.mjs b/tools/night/cli.test.mjs index f141e5b1..27fff6a1 100644 --- a/tools/night/cli.test.mjs +++ b/tools/night/cli.test.mjs @@ -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'); }); }); diff --git a/tools/night/nemye-prigovory.test.mjs b/tools/night/nemye-prigovory.test.mjs index 912c35fd..0b434c55 100644 --- a/tools/night/nemye-prigovory.test.mjs +++ b/tools/night/nemye-prigovory.test.mjs @@ -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', ]); }); diff --git a/tools/night/sverka-nastroek-s-resheniyami.mjs b/tools/night/sverka-nastroek-s-resheniyami.mjs index 7ce09b9c..b31d9e10 100644 --- a/tools/night/sverka-nastroek-s-resheniyami.mjs +++ b/tools/night/sverka-nastroek-s-resheniyami.mjs @@ -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); diff --git a/tools/night/sverka-nastroek-s-resheniyami.test.mjs b/tools/night/sverka-nastroek-s-resheniyami.test.mjs index 3814fa32..a730d761 100644 --- a/tools/night/sverka-nastroek-s-resheniyami.test.mjs +++ b/tools/night/sverka-nastroek-s-resheniyami.test.mjs @@ -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([]); }); }); diff --git a/tools/night/vetka-nochi-ne-otstayot.mjs b/tools/night/vetka-nochi-ne-otstayot.mjs new file mode 100644 index 00000000..75dd6262 --- /dev/null +++ b/tools/night/vetka-nochi-ne-otstayot.mjs @@ -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); +} diff --git a/tools/night/vetka-nochi-ne-otstayot.test.mjs b/tools/night/vetka-nochi-ne-otstayot.test.mjs new file mode 100644 index 00000000..3303f903 --- /dev/null +++ b/tools/night/vetka-nochi-ne-otstayot.test.mjs @@ -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'); + }); +});