32770b26d2
Находка М-407, правила 247-250. Промт смены 29 велел проверить остальных сторожей на слепоту смены 28 (правило 244). Замер вывел на беду в самом лекарстве. ЧТО ПОЧИНЕНО. `bezPrimechaniy`, заведённый сменой 28 против «за примечание отвечает код», был двумя грубыми заменами подряд. На живом хозяйстве он ЕЛ ЗЕРНО: - `supervisor.mjs` — 53 объявления → 42, строк 2591 → 2344; - `spawn.mjs` — 25 → 19, строк 770 → 614; - `celo-li-hozyaystvo.mjs` — 13 → 11; - `workspace.test.mjs` — строк 2375 → 602. Причина замерена, а не выведена: внутри обычного `//`-примечания `supervisor.mjs` строкой 5 стоит текст `/*.mjs`. Замена читает это как начало блочного примечания и выкусывает 230 строк ЖИВОГО КОДА до первого попавшегося `*/`. ⇒ Сторож правила 235, ради которого рез и заводился, всё это время судил по тексту, из которого выкушено пол-файла. Он был зелёным — но зелёным по изувеченному. Рез переписан с замен на посимвольный разбор — тем же порядком, каким уже работал `tolkoKod` в том же файле: образец → строка → примечание. Отличие одно и ради него рез существует: строковые значения сохраняются. Переносы внутри вырезанного блока возвращаются на место — кто режет перед построчным обходом, иначе получил бы номера строк от другого файла. Доказано вырезанием: вернул прежнее тело — покраснели ровно 4 проверки, посчитанные ДО прогона. Полный прогон после починки: 2939 чужих проверок зелены, ни одна не сломалась. Живого долга за слепотой не нашлось — так и записано, не больше. ДЕСЯТЬ САМОДЕЛЬНЫХ РЕЗОВ СВЕДЕНЫ В ОДИН, включая две полные копии `tolkoKod`. Замер копии из `cli.test.mjs` против общего по 52 рабочим файлам: расходятся на 19. На самом `cli.mjs` копия видела 148 зовов против 152 — четыре (`imyaOkna`, `bolshoyBukvoy`, `randomUUID`, `priyomkaDir`) сторож «точка входа не зовёт имён, которых у неё нет» не проверял вовсе. ЗЕРКАЛЬНАЯ СЛЕПОТА ЗАМЕРЕНА (правило 243, прибором, а не глазами): 33 места, где утверждается «в коде есть X» по сырому тексту. Живой слепоты — НОЛЬ. Зелены по двум причинам разом — 5. Чисто — 19. Все 33 переведены на рез В МЕСТЕ ВОПРОСА, а не в месте чтения: у `shoot.test.mjs` и `reviewer.test.mjs` к одному файлу стоят рядом три разных вопроса, и обёртка чтения превратила бы запрет на боевой адрес в дыру. Заведён сторож на класс `utverzhdenie-o-kode-ne-po-syromu.test.mjs` — он же стережёт единственность реза. Доказан вырезанием: вернул одно место к сырому — покраснела ровно одна проверка и назвала точный адрес. 247. У РЕЗА СПРАШИВАЙ НЕ ТОЛЬКО «ЧТО ОН РЕЖЕТ», НО И «НА ЧЁМ ЕГО МЕРИЛИ». Правило 234 смене 28 было задано — и получило ответ от подложек. Между ответом подложек и правдой живого хозяйства уместилось 11 объявлений из 53. 248. ПОДЛОЖКА, НЕ ВОСПРОИЗВОДЯЩАЯ БЕДУ, ДАЁТ ЗЕЛЁНЫЙ, КОТОРЫЙ НИЧЕГО НЕ ОХРАНЯЕТ. Две мои подложки были зелены на заведомо сломанном резе — не хватало пары знаков. 249. ОДНО ДЕЛО — ОДНО ТЕЛО, И РАСХОЖДЕНИЕ ТЕЛ ВСЕГДА МОЛЧАЛИВОЕ. Уточнение 239. 250. РЕЗ СТАВЯТ ТАМ, ГДЕ ЗАДАЮТ ВОПРОС, А НЕ ТАМ, ГДЕ ЧИТАЮТ ФАЙЛ. Числа: проверок 2940 в 109 файлах (было 2929/108), ноль красных, код 0. Приговор прибора 613 / 29 — состав сверен поимённо, тот же, что у смены 28. Живых рождений ноль. Ошибок надзирателя три, все названы: правил семь мест программой из-под оболочки вместо Edit (то самое правило, о котором предупреждала смена 28; правка дожила, проверено сразу же); подложки были зелены на сломанном; первый вид сторожа поймал сам себя, второй перепрыгивал через отрицание и врал номером строки. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1054 lines
75 KiB
JavaScript
1054 lines
75 KiB
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
import {
|
||
resolveClaudeBin, claudeLaunch, claudeNutro, buildArgs, readResult, isRealSuccess, parseStreamLine,
|
||
isStepBoundary, rateLimitFromEvent, workerEnv, WORKER_ENV_KEYS, golovaIzSobytiya,
|
||
golovySovpadayut, sverkaGolovy, golovyIzModelUsage,
|
||
sostavMasterskoyIzPotoka, masterskayaOtsechena,
|
||
dovodNePerezhivyotSkleyku, proverkaSkleyki, zapretSkleyki,
|
||
} from './spawn.mjs';
|
||
// 🔴 Второй и последний зовущий живого запуска — крошечный платный замер остатка. Его доводы
|
||
// ввозятся сюда НАСТОЯЩИМИ, а не переписанными от руки: переписанные доказали бы только то,
|
||
// что я умею печатать пустую строку. `probeArgs` — чистая мелочь без единого живого зова.
|
||
import { probeArgs } from './budget.mjs';
|
||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||
// 🔴 Путь к настоящему хозяйству СОБРАН РУКАМИ от корня хранилища (`REPO_ROOT`), а не ввезён
|
||
// готовым строителем `NIGHT_ROOT` из `paths.mjs`: сторож `nastoyashchee-hozyaystvo-v-proverkah
|
||
// .test.mjs` (вне области правки этой задачи) явно разрешает именно этот способ читать
|
||
// настоящий `.night` без поимённой записи в свой список — он ловит только готовые
|
||
// строители путей, а не путь, собранный вручную. Мы только ЧИТАЕМ («если он туда ПИШЕТ» —
|
||
// не про нас) один именованный, стабильный, датированный файл живой улики — не пишем
|
||
// в хозяйство ни байта и не заводим свой прогон, поэтому мигание, от которого сторож
|
||
// защищает, здесь не грозит.
|
||
import { REPO_ROOT } from './paths.mjs';
|
||
import { startWorker } from './spawn.mjs';
|
||
import { resolveBin, needsShell, PODMENA_HOZYAYSTVA, PRIMETY_PRIBORA, korenHozyaystva } from './paths.mjs';
|
||
import { existsSync, readdirSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { PREDEL_ZHIVOGO_ZAPUSKA, PREDEL_SAMOY_PROGRAMME } from './predel-zhivogo-zapuska.mjs';
|
||
import { recordSpawnAttempt, readSpawnAttempts } from './schyot-zamerov.mjs';
|
||
import { bezPrimechaniy } from './schyot-zovushchih.mjs';
|
||
|
||
// 🔴 ПРЕДЕЛ ВРЕМЕНИ ВСЕМУ ФАЙЛУ. Здесь вправду запускаются `claude --version` и живые
|
||
// процессы `node`. При пределе по умолчанию 5000 мс это краснело от соседей, а не
|
||
// от поломки. Откуда взято число — в самом `predel-zhivogo-zapuska.mjs`; своего числа
|
||
// тут не набирается.
|
||
vi.setConfig({ testTimeout: PREDEL_ZHIVOGO_ZAPUSKA });
|
||
|
||
describe('resolveClaudeBin — лечение 334 молчаливых провалов', () => {
|
||
it('находит запускаемый файл, а не голое имя', () => {
|
||
const bin = resolveClaudeBin();
|
||
expect(bin).toBeTruthy();
|
||
expect(existsSync(bin)).toBe(true);
|
||
});
|
||
|
||
it('не нашёлся — падает громко, а не возвращает "claude" в надежде', () => {
|
||
expect(() => resolveClaudeBin({ lookIn: ['C:/такого/пути/нет'], name: 'нетакойпрограммы' })).toThrow(/не найден/i);
|
||
});
|
||
|
||
it('оболочка в запуске — последнее средство: она изуродовала бы длинное поручение', () => {
|
||
// 🪤 `.cmd` без оболочки Node не запустит (EINVAL), а с оболочкой командная строка
|
||
// разбирается заново — и русское поручение с переводами строк рвётся на куски.
|
||
// Поэтому у `.exe` и у настоящего нутра программы оболочки нет вовсе.
|
||
expect(claudeLaunch('C:/Program Files/nodejs/node.exe')).toEqual({
|
||
command: 'C:/Program Files/nodejs/node.exe', prefixArgs: [], shell: false,
|
||
});
|
||
});
|
||
|
||
it('🔴 найденная программа ДЕЙСТВИТЕЛЬНО ЗАПУСКАЕТСЯ — иначе поиск ничего не доказал', () => {
|
||
// 🪤 Ровно та мина, что убила 334 прогона, только на новом месте: раньше все проверки
|
||
// смотрели «файл лежит», и ни одна ничего не запускала. Сломай запуск — покраснеет здесь.
|
||
const launch = claudeLaunch();
|
||
const out = execFileSync(launch.command, [...launch.prefixArgs, '--version'], {
|
||
encoding: 'utf8',
|
||
shell: launch.shell,
|
||
// 🔴 Предел САМОЙ ПРОГРАММЕ. Он работает только потому, что предел проверки (вверху
|
||
// файла) больше него: пока проверку убивали на 5000 мс, эта строка была мертва.
|
||
timeout: PREDEL_SAMOY_PROGRAMME,
|
||
});
|
||
expect(String(out)).toMatch(/\d+\.\d+\.\d+/);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// ПЕРЕЕЗД ПРОГРАММЫ И ЗАПРЕТ ОБОЛОЧКИ
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
describe('claudeNutro — имя нутра берётся из ЗАКАЗА пакета, а не зашито', () => {
|
||
let dir = null;
|
||
afterEach(() => {
|
||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||
dir = null;
|
||
});
|
||
|
||
// Собирает подложку: `<врем>/node_modules/@anthropic-ai/claude-code/…` рядом с «claude.cmd».
|
||
const podlozhka = ({ bin, kladyom = [] }) => {
|
||
dir = mkdtempSync(join(tmpdir(), 'nutro-'));
|
||
const pkg = join(dir, 'node_modules', '@anthropic-ai', 'claude-code');
|
||
mkdirSync(join(pkg, 'bin'), { recursive: true });
|
||
if (bin !== undefined) writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: '@anthropic-ai/claude-code', version: '0.0.0-подложка', bin }), 'utf8');
|
||
for (const f of kladyom) writeFileSync(join(pkg, f), 'подложка', 'utf8');
|
||
return join(dir, 'claude.cmd');
|
||
};
|
||
|
||
it('вид «мелочь с именами» — {"claude": "bin/claude.exe"}, ровно как у живого пакета 2.1.142', () => {
|
||
const target = podlozhka({ bin: { claude: 'bin/claude.exe' }, kladyom: ['bin/claude.exe'] });
|
||
expect(claudeNutro(target)).toBe(join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'));
|
||
});
|
||
|
||
it('вид «просто строка» — bin: "bin/claude.exe"', () => {
|
||
const target = podlozhka({ bin: 'bin/claude.exe', kladyom: ['bin/claude.exe'] });
|
||
expect(claudeNutro(target)).toBe(join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'));
|
||
});
|
||
|
||
it('🔴 записи «bin» НЕТ вовсе — это «не нашли», а не «нашли пустоту»', () => {
|
||
const target = podlozhka({ bin: undefined, kladyom: ['bin/claude.exe'] });
|
||
expect(claudeNutro(target)).toBe(null);
|
||
});
|
||
|
||
it('🔴 ЗАКАЗ, А НЕ ИСПОЛНЕНИЕ: в «bin» написано имя, а файла по нему НЕТ', () => {
|
||
// 🪤 Ровно тот вопрос, на котором споткнулись с `cli.js`: бумага пакета говорит «зови
|
||
// вот это» — но это ЗАКАЗ, а не доказательство, что файл лежит. Верь бумаге на слово —
|
||
// и получишь то же молчание, только по новому имени.
|
||
const target = podlozhka({ bin: { claude: 'bin/такого-нет.exe' }, kladyom: ['bin/claude.exe'] });
|
||
expect(claudeNutro(target)).toBe(null);
|
||
});
|
||
|
||
it('🔴 бумага пакета испорчена — не роняем всё, отвечаем «не нашли»', () => {
|
||
dir = mkdtempSync(join(tmpdir(), 'nutro-'));
|
||
const pkg = join(dir, 'node_modules', '@anthropic-ai', 'claude-code');
|
||
mkdirSync(pkg, { recursive: true });
|
||
writeFileSync(join(pkg, 'package.json'), '{это не бумага', 'utf8');
|
||
expect(claudeNutro(join(dir, 'claude.cmd'))).toBe(null);
|
||
});
|
||
|
||
it('🔴 ТРЕТЬЕ СОСТОЯНИЕ: нутро само оказалось `.cmd` — оно нутром НЕ считается', () => {
|
||
// Звать `.cmd` без оболочки — EINVAL, с оболочкой — та самая склейка. Отдать такое
|
||
// «нутро» значило бы вернуть беду с чёрного хода, выдав её за лечение.
|
||
const target = podlozhka({ bin: { claude: 'bin/claude.cmd' }, kladyom: ['bin/claude.cmd'] });
|
||
expect(claudeNutro(target)).toBe(process.platform === 'win32' ? null : join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.cmd'));
|
||
});
|
||
|
||
it('🔴 нутра нет — claudeLaunch падает ГРОМКО, а не сваливается на оболочку молча', () => {
|
||
if (process.platform !== 'win32') return; // `.cmd` оболочки требует только на Windows
|
||
dir = mkdtempSync(join(tmpdir(), 'nutro-'));
|
||
const target = join(dir, 'claude.cmd');
|
||
writeFileSync(target, '@echo подложка', 'utf8');
|
||
expect(() => claudeLaunch(target)).toThrow(/Нутро программы claude не найдено/);
|
||
// 🪤 И проверяем ИМЕННО отсутствие тихой дороги: раньше здесь возвращалось
|
||
// `{ shell: true }`, и всё выглядело удачей.
|
||
let vernul = null;
|
||
try { vernul = claudeLaunch(target); } catch { /* ждём отказа */ }
|
||
expect(vernul).toBe(null);
|
||
});
|
||
|
||
it('🔴 ЖИВАЯ машина: запуск головы идёт БЕЗ оболочки — единственная точка на всю затею', () => {
|
||
// Оба зовущих живого запуска (рождение работника в spawn.mjs и крошечный замер
|
||
// в budget.mjs) берут `shell` отсюда. Верни сюда `shell: true` — покраснеет здесь.
|
||
expect(claudeLaunch().shell).toBe(false);
|
||
});
|
||
|
||
it('🔴 жёсткого имени нутра в коде нет НИ ОДНОГО — ни старого `cli.js`, ни нового `claude.exe`', () => {
|
||
// 🪤 Капкан, в который затея уже попала: имя нутра, вписанное в код, живёт до переезда.
|
||
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'spawn.mjs'), 'utf8');
|
||
const kod = bezPrimechaniy(src);
|
||
expect(kod).not.toMatch(/['"`][^'"`]*cli\.js['"`]/);
|
||
expect(kod).not.toMatch(/['"`][^'"`]*claude\.exe['"`]/);
|
||
expect(kod).toMatch(/package\.json/); // имя спрашиваем у бумаги пакета
|
||
});
|
||
});
|
||
|
||
describe('🔴 сторож класса «довод не переживёт склейку оболочкой»', () => {
|
||
it('пустая строка — исчезает бесследно, ключ съедает следующий довод', () => {
|
||
expect(dovodNePerezhivyotSkleyku('')).toMatch(/пустая строка/);
|
||
});
|
||
|
||
it('перевод строки внутри довода — поручение работника рвётся на куски', () => {
|
||
expect(dovodNePerezhivyotSkleyku('делай пункт 1\nпотом пункт 2')).toMatch(/пробельный знак/);
|
||
});
|
||
|
||
it('пробел в пути — каталог владельца «портал crm» разваливается надвое', () => {
|
||
expect(dovodNePerezhivyotSkleyku('C:/моя/проекты/портал crm/night-settings.json')).toMatch(/пробельный знак/);
|
||
});
|
||
|
||
it('знаки самой оболочки (кавычка, &, |, %, !) — толкуются ею по-своему', () => {
|
||
for (const z of ['а"б', 'а&б', 'а|б', 'а%PATH%б', 'а!б', 'а^б', 'а>б', 'а(б)']) {
|
||
expect(dovodNePerezhivyotSkleyku(z)).toBeTruthy();
|
||
}
|
||
});
|
||
|
||
it('🪤 ГРАНИЦА: обычный довод склейку переживает — на нём краснеть нельзя', () => {
|
||
for (const z of ['--verbose', 'stream-json', 'C:/ws/night-settings.json', 'sonnet']) {
|
||
expect(dovodNePerezhivyotSkleyku(z)).toBe(null);
|
||
}
|
||
});
|
||
|
||
it('🔴 ГЛАВНАЯ ГРАНИЦА: БЕЗ оболочки те же доводы законны и молчат', () => {
|
||
// Красным обязан быть только СОЮЗ «оболочка + ломкий довод», а не довод сам по себе:
|
||
// многострочное поручение напрямую доходит целым — это замерено живьём.
|
||
const lomkie = ['-p', 'делай пункт 1\nпотом пункт 2', '--settings', 'C:/портал crm/n.json', '--tools', ''];
|
||
expect(proverkaSkleyki({ shell: false, args: lomkie })).toEqual([]);
|
||
expect(proverkaSkleyki({ shell: undefined, args: lomkie })).toEqual([]);
|
||
expect(() => zapretSkleyki({ shell: false, args: lomkie })).not.toThrow();
|
||
});
|
||
|
||
it('🔴 ТРЕТЬЕ СОСТОЯНИЕ `shell`: не только да/нет, но и СТРОКА «какой оболочкой»', () => {
|
||
// 🪤 Сравнение `shell === true` слило бы строку с «оболочки нет» — и сторож пропустил бы
|
||
// ровно тот запуск, который ломается. Смотрим на правдивость, а не на равенство.
|
||
expect(proverkaSkleyki({ shell: 'cmd.exe', args: ['--tools', ''] })).toHaveLength(1);
|
||
expect(proverkaSkleyki({ shell: '/bin/sh', args: ['a b'] })).toHaveLength(1);
|
||
});
|
||
|
||
it('называет беды ПОИМЁННО и с номером — чинить придётся каждую', () => {
|
||
const bedy = proverkaSkleyki({ shell: true, args: ['--tools', '', '-p', 'а\nб', '--ok'] });
|
||
expect(bedy.map(b => b.nomer)).toEqual([1, 3]);
|
||
expect(bedy[0].prichina).toMatch(/пустая строка/);
|
||
});
|
||
|
||
it('🔴 союз «оболочка + ломкий довод» — отказ, а не тихий запуск', () => {
|
||
expect(() => zapretSkleyki({ shell: true, args: ['-p', 'а\nб'], gde: 'Рождение работника' }))
|
||
.toThrow(/Рождение работника[\s\S]*не переживут/);
|
||
});
|
||
|
||
it('🔴 НАСТОЯЩИЕ доводы работника через оболочку — сторож ловит их сам, без списка имён', () => {
|
||
// Ни одного имени ключа сторожу не сказано: он смотрит на форму довода.
|
||
const args = buildArgs({
|
||
prompt: 'Пункт 1: почини.\nПункт 2: проверь.\nПункт 3: закоммить.',
|
||
settingsPath: 'C:/моя/проекты/портал crm/Документация/night-settings.json',
|
||
sessionId: '11111111-1111-4111-8111-111111111111',
|
||
model: 'голова-из-настроек',
|
||
});
|
||
const bedy = proverkaSkleyki({ shell: true, args });
|
||
expect(bedy.length).toBeGreaterThanOrEqual(2);
|
||
expect(bedy.some(b => /пробельный знак/.test(b.prichina) && b.dovod.includes('\n'))).toBe(true);
|
||
expect(bedy.some(b => b.dovod.includes('портал crm'))).toBe(true);
|
||
});
|
||
|
||
it('🟡 сторож ВПАЯН в рождение работника — и ДО счёта, не после', () => {
|
||
// 🔴 ЧЕСТНО О СЛАБОСТИ ЭТОГО ДАТЧИКА: он смотрит на ТЕКСТ кода, а не на исполнение.
|
||
// Живьём поймать зов нечем: на этой машине `claudeLaunch` отдаёт `shell: false`, и сторож
|
||
// при настоящем рождении молчит по делу — подсунуть ему оболочку снаружи нельзя, такого
|
||
// довода у `startWorker` нет. Датчик слабый, но вырезание зова он ловит, а ноль датчиков
|
||
// не ловит ничего. Порядок важен отдельно: отказ до счёта денег не стоит, после — крадёт
|
||
// у владельца рождение, которого не было.
|
||
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'spawn.mjs'), 'utf8');
|
||
const telo = src.slice(src.indexOf('export function startWorker'));
|
||
const gdeStorozh = telo.indexOf('zapretSkleyki(');
|
||
const gdeSchyot = telo.indexOf('rozhdenieSoSchyotom(');
|
||
expect(gdeStorozh).toBeGreaterThan(-1);
|
||
expect(gdeSchyot).toBeGreaterThan(-1);
|
||
expect(gdeStorozh).toBeLessThan(gdeSchyot);
|
||
});
|
||
|
||
it('🔴 ВТОРОЙ ЗОВУЩИЙ — крошечный платный замер: его доводы тоже ломкие', () => {
|
||
// 🪤 Замер два раза умирал именно так: `--tools ''` и `--setting-sources ''` — пустые
|
||
// строки. Через оболочку они исчезали, и ключ съедал следующий довод. Сегодня замер
|
||
// спасает не собственная защита, а то, что `claudeLaunch` больше НЕ отдаёт оболочку.
|
||
const args = probeArgs({ model: 'голова-из-настроек' });
|
||
expect(proverkaSkleyki({ shell: true, args }).length).toBeGreaterThanOrEqual(1);
|
||
expect(proverkaSkleyki({ shell: false, args })).toEqual([]);
|
||
expect(claudeLaunch().shell).toBe(false); // вот чем он спасён на самом деле
|
||
});
|
||
});
|
||
|
||
describe('buildArgs', () => {
|
||
// 🔴 `model` — обязательное поле: голова работника приходит из настроек владельца
|
||
// (`worker_model` в `night.config.json`), своего значения у кода нет.
|
||
const base = {
|
||
prompt: 'делай пункт 1',
|
||
settingsPath: 'C:/ws/night-settings.json',
|
||
sessionId: '11111111-1111-4111-8111-111111111111',
|
||
model: 'голова-из-настроек',
|
||
};
|
||
|
||
it('просит ПОТОК событий, а не один кусок в конце', () => {
|
||
const a = buildArgs(base);
|
||
expect(a).toContain('--output-format');
|
||
expect(a[a.indexOf('--output-format') + 1]).toBe('stream-json');
|
||
});
|
||
|
||
it('к потоку обязателен --verbose, иначе Claude Code откажется запускаться', () => {
|
||
expect(buildArgs(base)).toContain('--verbose');
|
||
});
|
||
|
||
it('одним куском (`json`) не просим никогда — надзирателю нужен ход дела', () => {
|
||
expect(buildArgs(base).filter(x => x === 'json')).toEqual([]);
|
||
});
|
||
|
||
it('предела в долларах в запуске НЕТ — своего счёта в деньгах затея не ведёт', () => {
|
||
// Остаток берётся у прибора системы (кусок 2), доллары не считаются нигде.
|
||
expect(buildArgs(base).some(x => String(x).includes('budget'))).toBe(false);
|
||
});
|
||
|
||
it('передаёт свои настройки — те, где живут запреты', () => {
|
||
expect(buildArgs(base)).toContain('C:/ws/night-settings.json');
|
||
});
|
||
|
||
it('задаёт свой номер сессии, чтобы работника можно было продолжить', () => {
|
||
expect(buildArgs(base)).toContain('--session-id');
|
||
});
|
||
|
||
it('ГОЛОВА работника берётся из настроек владельца, а не из кода', () => {
|
||
// 🔴 Решение владельца: разные головы едят недельный запас по-разному, а запас один
|
||
// на всё. Что положили в `worker_model`, то и уходит в запуск — слово в слово.
|
||
// Верни в `buildArgs` значение по умолчанию — покраснеет следующая проверка.
|
||
const a = buildArgs(base);
|
||
expect(a[a.indexOf('--model') + 1]).toBe('голова-из-настроек');
|
||
});
|
||
|
||
it('голову НЕ сказали — падаем громко, а не подставляем свою', () => {
|
||
// 🔴 Своего значения у кода нет вовсе. Молча подставить голову значит потратить
|
||
// недельный запас владельца головой, которую он не выбирал, и узнать об этом по счёту.
|
||
// Верни `model = 'sonnet'` в подпись — покраснеет здесь.
|
||
expect(() => buildArgs({ ...base, model: undefined })).toThrow(/worker_model/i);
|
||
expect(() => buildArgs({ ...base, model: '' })).toThrow(/worker_model/i);
|
||
});
|
||
|
||
// ━━━ 🔴🔴 ЛЕЧЕНИЕ 03.08.2026, МЕСТО 2 — РОЖДЕНИЕ РАБОТНИКА: ТОЛЬКО ОДИН КЛЮЧ ИЗ ТРЁХ ━━━
|
||
// Живой платный замер (2 живых потока — `.night/probe-raw-2026-08-03.jsonl` без лечения
|
||
// и `.night/probe-lechenie-2026-08-03.jsonl` с ним) показал: три ключа разом
|
||
// (`--tools ''`, `--strict-mcp-config`, `--setting-sources ''`) втрое роняют цену
|
||
// крошечного замера. Но работник — не крошечный замер: он правит код, гоняет проверки,
|
||
// коммитит, и ему мастерская НУЖНА. Решение по каждому ключу — раздельное:
|
||
//
|
||
// · `--tools ''` — НЕТ. Без инструментов (Read/Edit/Bash/Grep…) работник не работник.
|
||
// · `--strict-mcp-config` — ДА. Замерено по `worker-prompt.mjs` (весь протокол
|
||
// работника — файлы почтового ящика + система контроля версий (её консольная команда
|
||
// тут нарочно не набрана словом — см. сторож zhivoy-zapusk-v-proverkah.test.mjs)
|
||
// + команда проверок из плана; ни слова про MCP) и по `tools/night/*.mjs`
|
||
// (грепом — ни одного обращения к MCP-инструменту
|
||
// нигде в коде затеи). 51 внешняя служба из `.mcp.json` (playwright, github,
|
||
// laravel-boost, sentry, redis, research-паки…) работнику ночью не нужна ни разу —
|
||
// это встроенных Read/Edit/Bash/Grep/Glob не касается вовсе, флаг режет только MCP.
|
||
// · `--setting-sources ''` — НЕТ, и это НЕ забывчивость. Она обрубила бы источники,
|
||
// из которых приходит и предупреждение «БОЕВОЙ ПРОД» в CLAUDE.md, и хук
|
||
// `prod-db-guard`, который блокирует снос боевой базы, — тому же самому работнику,
|
||
// который всю ночь работает без человека рядом. Свои настройки (запреты, замки)
|
||
// работник и так получает отдельным, уже стоящим здесь ключом `--settings`
|
||
// (см. проверку выше «передаёт свои настройки») — `--setting-sources` тут ни при чём
|
||
// и трогать его для экономии в центы значило бы менять по ошибке предохранитель.
|
||
it('🔴🔴 --strict-mcp-config СТОИТ (51 служба работнику не нужна ни разу), а --tools/--setting-sources — НЕТ', () => {
|
||
const a = buildArgs(base);
|
||
expect(a).toContain('--strict-mcp-config');
|
||
// 🪤 Не спутать со СВОИМИ настройками (`--settings`, единственное число) — вот эта
|
||
// проверка сторожит другой, множественный ключ, который решили НЕ ставить.
|
||
expect(a).not.toContain('--tools');
|
||
expect(a).not.toContain('--setting-sources');
|
||
});
|
||
});
|
||
|
||
describe('🔴 голова работника не зашита словом НИГДЕ в коде затеи', () => {
|
||
it('слова `sonnet` нет ни в одном файле tools/night/*.mjs', () => {
|
||
// 🔴 Решение владельца: голова живёт в настройках, а не в коде. Эта проверка — сторож
|
||
// всего решения разом: она смотрит НЕ на одну функцию, а на весь каталог затеи,
|
||
// и краснеет, как только кто-нибудь снова зашьёт имя головы словом — хоть здесь,
|
||
// хоть в надзирателе, хоть в оркестраторе.
|
||
// 🪤 Она обязана смотреть на файлы КОДА и НЕ смотреть на два места, где это слово
|
||
// законно: образец настроек `night.config.example.json` (там оно и есть настройка)
|
||
// и сами файлы проверок `*.test.mjs` — вот этот в том числе, он же про это слово
|
||
// и написан. Не исключить проверки — сторож укажет сам на себя и будет красным всегда.
|
||
// Впиши имя головы словом куда угодно в `tools/night/*.mjs` — покраснеет здесь.
|
||
// 🪤 ОДНО ЗАКОННОЕ ИСКЛЮЧЕНИЕ, ПОЯВИВШЕЕСЯ 03.08.2026 И НАЗВАННОЕ ВСЛУХ.
|
||
// У системы есть ВИД ПРЕДЕЛА с именем `seven_day_sonnet` (замерено в самой программе
|
||
// `claude`, всего видов пять), и перечень этих пяти лежит в `budget.mjs` — по нему
|
||
// проверяется настройка владельца `rate_limit_type`. Это не голова работника: головой
|
||
// такое значение не бывает никогда, его даже не с чем спутать.
|
||
// 🔴 Сторож от этого не ослаб: вырезаются РОВНО два полных имени видов предела, а голое
|
||
// слово `sonnet` где угодно — хоть в настройках головы, хоть в подписи, хоть в строке —
|
||
// по-прежнему красит эту проверку. Проверено вырезанием: временно вписанное
|
||
// `model = 'sonnet'` в `spawn.mjs` краснит её и с этим исключением тоже.
|
||
const bezVidovPredela = text => text.replace(/seven_day_(sonnet|opus)/g, '<вид-предела>');
|
||
const dir = dirname(fileURLToPath(import.meta.url));
|
||
const guilty = readdirSync(dir)
|
||
.filter(f => f.endsWith('.mjs') && !f.endsWith('.test.mjs'))
|
||
.filter(f => /sonnet/i.test(bezVidovPredela(readFileSync(join(dir, f), 'utf8'))));
|
||
expect(guilty).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('workerEnv — урезанное окружение работника', () => {
|
||
const dirty = {
|
||
PATH: 'C:/bin',
|
||
SystemRoot: 'C:/Windows',
|
||
TEMP: 'C:/Temp',
|
||
USERPROFILE: 'C:/Users/Administrator',
|
||
APPDATA: 'C:/Users/Administrator/AppData/Roaming',
|
||
ANTHROPIC_API_KEY: 'sk-живой-ключ',
|
||
YOOKASSA_SECRET_KEY: 'живой',
|
||
DADATA_TOKEN: 'живой',
|
||
MAIL_PASSWORD: 'живой',
|
||
SMSC_PASSWORD: 'живой',
|
||
PGPASSWORD: 'живой',
|
||
};
|
||
|
||
it('список наследуемого закрыт и известен поимённо', () => {
|
||
expect(WORKER_ENV_KEYS).toEqual([
|
||
'PATH', 'SystemRoot', 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'APPDATA',
|
||
// Отведение хозяйства улик и приметы прибора — см. разбор у самого списка в spawn.mjs.
|
||
'NIGHT_HOZYAYSTVO_PODMENA', 'VITEST', 'VITEST_WORKER_ID', 'VITEST_POOL_ID',
|
||
]);
|
||
});
|
||
|
||
// 🔴 СТОРОЖ НА РАСХОЖДЕНИЕ ДВУХ СПИСКОВ. Имена примет живут в `paths.mjs` (их читает пол),
|
||
// а переносит их отсюда `workerEnv`. Добавь кто-нибудь примету только в одном месте —
|
||
// пол молча перестанет накрывать детей, и поймать это будет нечем.
|
||
it('🔴 перенос знает ВСЕ приметы прибора, а не часть — списки не разъехались', () => {
|
||
for (const primeta of PRIMETY_PRIBORA) {
|
||
expect(WORKER_ENV_KEYS, `примету ${primeta} ребёнку не переносят — пол его не накроет`)
|
||
.toContain(primeta);
|
||
}
|
||
expect(WORKER_ENV_KEYS).toContain(PODMENA_HOZYAYSTVA);
|
||
});
|
||
|
||
it('🔴 отведение и приметы ВПРАВДУ доезжают до ребёнка, когда они есть у родителя', () => {
|
||
const podPriborom = { ...dirty, VITEST: 'true', NIGHT_HOZYAYSTVO_PODMENA: 'C:/Temp/отвед/.night' };
|
||
const env = workerEnv({ from: podPriborom, dbName: 'night_r_1_w_1' });
|
||
expect(env.VITEST).toBe('true');
|
||
expect(env.NIGHT_HOZYAYSTVO_PODMENA).toBe('C:/Temp/отвед/.night');
|
||
});
|
||
|
||
it('🔴 в БОЕВОЙ ночи ничего лишнего не появляется — переносится только то, что есть', () => {
|
||
// 🪤 Ровно то место, где перенос мог бы навредить: выдумай он переменную от себя —
|
||
// боевая ночь решила бы, что идёт проверка, и уложила бы улики во временный каталог.
|
||
const env = workerEnv({ from: dirty, dbName: 'night_r_1_w_1' });
|
||
expect(env.VITEST).toBeUndefined();
|
||
expect(env.NIGHT_HOZYAYSTVO_PODMENA).toBeUndefined();
|
||
});
|
||
|
||
it('пропускает только то, без чего работник не сможет работать', () => {
|
||
const env = workerEnv({ from: dirty, dbName: 'night_r_1_w_1' });
|
||
expect(Object.keys(env).sort()).toEqual(
|
||
['APPDATA', 'DB_DATABASE', 'NIGHT_WORKER', 'PATH', 'SystemRoot', 'TEMP', 'USERPROFILE'].sort(),
|
||
);
|
||
});
|
||
|
||
it('ни одного ключа наружу не уходит — замок держится на отсутствии, а не на запрете', () => {
|
||
const env = workerEnv({ from: dirty, dbName: 'night_r_1_w_1' });
|
||
for (const key of ['ANTHROPIC_API_KEY', 'YOOKASSA_SECRET_KEY', 'DADATA_TOKEN', 'MAIL_PASSWORD', 'SMSC_PASSWORD', 'PGPASSWORD']) {
|
||
expect(env[key]).toBeUndefined();
|
||
}
|
||
});
|
||
|
||
it('имя СВОЕЙ базы уходит окружением — иначе проверки пойдут в общую базу', () => {
|
||
// 🪤 app/phpunit.xml:33 жёстко задаёт DB_DATABASE=liderra_testing. Правим не файл
|
||
// (он общий для всего продукта), а окружение.
|
||
expect(workerEnv({ from: dirty, dbName: 'night_r_1_w_3' }).DB_DATABASE).toBe('night_r_1_w_3');
|
||
});
|
||
|
||
it('двое работников получают РАЗНЫЕ базы — семеро не затрут друг другу проверки', () => {
|
||
const a = workerEnv({ from: dirty, dbName: 'night_r_1_w_1' });
|
||
const b = workerEnv({ from: dirty, dbName: 'night_r_1_w_2' });
|
||
expect(a.DB_DATABASE).not.toBe(b.DB_DATABASE);
|
||
});
|
||
|
||
it('имени базы не дали — запуска нет: врущая зелень хуже отказа', () => {
|
||
expect(() => workerEnv({ from: dirty, dbName: null })).toThrow(/имя своей базы/i);
|
||
});
|
||
});
|
||
|
||
describe('isRealSuccess — «успех» с пустым ответом успехом не считается', () => {
|
||
it('настоящий успех', () => {
|
||
expect(isRealSuccess({ is_error: false, result: 'сделал', total_cost_usd: 0.4 }).ok).toBe(true);
|
||
});
|
||
|
||
it('пустой ответ при is_error=false — это НЕ успех (случай 334 провалов)', () => {
|
||
const r = isRealSuccess({ is_error: false, result: '', total_cost_usd: 0 });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/пустой ответ/i);
|
||
});
|
||
|
||
it('ответа нет вовсе — не успех', () => {
|
||
expect(isRealSuccess({ is_error: false }).ok).toBe(false);
|
||
});
|
||
|
||
it('явная ошибка — не успех, и причина сохранена', () => {
|
||
const r = isRealSuccess({ is_error: true, result: 'spawn claude ENOENT' });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toContain('ENOENT');
|
||
});
|
||
|
||
it('вовсе не разобрали ответ — не успех', () => {
|
||
expect(isRealSuccess(null).ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('readResult', () => {
|
||
it('вытаскивает расход и номер сессии из ответа', () => {
|
||
const line = JSON.stringify({ type: 'result', is_error: false, result: 'ок', total_cost_usd: 0.049, session_id: 'abc' });
|
||
const r = readResult(line);
|
||
expect(r.total_cost_usd).toBeCloseTo(0.049, 6);
|
||
expect(r.session_id).toBe('abc');
|
||
});
|
||
|
||
it('в потоке много строк — годится ТОЛЬКО последняя, и берётся именно она', () => {
|
||
// 🪤 Живой поток — это десятки строк подряд, и итог лежит в самой последней.
|
||
// Проверка на потоке из одной строки про «последнюю» ничего не доказывает:
|
||
// одна строка и первая, и последняя разом. Здесь их четыре, и годна одна.
|
||
const stream = [
|
||
JSON.stringify({ type: 'system', subtype: 'init' }),
|
||
JSON.stringify({ type: 'assistant', text: 'делаю' }),
|
||
JSON.stringify({ type: 'user', text: 'дальше' }),
|
||
JSON.stringify({ type: 'result', is_error: false, result: 'готово', total_cost_usd: 0.7, session_id: 'zzz' }),
|
||
'', // живой поток кончается переводом строки — пустой хвост не должен всё сломать
|
||
].join('\n');
|
||
const r = readResult(stream);
|
||
expect(r.type).toBe('result');
|
||
expect(r.session_id).toBe('zzz');
|
||
expect(r.total_cost_usd).toBeCloseTo(0.7, 6);
|
||
});
|
||
|
||
it('мусор вместо ответа — null, а не падение', () => {
|
||
expect(readResult('не json')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('разбор потока — то, чем надзиратель видит ход дела', () => {
|
||
it('строка потока разбирается в событие', () => {
|
||
expect(parseStreamLine('{"type":"assistant"}').type).toBe('assistant');
|
||
});
|
||
|
||
it('порванная строка потока — null, а не падение всего надзирателя', () => {
|
||
expect(parseStreamLine('{"type":"assis')).toBeNull();
|
||
expect(parseStreamLine('')).toBeNull();
|
||
expect(parseStreamLine(null)).toBeNull();
|
||
});
|
||
|
||
it('ответ работника — граница шага: на ней можно применить «паузу» чисто', () => {
|
||
expect(isStepBoundary({ type: 'assistant' })).toBe(true);
|
||
expect(isStepBoundary({ type: 'user' })).toBe(true);
|
||
expect(isStepBoundary({ type: 'result' })).toBe(true);
|
||
});
|
||
|
||
it('служебное начало сессии границей шага НЕ считается', () => {
|
||
expect(isStepBoundary({ type: 'system', subtype: 'init' })).toBe(false);
|
||
expect(isStepBoundary(null)).toBe(false);
|
||
});
|
||
|
||
// 🔴 Вид предела ВЕЗДЕ называется доводом: зашитого умолчания у затеи больше нет (решение Р1).
|
||
const NEDELYA = 'seven_day';
|
||
|
||
it('замер запаса берётся из события прибора системы', () => {
|
||
const m = rateLimitFromEvent({
|
||
type: 'rate_limit_event',
|
||
rate_limit_info: { status: 'allowed_warning', resetsAt: 1785474000, rateLimitType: NEDELYA, utilization: 0.92 },
|
||
}, { kind: NEDELYA });
|
||
expect(m.utilization).toBeCloseTo(0.92, 6);
|
||
expect(m.resets_at).toBe(1785474000 * 1000);
|
||
expect(m.status).toBe('allowed_warning');
|
||
});
|
||
|
||
it('чужой вид предела не берётся вовсе — это другой запас', () => {
|
||
expect(rateLimitFromEvent({
|
||
type: 'rate_limit_event',
|
||
rate_limit_info: { rateLimitType: 'five_hour', status: 'allowed', utilization: 0.1, resetsAt: 1785474000 },
|
||
}, { kind: NEDELYA })).toBeNull();
|
||
});
|
||
|
||
it('событие не про пределы или пусто — null', () => {
|
||
expect(rateLimitFromEvent({ type: 'assistant' }, { kind: NEDELYA })).toBeNull();
|
||
expect(rateLimitFromEvent(null, { kind: NEDELYA })).toBeNull();
|
||
});
|
||
|
||
it('🔴 доля не число — это «доли НЕТ» (null), а не ноль: ноль соврал бы про запас', () => {
|
||
// 🔴 ИСПРАВЛЕНО 03.08.2026. Прежде такое событие отбрасывалось ЦЕЛИКОМ, и вместе с долей
|
||
// терялся светофор — а он-то как раз пришёл. Ровно на этом ворота запаса и стояли:
|
||
// живая система при спокойном расходе доли не присылает вовсе.
|
||
const m = rateLimitFromEvent({
|
||
type: 'rate_limit_event',
|
||
rate_limit_info: { rateLimitType: NEDELYA, status: 'allowed', utilization: 'много' },
|
||
}, { kind: NEDELYA });
|
||
expect(m.utilization).toBeNull();
|
||
expect(m.utilization).not.toBe(0);
|
||
expect(m.status).toBe('allowed');
|
||
});
|
||
|
||
it('🔴 вид предела не назвали — падаем громко, а не подставляем недельный молча', () => {
|
||
expect(() => rateLimitFromEvent({
|
||
type: 'rate_limit_event',
|
||
rate_limit_info: { rateLimitType: NEDELYA, status: 'allowed', utilization: 0.5 },
|
||
})).toThrow(/вид/i);
|
||
});
|
||
});
|
||
|
||
describe('сверка головы: заказанная против той, что вправду ответила (03.08.2026)', () => {
|
||
it('тянет имя ИЗ ОТВЕТА ПОМОЩНИКА (assistant), а не из заказа в system/init', () => {
|
||
// 🔴🔴 ЗАМЕРЕНО ЖИВЬЁМ: событие `system`/`init` тоже несёт поле `model`, но там лежит
|
||
// ЗАКАЗ (то, что попросили ключом --model), а не ИСПОЛНЕНИЕ. На живом потоке
|
||
// (.night/probe-raw-2026-08-03.jsonl) заказ был haiku, а сгенерировал ответ sonnet —
|
||
// и `system`/`init` при этом соврал бы «haiku», спрятав беду. Взять голову оттуда
|
||
// значило бы сверять заказ с самим собой, и проверка сходилась бы ВСЕГДА.
|
||
expect(golovaIzSobytiya({ type: 'system', subtype: 'init', model: 'claude-haiku-4-5-20251001' })).toBeNull();
|
||
expect(golovaIzSobytiya({ type: 'assistant', message: { model: 'claude-sonnet-4-6' } })).toBe('claude-sonnet-4-6');
|
||
});
|
||
|
||
it('событие без имени головы (или не assistant) — null, а не пустая строка', () => {
|
||
expect(golovaIzSobytiya({ type: 'assistant', message: {} })).toBeNull();
|
||
expect(golovaIzSobytiya({ type: 'assistant' })).toBeNull();
|
||
expect(golovaIzSobytiya({ type: 'result' })).toBeNull();
|
||
expect(golovaIzSobytiya(null)).toBeNull();
|
||
});
|
||
|
||
describe('golovyIzModelUsage — имена ГОЛОВ, КОТОРЫМИ СЧИТАНЫ ДЕНЬГИ (поправка координатора)', () => {
|
||
// 🔴🔴 ПОПРАВКА: заказ (`system`/`init`) — не единственная ловушка. Замерено на живом потоке
|
||
// командой `grep -o '"model":"[^"]*"' … | sort | uniq -c`: имя головы стоит в ДВУХ местах
|
||
// сразу — в `init` (заказ) и в ответе помощника (исполнение), а деньги (`total_cost_usd`)
|
||
// считаются по третьему полю, `modelUsage` итогового события `result`, и на живом потоке
|
||
// оно совпадает с ответом помощника, а не с заказом. Сверяем по обоим источникам ИСПОЛНЕНИЯ
|
||
// (`assistant` и `modelUsage`), а не только по одному — они не обязаны молча совпадать
|
||
// вечно, и датчик обязан ловить расхождение, в каком бы из двух оно ни показалось первым.
|
||
it('тянет ключи modelUsage из события result', () => {
|
||
const event = { type: 'result', modelUsage: { 'claude-sonnet-4-6': { inputTokens: 3 } } };
|
||
expect(golovyIzModelUsage(event)).toEqual(['claude-sonnet-4-6']);
|
||
});
|
||
|
||
it('событие не result, или modelUsage нет вовсе — пустой список, а не падение', () => {
|
||
expect(golovyIzModelUsage({ type: 'assistant' })).toEqual([]);
|
||
expect(golovyIzModelUsage({ type: 'result' })).toEqual([]);
|
||
expect(golovyIzModelUsage({ type: 'result', modelUsage: null })).toEqual([]);
|
||
expect(golovyIzModelUsage(null)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('golovySovpadayut — заказывают КОРОТКИМ именем, отвечает ПОЛНЫМ', () => {
|
||
// 🪤 Строгое равенство строк сравнивало бы разные алфавиты и падало бы КАЖДЫЙ раз,
|
||
// даже когда голова верна: заказывают `haiku`, а отвечает `claude-haiku-4-5-20251001`.
|
||
it('короткое имя внутри полного — совпадение', () => {
|
||
expect(golovySovpadayut('haiku', 'claude-haiku-4-5-20251001')).toBe(true);
|
||
});
|
||
|
||
it('регистр не важен', () => {
|
||
expect(golovySovpadayut('Haiku', 'claude-haiku-4-5-20251001')).toBe(true);
|
||
});
|
||
|
||
it('заказана одна голова, ответила другая — расхождение', () => {
|
||
// 🔴 Ровно та беда, ради которой строился датчик: заказали дешёвую, ответила другая.
|
||
expect(golovySovpadayut('haiku', 'claude-sonnet-4-6')).toBe(false);
|
||
});
|
||
|
||
it('без одного из имён сравнивать нечего — null, а не ложное совпадение/расхождение', () => {
|
||
expect(golovySovpadayut(null, 'claude-haiku-4-5-20251001')).toBeNull();
|
||
expect(golovySovpadayut('haiku', null)).toBeNull();
|
||
expect(golovySovpadayut('', '')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('sverkaGolovy — ГЛАВНАЯ ПРИЁМКА: на настоящем сыром живом потоке', () => {
|
||
// 🔴🔴 НЕ синтетика: тот самый файл, которым доказана сегодняшняя беда,
|
||
// `.night/probe-raw-2026-08-03.jsonl`, читается здесь как есть, 11 строк.
|
||
const potok = readFileSync(join(REPO_ROOT, '.night', 'probe-raw-2026-08-03.jsonl'), 'utf8');
|
||
|
||
it('🔴 ловит сегодняшнюю беду: заказан haiku, а на этом потоке в счёте только другая голова', () => {
|
||
// 🔴🔴 ПОПРАВКА КООРДИНАТОРА (замерено ОТДЕЛЬНОЙ живой проверкой): не «ключ --model
|
||
// не сработал» — заказанная голова сама по себе работает. На ЭТОМ КОНКРЕТНОМ потоке
|
||
// среди отвечавших/оплаченных нет ни одной головы, совпадающей с заказом, — датчик
|
||
// говорит именно это, а не выдумывает причину («заменили», «ключ не сработал»).
|
||
const r = sverkaGolovy({ zakazana: 'haiku', potok });
|
||
expect(r.sovpalo).toBe(false);
|
||
expect(r.otvetila).toEqual(['claude-sonnet-4-6']);
|
||
expect(r.zakazannaya_prisutstvuet).toBe(false);
|
||
expect(r.postoronnie).toEqual(['claude-sonnet-4-6']);
|
||
expect(r.soobshchenie).toContain('haiku');
|
||
expect(r.soobshchenie).toContain('claude-sonnet-4-6');
|
||
// 🔴 ПОПРАВКА 03.08.2026 (вечер): прежде здесь требовалось слово «не выяснено» —
|
||
// и это ЗАКРЕПЛЯЛО в утренней сводке владельца вывод, опровергнутый замером. Причина
|
||
// найдена (короткое имя головы + режим «plan») и вылечена в budget.mjs, поэтому датчик
|
||
// обязан назвать её владельцу, а не разводить руками. Верни сюда «не выяснено» —
|
||
// покраснеет эта проверка.
|
||
expect(r.soobshchenie).toMatch(/причина .{0,40}найдена/i);
|
||
expect(r.soobshchenie).toMatch(/plan/);
|
||
expect(r.soobshchenie).not.toMatch(/не выяснено/i);
|
||
expect(r.soobshchenie).not.toMatch(/--model/);
|
||
});
|
||
|
||
it('заказанная голова совпадает с тем, что в счёте, — сходится, слов владельцу нет', () => {
|
||
const r = sverkaGolovy({ zakazana: 'sonnet', potok });
|
||
expect(r.sovpalo).toBe(true);
|
||
expect(r.zakazannaya_prisutstvuet).toBe(true);
|
||
expect(r.postoronnie).toEqual([]);
|
||
expect(r.soobshchenie).toBeNull();
|
||
});
|
||
|
||
it('в потоке нет ни одного ответа помощника — третий, отдельный ответ (не "сходится")', () => {
|
||
const bezOtveta = [
|
||
JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-haiku-4-5-20251001' }),
|
||
JSON.stringify({ type: 'result', total_cost_usd: 0.01 }),
|
||
].join('\n');
|
||
const r = sverkaGolovy({ zakazana: 'haiku', potok: bezOtveta });
|
||
expect(r.sovpalo).toBeNull();
|
||
expect(r.otvetila).toEqual([]);
|
||
expect(r.soobshchenie).toMatch(/не пришл/i);
|
||
});
|
||
|
||
it('заказанную голову назвать забыли — падаем громко: сверять её не с чем', () => {
|
||
expect(() => sverkaGolovy({ zakazana: null, potok })).toThrow(/заказанной головы/i);
|
||
expect(() => sverkaGolovy({ zakazana: '', potok })).toThrow(/заказанной головы/i);
|
||
});
|
||
|
||
it('🔴 ловит расхождение ДАЖЕ БЕЗ события assistant — по одному только modelUsage(result)', () => {
|
||
// Поправка координатора: не единственный источник, откуда берётся исполнение.
|
||
// Оборванный поток (обрезали до result) — редкий, но возможный случай: событие
|
||
// assistant могло не долететь, а result с modelUsage — самостоятельный источник правды.
|
||
const tolkoResult = [
|
||
JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-haiku-4-5-20251001' }),
|
||
JSON.stringify({ type: 'result', total_cost_usd: 0.15, modelUsage: { 'claude-sonnet-4-6': { inputTokens: 3 } } }),
|
||
].join('\n');
|
||
const r = sverkaGolovy({ zakazana: 'haiku', potok: tolkoResult });
|
||
expect(r.sovpalo).toBe(false);
|
||
expect(r.otvetila).toEqual(['claude-sonnet-4-6']);
|
||
});
|
||
|
||
it('добавка (а не замена): заказанная голова в счёте ЕСТЬ, но рядом — ещё одна лишняя', () => {
|
||
// 🔴 ПОПРАВКА 03.08.2026 (вечер). Прежде здесь стояло «РОВНО ТА ФОРМА БЕДЫ, КОТОРУЮ
|
||
// ЗАМЕРИЛ КООРДИНАТОР» — неправда: живьём была ЗАМЕНА (заказанной головы в запросе не
|
||
// было вовсе), и её ловит соседняя проверка на настоящем потоке. Этот же случай —
|
||
// ВТОРАЯ, пока не встреченная живьём форма расхождения: в счёте две головы разом.
|
||
// Проверка остаётся, потому что стережёт РАЗНЫЕ слова владельцу в двух случаях
|
||
// («кроме неё» против «вместо неё»), а не потому, что так было замерено.
|
||
const dveGolovy = [
|
||
JSON.stringify({ type: 'assistant', message: { model: 'claude-haiku-4-5-20251001' } }),
|
||
JSON.stringify({
|
||
type: 'result',
|
||
total_cost_usd: 0.2,
|
||
modelUsage: {
|
||
'claude-haiku-4-5-20251001': { inputTokens: 3 },
|
||
'claude-sonnet-4-6': { inputTokens: 3 },
|
||
},
|
||
}),
|
||
].join('\n');
|
||
const r = sverkaGolovy({ zakazana: 'haiku', potok: dveGolovy });
|
||
expect(r.sovpalo).toBe(false);
|
||
expect(r.zakazannaya_prisutstvuet).toBe(true);
|
||
expect(r.postoronnie).toEqual(['claude-sonnet-4-6']);
|
||
expect(r.soobshchenie).toMatch(/кроме заказанной/i);
|
||
expect(r.soobshchenie).not.toMatch(/вместо неё/i);
|
||
// 🔴 То же исправление, что и выше: причина названа, а не объявлена открытой.
|
||
expect(r.soobshchenie).toMatch(/причина .{0,40}найдена/i);
|
||
expect(r.soobshchenie).not.toMatch(/не выяснено/i);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('второе место платных запусков — рождение работника: датчик готов, но НЕ ПОДКЛЮЧЁН', () => {
|
||
// 🔴🔴 ГРАНИЦА ЗАДАЧИ, СКАЗАННАЯ ВСЛУХ. Платных мест в затее ровно два: крошечный замер
|
||
// остатка (`probeUtilization` в budget.mjs — датчик подключён, см. выше) и рождение работника
|
||
// (`startWorker` в этом файле). У рождения работника поток читает НЕ этот файл — его построчно
|
||
// разбирает `eatLine` в `tools/night/supervisor.mjs` (там же, где сейчас пишутся замеры
|
||
// остатка через `recordMeasure`). `supervisor.mjs` НЕ входит в список файлов этой задачи
|
||
// (там прямо сейчас работает другая смена) — датчик туда НЕ подключается здесь.
|
||
//
|
||
// Что доказано вместо подключения: `sverkaGolovy`/`golovaIzSobytiya`/`golovyIzModelUsage`
|
||
// читают события ОБЩЕЙ формы (`assistant`, `result`) — той же самой, что несёт и поток
|
||
// замера, и поток работника (оба порождены одним и тем же `claude … --output-format
|
||
// stream-json`, см. `probeArgs` и `buildArgs` выше). Ниже — поток МНОГОШАГОВОГО вида
|
||
// (несколько `assistant`, как у настоящего работника, а не один, как у замера) — сенсор
|
||
// ловит расхождение и на нём той же логикой, без переделки.
|
||
it('голова совпадает на многошаговом потоке работника — сходится', () => {
|
||
const potokRabotnika = [
|
||
JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-opus-4-1-20250805' }),
|
||
JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-1-20250805' } }),
|
||
JSON.stringify({ type: 'user', message: { content: 'дальше' } }),
|
||
JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-1-20250805' } }),
|
||
JSON.stringify({ type: 'result', total_cost_usd: 1.2, modelUsage: { 'claude-opus-4-1-20250805': { inputTokens: 100 } } }),
|
||
].join('\n');
|
||
const r = sverkaGolovy({ zakazana: 'opus', potok: potokRabotnika });
|
||
expect(r.sovpalo).toBe(true);
|
||
});
|
||
|
||
it('🔴 та же расходящаяся голова ловится и на многошаговом потоке работника, не только на замере', () => {
|
||
const potokRabotnika = [
|
||
JSON.stringify({ type: 'assistant', message: { model: 'claude-haiku-4-5-20251001' } }),
|
||
JSON.stringify({ type: 'user', message: { content: 'дальше' } }),
|
||
JSON.stringify({ type: 'assistant', message: { model: 'claude-haiku-4-5-20251001' } }),
|
||
JSON.stringify({
|
||
type: 'result',
|
||
total_cost_usd: 1.2,
|
||
modelUsage: { 'claude-haiku-4-5-20251001': { inputTokens: 100 }, 'claude-sonnet-4-6': { inputTokens: 5 } },
|
||
}),
|
||
].join('\n');
|
||
const r = sverkaGolovy({ zakazana: 'opus', potok: potokRabotnika });
|
||
expect(r.sovpalo).toBe(false);
|
||
expect(r.postoronnie.sort()).toEqual(['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'].sort());
|
||
});
|
||
});
|
||
|
||
describe('имя своей базы доходит до ЖИВОГО процесса, а не только до объекта (проверка 46)', () => {
|
||
// 🪤 Ни одну внешнюю программу не зовём голым именем — даже здесь.
|
||
const NODE = resolveBin('node');
|
||
|
||
function chtoVidytRebyonok(dbName) {
|
||
// Настоящий ребёнок с настоящим урезанным окружением. Он печатает то, что видит сам.
|
||
// 🪤 `shell` подаём ВСЕГДА, а не «когда понадобится»: найденный по полному пути
|
||
// `.cmd`/`.bat` Node 24 без него падает `EINVAL`, и падает не здесь, а через неделю
|
||
// на чужой машине. `needsShell` сама решает, нужна оболочка этому файлу или нет.
|
||
return execFileSync(NODE, ['-e', 'process.stdout.write(String(process.env.DB_DATABASE))'], {
|
||
env: workerEnv({ from: process.env, dbName }),
|
||
shell: needsShell(NODE),
|
||
encoding: 'utf8',
|
||
}).trim();
|
||
}
|
||
|
||
it('рождённый ребёнок видит СВОЮ базу своим окружением', () => {
|
||
expect(chtoVidytRebyonok('night_r_1_w_3')).toBe('night_r_1_w_3');
|
||
});
|
||
|
||
it('двое живых детей видят РАЗНЫЕ базы — семеро не затрут друг другу проверки', () => {
|
||
expect(chtoVidytRebyonok('night_r_1_w_1')).not.toBe(chtoVidytRebyonok('night_r_1_w_2'));
|
||
});
|
||
|
||
it('в живом ребёнке общей базы проверок нет — она не «запрещена», её там просто нет', () => {
|
||
expect(chtoVidytRebyonok('night_r_1_w_1')).not.toBe('liderra_testing');
|
||
});
|
||
|
||
it('имени базы не дали — ребёнок НЕ РОЖДАЕТСЯ вовсе', () => {
|
||
// 🔴 Проверка 46: «не стартовал вовсе, и ни одной проверки по его пункту не пошло».
|
||
// Бросок случается при сборке настроек запуска — то есть ДО самого рождения ребёнка:
|
||
// родись он, никакого броска бы не было, а был бы живой работник с чужой базой.
|
||
//
|
||
// 🔴🔴 ПОЧЕМУ `cwd` указывает в НЕСУЩЕСТВУЮЩИЙ каталог — это не описка. Шаг 8 нарочно
|
||
// вырезает из `workerEnv` тот самый бросок. Укажи мы сюда рабочий каталог — на шаге 8
|
||
// ничто бы не остановило запуск, и `startWorker` РОДИЛ БЫ НАСТОЯЩЕГО работника `claude`
|
||
// прямо в хранилище: живой платный процесс с поручением «поручение», которого никто
|
||
// не гасит. С несуществующим каталогом ребёнок не заводится ни при каком исходе:
|
||
// с правилом — бросок про базу (зелено), без правила — отказ на самом запуске,
|
||
// и слов про базу в нём нет, значит проверка краснеет. Ровно этого мы и ждём.
|
||
expect(() => startWorker({
|
||
cwd: join(process.cwd(), 'net-takogo-kataloga-i-ne-budet'),
|
||
prompt: 'поручение',
|
||
settingsPath: 'night-settings.json',
|
||
sessionId: '00000000-0000-4000-8000-000000000000',
|
||
dbName: null,
|
||
model: 'sonnet',
|
||
})).toThrow(/имя своей базы/i);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 ЖИВОЙ БОЛЬНОЙ РЕБЁНОК: ТОТ, КТО ВПРАВДУ ЛЕЗЕТ В ХОЗЯЙСТВО УЛИК.
|
||
//
|
||
// Зачем отдельно от соседнего живого ребёнка выше. Тот печатает имя своей базы и модулей
|
||
// затеи не грузит — он ЗДОРОВ и на пол не опирается вовсе, потому и доказать полом ничего
|
||
// не может: он и без пола безвреден. Здесь ребёнок нарочно БОЛЬНОЙ — он делает ровно то,
|
||
// от чего беда: ввозит `paths.mjs` и спрашивает, где хозяйство улик.
|
||
//
|
||
// 🔴 ИМЕННО ЭТОТ СЛУЧАЙ 04.08.2026 ПРОХОДИЛ МИМО ПОЛА. Пол узнаёт прибор проверок по приметам
|
||
// в окружении, а окружение работника РЕЖЕТСЯ (`workerEnv`) — и приметы резались вместе с ним.
|
||
// Ребёнок приходил без единой приметы, честно решал, что он боевой, и получал корнем
|
||
// ОПЛАЧЕННЫЕ УЛИКИ ВЛАДЕЛЬЦА. Сторож улик при этом молчал: он смотрит на своё хранилище,
|
||
// а писал бы этот ребёнок в него же — то есть беда прошла бы у сторожа под носом.
|
||
//
|
||
// 🪤 Прежде тут стоял довод «сегодня так никто не делает». Он был ЛОЖНЫМ уже в день, когда
|
||
// его написали: соседний живой ребёнок выше рождается ровно этим урезанным списком. Дверь
|
||
// держалась не на устройстве, а на том, что в неё пока не толкались.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
describe('🛡️ живой ребёнок с урезанным окружением НЕ попадает в оплаченные улики', () => {
|
||
const NODE = resolveBin('node');
|
||
const putKPaths = join(dirname(fileURLToPath(import.meta.url)), 'paths.mjs');
|
||
|
||
// Ребёнок ввозит `paths.mjs` и печатает то, что он сам считает хозяйством улик.
|
||
// 🪤 Ввоз по ссылке на файл, а не по имени: у ребёнка свой рабочий каталог.
|
||
function gdeRebyonokVidytHozyaystvo(env) {
|
||
const ssylka = new URL(`file://${putKPaths.replace(/\\/g, '/')}`).href;
|
||
return String(execFileSync(
|
||
NODE,
|
||
['-e', `import(${JSON.stringify(ssylka)}).then(m => process.stdout.write(m.NIGHT_ROOT))`],
|
||
{ env, shell: needsShell(NODE), encoding: 'utf8', timeout: PREDEL_ZHIVOGO_ZAPUSKA },
|
||
)).trim();
|
||
}
|
||
|
||
const BOEVOE = join(REPO_ROOT, '.night');
|
||
|
||
it('🔴 ГЛАВНОЕ: ребёнок, рождённый урезанным окружением, видит НЕ боевое хозяйство', () => {
|
||
// 🔴 Окружение берётся у настоящего `workerEnv` — того самого, которым рождают работника.
|
||
// Перепиши я его здесь руками, проверка доказывала бы только моё представление о нём.
|
||
const env = workerEnv({ from: process.env, dbName: 'night_r_1_w_1' });
|
||
const uRebyonka = gdeRebyonokVidytHozyaystvo(env);
|
||
expect(uRebyonka, [
|
||
'Живой ребёнок с урезанным окружением получил корнем ОПЛАЧЕННЫЕ УЛИКИ ВЛАДЕЛЬЦА.',
|
||
`Он видит: ${uRebyonka}`,
|
||
`Боевое хозяйство: ${BOEVOE}`,
|
||
'Значит перенос отведения в `WORKER_ENV_KEYS` (spawn.mjs) сломан или обойдён, и всякая',
|
||
'запись такого ребёнка идёт в улики, часть которых оплачена живыми деньгами.',
|
||
].join('\n')).not.toBe(BOEVOE);
|
||
// И положительная половина: он видит ровно то же хозяйство, что и родитель.
|
||
expect(uRebyonka).toBe(korenHozyaystva(process.env));
|
||
});
|
||
|
||
it('🔴 ВЫРЕЗАНИЕ: отними у ребёнка перенос — и он ВПРАВДУ уходит в боевое', () => {
|
||
// 🔴 Без этой половины первая ничего не охраняет: зелёный, которого не видели красным,
|
||
// не сторожит ничего. Режем ровно то, что чинили, — переносимые приметы и отведение.
|
||
const urezannoe = workerEnv({ from: process.env, dbName: 'night_r_1_w_1' });
|
||
const bezPerenosa = { ...urezannoe };
|
||
delete bezPerenosa[PODMENA_HOZYAYSTVA];
|
||
for (const primeta of PRIMETY_PRIBORA) delete bezPerenosa[primeta];
|
||
|
||
expect(gdeRebyonokVidytHozyaystvo(bezPerenosa), [
|
||
'Вырезание не сработало: ребёнок БЕЗ переноса обязан был уйти в боевое хозяйство,',
|
||
'а ушёл куда-то ещё. Значит опыт ничего не доказывает — проверьте, что режется то самое.',
|
||
].join('\n')).toBe(BOEVOE);
|
||
});
|
||
});
|
||
|
||
describe('🔴 рождение работника идёт ЧЕРЕЗ СЧЁТ владельца, а не мимо него', () => {
|
||
// 🔴🔴 ЗДЕСЬ НЕ РОЖДАЕТСЯ НИ ОДНОГО НАСТОЯЩЕГО РАБОТНИКА, и держится это не на обещании,
|
||
// а на устройстве: `cwd` во всех проверках ниже указывает в НЕСУЩЕСТВУЮЩИЙ каталог.
|
||
// Приём взят у соседней проверки выше (про имя базы) и по той же причине: с таким `cwd`
|
||
// система отказывает ДО создания процесса (ENOENT на рабочий каталог), то есть живой
|
||
// `claude` не заводится ни при каком исходе — ни когда счёт отказал, ни когда пропустил.
|
||
// Замерено прямо здесь, отдельным запуском: `spawn` возвращает объект, а ошибка приходит
|
||
// событием — процесса при этом не создаётся вовсе.
|
||
//
|
||
// 🔴 Счёт подаётся СВОИМ, временным (`attemptsPath`), а настройки — своими (`cfg`).
|
||
// Без этого проверка писала бы в настоящее хозяйство `.night`, а его на рабочий угол
|
||
// один: соседний прибор проверок затирал бы нам счёт посреди опыта.
|
||
let dir;
|
||
let schyot;
|
||
|
||
beforeEach(() => {
|
||
dir = mkdtempSync(join(tmpdir(), 'rozhdenie-rabotnika-'));
|
||
schyot = join(dir, 'worker-spawns.jsonl');
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
const dovody = {
|
||
cwd: join(process.cwd(), 'net-takogo-kataloga-i-ne-budet'),
|
||
prompt: 'поручение',
|
||
settingsPath: 'night-settings.json',
|
||
sessionId: '00000000-0000-4000-8000-000000000000',
|
||
dbName: 'night_r_1_w_1',
|
||
model: 'sonnet',
|
||
};
|
||
|
||
it('🔴 на исчерпанном пределе работник НЕ рождается, и отказ — ЧЕЛОВЕЧЕСКАЯ строка', () => {
|
||
// 🔴 Вырежи счёт из `startWorker` — и здесь не будет никакого броска вовсе: рождение
|
||
// пойдёт как шло, предел владельца не сторожит ничего. Проверено вырезанием.
|
||
const now = Date.now();
|
||
for (let i = 0; i < 3; i += 1) recordSpawnAttempt(schyot, { at: now - 1000 + i, kto: 'w-старый' });
|
||
let beda = null;
|
||
try {
|
||
startWorker({ ...dovody, cfg: { spawn_budget_per_shift: 3 }, attemptsPath: schyot });
|
||
} catch (err) {
|
||
beda = err;
|
||
}
|
||
expect(beda, 'работник родился при исчерпанном пределе — предел не сторожит рубли').not.toBeNull();
|
||
expect(beda.message).toContain('spawn_budget_per_shift');
|
||
expect(beda.message).toContain('night.config.json');
|
||
expect(beda.message).toContain(schyot);
|
||
expect(beda.message, 'отказ похож на след стека, а не на слова для владельца').toMatch(/Что делать/);
|
||
// Лишней строки в счёт не легло: рождения не было.
|
||
expect(readSpawnAttempts(schyot)).toHaveLength(3);
|
||
});
|
||
|
||
it('🔴 попытка пишется ДО запуска: даже провалившееся рождение съедает счёт', () => {
|
||
// 🪤 Самый тихий обход: считай мы удачные рождения, сломанный запуск жёг бы деньги
|
||
// владельца сколько угодно раз при счётчике на нуле. Здесь запуск ВСЕГДА проваливается
|
||
// (каталога нет), и строка в счёте обязана появиться всё равно.
|
||
const child = startWorker({ ...dovody, cfg: { spawn_budget_per_shift: 30 }, attemptsPath: schyot });
|
||
// 🪤 Ошибка запуска придёт событием; без слушателя она убила бы весь прибор проверок.
|
||
child?.on?.('error', () => {});
|
||
expect(readSpawnAttempts(schyot), 'запуск был, а счёт остался на нуле').toHaveLength(1);
|
||
});
|
||
|
||
it('🔴 отказ по ненаписанным настройкам счёта НЕ съедает: их описка денег не стоит', () => {
|
||
// Голову не сказали — бросок про `worker_model` случается ДО счёта, и правильно:
|
||
// красть у владельца рождение, которого не было, нельзя.
|
||
expect(() => startWorker({
|
||
...dovody, model: undefined, cfg: { spawn_budget_per_shift: 30 }, attemptsPath: schyot,
|
||
})).toThrow(/worker_model/i);
|
||
expect(readSpawnAttempts(schyot)).toHaveLength(0);
|
||
|
||
expect(() => startWorker({
|
||
...dovody, dbName: null, cfg: { spawn_budget_per_shift: 30 }, attemptsPath: schyot,
|
||
})).toThrow(/имя своей базы/i);
|
||
expect(readSpawnAttempts(schyot)).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
// ━━━ 🔴🔴 ДАТЧИК МАСТЕРСКОЙ (03.08.2026): «ключ ПРИНЯТ» ≠ «ключ ПРИМЕНЁН» ━━━
|
||
// Ровно та беда, что уже дважды выстрелила в этот же день (Inworld тихо не применил потолок
|
||
// токенов; `--tools ''` стоял в коде и не срезал ничего). Лечить не программу — лечить умение
|
||
// её ПРОВЕРИТЬ: событие `system`/`init` в потоке честно называет, что вправду загружено
|
||
// (`tools`, `mcp_servers`, `skills`, `slash_commands`), и верить нужно ЕМУ, а не аргументам
|
||
// командной строки, которые сами по себе ничего не доказывают.
|
||
describe('sostavMasterskoyIzPotoka / masterskayaOtsechena — датчик по событию system/init', () => {
|
||
// 🔴🔴 НЕ СИНТЕТИКА, ОБА ПОТОКА ЖИВЫЕ И ОПЛАЧЕННЫЕ:
|
||
// без лечения — `.night/probe-raw-2026-08-03.jsonl` (тот же файл, что доказывает беду
|
||
// выше, у sverkaGolovy): tools 50, mcp_servers 51, skills 129, slash_commands 156;
|
||
// с лечением (`--tools '' --strict-mcp-config --setting-sources ''`) —
|
||
// `.night/probe-lechenie-2026-08-03.jsonl`: tools 0, mcp_servers 0, skills 8,
|
||
// slash_commands 20. Восемь умений и двадцать команд — встроенный костяк программы,
|
||
// а не недолеченный обвес: до нуля они не падают даже при всех трёх ключах разом.
|
||
const potokBezLecheniya = readFileSync(join(REPO_ROOT, '.night', 'probe-raw-2026-08-03.jsonl'), 'utf8');
|
||
const potokSLecheniem = readFileSync(join(REPO_ROOT, '.night', 'probe-lechenie-2026-08-03.jsonl'), 'utf8');
|
||
|
||
describe('sostavMasterskoyIzPotoka — сырые числа из события init', () => {
|
||
it('без лечения — обвес целиком: 50 инструментов, 51 служба, 129 умений, 156 команд', () => {
|
||
expect(sostavMasterskoyIzPotoka(potokBezLecheniya)).toEqual({
|
||
naydeno: true, tools: 50, mcp_servers: 51, skills: 129, slash_commands: 156,
|
||
});
|
||
});
|
||
|
||
it('с лечением — мастерская вправду отсечена: 0 инструментов, 0 служб, 8 умений, 20 команд', () => {
|
||
expect(sostavMasterskoyIzPotoka(potokSLecheniem)).toEqual({
|
||
naydeno: true, tools: 0, mcp_servers: 0, skills: 8, slash_commands: 20,
|
||
});
|
||
});
|
||
|
||
it('события system/init в потоке не было вовсе — честное "не найдено", а не нули', () => {
|
||
// 🔴 Ноль вместо «неизвестно» здесь было бы враньём того же класса, что и «доли нет»
|
||
// в budget.mjs: ноль означает «мастерская пуста», а не «мы не смотрели».
|
||
const bezInit = JSON.stringify({ type: 'result', is_error: false, result: 'есть' });
|
||
expect(sostavMasterskoyIzPotoka(bezInit)).toEqual({
|
||
naydeno: false, tools: null, mcp_servers: null, skills: null, slash_commands: null,
|
||
});
|
||
expect(sostavMasterskoyIzPotoka('')).toEqual({
|
||
naydeno: false, tools: null, mcp_servers: null, skills: null, slash_commands: null,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('masterskayaOtsechena — говорит вслух, только если сравнивать есть с чем', () => {
|
||
it('🔴 ГЛАВНАЯ ПРИЁМКА: на потоке БЕЗ лечения — говорит вслух (заказаны оба урезания)', () => {
|
||
const r = masterskayaOtsechena({ potok: potokBezLecheniya, zakazanTools: true, zakazanMcp: true });
|
||
expect(r.otsecheno).toBe(false);
|
||
expect(r.sostav.tools).toBe(50);
|
||
expect(r.sostav.mcp_servers).toBe(51);
|
||
expect(r.soobshchenie).toMatch(/ПРИНЯТА.*НЕ ПРИМЕНЕНА/is);
|
||
expect(r.soobshchenie).toContain('50');
|
||
expect(r.soobshchenie).toContain('51');
|
||
});
|
||
|
||
it('🔴 ГЛАВНАЯ ПРИЁМКА: на потоке С лечением — молчит (заказ выполнен)', () => {
|
||
const r = masterskayaOtsechena({ potok: potokSLecheniem, zakazanTools: true, zakazanMcp: true });
|
||
expect(r.otsecheno).toBe(true);
|
||
expect(r.soobshchenie).toBeNull();
|
||
});
|
||
|
||
it('ключ не заказывали — эта грань не сверяется вовсе, молчим и про необрезанный обвес', () => {
|
||
// Место 2 (рождение работника) не заказывает `--tools ''` — сверять его нечем,
|
||
// и датчик не должен врать «не отсечено» про грань, которую никто не резал.
|
||
const r = masterskayaOtsechena({ potok: potokBezLecheniya, zakazanTools: false, zakazanMcp: false });
|
||
expect(r.otsecheno).toBe(true);
|
||
expect(r.soobshchenie).toBeNull();
|
||
});
|
||
|
||
it('заказан только --strict-mcp-config (место 2) — сверяется только служба, не инструменты', () => {
|
||
const r = masterskayaOtsechena({ potok: potokSLecheniem, zakazanMcp: true });
|
||
expect(r.otsecheno).toBe(true);
|
||
const rBeda = masterskayaOtsechena({ potok: potokBezLecheniya, zakazanMcp: true });
|
||
expect(rBeda.otsecheno).toBe(false);
|
||
expect(rBeda.soobshchenie).toContain('51');
|
||
expect(rBeda.soobshchenie).not.toContain('--tools');
|
||
});
|
||
|
||
it('события init в потоке не было — сверить нечем, otsecheno = null, а не ложное "сошлось"', () => {
|
||
const r = masterskayaOtsechena({ potok: '', zakazanTools: true, zakazanMcp: true });
|
||
expect(r.otsecheno).toBeNull();
|
||
expect(r.soobshchenie).toMatch(/init/);
|
||
});
|
||
});
|
||
});
|