Files
portal/tools/night/plan-model.mjs
T

352 lines
29 KiB
JavaScript

// Разбор плана задачи. Пункт — заголовок «### 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,})(.*)$/;
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;
}
export function validatePlan(items) {
if (!Array.isArray(items)) {
throw new Error('validatePlan: подан не список пунктов. Это сбой зова, а не свойство плана: промахнись зовущий мимо разбора — и «ok» пустило бы в работу план, которого никто не смотрел.');
}
const items_count = items.length;
const long_count = items.filter(i => i?.long === true).length;
if (items_count === 0) {
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,
};
}
// 🔴🔴 МЕРНЫЙ ПРИБОР В `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(' ')}`;
}