Files
portal/tools/night/paths.mjs
T

476 lines
37 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 { join, resolve, basename, delimiter } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { execFileSync } from 'node:child_process';
export const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
export const NIGHT_ROOT = join(REPO_ROOT, '.night');
// Каталог всех прогонов. Его перебирают двое — точка входа (сколько прогонов идёт)
// и уборка старых углов, — поэтому имя строится ЗДЕСЬ, а не по месту у каждого.
export function runsDir() {
return join(NIGHT_ROOT, 'runs');
}
export function runDir(runId) {
return join(runsDir(), runId);
}
// 🪤 Один и тот же файл пишут по-разному: владелец запускает прогон ПОЛНЫМ путём
// (`night run C:\…\план.md`) и с обратными косыми, а сторож (lefthook) подаёт путь
// ОТ КОРНЯ ХРАНИЛИЩА и с прямыми — ЗАМЕРЕНО на lefthook 2.1.8: `{staged_files}`
// подставляется как `docs/superpowers/plans/probe.md`, рабочим каталогом стоит корень
// хранилища. Сравнение «как набрано» не узнаёт свой же план: замок пропускал бы правку
// идущего плана — тихо и зелено.
//
// 🔴 СРАВНИВАТЕЛЬ ОДИН НА ВСЮ ЗАТЕЮ, и живёт он здесь, в единственном месте, где сказано,
// что где лежит. Прежде их было двое — `samePlanPath` у печати (plan-fingerprint.mjs)
// и `samePath` у замка (plan-lock-hook.mjs), дословными копиями друг друга; свести мешал
// круг по ввозу (печать пришлось бы ввозить в замок и обратно). paths.mjs не ввозит из
// затеи НИЧЕГО, кроме своего — круга нет. Заведут копию снова — покраснеют два сторожа
// «сравниватель пути — ОДИН на всю затею» в paths.test.mjs.
//
// 🟡 ДОЛГ, названный вслух (02.08.2026): рядом живёт вторая мелочь про имена файлов —
// `odinVidPuti`, и о регистре букв они ПО-ПРЕЖНЕМУ спорят: здесь регистр опускается,
// там — нет. Почему там нельзя — написано у самой `odinVidPuti` (её ответ идёт в приказ
// на откат и дальше в `git`, а git сверяет имя пути по знакам). Здесь можно и нужно:
// `samePath` сравнивает ДВА пути и никому ничего не отдаёт — её ответ «да/нет», а не имя.
// Сводить эти две мелочи в одну нельзя и после решения владельца: у них разные зовущие,
// разные живые проверки и разное дело — `samePath` полный путь умеет сама (через `resolve`
// от рабочего каталога), а `odinVidPuti` отдаёт ОДИН вид имени и меряет от корня хозяйства.
export function samePath(a, b) {
if (!a || !b) return false;
const norm = p => resolve(String(p)).replace(/\\/g, '/').toLowerCase();
return norm(a) === norm(b);
}
export function workerDir(runId, workerId) {
return join(runDir(runId), 'workers', workerId);
}
// Рабочий угол работника лежит РЯДОМ с хозяйством, а не внутри него: git worktree
// внутрь собственного хранилища не заводится, и первая же попытка кончилась бы отказом.
export function workspaceRootFor(runId, workerId) {
return join(REPO_ROOT, '..', 'night-workspaces', runId, workerId);
}
// Настройки запуска работника — в его же углу: там их видит и он, и надзиратель.
export function workerSettingsPath(workspaceRoot) {
return join(workspaceRoot, 'night-settings.json');
}
// Почтовый ящик работника внутри его рабочего угла — единственный способ,
// которым работник разговаривает с надзирателем. Модулей затеи он не видит:
// он другой процесс в другом каталоге.
export function workerInboxDir(workspaceRoot) {
return join(workspaceRoot, '.night-worker');
}
// Снимки приёмщика — в бумагах приёмки, у каждой задачи свой каталог. В .night им нельзя:
// каталог закрыт от хранилища, и доказательства приёмки там исчезли бы.
export function shotsDir(taskId) {
const safe = String(taskId).replace(/[^A-Za-z0-9._-]+/g, '_');
return join(priyomkaDir(), `${safe}-shots`);
}
// След пробного долгого прогона. Путь от корня хранилища: его же видит работник
// в своём поручении и по нему же делает коммиты.
export const SMOKE_NOTES_REL = 'docs/superpowers/priyomka/smoke-notes.md';
export function commandsDir() {
return join(NIGHT_ROOT, 'commands');
}
export function configPath() {
return join(REPO_ROOT, 'night.config.json');
}
// Журнал замеров ПРИБОРА СИСТЕМЫ: доля израсходованного недельного запаса (`utilization`),
// когда запас обновится (`resets_at`) и когда мерили. Своего счёта в долларах затея не ведёт.
export function budgetMeasuresPath() {
return join(NIGHT_ROOT, 'budget-measures.jsonl');
}
// 🔴 ОБЩЕЕ МЕСТО СЧЁТА ДЕНЕГ — ОДНО НА ВСЮ ЗАТЕЮ, решение владельца 8 от 02.08.2026
// (протокол `docs/grilling/2026-08-02-nastroyki-nochi-i-ramka-deneg.md`).
//
// 🪤 ОТЧЕГО ОТМЕРЯЕТСЯ И ПОЧЕМУ ИМЕННО ОТСЮДА. Прежде журнал попыток лежал под `.night`,
// то есть внутри `REPO_ROOT` — а `REPO_ROOT` У КАЖДОГО СВОЙ: рабочий угол работника заводится
// РЯДОМ с хозяйством (`workspaceRootFor`), угол правщика — своей рабочей папкой git. Значит
// журналов было столько же, сколько углов, и предел владельца «до 30 замеров за смену»
// молча превращался в 30 × число углов (при семерых работниках — 210). Отказа при этом
// не видел никто: каждый угол честно считал свои три замера из тридцати.
//
// От чего честно отмерять общее место: НИ ОДИН путь внутри затеи для этого не годится —
// ни `REPO_ROOT` (свой у каждого), ни его родитель (у угла работника он `night-workspaces`,
// у хозяйства совсем другой), ни каталог прогона (журнал обязан пережить прогон).
// Отмеряем от ДОМА ВЛАДЕЛЬЦА на машине (`homedir`), и это не уловка, а точное совпадение
// со смыслом: предел стоит на ДЕНЬГАХ, а деньги — это недельный запас его подписки, один
// на учётную запись, а не на копию хранилища. Сколько бы углов, рабочих папок и клонов
// ни завелось на этой машине — кошелёк остаётся один, и счёт к нему один.
//
// 🔴 Живого зова здесь нарочно нет — ни `git`, ни чтения настроек: этот файл строит пути,
// а не ходит наружу. Дорога через `git rev-parse --git-common-dir` (общий каталог git для
// всех рабочих папок) рассматривалась и отведена: она заводит в мелочь про пути посторонний
// процесс с собственными отказами, а считает при этом клоны, а не кошелёк.
//
// Каталога может ещё не быть — его заводит сам счёт (`mkdirSync` в `podZamkom`,
// `schyot-zamerov.mjs`), второго заведения здесь не пишем.
const OBSHCHIY_KOREN = join(homedir(), '.night-shared');
// Журнал ПОПЫТОК крошечного замера остатка — по одной строке на каждый ЗАПУСК платной
// головы, удачный или нет. Отдельный файл, а не поле в журнале замеров: там лежат УДАЧНЫЕ
// замеры, а деньги тратит и неудачный. Считай мы по журналу замеров — сломанный прибор жёг
// бы деньги владельца сколько угодно раз, а счёт стоял бы на нуле.
export function probeAttemptsPath() {
return join(OBSHCHIY_KOREN, 'probe-attempts.jsonl');
}
// 🔴 Имя файла строк сводки знает ОДНО место. Его ищут двое: сводка — по номеру прогона
// (`summaryLinesPath`), надзиратель — по каталогу прогона, который ему дали на руки.
// Собери они имя каждый по-своему — один писал бы в один файл, другая читала бы другой.
export const SUMMARY_LINES_FILE = 'summary-lines.jsonl';
// Готовые строки сводки. Обещаны наружу как стык — значит имя строится здесь, а не по месту.
export function summaryLinesPath(runId) {
return join(runDir(runId), SUMMARY_LINES_FILE);
}
// Итог прогона для утренней сводки. Лежит в общем корне, а не в каталоге прогона:
// сводка читает его, когда самого прогона уже нет.
export function lastRunPath() {
return join(NIGHT_ROOT, 'last-run.json');
}
// Время последней УСПЕШНОЙ отправки сводки. На нём стоят ворота отклика владельца.
export function summarySentPath() {
return join(NIGHT_ROOT, 'last-summary.json');
}
// Печать плана: у каждого плана своя.
export function planSealPath(planPath) {
const safe = basename(String(planPath)).replace(/[^A-Za-z0-9._-]+/g, '_');
return join(NIGHT_ROOT, 'seals', `${safe}.json`);
}
// Отметка «замысел проверен»: рецензенты плюс слово владельца. Без файла воротам нечем питаться.
export function specApprovalPath() {
return join(NIGHT_ROOT, 'spec-approved.json');
}
export function mailFailuresPath() {
return join(NIGHT_ROOT, 'mail-failures.jsonl');
}
export function ownerRepliesPath() {
return join(NIGHT_ROOT, 'owner-replies.jsonl');
}
export function questionsPath() {
return join(NIGHT_ROOT, 'questions.jsonl');
}
// Бумаги приёмки живут В ХРАНИЛИЩЕ, а не под .night: .night закрыт от хранилища
// задачей 1, и доказательства приёмки там просто исчезли бы.
export function priyomkaDir() {
return join(REPO_ROOT, 'docs', 'superpowers', 'priyomka');
}
export function supervisorPath() {
return join(REPO_ROOT, 'tools', 'night', 'supervisor.mjs');
}
export function reviewerPath() {
return join(REPO_ROOT, 'tools', 'night', 'reviewer.mjs');
}
// 🪤 КЛАСС «голое имя программы». Из программы `spawn('composer')` и `spawn('npm')` падают
// с ENOENT: в путях поиска это скрипты без расширения, которые Windows сама запускать
// не умеет. Это ровно та болезнь, что убила 334 фоновых прогона на `spawn claude ENOENT`.
// Поэтому ВСЕ внешние программы затея зовёт только через resolveBin: composer, npm, npx,
// php, psql, git, node, claude.
//
// 🔴 Расширение перебирается ВНЕШНИМ кругом, каталог — внутренним. Проверено живьём:
// `C:\Users\Administrator\bin\composer` (bash-скрипт без расширения) лежит в путях поиска
// РАНЬШЕ, чем `C:\tools\composer\composer.bat`. Иди мы по каталогам снаружи — нашли бы
// негодный и получили бы ENOENT при полном пути на руках.
const EXTRA_BIN_DIRS = [
join(homedir(), 'AppData', 'Roaming', 'npm'),
join(homedir(), 'bin'),
join(homedir(), '.local', 'bin'),
'C:\\Program Files\\nodejs',
'C:\\Program Files\\Git\\cmd',
'C:\\Program Files\\PostgreSQL\\16\\bin',
'C:\\tools\\composer',
'C:\\tools\\php83',
'/usr/local/bin',
'/usr/bin',
];
export function resolveBin(name, { lookIn = null, env = process.env } = {}) {
const fromPath = String(env.PATH ?? env.Path ?? '').split(delimiter).filter(Boolean);
const dirs = lookIn ?? [...fromPath, ...EXTRA_BIN_DIRS];
const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : [''];
const tried = [];
let found = null;
outer: for (const ext of exts) {
for (const dir of dirs) {
const candidate = join(dir, `${name}${ext}`);
tried.push(candidate);
try {
if (existsSync(candidate) && statSync(candidate).isFile()) {
found = candidate;
break outer;
}
} catch { /* каталог недоступен — идём дальше */ }
}
}
if (!found) {
throw new Error(
`Запускаемый файл ${name} не найден. Голым именем звать нельзя — из программы это ENOENT. Искали ${tried.length} путей, первые: ${tried.slice(0, 6).join(', ')}`,
);
}
// 🔴 Вне try/catch поиска специально: если короткое имя не собралось, это должно
// упасть ГРОМКО и наружу, а не потеряться в «каталог недоступен — идём дальше».
return shortenIfShellPathHasSpace(found);
}
// 🪤 ТРЕТЬЯ, необнаруженная планом половина той же болезни, найдена живьём на этой машине.
// `npm` на ней стоит РОВНО и ТОЛЬКО под `C:\Program Files\nodejs\npm.cmd` — путь с пробелом.
// `.cmd` требует оболочку (`needsShell` выше), а Node при `shell: true` НЕ подставляет
// кавычки вокруг имени файла сам — cmd.exe видит «C:\Program» как имя команды и «Files\...»
// как её первый аргумент, и роняет EINVAL/«не распознано» ровно так же тихо, как ENOENT
// и EINVAL до него. Кавычки добавить негде: единственная точка вызова уже задана
// проверкой (`execFileSync(bin, args, { shell: needsShell(bin) })`) и путь туда идёт
// как есть. Правильный путь — не выдавать такой путь вовсе: короткое имя 8.3
// (`C:\PROGRA~1\nodejs\npm.cmd`) не содержит пробела, существует как обычный файл
// (`existsSync` видит его) и запускается оболочкой без единой кавычки.
//
// 🔴 ДЫРА, найденная надзирателем и здесь же закрытая: если короткое имя не собралось,
// молчаливый возврат исходного (заведомо нерабочего) пути — это ТОЧНО тот класс тихого
// отказа, от которого вся эта функция лечит. «Не нашли короткое имя» и «короткое имя
// не нужно» — два РАЗНЫХ исхода, и путать их нельзя: один — честный ответ, другой — сбой,
// о котором обязан узнать вызывающий, а не выяснить это ночью по молчанию работника.
// `run` — точка впрыска: по умолчанию правда зовёт PowerShell, проверки подставляют провал.
export function shortenIfShellPathHasSpace(candidate, { run = runPowerShellShortPath } = {}) {
if (process.platform !== 'win32') return candidate; // не Windows — короткого имени не бывает
if (!needsShell(candidate)) return candidate; // без оболочки пробел в пути не мешает
if (!candidate.includes(' ')) return candidate; // короткое имя НЕ ЗАПРАШИВАЕТСЯ — пробела нет
let out;
try {
out = String(run(candidate) ?? '').trim();
} catch (err) {
throw new Error(
`Короткое имя (8.3) для «${candidate}» не собралось: ${err.message}. Путь с пробелом ` +
`нельзя передать в оболочку без кавычек — их некуда вставить в точке вызова, а Node ` +
`при shell:true сам их не подставляет: команда развалится на «C:\\Program» и остаток, ` +
`и отказ будет молчаливым EINVAL/«не распознано». Проверить: включены ли короткие ` +
`имена на этом томе (fsutil 8dot3name query <буква диска>:).`,
);
}
if (!out) {
throw new Error(
`Короткое имя (8.3) для «${candidate}» не собралось: команда поиска вернула пустой ` +
`ответ. Путь с пробелом нельзя передать в оболочку без кавычек — их некуда вставить ` +
`в точке вызова, отказ будет молчаливым EINVAL/«не распознано», если отдать путь как есть.`,
);
}
return out;
}
function runPowerShellShortPath(longPath) {
const psExpr = `(New-Object -ComObject Scripting.FileSystemObject).GetFile('${longPath.replace(/'/g, "''")}').ShortPath`;
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', psExpr], {
encoding: 'utf8',
timeout: 5000,
});
}
// 🪤 ВТОРАЯ ПОЛОВИНА той же болезни, и без неё первая бесполезна. Найти файл мало:
// Node начиная с 18.20/20.12 (у нас 24) ОТКАЗЫВАЕТСЯ запускать `.bat` и `.cmd` через
// spawn/execFileSync без `shell` — это закрытая дыра CVE-2024-27980, ответ EINVAL.
// А `claude`, `npm` и `composer` на этой машине — ровно `.cmd`/`.bat`. То есть лечение
// «голого имени» без этой строки просто меняет ENOENT на EINVAL, и ни один работник
// за всю ночь не запустится.
export function needsShell(binPath) {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(String(binPath));
}
// 🪤 Один вид имени файла на всю затею. Работник и владелец пишут `app\A.php` (окно
// Windows), а `git` отдаёт `app/A.php`. Несведённые — это РАЗНЫЕ файлы: ворота
// пересечения не найдут столкновения, замок пропустит двоих на один файл, и все
// проверки останутся зелёными. Заводится ровно одна такая мелочь, и её зовут все,
// кто сравнивает имена: `filesDisjoint` (задача 2), `file-claims.mjs` (задача 3),
// `claims-audit.mjs` (задача 4).
//
// 🪤 Сведения косых МАЛО, и это замерено живьём 02.08.2026 на самом замке: файл занят
// как `app/A.php`, а второй работник спокойно занимал его же как `app/./A.php`,
// `app/zzz/../A.php`, `/app/A.php` и `app/A.php/`. Отказа не было ни разу — а значит
// не было бы и отката: двое молча правили бы один файл всю ночь при зелёных проверках.
// Поэтому имя разбирается НА КУСКИ, и пустые куски (ведущая, хвостовая и двойная косая),
// куски `.` («здесь же») и куски `..` («шаг назад») убираются по смыслу, а не по образцу
// в строке. Образцом в строке это не лечится: `app/zzz/yyy/../../A.php` требует помнить,
// сколько шагов назад уже сделано, — а строчная замена не помнит ничего.
//
// 🔴 Шаг назад В НАЧАЛЕ пути НЕ съедается. `../app/A.php` показывает ВЫШЕ корня хозяйства
// и означает ДРУГОЕ место. Съешь мы его молча — получилось бы `app/A.php`, и замок объявил
// бы столкновением два разных файла: работник встал бы до утра с отказом на файл, которого
// никто не занимал. Это беда того же рода, что и дырявый замок, только с другой стороны.
// Поэтому `..` съедает предыдущий кусок только тогда, когда этот кусок ЕСТЬ и сам не `..`;
// иначе `..` сохраняется как есть.
//
// 🔴🔴 ЭТО — «ИМЯ ДЛЯ ДЕЛА». РЕГИСТР БУКВ ЗДЕСЬ НЕ СВОДИТСЯ, И ЭТО РЕШЕНО, А НЕ ЗАБЫТО.
// Решение владельца 7а-ИСПРАВЛЕНО от 02.08.2026 (протокол
// `docs/grilling/2026-08-02-nastroyki-nochi-i-ramka-deneg.md`): развести ДВА ДЕЛА.
// · «ключ, по которому имена СРАВНИВАЮТ» — регистр опущен. Это `klyuchSravneniyaPuti`
// ниже, и её зовут все, кто отвечает на вопрос «это один и тот же файл?».
// · «имя, которым потом ДЕЛАЮТ» — регистр ровно такой, как на самом деле. Это ЭТА
// мелочь, и её зовут все, кто этим именем что-то делает: откат, приказы хранилищу,
// строки владельцу.
//
// 🪤 Почему имени регистр опускать НЕЛЬЗЯ. Ответ этой мелочи идёт в приказ на откат
// (`claims-audit.mjs`), а надзиратель подставляет его знак в знак в `git checkout … --
// <файл>`. Замерено командой на этой самой машине: `core.ignorecase=true`, и всё равно
// `git ls-files --error-unmatch app/app/casts/postgresintarray.php` отвечает «pathspec did
// not match any file(s) known to git» и кодом 1, тогда как настоящее
// `app/app/Casts/PostgresIntArray.php` — кодом 0. Git сверяет ИМЯ ПУТИ по знакам, а не
// по-хозяйски. Опусти мы здесь регистр — приказ на откат целился бы в `app/a.php` там,
// где хранилище знает `app/A.php`, и откат чужой правки МОЛЧА промахнулся бы мимо. Файлов
// с заглавными буквами под `app/` — 1921, то есть ровно там, куда работники и ходят.
// Это ровно тот класс тихого сбоя, от которого вся затея и строится.
//
// 🔴 Перепутать две мелочи трудно нарочно: имя второй ГОВОРИТ, что она отдаёт ключ ДЛЯ
// СРАВНЕНИЯ, а у каждого зовущего переменная названа своим словом — `klyuch` там, где
// спрашивают, `imya` там, где делают. И на самом опасном месте стоит живая проверка:
// приказ на откат обязан нести имя с заглавными буквами настоящего файла хозяйства
// (`registr-klyuch-i-imya.test.mjs`, «ОТКАТ ВПРАВДУ ВЕРНУЛ ФАЙЛ»).
//
// 🔴 ПОЛНЫЙ ПУТЬ СВОДИТСЯ К ПУТИ ОТ КОРНЯ ХОЗЯЙСТВА — та же решение 7. Раньше здесь стояло
// «осталось открытым»: `C:/…/Документация/app/A.php` и `app/A.php` замок считал разными,
// а владелец полные пути набирает (см. примечание к `samePath` выше) — случай живой.
// 🪤 Общего «того самого» корня на всех НЕ БЫВАЕТ: у каждого работника свой рабочий угол
// (`workspaceRootFor` — угол лежит РЯДОМ с хозяйством), и у каждого угла свой корень.
// Поэтому меряем от корня ТОГО хозяйства, в котором идёт работа, — от `REPO_ROOT`, который
// этот же файл и считает. Работник живёт внутри своего угла и других имён всё равно не видит.
// 🪤 Начало пути отрезается ТОЛЬКО целым куском (`корень` + `/`), а не как строка: сосед
// с именем `…/night1-zapas/app/A.php` начинается так же посимвольно, но это другое место.
//
// 🔴 Шаг назад В НАЧАЛЕ пути НЕ съедается — см. примечание выше, это правило живо и после
// обеих правок: `../app/A.php` шагает ВЫШЕ корня и означает другое место.
//
// 🪤 ОСТАЛОСЬ ОТКРЫТЫМ — написания, которыми починку ОБХОДЯТ. Замерено 02.08.2026 на живом
// замке (занять `app/A.php` за одним работником и полезть вторым), пролезли все пять:
// • короткое имя 8.3 у каталога с пробелом: `…/проекты/ПОРТАЛ~1/…` вместо `портал crm`;
// • путь через устройство: `\\?\C:\…\app\A.php`;
// • имя куска с хвостовым пробелом или точкой (`app /A.php`, `app./A.php`) — Windows их
// отбрасывает сама, и это тот же файл;
// • та же кириллица, набранная разложенной (NFD): `й` двумя знаками вместо одного;
// • имя как ссылка браузера: `file:///C:/…/app/A.php`.
//
// 🟢 NFD ЗАКРЫТ 02.08.2026 разведением «ключ / имя», и закрылся ДАРОМ: он лечится сменой
// самих знаков, а знаки можно менять у КЛЮЧА (он никуда не идёт) и нельзя у ИМЕНИ (оно
// идёт в `git`). `klyuchSravneniyaPuti` сводит имя к NFC — и замок перестал пускать двоих
// на файл с «й» или «ё» в имени, набранный по-разному. Само ИМЯ по-прежнему отдаётся как
// есть: `git` получил его от `git` же и знает ровно в том виде.
// 🪤 Только «й» и «ё» и распадаются на два знака — на слове «деталь» опыт был бы пустым
// и зеленел бы, ничего не проверяя. Поймано на себе при написании проверки.
//
// 🔴 Остальные четыре не чинятся НАРОЧНО. Это выдуманные написания: так путь не даёт ни
// `git`, ни владелец, ни ключ `--files`, а каждое новое правило в этой мелочи стоит дороже,
// чем беда, которую оно закрывает. Короткое имя 8.3 вдобавок не лечится здесь физически —
// его не развернуть, не сходив в файловую систему, а этот файл строит пути и наружу
// не ходит. Понадобятся — их место в КЛЮЧЕ, а не в имени, и это теперь дёшево.
export function odinVidPuti(file) {
const svedeno = svestiKuski(file);
// 🪤 Сравниваем начало пути с корнем, опустив регистр ТОЛЬКО у буквы диска. Найдено
// вырезанием второго рода 02.08.2026 на живом замке: `c:\…` и `C:\…` — один и тот же
// диск, а замок пускал на файл двоих. Здесь это можно, а у самого имени файла нельзя:
// буква диска в ответе не остаётся вовсе (путь отмеряется от корня и начинается с `app/`),
// а имя файла остаётся и идёт дальше в `git`.
const dlyaSravneniya = bezRegistraDiska(svedeno);
// Сам корень хозяйства — это не имя файла, а «имени не назвали».
if (dlyaSravneniya === KOREN_HOZYAYSTVA) return '';
if (KOREN_HOZYAYSTVA && dlyaSravneniya.startsWith(`${KOREN_HOZYAYSTVA}/`)) {
// 🔴 Режем ИСХОДНОЕ, а не сравниваемое: длина у них одна (меняется один знак),
// и наружу должно уйти имя, как его написали, а не как его сравнивали.
return svedeno.slice(KOREN_HOZYAYSTVA.length + 1);
}
return svedeno;
}
// 🔴🔴 ЭТО — «КЛЮЧ ДЛЯ СРАВНЕНИЯ», вторая половина решения владельца 7а-ИСПРАВЛЕНО
// от 02.08.2026. Её зовут ВСЕ, кто отвечает на вопрос «это один и тот же файл?»: замок
// занятий (`file-claims.mjs`), ворота пересечения задач (`gates.mjs`), сверка по коммитам
// (`claims-audit.mjs`). Её ответ НИКУДА не идёт дальше сравнения — ни в `git`, ни в глаза
// владельцу, — и именно поэтому знаки в нём менять можно: регистр опускается, разложенная
// кириллица собирается обратно.
//
// 🪤 СОБИРАЕТСЯ НЕ ИЗ ОТВЕТА `odinVidPuti`, и это не прихоть. «Опустить регистр у готового
// имени» дало бы дырку ровно в том месте, ради которого всё делается: имя сверяет начало
// пути с корнем хозяйства ПО ЗНАКАМ, значит полный путь, набранный в другом регистре
// (`C:/МОЯ/…/APP/A.PHP`), до корня не дорезался бы вовсе — и ключ вышел бы длинным
// и чужим, а замок пустил бы на файл двоих. Поэтому регистр опускается ДО сверки с корнем,
// и корень сведён тем же прибором.
// 🪤 Буквы диска здесь отдельно трогать не надо: она опускается вместе со всем остальным.
//
// 🔴 Разбор на куски — общий с именем (`svestiKuski`), и это обязано быть так: разъедься
// они в том, что считать шагом назад, — замок держал бы один файл, а откат целился
// в другой, и обе половины по отдельности зеленели бы. Сторож на это стоит в проверках
// («ключ и имя делят мир на файлы ОДИНАКОВО»).
export function klyuchSravneniyaPuti(file) {
const svedeno = vRegistrKlyucha(svestiKuski(file));
// Сам корень хозяйства — это не имя файла, а «имени не назвали».
if (svedeno === KLYUCH_KORNYA) return '';
if (KLYUCH_KORNYA && svedeno.startsWith(`${KLYUCH_KORNYA}/`)) {
return svedeno.slice(KLYUCH_KORNYA.length + 1);
}
return svedeno;
}
// Знаки, которые у КЛЮЧА равны между собой, а у ИМЕНИ — нет. Здесь их ровно два вида,
// и оба названы вслух: регистр букв и разложенная кириллица (NFD).
function vRegistrKlyucha(p) {
return String(p).normalize('NFC').toLowerCase();
}
// Буква диска — единственное место пути, где регистр на Windows не значит НИЧЕГО.
// Меняет ровно один знак и только в начале: длина строки остаётся прежней, и по ней
// можно резать исходное имя.
function bezRegistraDiska(p) {
return p.replace(/^([A-Za-z]):/, (_, bukva) => `${bukva.toLowerCase()}:`);
}
// Разбор имени на куски и сведение их по смыслу. Вынесено из `odinVidPuti` затем, что тем же
// прибором сводится и сам корень хозяйства: своди мы корень иначе — начало полного пути
// не совпало бы с ним ни разу, и лечение полного пути молча не работало бы.
function svestiKuski(imya) {
const kuski = String(imya ?? '')
.trim()
.replace(/\\/g, '/')
.split('/');
const svedeno = [];
for (const kusok of kuski) {
// Пустой кусок — это ведущая, хвостовая или двойная косая. Смысла не несёт.
if (kusok === '' || kusok === '.') continue;
if (kusok === '..') {
const est_chto_sedat = svedeno.length > 0 && svedeno[svedeno.length - 1] !== '..';
if (est_chto_sedat) svedeno.pop();
else svedeno.push('..');
continue;
}
svedeno.push(kusok);
}
return svedeno.join('/');
}
// Корень хозяйства, сведённый ТЕМ ЖЕ прибором, что и приходящие имена. Считается один раз:
// за ночь `odinVidPuti` зовут на каждое имя каждого работника.
const KOREN_HOZYAYSTVA = bezRegistraDiska(svestiKuski(REPO_ROOT));
// Тот же корень, но сведённый в КЛЮЧ. Отдельная строка, а не «опустить регистр у верхней»:
// у ключа свои правила знаков (ещё и NFC), и собирать его надо тем же прибором, каким
// сводятся приходящие имена, — иначе начало полного пути не совпало бы с корнем ни разу.
const KLYUCH_KORNYA = vRegistrKlyucha(svestiKuski(REPO_ROOT));