552 lines
40 KiB
JavaScript
552 lines
40 KiB
JavaScript
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);
|
||
});
|
||
});
|