Files
portal/tools/night/paths.test.mjs
T

552 lines
40 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.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
NIGHT_ROOT, REPO_ROOT, runDir, runsDir, workerDir, commandsDir, configPath, priyomkaDir,
budgetMeasuresPath, summaryLinesPath, SUMMARY_LINES_FILE, lastRunPath, summarySentPath,
planSealPath, specApprovalPath, resolveBin, needsShell, mailFailuresPath, ownerRepliesPath,
questionsPath, workspaceRootFor, workerSettingsPath, workerInboxDir, shotsDir, SMOKE_NOTES_REL,
supervisorPath, reviewerPath, shortenIfShellPathHasSpace, odinVidPuti, probeAttemptsPath,
samePath, klyuchSravneniyaPuti,
} from './paths.mjs';
import { DISK_BAD_PROBES_DEFAULT } from './supervisor.mjs';
// 🔴 Замок занятий и ворота пересечения ввозятся сюда НАРОЧНО. `odinVidPuti` — не сама
// по себе мелочь, а то, на чём держится обещание затеи «двое не трогают один файл».
// Зелёная проверка мелочи в отрыве не доказывает НИЧЕГО: правило пути можно починить,
// а замок всё равно оставить дырявым. Поэтому ниже проверяется ЖИВОЙ замок — занять файл
// одним работником и полезть к нему вторым каждым из написаний.
import { claimFiles, mayTouch } from './file-claims.mjs';
import { filesDisjoint } from './gates.mjs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { existsSync, readFileSync, readdirSync, mkdtempSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { PREDEL_ZHIVOGO_ZAPUSKA, PREDEL_SAMOY_PROGRAMME } from './predel-zhivogo-zapuska.mjs';
// 🔴 ПРЕДЕЛ ВРЕМЕНИ ВСЕМУ ФАЙЛУ. Здесь живут проверки, которые вправду запускают программу:
// `resolveBin('npm')` на этой машине сам зовёт PowerShell (короткое имя 8.3 для пути
// с пробелом), а «программа действительно запускается» запускает ещё и сам `npm`.
// Под нагрузкой семерых это доходило до 4425 мс при пределе по умолчанию 5000 мс — и
// проверка краснела от соседей. Живьём поймано 02.08.2026: падение на 5023 мс.
// Откуда взято число и почему длинный предел здесь ничего не стоит — в самом
// `predel-zhivogo-zapuska.mjs`. Своего числа тут не набирается.
vi.setConfig({ testTimeout: PREDEL_ZHIVOGO_ZAPUSKA });
// Все файлы затеи, кроме самих проверок. Читаются как ТЕКСТ: два сторожа ниже смотрят
// не на поведение, а на то, что написано в исходниках.
const NIGHT_DIR = join(REPO_ROOT, 'tools', 'night');
function ishodniki() {
return readdirSync(NIGHT_DIR)
.filter(f => f.endsWith('.mjs') && !f.endsWith('.test.mjs'))
.sort();
}
function tekst(file) {
return readFileSync(join(NIGHT_DIR, file), 'utf8');
}
describe('paths', () => {
it('всё состояние лежит под одним корнем .night', () => {
expect(NIGHT_ROOT.endsWith('.night')).toBe(true);
});
it('каталог прогона строится по его номеру', () => {
expect(runDir('r-2026-07-31-01')).toBe(join(NIGHT_ROOT, 'runs', 'r-2026-07-31-01'));
});
it('каталог работника лежит внутри каталога прогона', () => {
expect(workerDir('r-1', 'w-3')).toBe(join(NIGHT_ROOT, 'runs', 'r-1', 'workers', 'w-3'));
});
it('каталог всех прогонов строится ЗДЕСЬ — его перебирают и точка входа, и уборка углов', () => {
// 🪤 Второе место, собирающее тот же путь по-своему, — это два расходящихся ответа
// на один вопрос. Их разность вылезет ночью, а не на проверках.
expect(runsDir()).toBe(join(NIGHT_ROOT, 'runs'));
expect(runDir('r-1').startsWith(runsDir())).toBe(true);
});
it('команды снаружи складываются в общий каталог, не в каталог прогона', () => {
expect(commandsDir()).toBe(join(NIGHT_ROOT, 'commands'));
});
it('настройки затеи лежат в корне хозяйства, а не под .night', () => {
expect(configPath().endsWith('night.config.json')).toBe(true);
expect(configPath().includes('.night')).toBe(false);
});
it('бумаги приёмки лежат в ХРАНИЛИЩЕ, а не под .night — иначе они исчезли бы', () => {
expect(priyomkaDir()).toBe(join(REPO_ROOT, 'docs', 'superpowers', 'priyomka'));
expect(priyomkaDir().includes('.night')).toBe(false);
});
it('журнал замеров прибора лежит под общим корнем, а не в каталоге прогона', () => {
expect(budgetMeasuresPath()).toBe(join(NIGHT_ROOT, 'budget-measures.jsonl'));
});
it('строки сводки лежат ВНУТРИ каталога прогона — это обещанный наружу стык', () => {
expect(summaryLinesPath('r-1')).toBe(join(NIGHT_ROOT, 'runs', 'r-1', 'summary-lines.jsonl'));
});
it('имя файла строк сводки знает ОДНО место — их находят двое, и разойтись им нельзя', () => {
// 🪤 Сводка ищет строки по номеру прогона (`summaryLinesPath`), а надзиратель — по каталогу
// прогона, который ему дали на руки. Собери они имя файла каждый по-своему — надзиратель
// писал бы в один файл, а сводка читала другой, и владелец не увидел бы ни строки.
expect(SUMMARY_LINES_FILE).toBe('summary-lines.jsonl');
expect(summaryLinesPath('r-1').endsWith(SUMMARY_LINES_FILE)).toBe(true);
});
it('итог прогона для утренней сводки лежит в общем корне: прогона может уже не быть', () => {
expect(lastRunPath()).toBe(join(NIGHT_ROOT, 'last-run.json'));
});
it('время последней успешной отправки — свой файл: на нём стоят ворота отклика владельца', () => {
expect(summarySentPath()).toBe(join(NIGHT_ROOT, 'last-summary.json'));
});
it('неудача отправлялки пишется своим файлом — иначе о немом письме никто не узнает', () => {
expect(mailFailuresPath()).toBe(join(NIGHT_ROOT, 'mail-failures.jsonl'));
});
it('вопросы работника и ответы владельца лежат каждый своим файлом в общем корне', () => {
expect(questionsPath()).toBe(join(NIGHT_ROOT, 'questions.jsonl'));
expect(ownerRepliesPath()).toBe(join(NIGHT_ROOT, 'owner-replies.jsonl'));
expect(questionsPath()).not.toBe(ownerRepliesPath());
});
it('печать плана лежит под .night, и у каждого плана своя', () => {
expect(planSealPath('docs/superpowers/plans/a.md').startsWith(join(NIGHT_ROOT, 'seals'))).toBe(true);
expect(planSealPath('docs/superpowers/plans/a.md')).not.toBe(planSealPath('docs/superpowers/plans/b.md'));
});
it('отметка «замысел проверен» живёт файлом — иначе воротам нечем питаться', () => {
expect(specApprovalPath()).toBe(join(NIGHT_ROOT, 'spec-approved.json'));
});
it('рабочий угол работника лежит ВНЕ хранилища — иначе worktree лёг бы внутрь самого себя', () => {
const ws = workspaceRootFor('r-1', 'w-2');
expect(ws.includes('night-workspaces')).toBe(true);
expect(ws.startsWith(REPO_ROOT)).toBe(false);
expect(workspaceRootFor('r-1', 'w-2')).not.toBe(workspaceRootFor('r-1', 'w-3'));
});
it('настройки запуска работника лежат в его углу, а не в хозяйстве', () => {
expect(workerSettingsPath('C:/ws/w-1')).toBe(join('C:/ws/w-1', 'night-settings.json'));
});
it('почтовый ящик работника лежит в его углу — другого способа говорить у него нет', () => {
// Работник — другой процесс в другом каталоге, модулей затеи он не видит вовсе.
expect(workerInboxDir('C:/ws/w-1')).toBe(join('C:/ws/w-1', '.night-worker'));
});
it('снимки приёмщика ложатся в бумаги приёмки — то есть в ХРАНИЛИЩЕ, у каждой задачи свой каталог', () => {
expect(shotsDir('t-1')).toBe(join(priyomkaDir(), 't-1-shots'));
expect(shotsDir('t-1')).not.toBe(shotsDir('t-2'));
});
it('след пробного долгого прогона — путь от корня хранилища, его же видит работник в поручении', () => {
expect(SMOKE_NOTES_REL).toBe('docs/superpowers/priyomka/smoke-notes.md');
});
it('надзиратель и приёмщик находятся по пути, а не по голому имени — их запускают программой', () => {
expect(supervisorPath()).toBe(join(REPO_ROOT, 'tools', 'night', 'supervisor.mjs'));
expect(reviewerPath()).toBe(join(REPO_ROOT, 'tools', 'night', 'reviewer.mjs'));
});
});
describe('образец настроек — про КАЖДУЮ настройку, которую читает код', () => {
// 🔴 Класс поломки: правщик заводит новую настройку `cfg?.что-то`, а в образец её
// не дописывает. Код работает (у настройки есть умолчание), проверки зелены, и владелец
// о настройке НЕ УЗНАЁТ НИКОГДА — образец единственное место, где он их видит. Ровно так
// потерялась `disk_bad_probes`. Одной строкой про неё этот класс не закрыть — поэтому
// сторож перечисляет ВСЕ чтения из настроек, и следующая забытая покраснеет тоже.
it('в образце есть каждая настройка, которую код берёт из cfg', () => {
const obrazets = JSON.parse(readFileSync(join(REPO_ROOT, 'night.config.example.json'), 'utf8'));
const est = new Set(Object.keys(obrazets));
const chitaemye = new Map(); // настройка → кто её читает
for (const file of ishodniki()) {
// Оба написания: `cfg.имя` и `cfg?.имя`. Вложенное (`cfg.mail.host`) считается
// по ПЕРВОМУ колену — в образце оно и лежит одним ключом.
for (const m of tekst(file).matchAll(/\bcfg\??\.([A-Za-z_][A-Za-z0-9_]*)/g)) {
if (!chitaemye.has(m[1])) chitaemye.set(m[1], []);
if (!chitaemye.get(m[1]).includes(file)) chitaemye.get(m[1]).push(file);
}
}
// Сторож бесполезен, если читать из настроек вдруг перестали вовсе: пустой список
// «забытых» тогда не значит ничего.
expect(chitaemye.size).toBeGreaterThan(10);
const zabytye = [...chitaemye.entries()]
.filter(([klyuch]) => !est.has(klyuch))
.map(([klyuch, gde]) => `${klyuch} (читают: ${gde.join(', ')})`);
expect(zabytye).toEqual([]);
});
it('настройка «осечка замера места — не приговор» названа в образце и с умолчанием кода', () => {
const obrazets = JSON.parse(readFileSync(join(REPO_ROOT, 'night.config.example.json'), 'utf8'));
expect(obrazets.disk_bad_probes).toBe(DISK_BAD_PROBES_DEFAULT);
});
});
describe('сравниватель пути — ОДИН на всю затею', () => {
// 🔴 Прежде их было двое: `samePlanPath` у печати (plan-fingerprint.mjs) и `samePath`
// у замка (plan-lock-hook.mjs), дословными копиями друг друга. Два текста про одно
// правило разъезжаются МОЛЧА — на этом уже обжигались. Заведут копию снова — покраснеет
// здесь, а не ночью на пропущенной правке идущего плана.
const TELO = String.raw`replace(/\\/g, '/').toLowerCase()`;
it('приведение пути к одному виду написано ровно в одном файле — в paths.mjs', () => {
const gde = ishodniki().filter(f => tekst(f).includes(TELO));
expect(gde).toEqual(['paths.mjs']);
});
it('функцию сравнения пути объявляет ровно один файл — paths.mjs', () => {
// Имя копии каждый раз было своё — `samePlanPath` у печати, `samePath` у замка,
// `samePaperPath` у бумаг, — поэтому ловим по образцу имени, а не по трём точным словам.
const gde = ishodniki().filter(f => /function\s+same\w*Path\s*\(/.test(tekst(f)));
expect(gde).toEqual(['paths.mjs']);
});
});
describe('resolveBin — лечение класса «голое имя программы»', () => {
it('находит настоящий файл программы, а не голое имя', () => {
const bin = resolveBin('git');
expect(existsSync(bin)).toBe(true);
});
it('на Windows отдаёт ЗАПУСКАЕМОЕ расширение, а не bash-скрипт без расширения', () => {
// 🪤 Проверено живьём: `composer` в путях поиска — это bash-скрипт
// C:\Users\Administrator\bin\composer, и он лежит РАНЬШЕ, чем C:\tools\composer\composer.bat.
// Windows такой файл сама не запустит, из программы выходит ENOENT. Значит расширение
// перебирается ВНЕШНИМ кругом, а каталог — внутренним: любой .cmd/.bat/.exe важнее
// любого файла без расширения.
const bin = resolveBin('composer');
if (process.platform === 'win32') expect(bin).toMatch(/\.(cmd|bat|exe)$/i);
expect(existsSync(bin)).toBe(true);
});
it('npm тоже находится настоящим файлом — через resolveBin идут ВСЕ внешние программы затеи', () => {
// composer, npm, npx, php, psql, git, node, claude — ни одну затея не зовёт голым именем.
const bin = resolveBin('npm');
expect(existsSync(bin)).toBe(true);
if (process.platform === 'win32') expect(bin).toMatch(/\.(cmd|bat|exe)$/i);
});
it('не нашёлся — падает громко, а не возвращает голое имя в надежде', () => {
expect(() => resolveBin('нетакойпрограммы', { lookIn: ['C:/такого/пути/нет'] })).toThrow(/не найден/i);
});
it('claude находится настоящим файлом — та самая программа, чей отказ доказан 334 раза', () => {
// 🔴 Из восьми внешних программ затеи `claude` — единственная, чей отказ доказан живьём:
// 334 прогона с мая упали на `spawn claude ENOENT`. Проверять три другие, а эту нет —
// значит не проверять ничего. Сломай поиск именно её (например у неё окажется
// расширение, которого нет в списке) — покраснеет эта строка, а не ночь через месяц.
const bin = resolveBin('claude');
expect(existsSync(bin)).toBe(true);
if (process.platform === 'win32') expect(bin).toMatch(/\.(cmd|bat|exe)$/i);
});
it('php и psql находятся настоящими файлами — без них рабочий угол не заводится вовсе', () => {
// Задача 7 зовёт их живьём: `php artisan migrate`, `psql -c "CREATE DATABASE"`.
// Не найдётся любая из них — угол не заведётся, и узнаётся это ночью, на первом же угле.
for (const name of ['php', 'psql']) {
expect(existsSync(resolveBin(name))).toBe(true);
}
});
});
describe('needsShell — вторая половина той же болезни', () => {
it('`.cmd` и `.bat` на Windows требуют оболочки, иначе Node отвечает EINVAL', () => {
if (process.platform !== 'win32') return;
expect(needsShell('C:/tools/composer/composer.bat')).toBe(true);
expect(needsShell('C:/Users/x/AppData/Roaming/npm/claude.cmd')).toBe(true);
});
it('обычный `.exe` оболочки не требует — лишняя оболочка это лишний разбор кавычек', () => {
expect(needsShell('C:/Program Files/PostgreSQL/16/bin/psql.exe')).toBe(false);
expect(needsShell('/usr/bin/git')).toBe(false);
});
it('🔴 найденная программа ДЕЙСТВИТЕЛЬНО ЗАПУСКАЕТСЯ, а не просто лежит на диске', () => {
// 🪤 Самая дорогая мина этой затеи: все прежние проверки поиска смотрели `existsSync`
// и ни одна ничего не запускала. Найти файл и не суметь его запустить — это тот же
// молчаливый сбой, только на новом месте. Здесь программа запускается по-настоящему.
const bin = resolveBin('npm');
const out = execFileSync(bin, ['--version'], {
encoding: 'utf8',
shell: needsShell(bin),
// 🔴 Предел САМОЙ ПРОГРАММЕ. Он работает только потому, что предел проверки (вверху
// файла) больше него: пока проверку убивали на 5000 мс, эта строка была мертва —
// до неё дело не доходило никогда. Оба числа связаны и лежат в одном месте.
timeout: PREDEL_SAMOY_PROGRAMME,
});
expect(String(out)).toMatch(/\d+\.\d+\.\d+/);
});
});
describe('shortenIfShellPathHasSpace — третья половина той же болезни, найденная надзирателем', () => {
it('пробела в пути нет — короткое имя НЕ ЗАПРАШИВАЕТСЯ вовсе, run не зовётся', () => {
if (process.platform !== 'win32') return;
const run = vi.fn(() => { throw new Error('run не должен был вызваться'); });
const candidate = 'C:\\tools\\composer\\composer.bat'; // .bat через оболочку, но без пробела
expect(shortenIfShellPathHasSpace(candidate, { run })).toBe(candidate);
expect(run).not.toHaveBeenCalled();
});
it('оболочка не нужна (.exe) — короткое имя не запрашивается, даже если в пути есть пробел', () => {
if (process.platform !== 'win32') return;
const run = vi.fn(() => { throw new Error('run не должен был вызваться'); });
const candidate = 'C:\\Program Files\\Git\\cmd\\git.exe';
expect(shortenIfShellPathHasSpace(candidate, { run })).toBe(candidate);
expect(run).not.toHaveBeenCalled();
});
it('🔴 короткое имя НЕ СОБРАЛОСЬ (run упал) — падает понятной фразой, а не отдаёт сломанный путь', () => {
// Раньше здесь было `catch { return candidate; }` — то есть заведомо нерабочий путь
// с пробелом тихо уходил наружу как будто всё в порядке. Ночью это давала бы ту же
// тихую смерть, от которой вся эта функция лечит: работник не запустится, и никто
// не узнает почему.
if (process.platform !== 'win32') return;
const candidate = 'C:\\Program Files\\nodejs\\npm.cmd';
const run = () => { throw new Error('короткие имена отключены на томе'); };
expect(() => shortenIfShellPathHasSpace(candidate, { run })).toThrow(/короткое имя.*не собралось/i);
expect(() => shortenIfShellPathHasSpace(candidate, { run })).toThrow(/короткие имена отключены на томе/);
});
it('🔴 короткое имя вернулось ПУСТЫМ — тоже падает, а не отдаёт сломанный путь', () => {
if (process.platform !== 'win32') return;
const candidate = 'C:\\Program Files\\nodejs\\npm.cmd';
const run = () => ' '; // пустой ответ после обрезки пробелов
expect(() => shortenIfShellPathHasSpace(candidate, { run })).toThrow(/короткое имя.*не собралось/i);
});
it('короткое имя собралось честно — используется оно, а не исходный путь с пробелом', () => {
if (process.platform !== 'win32') return;
const candidate = 'C:\\Program Files\\nodejs\\npm.cmd';
const run = vi.fn(() => 'C:\\PROGRA~1\\nodejs\\npm.cmd');
expect(shortenIfShellPathHasSpace(candidate, { run })).toBe('C:\\PROGRA~1\\nodejs\\npm.cmd');
expect(run).toHaveBeenCalledWith(candidate);
});
});
// 🔴🔴 На `odinVidPuti` держится ГЛАВНОЕ обещание затеи: двое работников не трогают один
// файл. Замок занятий, ворота пересечения задач и сверка по коммитам сравнивают имена
// файлов ЗНАК В ЗНАК — но только после того, как каждое имя прошло через эту мелочь.
// Не сведённое написание — это для всех троих ДРУГОЙ файл: столкновение не находится,
// замок открывается, сверка отвечает «чужого не тронуто», и ни одна проверка не краснеет.
// Отката при этом не будет — двое молча портят один файл до утра.
const ETALON_PUTI = 'app/A.php';
// Полный путь к тому же файлу — от корня ТОГО хозяйства, в котором идёт работа.
// 🪤 Собирается из `REPO_ROOT`, а не набирается строкой: у каждого работника свой угол,
// и приклеенный путь проверял бы только машину того, кто его набрал.
const POLNYY = join(REPO_ROOT, 'app', 'A.php');
// Тот же полный путь, но БУКВА ДИСКА другого регистра. Найдено вырезанием второго рода
// 02.08.2026: `c:\…` и `C:\…` — на Windows один и тот же диск, а замок их не сводил
// и пускал второго работника. Собирается прибором, а не набирается строкой: приклей я
// «c:», и на Linux (где буквы диска нет вовсе) проверка сравнивала бы путь сам с собой
// и зеленела бы, ничего не проверяя.
const POLNYY_DRUGAYA_BUKVA = POLNYY.replace(/\\/g, '/')
.replace(/^([A-Za-z]):/, (_, b) => `${b === b.toUpperCase() ? b.toLowerCase() : b.toUpperCase()}:`);
// Написания, которые БЕССПОРНО означают тот же самый файл, что и `app/A.php`.
const TO_ZHE_IMYA = [
'app/A.php', // как есть
'./app/A.php', // «здесь же» в начале
'app\\A.php', // обратные косые из окна Windows
'app//A.php', // двойная косая
' app/A.php ', // пробелы по краям
'app/./A.php', // точка ПОСРЕДИ пути
'app/././A.php', // и не одна
'app/zzz/../A.php', // шаг назад посреди пути
'app/zzz/yyy/../../A.php', // два шага назад подряд
'app/ /../A.php', // шаг назад съедает каталог с именем «пробел»
'/app/A.php', // ведущая косая
'\\app\\A.php', // она же обратной косой
'app/A.php/', // хвостовая косая
'app/A.php/.', // хвостовая точка
'.//app/./zzz/../A.php', // всё сразу
// ↓ решение владельца 7б от 02.08.2026: полный путь — тот же файл, что и путь от корня
POLNYY, // полный путь как его отдаёт система
POLNYY.replace(/\\/g, '/'), // он же прямыми косыми
`/${POLNYY.replace(/\\/g, '/')}`, // он же с лишней косой спереди
// 🪤 Склеено строкой, а не через `join`: `join` шаг назад съедает сам, и получился бы
// ещё один список того же самого полного пути вместо нового написания.
`${join(REPO_ROOT, 'app')}/zzz/../A.php`, // полный путь с шагом назад посреди
`${join(REPO_ROOT, 'app')}\\.\\A.php`, // полный путь с «здесь же» посреди
POLNYY_DRUGAYA_BUKVA, // полный путь с буквой диска другого регистра
];
describe('odinVidPuti — один вид имени файла на всю затею', () => {
for (const napisanie of TO_ZHE_IMYA) {
it(`сводит ${JSON.stringify(napisanie)} к ${ETALON_PUTI}`, () => {
expect(odinVidPuti(napisanie)).toBe(ETALON_PUTI);
});
}
it('🔴 шаг назад В НАЧАЛЕ пути НЕ съедается — это другое место, а не тот же файл', () => {
// `../app/A.php` шагает ВЫШЕ корня хозяйства. Съешь мы его молча — получилось бы
// `app/A.php`, и замок объявил бы столкновением два РАЗНЫХ файла: работник получил бы
// отказ на файл, которого никто не занимал, и встал бы до утра без причины.
// Беда того же рода, что и дырявый замок, только с другой стороны.
expect(odinVidPuti('../app/A.php')).toBe('../app/A.php');
expect(odinVidPuti('../../app/A.php')).toBe('../../app/A.php');
expect(odinVidPuti('app/../../A.php')).toBe('../A.php');
expect(odinVidPuti('..\\app\\A.php')).toBe('../app/A.php');
// 🔴 И главное: шаг назад НЕ сливается со сведённым именем ни при каком написании —
// в том числе после того, как сведение научилось полному пути.
expect(odinVidPuti('../app/A.php')).not.toBe(ETALON_PUTI);
expect(odinVidPuti(`${REPO_ROOT}/../app/A.php`)).not.toBe(ETALON_PUTI);
});
it('пустое остаётся пустым, а не превращается в имя файла', () => {
// 🪤 Пустая строка на выходе — это «имени не назвали», и зовущие на неё смотрят:
// `claimFiles` отсеивает пустые, `claims-audit` пропускает. Отдай мы вместо пустоты
// хоть один знак — пустое имя стало бы занимать файл.
expect(odinVidPuti('')).toBe('');
expect(odinVidPuti(null)).toBe('');
expect(odinVidPuti(undefined)).toBe('');
expect(odinVidPuti(' ')).toBe('');
expect(odinVidPuti('.')).toBe('');
expect(odinVidPuti('/')).toBe('');
expect(odinVidPuti('./')).toBe('');
});
it('🔴 полный путь СВОДИТСЯ к пути от корня хозяйства — решение владельца 7б от 02.08.2026', () => {
// 🪤 Корень берётся у ТОГО хозяйства, в котором идёт работа (`REPO_ROOT`): у каждого
// работника свой угол, и общего «того самого» корня на всех не бывает.
expect(odinVidPuti(POLNYY)).toBe(ETALON_PUTI);
expect(odinVidPuti(join(REPO_ROOT, 'docs', 'A.md'))).toBe('docs/A.md');
expect(odinVidPuti(REPO_ROOT)).toBe(''); // сам корень — это не имя файла
// 🔴 И имя остаётся ГОДНЫМ ДЛЯ ДЕЛА: это путь от корня хранилища, ровно такой,
// какой берёт `git checkout … -- <файл>` при откате чужой правки.
expect(odinVidPuti(POLNYY).startsWith('/')).toBe(false);
expect(odinVidPuti(POLNYY).includes(':')).toBe(false);
});
it('🔴 БУКВА ДИСКА регистра не имеет — найдено вырезанием второго рода 02.08.2026', () => {
// 🪤 Написание, которого в списке надзирателя не было: `c:\…` против `C:\…`. Диск
// на Windows один и тот же, а замок пускал на файл двоих — та же беда, что и с полным
// путём, только на один знак левее. Сводится ТОЛЬКО буква диска: регистр самого имени
// файла тут не трогается (почему — в примечании к `odinVidPuti`).
if (process.platform !== 'win32') return; // буквы диска нет — и проверять нечего
expect(POLNYY_DRUGAYA_BUKVA).not.toBe(POLNYY.replace(/\\/g, '/')); // опыт не пустой
expect(odinVidPuti(POLNYY_DRUGAYA_BUKVA)).toBe(ETALON_PUTI);
// 🔴 Обратная сторона: чужой диск остаётся чужим, а не слипается с нашим.
expect(odinVidPuti(POLNYY_DRUGAYA_BUKVA.replace(/^./, 'Z'))).not.toBe(ETALON_PUTI);
});
it('🔴 ВЫРЕЗАНИЕМ: полный путь МИМО корня хозяйства НЕ слипается с местным именем', () => {
// 🪤 Обратная сторона сведения полного пути. Съешь мы начало любого полного пути —
// файл из чужого каталога стал бы «тем же самым», и замок объявил бы столкновение
// там, где его нет: работник встал бы до утра на пустом месте.
expect(odinVidPuti('C:/chuzhoe/app/A.php')).not.toBe(ETALON_PUTI);
expect(odinVidPuti('C:/chuzhoe/app/A.php')).toBe('C:/chuzhoe/app/A.php');
// 🔴 Сосед по имени: каталог рядом с хозяйством, чьё имя НАЧИНАЕТСЯ теми же знаками.
// Отрезай мы начало как строку, а не целым куском, — его файлы стали бы нашими.
expect(odinVidPuti(`${REPO_ROOT}-zapas/app/A.php`)).not.toBe(ETALON_PUTI);
});
it('🔴 регистр букв НЕ сводится — это ИМЯ ДЛЯ ДЕЛА, решение владельца 7а-ИСПРАВЛЕНО', () => {
// 🔴 Владелец 02.08.2026 решил РАЗВЕСТИ ДВА ДЕЛА, а не свести регистр везде: имена
// сравнивают ключом (регистр опущен), а делают — именем (регистр как есть). Эта мелочь
// и есть ИМЯ: её ответ идёт в приказ на откат, а оттуда — в `git checkout … -- <файл>`.
// Замерено на этой машине: при `core.ignorecase=true` git всё равно сверяет имя пути
// ПО ЗНАКАМ (`git ls-files --error-unmatch app/app/casts/postgresintarray.php` → отказ,
// код 1; настоящее `app/app/Casts/PostgresIntArray.php` → код 0). Опусти регистр
// здесь — откат чужой правки молча промахнулся бы мимо 1921 файла под `app/`.
// Ключ сравнения и живые доказательства — в `registr-klyuch-i-imya.test.mjs`.
expect(odinVidPuti('APP/A.php')).toBe('APP/A.php');
expect(odinVidPuti('app/A.PHP')).toBe('app/A.PHP');
});
it('🟢 ДОЛГ ЗАКРЫТ: про регистр отвечает КЛЮЧ, а `samePath` остаётся про своё', () => {
// 🔴 Прежде здесь стояло «долг названо: `samePath` и `odinVidPuti` про регистр
// РАЗНОГО мнения». Спор снят решением владельца 7а-ИСПРАВЛЕНО: мнений и должно быть
// два, потому что дела два. Ключ регистр опускает, имя — нет, и обе правы.
expect(klyuchSravneniyaPuti('APP/A.php')).toBe(klyuchSravneniyaPuti('app/a.php'));
expect(odinVidPuti('APP/A.php')).not.toBe(odinVidPuti('app/a.php'));
// 🔴 `samePath` НЕ ГОДИТСЯ ключом сравнения — почему, замерено НАСТОЯЩИМ опытом
// из чужого рабочего каталога в `registr-klyuch-i-imya.test.mjs`. Здесь только
// то, что видно отсюда: про регистр эти двое согласны, и путать их надо не по нему.
expect(samePath('APP/A.php', 'app/a.php')).toBe(true);
const svoyPolnyy = join(REPO_ROOT, 'app', 'A.php');
expect(klyuchSravneniyaPuti('app/A.php')).toBe(klyuchSravneniyaPuti(svoyPolnyy));
});
});
describe('🔴 счёт денег ОДИН на всю затею — решение владельца 8 от 02.08.2026', () => {
// 🪤 Ворота вслепую нашли: журнал ПОПЫТОК платного замера лежал под `.night` рабочего
// угла, а углов семеро — предел «до 30 за смену» молча превращался в 210.
it('журнал попыток НЕ лежит в углу того, кто спрашивает', () => {
expect(probeAttemptsPath().startsWith(REPO_ROOT)).toBe(false);
expect(probeAttemptsPath().startsWith(NIGHT_ROOT)).toBe(false);
});
it('журнал попыток лежит в общем месте владельца — одно на все углы этой машины', () => {
expect(probeAttemptsPath()).toBe(join(homedir(), '.night-shared', 'probe-attempts.jsonl'));
});
it('🔴 ВЫРЕЗАНИЕМ: журнал ЗАМЕРОВ остался на месте — он про другое и общим быть не обязан', () => {
// Общим сделан ровно один файл. Съедь сюда же журнал удачных замеров — это была бы
// уже не починка предела, а тихий переезд хозяйства.
expect(budgetMeasuresPath()).toBe(join(NIGHT_ROOT, 'budget-measures.jsonl'));
expect(probeAttemptsPath()).not.toBe(budgetMeasuresPath());
});
});
describe('🔴 ЖИВОЙ замок занятий: ни одно сведённое написание не пускает второго работника', () => {
let ugol;
beforeEach(() => { ugol = mkdtempSync(join(tmpdir(), 'night-odinvid-')); });
afterEach(() => { rmSync(ugol, { recursive: true, force: true }); });
for (const napisanie of TO_ZHE_IMYA) {
it(`файл занят за w-1 как ${ETALON_PUTI} — w-2 не занимает его как ${JSON.stringify(napisanie)}`, () => {
expect(claimFiles(ugol, 'w-1', [ETALON_PUTI]).ok).toBe(true);
const v = claimFiles(ugol, 'w-2', [napisanie]);
expect(v.ok).toBe(false);
expect(v.clash_with).toBe('w-1');
// И правило «можно ли трогать» отвечает тем же — на нём стоит сверка по коммитам.
expect(mayTouch(ugol, 'w-2', napisanie).ok).toBe(false);
});
}
it('🔴 ОБРАТНАЯ СТОРОНА на живом замке: шаг назад — ДРУГОЕ место, отказа быть не должно', () => {
// 🪤 Сведи мы лишнего — работник получил бы отказ на файл, которого никто не занимал,
// и встал бы до утра. Это беда того же рода, что дырявый замок, только с другой стороны.
expect(claimFiles(ugol, 'w-1', [ETALON_PUTI]).ok).toBe(true);
for (const chuzhoe of ['../app/A.php', '../../app/A.php', 'zzz/app/A.php', 'app/B.php',
`${REPO_ROOT}-zapas/app/A.php`, 'C:/chuzhoe/app/A.php']) {
expect(claimFiles(ugol, 'w-2', [chuzhoe]).ok, chuzhoe).toBe(true);
expect(mayTouch(ugol, 'w-2', chuzhoe).ok, chuzhoe).toBe(true);
}
});
it('чужой файл написанием не подделать: занятие своего же файла другим написанием — не отказ', () => {
// 🪤 Обратная сторона: сведение обязано узнавать СВОЁ занятие, иначе работник получит
// отказ на файл, который сам же и занял минуту назад.
expect(claimFiles(ugol, 'w-1', [ETALON_PUTI]).ok).toBe(true);
for (const napisanie of TO_ZHE_IMYA) {
expect(claimFiles(ugol, 'w-1', [napisanie]).ok, napisanie).toBe(true);
expect(mayTouch(ugol, 'w-1', napisanie).ok, napisanie).toBe(true);
}
});
});
describe('🔴 ЖИВЫЕ ворота пересечения задач: то же написание не пускает вторую задачу', () => {
for (const napisanie of TO_ZHE_IMYA) {
it(`идущая задача держит ${ETALON_PUTI} — новая с ${JSON.stringify(napisanie)} не стартует`, () => {
const v = filesDisjoint([napisanie], [[ETALON_PUTI]]);
expect(v.ok).toBe(false);
expect(v.reason).toMatch(/задевает те же файлы/);
});
}
it('обратная сторона: РАЗНЫЕ места по-прежнему пропускаются, а не слипаются', () => {
// Проверка вырезанием: сведи мы лишнего — покраснеет здесь.
expect(filesDisjoint(['../app/A.php'], [['app/A.php']]).ok).toBe(true);
expect(filesDisjoint(['app/B.php'], [['app/A.php']]).ok).toBe(true);
expect(filesDisjoint(['zzz/app/A.php'], [['app/A.php']]).ok).toBe(true);
});
});