Files
portal/tools/night/plan-model.mjs
T
Дмитрий 99edc75a61 fix(ночь): правило переехало в ворота, а старое тело осталось жить в стороже
Находка М-410, правило 254. Смена 31 исполнила задание смены 30 — задала правилу 253
вопрос «нет ли близнеца с тем же ремеслом» остальному хозяйству. Больных нашлось ДВА.

━━ 1. СТОРОЖ ПЛАНОВ И ЖИВЫЕ ВОРОТА СЧИТАЛИ ЗАДАЧИ РАЗНЫМИ ТЕЛАМИ ━━

Правило «план размечен для ночи НАПОЛОВИНУ» — самое дорогое в своей семье: беда выглядит
РОВНО как успех (ночь делает три задачи из девяти и отчитывается успехом). Оно родилось
в стороже (`polumechen`) и позже переехало на дорогу живого запуска (`validatePlan`).
Переезд состоялся — а старая копия осталась жить на прежнем месте, со своей приметой.

Замерено по ВСЕМ 594 живым планам обоими настоящими телами. Приговор «кричать или
молчать» сошёлся везде. А число, которое видит владелец, разошлось на живой бумаге:
`2026-08-01-kusok2-progon-ne-nachnyotsya.md` — сторож 12, ворота 11.

Двенадцатая «задача» — строка `## Задача 1: оставить след` на 6122-й строке плана.
Она лежит ВНУТРИ рамки с подписью powershell: это текст, который команда записывает
в пустышку, а не работа плана. Ворота правы, сторож ошибался.

🔴 И своё неверное число сторож держал утверждением `expect(schyot).toEqual({… 12 …})`.
Пока оно стояло, оно ошибку ОХРАНЯЛО: почини кто-нибудь тело — покраснел бы верный ответ.

Разъехались тремя разрезами, все три замерены подложками: рамка-образец (ворота
пропускают, сторож считал), «####» (у ворот подзаголовок, у сторожа задача), буква «ё»
в номере (у ворот `[а-яё]`, у сторожа `[а-я]` — теряла).

Лечение по правилу 249, а не подгонкой приметы: `skolkoRusskihZadach` вывезено наружу,
своей приметы у сторожа больше НЕТ вовсе. Разъехаться им теперь физически нечем.

Доказано вырезанием: вернул прежнее тело — покраснели ровно 5 проверок, посчитанные ДО
прогона (четыре новых сторожа + закреплённое неверное число).

━━ 2. ИМЯ, ИЗ КОТОРОГО СОБИРАЮТ ОБРАЗЕЦ, ОБЕЗВРЕЖИВАЛИ ТРИ МЕСТА ИЗ ПЯТИ ━━

В `schyot-zovushchih.mjs` пять мест делают одно ремесло — берут имя из чужого кода
и собирают из него образец. Три обезвреживали знак `$`, `zovyImeni` — нет,
и `vvoziatLiModulCelikom` не обезвреживал имя файла.

`$` — законный знак имени, а в образце значит «конец строки». Имя `ceny$vRublyah`
в образце совпасть не может НИКОГДА: прибор ответит «зовущих нет», сито увидит «нигде
не ввезено, дома не зовётся» и вынесет «ТОЧНО ДОЛГ» — самый твёрдый приговор, у которого
оговорки «посмотри глазами» нет вовсе. Живой код пошли бы сносить как мёртвый.

🟢 Честная граница: живого вреда на день находки НЕТ. Имён со знаком `$` в хозяйстве
ноль из 613 вывозимых, расхождений по живому — ноль. Правится не потому, что врёт
сегодня, а потому что соврёт молча и в приговоре, который никто не перепроверяет.

Доказано вырезанием: ровно 2 красных, посчитанные до прогона; второй сказал последствие
словами — живое имя оказалось в списке «точно долг».

━━ ЧИСЛА ━━

Проверок 2954 в 109 файлах (было 2948/109), ноль красных, код 0. Вывозится наружу
614 (было 613 — прибавилось общее тело `skolkoRusskihZadach`). Точных долгов 29,
состав сверен поимённо командой `sort | diff` до и после лечения — те же 29 имён.
Живых рождений ноль, денег не потрачено.

