476 lines
37 KiB
JavaScript
476 lines
37 KiB
JavaScript
// Единственное место, где сказано, что где лежит — включая путь к внешним программам.
|
||
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));
|