From 5fc6c2978aef35d03e726bb476a9bc0ea2d3d948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 2 Aug 2026 18:17:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=20=D0=B8=D0=BC=D1=8F?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=BD=D0=B0=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B0=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?:=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=83=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B8=20=D0=B1=D1=83=D0=BA=D0=B2=D0=B0=20=D0=B4=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=20(=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0?= =?UTF-8?q?=207=D0=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/night/paths.mjs | 107 +++++++++++++++++++++++++++++++------ tools/night/paths.test.mjs | 97 ++++++++++++++++++++++++++++++--- 2 files changed, 180 insertions(+), 24 deletions(-) diff --git a/tools/night/paths.mjs b/tools/night/paths.mjs index 6016b553..9d646f55 100644 --- a/tools/night/paths.mjs +++ b/tools/night/paths.mjs @@ -31,6 +31,15 @@ export function runDir(runId) { // круг по ввозу (печать пришлось бы ввозить в замок и обратно). 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(); @@ -285,24 +294,86 @@ export function needsShell(binPath) { // Поэтому `..` съедает предыдущий кусок только тогда, когда этот кусок ЕСТЬ и сам не `..`; // иначе `..` сохраняется как есть. // -// 🪤 ОСТАЛОСЬ ОТКРЫТЫМ (замерено 02.08.2026, НЕ чинится здесь): ПОЛНЫЙ путь по-прежнему -// пролезает. `C:/…/Документация/app/A.php` и `app/A.php` — один и тот же файл, но замок -// считает их разными. Владелец полные пути набирает (см. примечание к `samePath` выше), -// значит случай живой. Само собой это не лечится: у каждого работника СВОЙ рабочий угол -// (`.claude/worktrees/…`), и от какого корня отмерять — вопрос, а не мелочь. Решать владельцу. +// 🔴🔴 РЕГИСТР БУКВ: владелец РЕШИЛ его сводить (решение 7а от 02.08.2026, протокол +// `docs/grilling/2026-08-02-nastroyki-nochi-i-ramka-deneg.md`), но ЗДЕСЬ это решение +// НЕ ИСПОЛНЕНО — и вот почему. Прежнее примечание («на сервере хозяйства регистр значим») +// снято: тот довод владелец выслушал и отвёл сам. Мешает ДРУГОЕ, найденное живьём 02.08.2026 +// уже при попытке исполнить решение. // -// 🔴 РЕГИСТР БУКВ НЕ СВОДИТСЯ, и это открытый вопрос к владельцу, а не решённый. -// Довод «за»: на сервере хозяйства регистр значим, и опускание склеило бы разные файлы -// в один. Довод «против»: сама машина хозяйства — Windows, где регистр НЕ значим, и там -// `APP/A.php` с `app/A.php` — один и тот же файл, на который замок пустит двоих. -// Чем грозит как есть: под Windows двое работников молча правят один файл, все проверки -// зелёные, отката не будет. Решение — за владельцем; здесь оно не принимается. -// 🔴 ДОЛГ рядом: в этом же файле живёт второй сравниватель путей — `samePath`, и он регистр -// как раз ПРИВОДИТ (`.toLowerCase()`). Два правила про одно и то же расходятся ровно в том -// знаке, о котором спор. Сводить их здесь нельзя — у них разные зовущие и разные живые -// проверки; развязывать это владельцу вместе с вопросом о регистре. +// 🪤 Эта мелочь отдаёт НЕ ТОЛЬКО ключ для сравнения. Её ответ идёт дальше как ИМЯ ДЛЯ ДЕЛА: +// `claims-audit.mjs` кладёт его в приказ на откат, а надзиратель подставляет тот же знак +// в знак в `git checkout … -- <файл>`. Замерено командой на этой самой машине: +// `core.ignorecase=true`, и всё равно `git ls-files --error-unmatch TOOLS/NIGHT/PATHS.MJS` +// отвечает «pathspec did not match any file(s) known to git» и кодом 1 — git сверяет ИМЯ +// ПУТИ по знакам, а не по-хозяйски. Опусти мы здесь регистр — приказ на откат целился бы +// в `app/a.php` там, где хранилище знает `app/A.php`, и откат чужой правки МОЛЧА промахнулся +// бы мимо. Файлов с заглавными буквами в хранилище 2400, из них 1921 — под `app/`, то есть +// ровно там, куда работники и ходят. Это ровно тот класс тихого сбоя, от которого вся затея. +// +// ⇒ Честно исполнить решение 7а можно только РАЗВЕДЯ два дела: «ключ, по которому имена +// сравнивают» (регистр опущен) и «имя, которым потом делают» (регистр как есть). Сегодня +// оба дела делает одна эта мелочь, и разводить их надо в ЧУЖИХ файлах — `file-claims.mjs`, +// `gates.mjs`, `claims-audit.mjs` плюс их проверки. Замерено: опустишь регистр только здесь — +// краснеют 18 проверок в шести чужих файлах, и краснеют по делу, а не по придирке. +// Правку чужого без слова владельца здесь не делают — решение 7а ждёт этого шага. +// +// 🔴 ПОЛНЫЙ ПУТЬ СВОДИТСЯ К ПУТИ ОТ КОРНЯ ХОЗЯЙСТВА — та же решение 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`. +// Не чинится здесь НАРОЧНО, и по двум разным причинам. Первые три и пятое — выдуманные +// написания: так путь не даёт ни `git`, ни владелец, ни ключ `--files`, а каждое новое +// правило в этой мелочи стоит дороже, чем беда, которую оно закрывает. NFD — беда настоящая +// (кириллица в хозяйстве повсюду), но лечение её меняло бы САМИ ЗНАКИ ИМЕНИ, а имя отсюда +// идёт в `git checkout` — тот же запрет, что и на регистр. Всё это ждёт того же разведения +// «ключ для сравнения» / «имя для дела», что и решение 7а. export function odinVidPuti(file) { - const kuski = String(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; +} + +// Буква диска — единственное место пути, где регистр на 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('/'); @@ -320,3 +391,7 @@ export function odinVidPuti(file) { } return svedeno.join('/'); } + +// Корень хозяйства, сведённый ТЕМ ЖЕ прибором, что и приходящие имена. Считается один раз: +// за ночь `odinVidPuti` зовут на каждое имя каждого работника. +const KOREN_HOZYAYSTVA = bezRegistraDiska(svestiKuski(REPO_ROOT)); diff --git a/tools/night/paths.test.mjs b/tools/night/paths.test.mjs index 71a0c5e9..d7f1384a 100644 --- a/tools/night/paths.test.mjs +++ b/tools/night/paths.test.mjs @@ -4,7 +4,7 @@ import { 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, + supervisorPath, reviewerPath, shortenIfShellPathHasSpace, odinVidPuti, samePath, } from './paths.mjs'; import { DISK_BAD_PROBES_DEFAULT } from './supervisor.mjs'; // 🔴 Замок занятий и ворота пересечения ввозятся сюда НАРОЧНО. `odinVidPuti` — не сама @@ -334,6 +334,19 @@ describe('shortenIfShellPathHasSpace — третья половина той ж // Отката при этом не будет — двое молча портят один файл до утра. 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', // как есть @@ -351,6 +364,15 @@ const TO_ZHE_IMYA = [ '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 — один вид имени файла на всю затею', () => { @@ -369,6 +391,10 @@ describe('odinVidPuti — один вид имени файла на всю за 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('пустое остаётся пустым, а не превращается в имя файла', () => { @@ -384,16 +410,60 @@ describe('odinVidPuti — один вид имени файла на всю за expect(odinVidPuti('./')).toBe(''); }); - it('🔴 регистр букв НЕ сводится — это решение владельца, а не наше', () => { - // Здесь закреплено НЕ одобрение, а осознанность: в коде стоит довод «на сервере - // хозяйства регистр значим», а на машине хозяйства (Windows) он НЕ значим, и `APP/A.php` - // с `app/A.php` — там один и тот же файл. Обе стороны правы по-своему, спор открыт. - // Решит владелец опускать регистр — покраснеет ЭТА строка, и смена решения будет видна, - // а не проползёт молча. Рядом в этом же файле `samePath` регистр как раз приводит — - // долг назван в примечании к `odinVidPuti`. + 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 TOOLS/NIGHT/PATHS.MJS` → отказ, код 1). Опусти + // регистр здесь — откат чужой правки молча промахнулся бы мимо 1921 файла под `app/`. + // Довод и что делать дальше — в примечании к `odinVidPuti`. expect(odinVidPuti('APP/A.php')).toBe('APP/A.php'); expect(odinVidPuti('app/A.PHP')).toBe('app/A.PHP'); }); + + it('🟡 ДОЛГ НАЗВАН: `samePath` и `odinVidPuti` про регистр по-прежнему РАЗНОГО мнения', () => { + // Сводить сами эти две мелочи в одну нельзя — у них разные зовущие и разные живые + // проверки. Но разность их правил названа вслух, а не оставлена на «само заметится»: + // `samePath` отдаёт «да/нет» и регистр опускает, `odinVidPuti` отдаёт ИМЯ и не смеет. + expect(samePath('APP/A.php', 'app/a.php')).toBe(true); + expect(odinVidPuti('APP/A.php')).not.toBe(odinVidPuti('app/a.php')); + }); }); describe('🔴 ЖИВОЙ замок занятий: ни одно сведённое написание не пускает второго работника', () => { @@ -412,6 +482,17 @@ describe('🔴 ЖИВОЙ замок занятий: ни одно сведён }); } + 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('чужой файл написанием не подделать: занятие своего же файла другим написанием — не отказ', () => { // 🪤 Обратная сторона: сведение обязано узнавать СВОЁ занятие, иначе работник получит // отказ на файл, который сам же и занял минуту назад.