# Реестр проектных решений

Каждое решение зафиксировано в формате: **контекст → решение → обоснование →
чем заплатили**. Записи нумерованы и на них ссылаются комментарии в коде и
тесты.

---

## DR-01 · Один файл, без сборки и зависимостей

**Контекст.** Инструмент ставят на боевые хосты и в контейнеры, где нет ни
пакетного менеджера, ни сети. Пользователь должен иметь возможность
`curl … > ctf.sh && chmod +x ctf.sh` и работать.

**Решение.** `ctf.sh` — единственный исполняемый файл для POSIX-систем;
никаких внешних библиотек, никакого `source` сторонних модулей, никакой
сборки. `ctf.ps1` — единственный файл для Windows.

**Обоснование.** Цена — 2222 строки в одном файле — окупается тем, что файл
можно скопировать, передать в CI-артефакт, вложить в образ контейнера и
обновить через `--update` без инфраструктуры.

**Чем заплатили.** Файл длинный; компенсация — жёсткая нумерация разделов
(1…19) с заголовками-разделителями и отсутствие перекрёстных зависимостей
между разделами.

---

## DR-02 · Bash 4.2 как нижняя граница

**Контекст.** CentOS/RHEL 7 поставляют bash 4.2 и до сих пор встречаются в
парке. Требовать 4.3+ значит отрезать заметную часть аудитории.

**Решение.** Запрещены конструкции новее 4.2: `local -n` (4.3), `wait -n`
(4.3), `mapfile -d` / `readarray -d` (4.4), `${v@Q}` / `${v@U}` (4.4),
`EPOCHREALTIME`. Пустые массивы раскрываются как `${arr[@]+"${arr[@]}"}`,
потому что bash 4.2 при `set -u` считает `"${arr[@]}"` несуществующей
переменной.

**Обоснование.** Проверяется автоматически: `tests/test_meta.sh` ищет
запрещённые конструкции по шаблону и падает при их появлении.

**Чем заплатили.** Пакетная обработка идёт через индексы и глобальные
массивы (`CTF_BATCH`, `CTF_BATCH_SRC`) вместо передачи массива по ссылке —
менее элегантно, зато переносимо.

---

## DR-03 · Никаких конвейеров с ранним выходом потребителя

**Контекст.** Именно так возник дефект D1: `grep -q` закрывает канал, `od`
получает SIGPIPE, `pipefail` превращает это в «файл текстовый», и результат
начинает зависеть от планировщика.

**Решение.** Правило для всего кода: **ни один потребитель в конвейере не
имеет права завершаться досрочно**. Запрещены `grep -q`, `grep -m1`, `head -n`
в позиции последнего звена конвейера, если слева идёт генератор данных.
Там, где нужен ранний выход (проверка наличия признака), используется
`grep -c` / `awk`, читающие вход до конца.

**Обоснование.** Класс дефектов, а не единичная ошибка: любая новая проверка
с `grep -q` вернула бы недетерминированность.

**Чем заплатили.** Читаем весь вход даже тогда, когда ответ известен после
первого совпадения. Для окна в 8 КиБ это несущественно.

**Контроль.** `tests/test_binary.sh` прогоняет один и тот же вход 8 раз и
требует идентичный sha256 документа.

---

## DR-04 · Детектор бинарности: NUL + управляющие байты, но не «высокие» байты

**Контекст.** Нужен признак, работающий без `file(1)`, одинаково на GNU и BSD
и не отбраковывающий реальный исходный код.

**Решение.** Бинарным считается файл, если в первых 8 КиБ есть NUL **или**
доля управляющих байтов C0 (кроме `\t \n \v \f \r`) плюс DEL превышает 5 %.
Байты ≥ 0x80 признаком бинарности **не являются**.

**Обоснование.** Байт ≥ 0x80 — это нормальный UTF-8, CP1251, KOI8-R, Shift-JIS.
Первая реализация v4 считала «доминирование высоких байтов» признаком бинарности
и отбраковала файл из 12 байт кириллицы в CP1251 — то есть молча теряла
неанглийские исходники. Порог 5 % по управляющим байтам выбран потому, что
реальный текст их практически не содержит, а случайные или структурные
бинарные данные содержат почти всегда.

