668 lines
55 KiB
JavaScript
668 lines
55 KiB
JavaScript
// Единственное место, где сказано, что где лежит — включая путь к внешним программам.
|
||
import { join, resolve, basename, delimiter } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { existsSync, statSync, mkdtempSync, mkdirSync } from 'node:fs';
|
||
import { homedir, tmpdir } from 'node:os';
|
||
import { execFileSync } from 'node:child_process';
|
||
|
||
export const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
||
|
||
// 🔴🔴 ХОЗЯЙСТВО УЛИК — ОДНО, И ПРОВЕРКАМ В НЁМ НЕ МЕСТО. Замерено 03.08.2026 счётом
|
||
// каталогов в `.night/runs` за один вечер: 101 → 110 → 115 → 136, и из них следами
|
||
// НАСТОЯЩИХ прогонов были единицы. Остальное оставили проверки — каждый прогон
|
||
// `npm run test:tools` подкладывал в боевое хранилище новые карточки.
|
||
//
|
||
// 🪤 ЧЕМ ЭТО ВРЕДИТ, И ЭТО НЕ ТЕОРИЯ. Карточка `r-punkty-plana-45014ebb` со словом
|
||
// «идёт» (выдуманный план `docs/superpowers/plans/пример.md`, выдуманные файлы `app/A.php`,
|
||
// надзиратель с номером процесса 1) заняла ОДНО из двух мест потолка и заперла файлы:
|
||
// живой запуск получил отказ «сейчас идёт задач: 2, а потолок — 2». То есть ночь может
|
||
// не начаться на пустом месте, а владельцу скажут «занято». Вторая половина вреда тише:
|
||
// настоящие улики тонут — их девять на сотню.
|
||
//
|
||
// 🔴 ПОЧЕМУ ЛЕЧИТСЯ ИМЕННО ЗДЕСЬ. Каталог прогона считают САМИ рабочие мелочи
|
||
// (`startAll` через `runDir`, `spawnSupervisor` через `workerDir`, `zavestiProdolzhenie`
|
||
// через оба) — четвёртого довода «а куда писать» их подписи не предусматривают, и
|
||
// подставить путь проверке НЕЧЕМ. Значит развилка одна: либо каждая проверка городит
|
||
// свою подставку (а подставка доказывает только саму себя), либо КОРЕНЬ хозяйства
|
||
// на время проверок отводится в сторону — один раз, для всех, ниже любой подписи.
|
||
// Взято второе.
|
||
//
|
||
// 🔴 ПОДМЕНА ПРИНИМАЕТСЯ ТОЛЬКО ПОД ПРИБОРОМ ПРОВЕРОК, и это не перестраховка. Утечь
|
||
// такая настройка может ровно одним способом — осталась в окне у владельца, — и тогда
|
||
// НАСТОЯЩАЯ ночь молча писала бы улики во временный каталог, который система сотрёт.
|
||
// Улики, оплаченные живыми деньгами, исчезли бы тихо. Поэтому здесь ГРОМКИЙ отказ,
|
||
// а не тихое «ну ладно»: ночь, не начавшаяся с внятным объяснением, поправима за минуту;
|
||
// ночь, отработавшая в никуда, — нет.
|
||
export const PODMENA_HOZYAYSTVA = 'NIGHT_HOZYAYSTVO_PODMENA';
|
||
|
||
// 🪤 Приметы «идёт прибор проверок». Их ставит сам vitest в каждом рабочем процессе, и они же
|
||
// достаются всем, кого проверка запускает следом (надзиратель, работник, точка входа):
|
||
// дети наследуют окружение родителя. Примет несколько нарочно — довольно любой.
|
||
//
|
||
// 🔴 ПЕРЕЧЕНЬ ВЫВЕЗЕН НАРУЖУ, И ЭТО НЕ УКРАШЕНИЕ. Его читает ВТОРОЙ хозяин — урезанное
|
||
// окружение работника (`workerEnv` в `spawn.mjs`): оно переносит ребёнку ровно то, что здесь
|
||
// перечислено, иначе ребёнок не узнал бы, что он под прибором, и полез бы в боевые улики.
|
||
// Разъедься эти два списка — примету добавили бы здесь, а переносить забыли там, и пол молча
|
||
// перестал бы накрывать детей. Сторож на это стоит в `spawn.test.mjs`.
|
||
export const PRIMETY_PRIBORA = ['VITEST', 'VITEST_WORKER_ID', 'VITEST_POOL_ID'];
|
||
|
||
export function podPriboromProverok(env = process.env) {
|
||
return PRIMETY_PRIBORA.some(primeta => Boolean(env[primeta]));
|
||
}
|
||
|
||
// 🔴🔴 ОТВЕДЕНИЕ, КОТОРОЕ НЕ ЗАВИСИТ ОТ НАСТРОЙКИ ПРИБОРА — И ПОЧЕМУ ЭТО ГЛАВНОЕ.
|
||
// До 04.08.2026 отведение корня задавала настройка прибора (`app/vitest.config.tools.mjs`
|
||
// клала `NIGHT_HOZYAYSTVO_PODMENA`). Дыра была ровно в том, что ОТВЕТ, НА КОТОРЫЙ СМОТРИТ
|
||
// ЭТА ДВЕРЬ, ВВОДИЛ ТОТ ЖЕ, КОГО ОНА ОГРАНИЧИВАЕТ: нет настройки — значит боевое хозяйство,
|
||
// даже под прибором проверок. Заведи кто-нибудь свою настройку на скорую руку под один файл
|
||
// (а так делают постоянно) — и проверки писали бы прямо в оплаченные улики владельца.
|
||
// 🔴 Замерено живьём 04.08.2026: настройка без `env` на ОДНОМ `cli.test.mjs` дала
|
||
// 38 попыток записи в `.night`, включая каталог продолжения `продолж-908aaa7e`.
|
||
// И это не теория задним числом: за один день в хозяйство налило 138 поддельных следов
|
||
// против 8 настоящих улик, а последние семь — прямо во время смены, по одному на прогон.
|
||
//
|
||
// 🔴 ПОЭТОМУ УМОЛЧАНИЕ ПЕРЕВЁРНУТО. Под прибором проверок корень хозяйства НЕ БЫВАЕТ боевым
|
||
// НИКОГДА — ни с настройкой, ни без неё, ни с забытой. Настройка теперь может только
|
||
// ВЫБРАТЬ, куда именно отвести; отменить отведение она не может вовсе. Дверь больше не
|
||
// спрашивает разрешения у того, кого не пускает.
|
||
let svoyoOtvedennoe = null;
|
||
function otvestiSamim(env) {
|
||
// Один раз на процесс: путь обязан быть постоянным внутри прогона, иначе половина затеи
|
||
// писала бы в один каталог, а читала из другого.
|
||
if (!svoyoOtvedennoe) {
|
||
// 🪤 ПОСЛЕДНИЙ КУСОК ИМЕНИ ОБЯЗАН ОСТАТЬСЯ `.night`. Замерено 03.08.2026 вырезанием:
|
||
// отведи корень в `…/night-proverki-XXXX` — и краснеет чужая проверка, сверяющая путь
|
||
// по последнему куску имени (`endsWith(join('.night', 'probe-raw'))`). Отведение обязано
|
||
// менять МЕСТО хозяйства, а не его ИМЯ.
|
||
svoyoOtvedennoe = join(mkdtempSync(join(tmpdir(), 'night-proverki-')), '.night');
|
||
mkdirSync(svoyoOtvedennoe, { recursive: true });
|
||
}
|
||
// 🔴 Пишем выбранное обратно в окружение: детей проверка запускает настоящими процессами
|
||
// (надзиратель, работник), и без этого КАЖДЫЙ из них отвёл бы себе СВОЙ каталог — родитель
|
||
// писал бы в один, ребёнок в другой, и проверки врали бы обоим.
|
||
if (env === process.env) env[PODMENA_HOZYAYSTVA] = svoyoOtvedennoe;
|
||
return svoyoOtvedennoe;
|
||
}
|
||
|
||
// 🔴 `env` — довод, а не `process.env` изнутри: иначе это правило нечем показать красным,
|
||
// а сторож, которого не видели красным, не сторожит ничего.
|
||
export function korenHozyaystva(env = process.env) {
|
||
const podmena = String(env[PODMENA_HOZYAYSTVA] ?? '').trim();
|
||
if (!podmena) {
|
||
// 🔴 ПОЛ, А НЕ ПОТОЛОК: под прибором проверок молчаливое умолчание — отведение, а не
|
||
// боевое хозяйство. Настройки нет, настройку забыли, настройка своя — всё равно мимо улик.
|
||
if (podPriboromProverok(env)) return otvestiSamim(env);
|
||
return join(REPO_ROOT, '.night');
|
||
}
|
||
if (!podPriboromProverok(env)) {
|
||
throw new Error(
|
||
`Настройка ${PODMENA_HOZYAYSTVA} отводит хозяйство улик в «${podmena}», но прибора ` +
|
||
`проверок здесь нет. Это боевой запуск, и улики он обязан класть в настоящее ` +
|
||
`хозяйство рядом с хранилищем. Уберите настройку из окружения (в PowerShell: ` +
|
||
`Remove-Item Env:\\${PODMENA_HOZYAYSTVA}) и запустите заново. Отказ громкий нарочно: ` +
|
||
`тихая подмена увела бы улики целой ночи во временный каталог, который система сотрёт.`,
|
||
);
|
||
}
|
||
return resolve(podmena);
|
||
}
|
||
|
||
export const NIGHT_ROOT = korenHozyaystva();
|
||
|
||
// Каталог всех прогонов. Его перебирают двое — точка входа (сколько прогонов идёт)
|
||
// и уборка старых углов, — поэтому имя строится ЗДЕСЬ, а не по месту у каждого.
|
||
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, проверки подставляют провал.
|
||
//
|
||
// 🔴🔴 03.08.2026 — БЕДА, СТОИВШАЯ ДВУХ СМЕН. Прежний catch ловил `err` целиком и выбрасывал
|
||
// именно те поля, которые НАСТОЯЩИЕ (`err.code`, `err.errno`, `err.status`, `err.stdout`,
|
||
// `err.stderr`), а взамен дописывал ОТ СЕБЯ «проверьте fsutil 8dot3name». На экране это
|
||
// выглядело неотличимо от ответа системы, и две смены подряд читали НАШУ догадку как
|
||
// установленный факт. Замерено, что догадка ЛОЖНАЯ: короткие имена на томе включены
|
||
// (`fsutil 8dot3name query C:`), а сам вызов укладывается в ~480 мс что поодиночке, что
|
||
// тремя разом — то есть настоящий отказ (скорее всего само создание процесса) к этому
|
||
// совету отношения не имел. Решение: совет про fsutil СНЯТ насовсем, а не спрятан за
|
||
// границу, — он уже дважды увёл расследование не туда, и ни разу не подтвердился. Вместо
|
||
// него в сообщение идут ТОЛЬКО настоящие поля отказа через `slovaSistemy` ниже.
|
||
// ━━━ 🔴🔴 ОТВЕТ СИСТЕМЫ ЗАПОМИНАЕТСЯ ОДИН РАЗ НА ПРОГРАММУ (04.08.2026) ━━━
|
||
//
|
||
// 🔴 ЗАЧЕМ. Короткое имя 8.3 для пути спрашивается ЗАПУСКОМ ВНЕШНЕЙ ПРОГРАММЫ, и стоит это
|
||
// около 0,6 секунды. Спрашивалось оно ЗАНОВО на каждое заведение рабочего угла — то есть
|
||
// на каждого из семи работников, каждую ночь. Ответ при этом измениться не может: короткое
|
||
// имя файла на томе не меняется, пока идёт наша программа.
|
||
//
|
||
// 🔴 И ЭТО НЕ ПРО СКОРОСТЬ, А ПРО СОРВАННУЮ НОЧЬ. У запроса есть предел ожидания (5 секунд).
|
||
// Не ответила система за это время — заведение угла падает целиком. Замерено: на свободной
|
||
// машине ответ приходит за 529–596 мс, при соседнем полном прогоне 591–652 мс, десять
|
||
// запросов разом — 586–672 мс. Запас восьмикратный, и обычная теснота его не съедает.
|
||
// Но ночью семеро РАЗОМ ставят части (замерено раньше: 2757 МБ на угол) — нагрузка много
|
||
// тяжелее любой, какую удалось устроить руками, и в уликах уже лежит чужой итог словами
|
||
// «Рабочий угол не завёлся». Каждый лишний запрос — это лишний бросок кости на живых деньгах.
|
||
// Запоминание убирает броски: было по запросу на каждый угол, стало ОДИН на всю программу.
|
||
//
|
||
// 🔴 ПРЕДЕЛ НЕ ТРОГАЕТСЯ И ПОВЕДЕНИЕ НЕ МЕНЯЕТСЯ. Лечится не «подождём подольше», а «не будем
|
||
// спрашивать одно и то же семь раз». Поднять предел значило бы растянуть молчание, а не
|
||
// убрать его причину.
|
||
//
|
||
// 🔴🔴 ЗАПОМИНАЕТСЯ ТОЛЬКО ОТВЕТ САМОЙ СИСТЕМЫ — И ЭТО ГЛАВНОЕ УСЛОВИЕ ПРАВИЛЬНОСТИ.
|
||
// Подставной ответ проверки (`run` доводом) в общий ящик НЕ КЛАДЁТСЯ и ИЗ НЕГО НЕ ЧИТАЕТСЯ.
|
||
// Иначе вышла бы ровно та беда, от которой всё хозяйство лечится: проверки подают сюда
|
||
// ОДИН И ТОТ ЖЕ настоящий путь то с удачным ответом, то с падением (так написано в проверках
|
||
// самого этого файла), и запомненная удача молча отменила бы соседкино падение — обе стороны
|
||
// остались бы зелёными, а правило перестало бы работать. Ящик помнит систему, а не выдумку.
|
||
//
|
||
// 🔴 УДАЧА ЗАПОМИНАЕТСЯ, ОТКАЗ — НЕТ. Отказ здесь по своей природе временный (система
|
||
// не успела ответить). Запомни мы его — одна случайная заминка убила бы заведение углов
|
||
// до самого конца ночи, превратив мигание в отказ навсегда. Это ровно наоборот тому,
|
||
// ради чего запоминание заводится.
|
||
const KESH_KOROTKIH_IMEN = new Map();
|
||
|
||
// Куда складывать ответ. Вынесено отдельной мелочью нарочно: так выбор ящика можно
|
||
// показать красным, не запуская ни одной внешней программы.
|
||
// `kesh` доводом — ящик проверки (или `null`, «не запоминать вовсе»); не подали — решает
|
||
// происхождение ответа: система запоминается, подставка нет.
|
||
export function kudaZapominat({ run = runPowerShellShortPath, kesh } = {}) {
|
||
if (kesh !== undefined) return kesh;
|
||
return run === runPowerShellShortPath ? KESH_KOROTKIH_IMEN : null;
|
||
}
|
||
|
||
// Сколько раз за жизнь программы система вправду спрашивалась. Датчик для приёмки и для
|
||
// замера: по нему видно, что запоминание работает, без единого разглядывания кода.
|
||
let zhivyhSprosov = 0;
|
||
export function skolkoZhivyhSprosov() {
|
||
return zhivyhSprosov;
|
||
}
|
||
export function chtoZapomneno() {
|
||
return new Map(KESH_KOROTKIH_IMEN);
|
||
}
|
||
|
||
export function shortenIfShellPathHasSpace(candidate, { run = runPowerShellShortPath, kesh } = {}) {
|
||
if (process.platform !== 'win32') return candidate; // не Windows — короткого имени не бывает
|
||
if (!needsShell(candidate)) return candidate; // без оболочки пробел в пути не мешает
|
||
if (!candidate.includes(' ')) return candidate; // короткое имя НЕ ЗАПРАШИВАЕТСЯ — пробела нет
|
||
// 🪤 Ящик спрашивается ПОСЛЕ трёх отказов выше нарочно: те три ничего не стоят и внешнюю
|
||
// программу не запускают вовсе — запоминать там нечего.
|
||
const yashchik = kudaZapominat({ run, kesh });
|
||
if (yashchik?.has(candidate)) return yashchik.get(candidate);
|
||
let out;
|
||
try {
|
||
out = String(run(candidate) ?? '').trim();
|
||
} catch (err) {
|
||
throw new Error(
|
||
`Короткое имя (8.3) для «${candidate}» не собралось. Сказала система: ${slovaSistemy(err)}. ` +
|
||
`Путь с пробелом нельзя передать в оболочку без кавычек — их некуда вставить в точке ` +
|
||
`вызова, а Node при shell:true сам их не подставляет: команда развалится на ` +
|
||
`«C:\\Program» и остаток, и отказ будет молчаливым EINVAL/«не распознано».`,
|
||
{ cause: err },
|
||
);
|
||
}
|
||
if (!out) {
|
||
throw new Error(
|
||
`Короткое имя (8.3) для «${candidate}» не собралось: команда поиска вернула пустой ` +
|
||
`ответ. Путь с пробелом нельзя передать в оболочку без кавычек — их некуда вставить ` +
|
||
`в точке вызова, отказ будет молчаливым EINVAL/«не распознано», если отдать путь как есть.`,
|
||
);
|
||
}
|
||
// 🔴 Кладём только УДАЧУ и только сюда — отказы выше уходят броском наружу и не оседают.
|
||
yashchik?.set(candidate, out);
|
||
return out;
|
||
}
|
||
|
||
// 🔴 ТО, ЧТО ДЕЙСТВИТЕЛЬНО СКАЗАЛА СИСТЕМА — и ничего от себя. Каждое поле идёт в текст,
|
||
// только если оно ЕСТЬ у объекта отказа: `code`/`errno` — у сбоя самого запуска (ENOENT
|
||
// и т.п.), `status`/`signal` — у завершения с ошибкой, `stdout`/`stderr` — оба потока
|
||
// вывода программы. `err.message` идёт всегда — он есть у любой ошибки JS. Ничего здесь
|
||
// не сочиняется и не оценивается: это перекладка полей, а не диагноз.
|
||
function slovaSistemy(err) {
|
||
const polya = [];
|
||
if (err?.code !== undefined) polya.push(`code=${err.code}`);
|
||
if (err?.errno !== undefined) polya.push(`errno=${err.errno}`);
|
||
if (err?.status !== undefined) polya.push(`status=${err.status}`);
|
||
if (err?.signal !== undefined) polya.push(`signal=${err.signal}`);
|
||
const stdout = err?.stdout !== undefined && err.stdout !== null ? String(err.stdout).trim() : '';
|
||
if (stdout) polya.push(`stdout=${stdout}`);
|
||
const stderr = err?.stderr !== undefined && err.stderr !== null ? String(err.stderr).trim() : '';
|
||
if (stderr) polya.push(`stderr=${stderr}`);
|
||
const soobshchenie = err?.message ? String(err.message) : String(err);
|
||
return polya.length ? `${soobshchenie} [${polya.join(', ')}]` : soobshchenie;
|
||
}
|
||
|
||
function runPowerShellShortPath(longPath) {
|
||
// 🔴 Счётчик стоит ЗДЕСЬ, у самого запуска, а не у зовущего: считать надо настоящие
|
||
// запуски внешней программы, а не обращения — иначе запоминание нечем показать
|
||
// работающим (обращений как было, так и осталось, а запусков стало меньше).
|
||
zhivyhSprosov += 1;
|
||
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));
|