🔴 Свои ошибки, названные честно, — их две. Первая: мерил число ворот своей приметой
по их же словам, и примета цепляла ПРИМЕР из самих слов отказа (правило 246 с новой
стороны) — поймал сам, перемерил. Вторая: взял `git stash` в углу, у которого заначка
ОБЩАЯ со всеми параллельными сессиями; толкнись сосед между моими push и pop — pop без
адреса вытащил бы чужую работу в мой угол молча. Обошлось по удаче, приём вычеркнут.

🟢 Правку «только Edit/Write» смена 31 НЕ нарушила: оба вырезания сделаны через Edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:19:37 +03:00

446 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Разбор плана задачи. Пункт — заголовок «### Task N: Название».
//
// 🔴 Помету ставит ТОТ, КТО ПИШЕТ ПЛАН, а не работник на ходу — так дословно требует
// проверка 41. Отсюда и вид: помета — строка-примечание под заголовком, и ищется она
// в ближайших ТРЁХ строках. Слово «long=yes», написанное в тексте пункта, пометкой
// не считается: иначе датчик топтания выключался бы изнутри, теми же руками, за которыми
// он следит.
//
// 🔴 `tests=` — то, чем меряется закрытие пункта (проверки 38 и 39). Пусто здесь — и пункт
// закрывается по одному слову работника. `files=` держит три замка куска 3: ворота
// «задачи не пересекаются по файлам», разрез по файлам внутри задачи и список чужих файлов
// в поручении работнику.
//
// 🪤 Ни одного «наверное»: подали не текст или не список — падаем ГРОМКО. Пустышка, тихо
// отвечающая «всё хорошо», за три куска ловилась пять раз, и здесь она стоила бы дороже
// всего: ворота прочли бы пустой разбор как «в плане нет пунктов» и написали бы владельцу
// неправду о его целом плане.
// 🔴🔴 СПИСОК МЕРНЫХ ПРИБОРОВ ОДИН НА ВСЮ ЗАТЕЮ и живёт в `claims-audit.mjs`. Ввозится,
// а не переписывается: два списка разъезжаются молча — ворота пускали бы то, что сверка
// тронутых файлов потом откатывает, и ночь сгорала бы ровно так, как описано у самих ворот.
// 🔴 Кольца ЗДЕСЬ нет: `claims-audit.mjs` ввозит `file-claims.mjs` и `paths.mjs`, и ни один
// из троих не ввозит этот файл. Кольцевой ввоз есть у замка занятий — там он назван вслух
// в его же шапке и сторожится живой проверкой.
// 🪤 Долг, названный вслух: раньше `plan-model.mjs` не ввозил из затеи НИЧЕГО, и на это
// прямо ссылается примечание к ввозу в `item-audit.mjs` («кольца тут нет: plan-model
// не ввозит из затеи ничего»). Теперь ввозит — кольца по-прежнему нет, но то примечание
// устарело. Чинить его отсюда нельзя: чужой файл.
import { mernyyPribor } from './claims-audit.mjs';
// Заголовок пункта. Ровно три решётки: «####» — это уже подзаголовок внутри пункта.
const HEAD = /^###\s+Task\s+([\w.]+)\s*:\s*(.+?)\s*$/;
// Помета. Только строка-примечание целиком — кусок такого вида посреди прозы не годится.
const MARK = /^<!--\s*night:\s*(.+?)\s*-->$/;
// Сколько строк под заголовком просматриваем. Число названо в договоре о стыках дословно.
const OKNO_STROK = 3;
// Рамка-образец. До трёх пробелов отступа, три и больше кавычек ИЛИ тильд, дальше подпись.
const RAMKA = /^ {0,3}(`{3,}|~{3,})(.*)$/;
// 🔴🔴 ВТОРОЙ СЛОВАРЬ СЛОВА «ЗАДАЧА» — тот, которым пишут ЛЮДИ.
//
// 🔴 Это НЕ пункт ночи и пунктом не станет. Разбор выше не тронут ни на знак: ночь
// по-прежнему берёт только «### Task N: Название» с пометой. Здесь считается ДРУГОЕ —
// сколько работы описано в бумаге по-человечески, — и нужно это ровно для одного:
// чтобы отказ ворот не соврал владельцу.
//
// 🔴 ЗАЧЕМ, ЗАМЕРЕНО 05.08.2026. Позови разбор на любой из семи кусков затеи — ответ
// «0 пунктов», и ворота говорят: «В плане нет ни одного пункта». Про бумагу, где описано
// шестнадцать задач, это звучит как «план пустой». Три смены подряд читали этот отказ
// и делали один и тот же вывод: «надо научить ночь русским заголовкам» либо «надо
// переписать заголовки в семи планах». Оба вывода неверны и оба дороги: куски 1–7 —
// это план стройки самого хозяйства, его исполняют дневные смены (решение владельца 26
// от 06.08: «это не поломка разметки — это недостающая работа»), работа по ним уже
// сделана, а отмечать сделанное в плане нечем — ночь помнит закрытые пункты только
// в состоянии своего прогона. Оживи чтение этих планов — и ночь пойдёт делать заново
// то, что готово. Владелец про это сказал дословно: «жечь впустую недопустимо».
//
// 🪤 Примета — строго разметка, и границы названы вслух: две или три решётки (одна —
// это заголовок всей бумаги, четыре — подзаголовок внутри задачи), слово с БОЛЬШОЙ буквы
// и в единственном числе, номер, и сразу за ним двоеточие либо тире. «## Задача 1:»
// куска 4 и «### Задача 1а —» куска 7 — да; «## Задачи», «## задача», строка в прозе — нет.
const RUSSKAYA_ZADACHA = /^#{2,3}\s+Задача\s+\d+[а-яё]?\s*[—:-]/u;
// Сколько задач описано в бумаге по-человечески. Рамки-образцы пропускаются той же
// разметкой, что и у пунктов: план показывает, КАК писать задачу, куском внутри рамки —
// посчитай эти картинки, и здоровый план объявится полумеченым на ровном месте.
// 🪤 Текст не подан или разметка рамок не читается — отвечаем 0, то есть «разговора
// про словари не будет». Это не тихая подмена: отказ в таком случае остаётся прежним,
// полным и честным, просто без объяснения про дневную бумагу.
// 🔴 ВЫВОЗИТСЯ НАРУЧУ НАРОЧНО (правило 249, смена 31). Это же дело делал сторож папки
// планов (`plany-ne-uhodyat-v-noch-pustymi.test.mjs`) — своей, ДРУГОЙ приметой, и они
// разошлись молча: на живом куске 2 сторож насчитывал 12 задач, ворота — 11 (двенадцатая
// лежала картинкой внутри рамки ```powershell). Теперь тело одно и разъехаться нечем.
export function skolkoRusskihZadach(text) {
if (typeof text !== 'string') return 0;
const lines = text.split(/\r?\n/);
let vRamke;
try {
vRamke = razmetkaRamok(lines);
} catch {
return 0;
}
let n = 0;
for (let i = 0; i < lines.length; i += 1) {
if (!vRamke[i] && RUSSKAYA_ZADACHA.test(lines[i])) n += 1;
}
return n;
}
function spisok(value) {
return value.split(',').map(s => s.trim()).filter(Boolean);
}
// 🔴🔴 Где в плане КАРТИНКА пункта, а где сам пункт.
//
// Планы показывают, как выглядит пункт, — куском-образцом внутри рамки ``` … ```.
// Разбор брал эти картинки за работу: в живом плане `2026-08-01-kusok4-...md`
// он видел СЕМНАДЦАТЬ пунктов при нуле настоящих, в `...kusok3-...md` — четыре.
// Ворота при этом отвечали «план годен», а помощник соседней задачи вправду получил
// куски примеров как задание и чуть не записал ложный вывод о самих планах.
//
// 🔴 Примета — РАЗМЕТКА, а не слово: никакого «рамка», «пример», «образец» в тексте
// искать нельзя, этих слов в живых планах нет. Считаем ровно так, как размётку читает
// сам markdown, и два места читаются по-разному:
//
// 🪤 подпись рамки на кавычках не может содержать кавычку. Строка
// «```` ```verified-context-json ````» — это вставка кода В СТРОКУ, а не начало рамки.
// Простая примета «начинается с трёх кавычек» открывает на ней рамку, рамка не
// закрывается никогда — и ТРИ настоящих пункта ниже пропадают МОЛЧА. Не выдумка:
// эта строка лежит в `docs/superpowers/plans/2026-06-12-router-mentor-activation-wrapper.md`.
//
// 🪤 закрывает рамку только та же кавычка (или тильда) и не короче открывающей, и после
// неё на строке ничего нет. Иначе рамка на четырёх кавычках закрывалась бы тройкой
// изнутри себя же, и половина образца читалась бы как настоящие пункты.
//
// 🔴 Рамка, которую забыли закрыть, глотает всё до конца плана. Съела заголовки — падаем
// ГРОМКО: съесть план ЦЕЛИКОМ не страшно (ворота скажут «нет пунктов»), а вот съесть
// ПОЛОВИНУ — это ночь по урезанному плану, о которой владелец не узнает никогда.
// Не съела ничего — молчим: так кончаются семь живых планов, и ронять их не за что.
function razmetkaRamok(lines) {
const vRamke = new Array(lines.length).fill(false);
let ramka = null;
let sedeno = 0;
for (let i = 0; i < lines.length; i += 1) {
const m = RAMKA.exec(lines[i]);
if (m) {
const znak = m[1][0];
const dlina = m[1].length;
const podpis = m[2];
if (ramka) {
if (znak === ramka.znak && dlina >= ramka.dlina && podpis.trim() === '') {
ramka = null;
sedeno = 0;
} else {
vRamke[i] = true;
}
continue;
}
if (znak === '`' && podpis.includes('`')) continue; // вставка в строку, а не рамка
ramka = { znak, dlina, stroka: i + 1 };
sedeno = 0;
vRamke[i] = true;
continue;
}
if (!ramka) continue;
vRamke[i] = true;
if (HEAD.test(lines[i])) sedeno += 1;
}
if (ramka && sedeno > 0) {
throw new Error(`parsePlan: рамка-образец открыта на строке ${ramka.stroka} и не закрыта до конца плана. Внутри неё осталось пунктов: ${sedeno} — и молча взять их за примеры нельзя: с тем же успехом это настоящая работа, которую ночь просто не увидит. Закройте рамку («${ramka.znak.repeat(ramka.dlina)}» отдельной строкой) и повторите.`);
}
return vRamke;
}
export function parsePlan(text) {
if (typeof text !== 'string') {
throw new Error('parsePlan: подан не текст плана. Разбирать нечего, и молчать об этом нельзя: пустой список пунктов ворота прочли бы как «в плане нет ни одного пункта» и написали бы владельцу неправду о его плане.');
}
const lines = text.split(/\r?\n/);
const vRamke = razmetkaRamok(lines);
const items = [];
for (let i = 0; i < lines.length; i += 1) {
if (vRamke[i]) continue; // это картинка пункта в образце, а не пункт
const h = HEAD.exec(lines[i]);
if (!h) continue;
// Умолчания честные и пустые: не написано — значит пусто, а не «наверное всё».
const item = { id: h[1], title: h[2], tests: [], files: [], long: false }; const doStroki = Math.min(i + 1 + OKNO_STROK, lines.length);
for (let j = i + 1; j < doStroki; j += 1) {
// 🔴 Окно обрывается на заголовке СЛЕДУЮЩЕГО пункта. Без этого пункт без пометы
// дотягивается до пометы соседа и забирает ЕГО проверки: проверка 39 обходится
// молча, а закрытие пункта меряется чужой зеленью. Ровно от этой беды и заведено
// окно в три строки — но одного числа мало, когда пункты идут вплотную.
// 🪤 Помета ИЗ ОБРАЗЦА настоящему пункту не достаётся: мало не считать заголовок
// из рамки пунктом — отдай ему чужие `tests`, и закрытие пункта померяется
// зеленью, которой он не касался (проверка 38). Строки рамки просто пропускаем,
// окно на них не обрывая: настоящая помета может стоять и ниже образца.
if (vRamke[j]) continue;
if (HEAD.test(lines[j])) break;
const m = MARK.exec(lines[j].trim());
if (!m) continue;
for (const part of m[1].split(';')) {
const [rawKey, ...rest] = part.split('=');
const key = rawKey.trim();
// Склеиваем обратно: в значении может стоять свой знак «=».
const value = rest.join('=').trim();
if (key === 'tests') item.tests = spisok(value);
if (key === 'files') item.files = spisok(value);
if (key === 'long') item.long = value.toLowerCase() === 'yes';
}
break; // первая помета под заголовком — она и есть помета этого пункта
}
items.push(item);
}
return items;
}
// 🔴 ВТОРОЙ ДОВОД — САМ ТЕКСТ ПЛАНА — необязателен НАРОЧНО.
// Подан — ворота вдобавок сверяют два словаря слова «задача» (см. `RUSSKAYA_ZADACHA`)
// и ловят полумеченый план. Не подан — ответ ровно тот же, что был до 05.08.2026,
// и ни один зовущий не сломан: `validatePlan(parsePlan(text))` работает как работал.
// 🪤 Умолчание здесь — НЕ «наверное всё хорошо»: без текста просто нет сведений, чтобы
// судить о втором словаре, и ворота об этом молчат, а не выдают догадку за проверку.
export function validatePlan(items, text = null) {
if (!Array.isArray(items)) {
throw new Error('validatePlan: подан не список пунктов. Это сбой зова, а не свойство плана: промахнись зовущий мимо разбора — и «ok» пустило бы в работу план, которого никто не смотрел.');
}
const items_count = items.length;
const long_count = items.filter(i => i?.long === true).length;
const russkih = skolkoRusskihZadach(text);
if (items_count === 0) {
// 🔴 Отказ разный не по вкусу, а по тому, ЧТО владельцу делать дальше. Бумага без
// единой задачи — её надо написать. Бумага с задачами по-русски — её писать не надо,
// её надо НЕ ТРОГАТЬ: это дневной план, и переписывать его заголовки под ночь значит
// послать ночь переделывать готовое.
if (russkih > 0) {
return {
ok: false,
reason: `Ночных пунктов в этом плане нет, но задач, описанных по-русски («## Задача 1: …»), в нём ${russkih}. Значит это дневная бумага, а не задание ночи: по таким планам работа уже идёт руками, и отметок «сделано» в них нет — ночь про сделанное не узнает и пойдёт делать заново. 🔴 Переписывать эти заголовки под ночной вид НЕЛЬЗЯ. Ночное задание пишется отдельной бумагой и оно маленькое: заголовок «### Task 1: Название» и строкой ниже помета «<!-- night: tests=…; files=… -->» — чем пункт меряется и какие файлы правит.`,
items_count,
long_count,
};
}
return { ok: false, reason: 'В плане нет ни одного пункта — запускать нечего. Пункт плана — это заголовок вида «### Task 1: Название».', items_count, long_count };
}
// 🔴🔴 Номер пункта — это КЛЮЧ, а не подпись. По нему ведут очередь, по нему запирают
// файлы, им работник заявляет «закрыл». Два пункта под одним номером — двое работников
// закроют друг друга, и оба отчитаются ПРАВДОЙ: каждый закрыл «Task 13».
//
// 🔴 Стоит РАНЬШЕ всех остальных отказов намеренно. Ниже виновных зовут по имени
// («Task 1»), и пока ключ не уникален, любое такое имя двусмысленно: владелец не
// поймёт, который из двух чинить. Сперва уникальность ключа — потом всё, что на нём
// держится.
const schyot = new Map();
for (const i of items) {
const id = String(i?.id ?? '?');
schyot.set(id, (schyot.get(id) ?? 0) + 1);
}
const povtory = [...schyot].filter(([, n]) => n > 1);
if (povtory.length) {
const imena = povtory.map(([id, n]) => `Task ${id} (${n} раза)`).join(', ');
return {
ok: false,
reason: `В плане повторяются номера пунктов: ${imena}. Номер — ключ, по которому ведут очередь, запирают файлы и заявляют «пункт закрыт»; двое работников под одним номером закроют друг друга, и оба отчитаются правдой. Перенумеруйте так, чтобы каждый номер встречался ровно один раз.`,
items_count,
long_count,
};
}
// 🔴🔴 ПЛАН, РАЗМЕЧЕННЫЙ ДЛЯ НОЧИ НАПОЛОВИНУ. Самая дорогая беда этой семьи.
//
// 🔴 ЧЕМ ОНА ХУЖЕ ОТКАЗА. Пометь автор ночными пунктами три задачи из девяти — ворота
// до сего дня отвечали «план годен» (пункты есть, у каждого свои проверки), ночь честно
// делала три и гасла, а владелец читал утром «план отработан» и считал, что сделаны
// девять. Ни отказа, ни красноты, ни строки в сводке: беда выглядит РОВНО как успех.
// Тот же разрез, что у мерного прибора выше, — и потому она стоит рядом с ним, раньше
// отказа «пункт без проверок»: тот пункт владелец и так не запустит, а этот выглядит
// отработавшим.
//
// 🟡 ГРАНИЦА, НАЗВАННАЯ ВСЛУХ. Правило судит только тогда, когда ночные пункты ЕСТЬ.
// Ноль ночных пунктов при девяти задачах по-русски — это не полумеченый план, а дневная
// бумага, и отвечено про неё выше, своими словами. Правило это старше моего круга: оно
// жило в проверке соседа (`plany-ne-uhodyat-v-noch-pustymi.test.mjs`, `polumechen`)
// и ходило по папке планов во время прогона проверок — то есть на ЖИВОМ запуске
// не стояло вовсе. Здесь оно встаёт на дорогу самого запуска.
if (russkih > items_count) {
return {
ok: false,
reason: `План размечен для ночи НАПОЛОВИНУ: задач, описанных по-русски, в нём ${russkih}, а ночных пунктов размечено ${items_count}. Ночь сделала бы ${items_count} и отчиталась успехом, а ${russkih - items_count} задач вы не досчитались бы молча — ни отказа, ни красноты в сводке. Разметьте пометой «<!-- night: tests=…; files=… -->» либо все задачи, либо вынесите в ночное задание только те, что вправду отдаёте ночи, — отдельной бумагой.`,
items_count,
long_count,
};
}
// 🔴🔴 МЕРНЫЙ ПРИБОР В `files=` ПУНКТА — ПЛАНА НЕТ.
//
// 🔴 ЗАЧЕМ ЭТО ЗДЕСЬ, А НЕ ТОЛЬКО В ЗАМКЕ. Беда ловится в трёх местах, и это самое
// дешёвое из них: владелец правит план утром за минуту. Замерено 02.08.2026, вот путь
// без этих ворот: план с `package.json` в `files=` признаётся годным (про приборы ворота
// не знали) → работник честно занимает файл (занятия отвечают «можно»: у прибора нет
// хозяина) → работник правит и коммитит → сверка тронутых файлов видит прибор, ОТКАТЫВАЕТ
// правку и ОТБИРАЕТ пункт → работник берёт тот же пункт снова, и так каждые полминуты
// до утра. Владелец читает «работал всю ночь» и видит ноль сделанного. Это ХУЖЕ
// отказа: отказ виден сразу, а такая ночь сгорает МОЛЧА и выглядит как работа.
//
// 🔴 СТОИТ РАНЬШЕ отказа «пункт без проверок» намеренно: тот пункт владелец и так
// не запустит, а этот — единственный, который выглядит запущенным и работающим.
//
// 🟡 ГРАНИЦА, названная вслух: ворота видят только `files=`. Напиши автор плана «правь
// package.json» СЛОВАМИ в тексте пункта, а в `files=` поставь другой файл — ворота
// не увидят ничего, и беду поймает только сверка тронутых файлов, то есть уже ночью.
// Текст пункта здесь не разбирается вовсе, и заводить его разбор ради этого дороже,
// чем беда: `files=` — это то, чем пункт запирается, и правит работник ровно его.
const spribolom = [];
for (const i of items) {
for (const f of Array.isArray(i?.files) ? i.files : []) {
const pribor = mernyyPribor(f);
if (pribor) spribolom.push(`Task ${i?.id ?? '?'}${f} (${pribor.chem})`);
}
}
if (spribolom.length) {
return {
ok: false,
reason: `В плане есть пункты, которые правят мерный прибор — файл, которым хозяйство меряет само себя: ${spribolom.join('; ')}. Такой пункт не «упадёт», он сгорит молча: работник займёт файл, будет править его всю ночь, а сверка каждый раз откатит правку и отберёт пункт — утром в сводке будет «работал всю ночь» и ноль сделанного. Уберите эти файлы из списка файлов пункта (помета «files=»); если работа вправду в них — такую ночь ведут при вас, а не без вас.`,
items_count,
long_count,
};
}
// 🔴🔴 ПУНКТ, ПРОВЕРКИ КОТОРОГО РАЗНОГО РОДА. Довод и живой замер — в шапке
// `razdvoenieProverok`; здесь только отказ словами владельца.
//
// 🔴 ЗАЧЕМ ЭТО ЗДЕСЬ, А НЕ ТОЛЬКО У ЗАМКА — довод тот же, что и у мерного прибора выше:
// это самое дешёвое из мест. Владелец правит план утром за минуту, и правка простая —
// разбить пункт надвое. Оставь мы одну защиту у замка — план прошёл бы ворота, работник
// взял бы пункт, сделал и закрыл, а замок каждый раз отвечал бы «замерить нечем»: пункт
// не засчитан, работник берёт его снова, и так до утра. Ночь сгорает молча и выглядит
// как работа — та самая беда, ради которой заведены ворота на приборы.
//
// 🔴 СТОИТ РАНЬШЕ отказа «пункт без проверок» намеренно. Обе беды из одной семьи —
// «пункт нечем померить», — но пункт без проверок видно глазами, а этот выглядит
// безупречно: проверки названы, имена настоящие, и до сего дня он вправду зеленел.
// Первым называем менее очевидного.
const smeshannye = [];
for (const i of items) {
const r = razdvoenieProverok(i);
if (r) smeshannye.push(`Task ${i?.id ?? '?'} (продукт: ${r.php.join(', ')}; затея: ${r.nashi.join(', ')})`);
}
if (smeshannye.length) {
return {
ok: false,
reason: `В плане есть пункты, проверки которых разного рода, а одной командой два рода не гоняются: ${smeshannye.join('; ')}. Проверки продукта («.php») запускает одна программа, проверки самой затеи — другая, и чужую каждая из них молча пропускает: замерено — из двух названных проверок отрабатывает одна, ответ выходит без единой поломки, и про вторую не говорится ни слова. Такой пункт засчитывался бы наполовину и молча. Разбейте его на два пункта: в один соберите проверки продукта, в другой — проверки затеи.`,
items_count,
long_count,
};
}
// Проверка 39б: пункт, для которого не записано ни одной проверки, закрыть нельзя,
// значит и запускать по такому плану нечего. Виновные названы ВСЕ разом: владелец
// чинит план по этому списку, и получать отказ по одному пункту за ночь ему незачем.
const golye = items.filter(i => !Array.isArray(i?.tests) || i.tests.length === 0);
if (golye.length) {
const imena = golye.map(i => `Task ${i?.id ?? '?'}`).join(', ');
return {
ok: false,
reason: `В плане есть пункты, для которых не записано ни одной проверки: ${imena}. Закрытие пункта меряется зелёными проверками именно этого пункта, а их не названо — закрыть такой пункт нечем. План не годится.`,
items_count,
long_count,
};
}
// Проверка 41: длинными помечено больше половины — плана к запуску нет.
if (long_count * 2 > items_count) {
return {
ok: false,
reason: `Длинными помечено ${long_count} пунктов из ${items_count} — больше половины. Так выключается единственный датчик топтания, и запуска по такому плану нет. Пометку ставит тот, кто пишет план: перечитайте, вправду ли все эти пункты длинные.`,
items_count,
long_count,
};
}
return { ok: true, reason: null, items_count, long_count };
}
// 🔴🔴 РОД ПРОВЕРКИ — ЧЕМ ЕЁ ВПРАВДУ ГОНЯЮТ. Родов ровно два, и это не деление ради
// деления: php-шную проверку запускает прогон продукта (`php artisan test`), нашу —
// прогон затеи (`npm run test:tools`), и ЧУЖУЮ каждый из них не запускает вовсе.
// Всё, что не кончается на `.php`, считается нашим: наш прогон принимает имя как строку
// отбора, и «наше» тут — это «то, что ищется среди наших файлов проверок».
// 🪤 Регистр не важен намеренно: строгое сравнение отправило бы «A.PHP» в наш прогон,
// то есть ровно в ту беду, от которой всё это и заведено.
export function rodProverki(t) {
return /\.php$/i.test(String(t)) ? 'php' : 'nash';
}
// 🔴🔴 ПРОВЕРКИ ПУНКТА РАЗНОГО РОДА — ОДНОЙ КОМАНДОЙ ИХ НЕ ПОМЕРИТЬ.
//
// 🔴 ЗАМЕРЕНО ЖИВЬЁМ 02.08.2026, а не выведено рассуждением. Прежнее правило смешанный
// набор отдавало нашему прогону целиком, с доводом «`php artisan test` наш `.mjs` не
// запустит вовсе». Довод верен ровно наполовину — и этой половиной опасен. Выполнено руками:
// npm run test:tools -- app/tests/Feature/ProjectSyncStatusFromPivotTest.php \
// tools/night/plan-fingerprint.test.mjs
// Ответ: «Test Files 1 passed (1) · Tests 15 passed (15)», код 0. Из ДВУХ названных
// проверок отработала ОДНА. Имя php-шного файла наш прогон берёт за строку отбора,
// ни одного файла по ней не находит и молча идёт дальше — потому что второе имя что-то нашло.
//
// 🔴 ПОЧЕМУ ЭТО ХУЖЕ ПАДЕНИЯ. Прогон вышел с нулём И что-то напечатал — значит и заслон
// «программа ничего не сказала» (`item-audit.mjs`) его не ловит. Замок объявляет проверки
// зелёными и засчитывает пункт, у которого половина проверок не запускалась. Владелец
// наутро читает «проверки прогнаны надзирателем и зелены» — про бэкенд, которого не мерил
// никто. 🪤 Тихо это ТОЛЬКО у смешанного: набор из одних php-шных имён, поданный тому же
// прогону, отвечает кодом 1 («No test files found») — замерено отдельно.
//
// 🟡 ГРАНИЦА, НАЗВАННАЯ ВСЛУХ. Родов здесь два, и второй — «всё остальное». Напиши автор
// плана в `tests=` имя, которое не находит НИ ОДНОГО нашего файла (опечатка, `.py`, просто
// слово), — рядом с настоящей нашей проверкой оно так же молча пропадёт, и здесь это не
// ловится. Ловить это значит требовать, чтобы каждое имя вправду что-то находило, а такого
// прибора у нас нет: наш прогон отвечает одним кодом на весь набор. Долг записан, не закрыт.
//
// 🔴🔴 «ТРЕТЬЕ», КОТОРОЕ СЮДА ПРИХОДИТ. Пункт попадает к замку не только из разбора плана,
// но и с диска — из карточки работника. Порченый или правленный рукой JSON даёт `tests`
// строкой, а не списком, и первый вид этой защиты падал на такой строке `TypeError`-ом
// ВЫШЕ того места, где замок ловит сбои прогона: вместо «пункт не засчитан» умирал бы
// надзиратель, и не на один пункт, а на весь остаток ночи. Замерено 02.08.2026.
// Ответ здесь — «не мой вопрос»: родов не видно, судить нечего, молчим. Негодный `tests`
// ловят двое других — ворота плана (там `Array.isArray` прописан прямо) и сам замок,
// у которого этот сбой попадает в разбор «замерить нечем».
export function razdvoenieProverok(item) {
if (!Array.isArray(item?.tests)) return null;
const tests = item.tests.filter(Boolean).map(String);
const php = tests.filter(t => rodProverki(t) === 'php');
const nashi = tests.filter(t => rodProverki(t) !== 'php');
if (!php.length || !nashi.length) return null;
return { php, nashi };
}
// Чем гонять проверки ИМЕННО ЭТОГО пункта.
//
// 🔴🔴 Строится ИЗ его `tests`, и это сердце проверки 38. Возьми правило общий набор —
// «зелень по этому пункту» стала бы «зеленью вообще», и пункт засчитывался бы за чужую
// работу при полностью зелёных проверках. Ломается ТИХО.
//
// 🔴 Правило лежит здесь, а не рядом с командной строкой, потому что нужно оно ДВОИМ:
// `cli.mjs` — чтобы сказать работнику, чем меряется его первый пункт (задача 6), и
// надзирателю — чтобы гонять проверки КАЖДОГО судимого пункта (задача 5). Второй дороги
// к этому правилу не заводить.
//
// 🪤 Здесь мы только СТРОИМ строку. Программу по полному пути ищет замок
// (`progonProverokPunkta`, задача 4) — голым именем отсюда ничего не зовётся.
export function komandaProverokPunkta(item) {
const tests = (item?.tests ?? []).filter(Boolean).map(String);
if (!tests.length) return null;
// 🔴 Разного рода — команды НЕТ. Довод — в шапке `razdvoenieProverok`. Пустой ответ здесь
// читается замком как «замерить нечем» и пункт НЕ засчитывается: это единственный честный
// ответ, пока команда одна, а родов два.
if (razdvoenieProverok(item)) return null;
const phpshnye = tests.filter(t => rodProverki(t) === 'php');
// Все php-шные — гоним прогоном продукта по именам.
if (phpshnye.length === tests.length) {
const imena = phpshnye.map(t => t.split(/[\\/]/).pop().replace(/\.php$/i, ''));
return `php artisan test --filter=${imena.join('|')}`;
}
return `npm run test:tools -- ${tests.join(' ')}`;
}