**Чем заплатили.** Файл < ~1 КБ из высокоэнтропийных данных без NUL и почти
без управляющих байтов может пройти как текст (см. «Остаточные ограничения»
в `docs/ANALYSIS.md`). Это осознанный выбор: ложноположительное «текст»
заметно и обратимо (документ видно глазами), а ложноотрицательное «бинарный»
молча теряет исходник.

---

## DR-05 · Пакетное чтение метаданных

**Контекст.** Замер показал: 2000 файлов → 19 с в v3 и 39 с в первом
черновике v4. Профилирование (`--dry-run` без записи: 30 с) показало, что
время уходит не на ввод-вывод, а на создание процессов: ~4–6 fork на файл.

**Решение.** Три операции переведены в пакеты по ~200 файлов
(ограничение и по числу, и по длине argv):

| Данные | Механизм | Процессов |
|:---|:---|:---|
| размеры | `stat --printf '%s\t%n\0'` | 1 на пакет |
| бинарность (NUL) | `grep -IlZ ''` | 1 на пакет |
| строки, fence-прогоны, сумма байтов, управляющие байты | один проход `awk` по многим файлам с переключением по `FILENAME` | 1 на пакет |

Единственный процесс на файл — `cat`, копирующий содержимое. Функции горячего
пути (`rel_of`, `sanitize_display`, `map_lang`, `choose_fence`, `md_anchor`)
возвращают результат через глобальные переменные `CTF_RET*`, а не через
`$( )`, чтобы не создавать subshell на каждый файл.

**Обоснование.** 19,0 с → 9,0 с на 2000 файлов; 2,7 с на 600 файлов / 13 МБ.

**Чем заплатили.** Сложность: формат записи пакета — «числа, TAB, путь, NUL»
(числа в одном поле, потому что путь может содержать TAB). Отсутствие
`stat --printf` (BSD) и `grep -I/-Z` обрабатывается резервными пофайловыми
путями; наличие возможностей определяется стартовым зондом и показывается в
`--print-config`.

**Контроль.** Детерминированность и побайтовое совпадение с небатчевой
реализацией проверяются тестами; `--dry-run` на 2000 файлов даёт тот же
документ, что и до введения пакетов.

---

## DR-06 · Формат записи пакета: числа впереди, путь последним

**Контекст.** Имя файла может содержать любой байт кроме `/` и NUL — включая
TAB и LF. Любая табличная кодировка «ключ TAB значение» на таких именах
ломается (именно так возникал дефект D6).

**Решение.** Во всех пакетных протоколах (`stat`, `awk`-скан, сортировка)
числовые поля идут первыми и разделяются TAB, а путь — последний и
терминируется NUL. Разбор: `nums="${rec%%$'\t'*}"`, `path="${rec#*$'\t'}"`.

**Обоснование.** Числа не могут содержать ни TAB, ни LF, поэтому первое
разделение однозначно, а всё оставшееся — путь, каким бы он ни был.

---

## DR-07 · Сортировка по «ключ TAB индекс», с проверкой инварианта

**Контекст.** Сортировать нужно пары (ключ, путь), а путь может содержать LF.

**Решение.** В таблицу сортировки попадает не путь, а **индекс** элемента;
ключ предварительно проходит `sort_key` (TAB/LF/CR → пробел). После сортировки
проверяется, что на выход попало ровно столько же записей, сколько вошло; при
расхождении — предупреждение и порядок обхода вместо молчаливой потери.

**Обоснование.** Инвариант `candidates = collected + skipped` — главное
средство самоконтроля: именно его нарушение вскрыло дефект D6.

**Чем заплатили.** Для имён с TAB/LF порядок определяется заменой этих
символов пробелом, а не их кодом. Это задокументировано и **одинаково на обеих
платформах** (`ctf.ps1` применяет тот же `sortKey`), поэтому паритет не
нарушается.

---

## DR-08 · `-mindepth 1` при исключениях каталогов

**Контекст.** `find "$root" -name out -prune` совпадает со стартовой точкой:
дерево с корнем `out`, `build`, `dist`, `target`, `obj`, `release`, `coverage`
вырезалось целиком (дефект D3).

