одно имя файла на все написания: полный путь и буква диска (решение владельца 7б)

This commit is contained in:
Дмитрий
2026-08-02 18:17:11 +03:00
parent 5f9bf88f9b
commit 5fc6c2978a
2 changed files with 180 additions and 24 deletions
+91 -16
View File
@@ -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));
+89 -8
View File
@@ -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('чужой файл написанием не подделать: занятие своего же файла другим написанием — не отказ', () => {
// 🪤 Обратная сторона: сведение обязано узнавать СВОЁ занятие, иначе работник получит
// отказ на файл, который сам же и занял минуту назад.