**Решение.** `-mindepth 1` исключает корень из области действия `-prune`.
Поддержка определяется зондом; если `-mindepth` нет, корень отфильтровывается
после выдачи.

**Чем заплатили.** Зависимость от расширения GNU/BSD findutils (есть и там, и
там) плюс резервный путь.

---

## DR-09 · Общие fence-правила: считаем только «закрывающие» строки

**Контекст.** Нужно гарантировать, что содержимое файла не может закрыть
fenced-блок.

**Решение.** Ищутся строки, состоящие **только** из символов fence (с отступом
до 3 пробелов — ровно правило CommonMark). Берётся максимальная длина прогона
для `` ` `` и для `~`, fence выбирается на единицу длиннее, минимум 3; из двух
вариантов берётся более короткий.

**Обоснование.** Строка `has ``` backticks` блок не закрывает, поэтому fence из
трёх кавычек корректен; завышение длины fence портило бы подсветку и диффы.
Файл со строкой из пяти кавычек получает fence из шести; файл со строкой из
трёх тильд — fence из трёх кавычек (тильды кавышечный блок не закрывают).

---

## DR-10 · Документ собирается в три временных файла

**Контекст.** Оглавление и счётчики в шапке зависят от того, какие файлы
прошли фильтр бинарности, то есть становятся известны только после рендера.
При `-o -` шапку нужно выдать раньше тела.

**Решение.** Три временных файла: `HEAD`, `BODY`, `FINAL`. Сначала рендерится
`BODY` (попутно заполняется список реально выданных путей), затем строятся
шапка и оглавление в `HEAD`, затем `cat HEAD BODY > FINAL` и атомарный `mv` в
назначение. Для `-o -` тот же `FINAL` выводится на stdout.

**Обоснование.** Память не расходуется (всё на диске), порядок байтов в
документе правильный, а `-o -` остаётся чистым каналом: посторонний вывод в
stdout невозможен. Именно эту гарантию нарушал черновик, где `choose_fence` и
`map_lang` печатали значение на stdout — при `-o -` мусор попадал в документ.

**Правило, выведенное из той ошибки:** функции горячего пути ничего не печатают.

---

## DR-11 · Временные файлы создаются в каталоге вывода и явно исключаются

**Контекст.** `mv` атомарен только в пределах одной файловой системы, поэтому
временные файлы должны лежать рядом с результатом. Но тогда при
`ctf -o ./bundle.md .` они оказываются внутри сканируемого дерева и попадают в
документ (реальный дефект, пойманный на сравнении с `ctf.ps1`).

**Решение.** Префиксы `.ctf-head.`, `.ctf-body.`, `.ctf-final.` в каталоге
вывода распознаются функцией `is_own_temp_file` и отбрасываются на этапе
отбора со счётчиком `skip_temporary_file`; дополнительно в рендере все три
файла проверяются через `-ef`.

---

## DR-12 · Позиционные аргументы: слоты заполняются по порядку, каталог — всегда SOURCE_DIR

**Контекст.** Форма `ctf EXT SRC OUT` должна продолжать работать. Но если
пользователь уже задал `--ext`, единственный позиционный аргумент — это
каталог, а не второе расширение. Черновик трактовал его как расширение и
молча сканировал текущий каталог.

**Решение.** Позиционные аргументы заполняют слоты `EXTENSION`, `SOURCE_DIR`,
`OUTPUT_FILE` по порядку, **пропуская уже заполненные опциями**. Аргумент,
указывающий на существующий каталог, всегда занимает слот `SOURCE_DIR`.

| Вызов | Расширение | Источник | Вывод |
|:---|:---|:---|:---|
| `ctf php ./src out.md` | php | ./src | out.md |
| `ctf -e php ./src` | php | ./src | по умолчанию |
| `ctf ./src` | — | ./src | по умолчанию |
| `ctf -e php -o out.md ./src` | php | ./src | out.md |

Таблица продублирована в README и проверена `tests/test_cli.sh`.

---

## DR-13 · Конфигурационный файл — данные, а не код

**Контекст.** Естественное решение для bash — `source` конфига. Это исполнение
произвольного кода из файла, который может быть подменён.

**Решение.** Конфиг читается построчно как `VAR=VALUE`: белый список
`CTF_*`, одна операция `printf -v` (без `eval`), отклонение значений с `$(`,
обратными кавычками, `;`, `|`, `&`, `<`, `>`, `$`; файл с групповой или
мировой записью игнорируется с предупреждением.

**Обоснование.** `eval` в bash-коде отсутствует полностью; это проверяется
тестом, который предварительно вырезает тела heredoc (внутри perl-нагрузки
HTTP-транспорта есть `perl-eval`, оболочкой он не исполняется).

**Чем заплатили.** В конфиге нельзя вычислять значения. Принято осознанно:
конфиг хранит настройки, а не логику.

---

## DR-14 · Самообновление: проверка до записи, запись сквозь inode

**Контекст.** `--update` перезаписывает исполняемый файл. Ошибка здесь
превращает инструмент в нерабочий для всех, кто его обновил.

**Решение.** Порядок операций:

1. скачать во временный файл;
2. проверить: размер ≥ 4096 байт (отсечение HTML-страниц ошибок), первая
   строка начинается с `#!`, `bash -n` проходит без ошибок, встроенная версия
   согласована с `VERSION`;
3. сделать резервную копию `ctf.sh.bak-<UTC-метка>`;
4. записать **сквозь существующий inode** (`cat > target`), чтобы права,
   владелец и жёсткие ссылки сохранились; `mv` — только как резервный путь;
5. при любой ошибке записи восстановить резервную копию;
6. обновить соседние `VERSION`, `ctf.ps1`, `ctf.bat`, если они уже лежат рядом.

**Обоснование.** Каждый пункт покрыт тестом `tests/test_update.sh`, который
гоняет полный цикл против локального HTTP-сервера (порт выдаёт ОС, поэтому
коллизии исключены) и отдельно проверяет, что повреждённая загрузка —
без shebang, с синтаксической ошибкой, HTML-страница, 6 байт — **не**
перезаписывает рабочий скрипт.

**Дополнительно.** `CTF_UPDATE_BASE_URL` переопределяет корень загрузки:
внутреннее зеркало, офлайн-хранилище артефактов и тот же тестовый сервер.
Транспорт выбирается по цепочке `curl → wget → python3 → perl → bash /dev/tcp`;
последний не умеет TLS и сообщает об этом явно, а не падает загадочно.

---

## DR-15 · `--check-update` возвращает 3 и когда есть обновление, и когда сеть недоступна

**Контекст.** Нужен один код для использования в CI-гейте.

**Решение.** `0` — актуальная версия, `3` — есть новая **или** удалённый
`VERSION` недоступен/нераспознан. Строка `local=… remote=… channel=…
status=up-to-date|newer|unreachable` печатается всегда, поэтому причину видно.

**Чем заплатили.** Нельзя различить «есть обновление» и «нет сети» по коду —
только по `status=`. Принято, потому что для гейта обе ситуации означают
«нельзя считать версию подтверждённой».

---

## DR-16 · Windows: тонкий `.bat` + полноценный `.ps1`, а не полиглот

**Контекст.** v3 хранил PowerShell внутри `.bat` и извлекал его через
`more +N`. Такой код не линтуется, ломается от правок выше маркера и портит
длинные строки и не-ASCII.

**Решение.** `ctf.ps1` — самостоятельный скрипт; `ctf.bat` — 68 строк
лаунчера (поиск `ctf.ps1` рядом или в PATH, выбор `pwsh.exe` с откатом на
`powershell.exe`, UTF-8 кодовая страница с восстановлением прежней, проброс
`%*`, возврат кода выхода).

**Обоснование.** Одна реализация на платформу, проверяемая парсером
PowerShell в CI. Переводы строк закреплены в `.gitattributes`, и CI-джоба
`line-endings` требует CRLF для `.bat`/`.ps1` и LF для `.sh`.

---

## DR-17 · Паритет интерфейса и побайтовый паритет документа

**Контекст.** Две реализации расходятся незаметно: расхождение в одной детали
сортировки или в одном разделителе метаданных делает документы разными, и
пользователь, перешедший с Linux на Windows, получает другой результат.

**Решение.**

1. Каждой длинной опции `ctf.sh` соответствует алиас в `ctf.ps1`.
   PowerShell сопоставляет `--name` без учёта регистра, поэтому однословные
   опции работают как есть, а для составных объявлены алиасы с дефисом.
2. Ограничение платформы зафиксировано явно: алиас, совпадающий с именем
   параметра без учёта регистра, запрещён самим PowerShell (так `--ext`
   несовместим с параметром `$Ext` как отдельный алиас), а `-Verbose` —
   общий параметр, поэтому bash-опция `--verbose` на Windows соответствует
   `-Trace`/`-v`. Пары, различающиеся только регистром (`-e/-E`, `-v/-V`),
   в PowerShell неразличимы: `--exclude` и `--version` принимаются только
   в длинной форме.
3. Сортировка — ordinal (`StringComparer.Ordinal`), а не культурно-зависимая:
   `Sort-Object` по умолчанию ставил `b/crlf.txt` перед `b/Makefile`, тогда
   как bash при `LC_ALL=C` — наоборот.
4. Ключ сортировки нормализует TAB/LF/CR одинаково на обеих платформах (DR-07).
5. Содержимое файла в PS копируется дословно: split/join с единым
   разделителем нормализовал бы CRLF в LF.
6. Вывод пишется как UTF-8 без BOM с явным LF, поэтому документ совпадает
   с bash-версией побайтово.

**Контроль.** `tests/test_parity.sh`: 21 комбинация опций сравнивается
побайтово (кроме строки с именем инструмента), сравнивается `--stats`,
и механически проверяется, что все 43 портируемые длинные опции принимаются
`ctf.ps1` без `MetadataError`. Три не портируемые опции перечислены явно.

---

## DR-18 · Политика линтинга

**Контекст.** Требовать «0 замечаний shellcheck на уровне style» от тестов
нереалистично: тестовый каркас намеренно использует общие переменные
(`CTF_OUT`, `STAT`), которые shellcheck считает неиспользуемыми, и литералы
с одинарными кавычками в проверяемых строках.

**Решение.**

| Объект | Уровень | Требование |
|:---|:---|:---|
| `ctf.sh` | `style` | 0 замечаний |
| `install.sh` | `style` | 0 замечаний |
| `tests/*.sh` | `warning` | 0 замечаний |
| `ctf.ps1` | парсер PowerShell | 0 ошибок |

Намеренные отключения оформляются точечной директивой с пояснением
(например, `# shellcheck disable=SC2053` у `glob_match`, где незакавыченная
правая часть `==` и есть смысл функции), а не глобальным `.shellcheckrc`.

---

## DR-19 · Счётчики и причины пропуска — часть контракта вывода

**Контекст.** Самый частый вопрос к такому инструменту: «почему файла нет в
документе?». Ответ «Skipped: 5» бесполезен.

**Решение.** Каждый пропуск имеет причину (`binary`, `symlink`, `extension`,
`excluded-dir`, `default-exclude`, `exclude(<glob>)`, `not-included`,
`too-large`, `too-small`, `unreadable`, `non-regular`, `missing`, `directory`,
`output-file`, `temporary-file`, `duplicate`, `token-budget`). Причины
печатаются таблицей в конце документа, доступны в `--stats` как
`skip_<причина>=N` и в `-v` — по каждому файлу.

**Инвариант.** `candidates = collected + skipped`. Он проверяется тестом, и
именно его нарушение позволило обнаружить дефект D6.

---

## DR-20 · Оценка токенов — `ceil(bytes / 4)`, явно и только как оценка

**Контекст.** `--token-budget` бесполезен, если пользователь не понимает, как
считаются токены, и опасен, если выдаёт точность, которой нет.

**Решение.** Оценка — `ceil(bytes / 4)`, везде помечена как оценочная
(`Estimated tokens`, `est_tokens`, `~N est. tokens`). Реальный токенайзер не
подключается: это зависимость и расход времени, несовместимый с DR-01.

**Обоснование.** Для исходного кода 4 байта на токен — консервативная оценка
(реальная плотность обычно ниже), поэтому бюджет скорее недоиспользуется, чем
переполняется. Для CJK оценка занижает число токенов — это указано в README.
