# Аудит v3.1.0 → обоснование v4.0.0

Документ фиксирует состояние репозитория `ctf-collect-to-file` до рефакторинга
(HEAD `9c90243`, файлы `ctf.sh` v3.0.0, `ctf.bat` v3.1.0-win, `README.md`,
`LICENSE`) и то, что именно было исправлено. Каждый дефект сопровождается
командой воспроизведения и фактическим выводом — чтобы утверждение можно было
проверить, а не принять на веру.

Условия стенда: Debian 12, bash 5.2.15, GNU coreutils 9.1, GNU findutils,
mawk-совместимый `awk`, `file(1)` отсутствует (проверялся и fallback-путь),
2 CPU, контейнер. Все прогоны — от root, поэтому проверки прав на чтение
файлов помечены отдельно.

Обозначения серьёзности:

| Уровень | Смысл |
|:---|:---|
| **critical** | Результат работы инструмента неверен или невоспроизводим |
| **high** | Поведение опасно либо молча теряет данные |
| **medium** | Существенное ограничение или расхождение с документацией |
| **low** | Косметика, стиль, гигиена репозитория |

---

## Сводка

| # | Дефект | Серьёзность | Где | Статус в v4 |
|:--|:---|:---|:---|:---|
| D1 | Недетерминированное определение бинарности (SIGPIPE + `pipefail`) | critical | `ctf.sh:186-206` | исправлено, `tests/test_binary.sh` |
| D2 | Бинарные данные попадают в документ, NUL-байты в Markdown | critical | следствие D1 | исправлено |
| D3 | Корень с именем из списка исключений вырезается целиком | critical | `find -prune` | исправлено (`-mindepth 1`) |
| D4 | `.git/`, `node_modules`, lock-файлы собираются по умолчанию | high | `ctf.sh` (нет исключений) | исправлено |
| D5 | Симлинки молча теряются | high | `find -type f` | исправлено (счётчик + `-L`) |
| D6 | Имя файла с LF ломает структуру Markdown | high | `ctf.sh:687-694` | исправлено |
| D7 | Все ошибки возвращают код 1 | medium | `die()` | исправлено (0/1/2/3/4) |
| D8 | Расхождение версий: 3.0.0 / 3.1.0 / 3.1.0-win | medium | все файлы | исправлено (`VERSION`) |
| D9 | `ctf.bat` хранится с LF | medium | `ctf.bat` | исправлено (`.gitattributes`) |
| D10 | `ctf.bat` самораспаковывается через `more +N` | medium | `ctf.bat:57` | устранено (тонкий лаунчер) |
| D11 | Нет source-guard: скрипт нельзя тестировать | medium | `ctf.sh:746` | исправлено |
| D12 | Результат имеет права 0600 | medium | `mktemp` + `mv` | исправлено (umask) |
| D13 | Нет механизма исключений/включений/лимита глубины и размера | medium | — | добавлено |
| D14 | Нет управления объёмом под контекстное окно | medium | — | добавлено (`--token-budget`) |
| D15 | Нет машинно-читаемого вывода | medium | — | добавлено (`json`, `jsonl`, `--stats`) |
| D16 | Нет тестов, CI, CHANGELOG, installer | medium | репозиторий | добавлено |
| D17 | 19,0 с на 2000 файлов | low | 4–8 fork на файл | 9,0 с (батчинг) |
| D18 | Замечания shellcheck SC2155, SC2016 | low | `ctf.sh:50,694` | 0 замечаний |
| D19 | README: 1061 строка маркетинга, нет справочника опций | low | `README.md` | переписан |
| D20 | Одиночный `-` трактуется как расширение | low | `ctf.sh:1355` | исправлено |

---

## D1 · Недетерминированное определение бинарности — **critical**

### Код v3

```bash
# ctf.sh:196-204
if (( HAVE_OD )) && (( HAVE_HEAD )); then
    if head -c 8192 < "$file" 2>/dev/null \
        | od -A n -v -t x1 2>/dev/null \
        | tr -s '[:space:]' '\n' \
        | grep -qx '00'; then
        return 0
    fi
fi
```

### Механизм

`grep -q` прекращает чтение при первом совпадении и закрывает канал. `od`
получает SIGPIPE и завершается с кодом 141. С включённым `set -o pipefail`
код конвейера становится 141, условие `if` ложно, и функция возвращает 1 —
«файл текстовый». Результат зависит от того, успел ли `od` завершиться до
закрытия канала, то есть от планировщика.

### Воспроизведение

```console
$ for mode in pipefail nopipefail; do
    if [ $mode = pipefail ]; then set -o pipefail; else set +o pipefail; fi
    head -c 8192 < src/b/rand.bin | od -A n -v -t x1 | tr -s '[:space:]' '\n' | grep -qx '00'
    echo "  $mode: rc=$?"
  done
  pipefail:   rc=141     # -> «текст», НЕВЕРНО
  nopipefail: rc=0       # -> «бинарный», верно
```

Порог непостоянен даже для одного и того же размера — исход определяется
гонкой, а не данными:

```console
$ for n in 1000 4096 8192 20000 100000; do
    head -c $n /dev/urandom > r$n.bin
    set -o pipefail
    head -c 8192 < r$n.bin | od -A n -v -t x1 | tr -s '[:space:]' '\n' | grep -qx '00'
    echo "  $n байт: NUL в первых 8K=$(head -c 8192 < r$n.bin | od -A n -v -t x1 | tr -s '[:space:]' '\n' | grep -cx '00')  обнаружен=$([ $? = 0 ] && echo да || echo НЕТ)"
  done
    1000 байт: NUL=5   обнаружен=да
    4096 байт: NUL=14  обнаружен=да
    8192 байт: NUL=23  обнаружен=НЕТ
   20000 байт: NUL=30  обнаружен=да
  100000 байт: NUL=23  обнаружен=НЕТ
```

### Следствие — D2

Шесть одинаковых прогонов одного дерева дают разный документ:

```console
$ for i in 1 2 3; do bash ctf.sh "" /tmp/ctftest/src /tmp/v3n$i.md >/dev/null 2>&1
    printf '  прогон %d: заголовков=%s NUL-байтов=%s размер=%s\n' "$i" \
      "$(grep -ac '^### ' /tmp/v3n$i.md)" \
      "$(LC_ALL=C tr -dc '\0' < /tmp/v3n$i.md | wc -c)" \
      "$(stat -c %s /tmp/v3n$i.md)"
  done
  прогон 1: заголовков=17 NUL-байтов=0    размер=1133
  прогон 2: заголовков=18 NUL-байтов=1173 размер=301169
  прогон 3: заголовков=17 NUL-байтов=0    размер=1133
```

Во втором прогоне в документ попал 300-килобайтный случайный бинарник
(`b/rand.bin`), документ содержит 1173 NUL-байта и перестаёт открываться как
текст (`grep` сообщает `binary file matches`). Для инструмента, чья задача —
готовить контекст модели и воспроизводимый снимок кода, это означает:

- один и тот же запуск даёт разный результат (невозможно зафиксировать в CI);
- документ может оказаться непечатаемым;
- объём вырастает в 266 раз (1 133 → 301 169 байт), то есть «съедает»
  контекстное окно мусором.

### Решение в v4

Ранний выход из конвейера исключён: `od` читается `awk` до конца. Сигналов
два, оба не дают ложных срабатываний на реальном тексте:

```bash
od -A n -v -t u1 -N 8192 -- "$f" | awk '
    { for (i = 1; i <= NF; i++) { b = $i + 0; n++
        if (b == 0) nul = 1
        else if (b < 9 || (b > 13 && b < 32) || b == 127) ctrl++ } }
    END { if (n == 0) print "text"
          else if (nul == 1) print "binary"
          else print (ctrl * 100 > n * 5) ? "binary" : "text" }'
```

Байты ≥ 0x80 намеренно **не** считаются признаком бинарности: иначе
отбраковывался бы любой неанглийский исходник. Это подтверждено матрицей в
`tests/test_binary.sh` — UTF-8 (кириллица, эмодзи, CJK), CP1251 и файл с
допустимыми управляющими символами проходят как текст, а PNG-заголовок,
`MZ…`, файл с NUL и псевдослучайные данные 64 Б / 4 КБ / 300 КБ — как бинарные.

Для пакетной проверки используется `grep -IlZ ''` (один процесс на ~200
файлов): GNU grep считает бинарным файл, содержащий NUL, что совпадает с
первым сигналом. При отсутствии `grep -I/-Z` выполняется пофайловый
`od | awk`-путь.

Результат: 8 прогонов дают идентичный sha256, NUL-байтов в документе нет.

---

## D3 · Корень с именем из списка исключений вырезается целиком — **critical**

### Механизм

В v4 (и в любом решении на `find … -prune`) выражение `-name <dir> -prune`
совпадает и со **стартовой точкой** поиска. Каталоги `out`, `build`, `dist`,
`release`, `target`, `obj`, `coverage` — обычные имена корневых каталогов
сборки.

### Воспроизведение (на промежуточной сборке v4, до исправления)

```console
$ for d in out build dist release target obj coverage; do
    mkdir -p /tmp/rt/$d/sub; printf 'x=1\n' > /tmp/rt/$d/sub/a.js
    printf '  корень %-9s candidates=%s\n' "$d" "$(ctf -q --stats /tmp/rt/$d | sed -n 's/^candidates=//p')"
  done
  корень out        candidates=0
  корень build      candidates=0
  ...
```

То есть `ctf js ./dist` молча собирал **ноль** файлов и завершался с кодом 0.

### Решение

`find "$root" -mindepth 1 …` исключает стартовую точку из области действия
`-prune`; для сборок `find` без `-mindepth` добавлен резервный фильтр по
совпадению с корнем. Тест проверяет все семь имён.

---

## D4 · VCS- и зависимостный мусор в документе — **high**

В v3 нет ни механизма исключений, ни списка по умолчанию.

```console
$ cp -a /home/user/ctf /tmp/v3g            # репозиторий: 4 файла
$ bash ctf.sh "" /tmp/v3g /tmp/v3g.md
[INFO] Done: 27 file(s) written, 3 skipped.
$ grep -a '^### ' /tmp/v3g.md | head -6
### `.git/HEAD`
### `.git/config`
### `.git/description`
### `.git/hooks/applypatch-msg.sample`
### `.git/hooks/commit-msg.sample`
### `.git/hooks/fsmonitor-watchman.sample`
$ grep -ac '^### ' /tmp/v3g.md
61
```

Из четырёх файлов репозитория 27 попадают в документ, и 61 заголовок
приходится на внутренности `.git`. Для LLM-контекста это прямой расход
токенов на шум.

### Решение в v4

- 50 имён каталогов и 22 шаблона файлов по умолчанию (VCS, зависимости,
  сборка, кэши, IDE, lock-файлы, объектный код);
- каталоги вырезаются на уровне `find -prune`, то есть не обходятся вовсе;
- `--no-default-excludes`, `--list-default-excludes`, `--exclude-dir`,
  `-E/--exclude`, `-I/--include`;
- `--git tracked|all` — перечисление через индекс git с учётом `.gitignore`
  (обычно именно это и нужно внутри репозитория).

Тот же прогон в v4: `candidates=4`, `collected=4`.

---

## D5 · Симлинки молча теряются — **high**

```console
$ ls /tmp/ctftest/src/a
link.sh  link_abs.txt  one.sh  two.PHP        # 4 записи, 2 из них симлинки
$ bash ctf.sh "" /tmp/ctftest/src/a /tmp/v3sym.md
[INFO] Found 2 candidate file(s).
[INFO] Done: 2 file(s) written, 0 skipped.
```

`find -type f` без `-L` не возвращает симлинки, поэтому они не попадают даже в
кандидаты, и `Skipped: 0` сообщает пользователю, что потеряно ничего не было.
Для проекта, где часть исходников лежит по симлинкам (монорепо, `node_modules`
локальных пакетов, конфигурации), это тихая потеря данных.

### Решение в v4

`--follow/-L` включает сбор симлинков; без него они учитываются в
`skip_symlink` и видны в таблице причин пропуска и в `--stats`. При
`--files-from` симлинк становится кандидатом и также учитывается.

---

## D6 · Имя файла с переводом строки ломает Markdown — **high**

POSIX разрешает в имени файла любой байт, кроме `/` и NUL. В v3 имя
подставлялось в заголовок как есть:

```console
### `b/with
newline.txt`
```

Заголовок разрывался на две строки, структура документа и любые якоря
оглавления ломались. Аналогично имя с табуляцией разрушало двухколоночную
таблицу сортировки, из-за чего файл **молча выпадал** из выдачи, нарушая
инвариант `candidates = collected + skipped`.

### Решение в v4

- отображаемые строки (заголовок, оглавление, TSV) проходят `sanitize_display`:
  LF/CR → `␤` (U+2424), TAB → пробел; **содержимое файла копируется дословно**;
- ключ сортировки заменяет TAB/LF/CR пробелом, а после сортировки проверяется
  инвариант «сколько вошло, столько и вышло»; при расхождении — предупреждение
  и порядок обхода вместо молчаливой потери;
- тот же ключ сортировки реализован в `ctf.ps1`, иначе платформы расходились
  в порядке файлов с TAB (0x09) и LF (0x0A) в имени. Проверено
  `tests/test_parity.sh`.

---

## D7 · Все ошибки возвращают код 1 — **medium**

```console
$ bash ctf.sh --bogus            >/dev/null 2>&1; echo $?
1
$ bash ctf.sh php /no/such/dir   >/dev/null 2>&1; echo $?
1
```

Вызывающая сторона (Makefile, CI, обёртка) не может отличить ошибку
использования от сбоя ввода-вывода. В v4: `0` успех, `1` рантайм,
`2` использование, `3` обновление/сеть, `4` ничего не собрано при `--strict`.

---

## D8 · Три разных версии в одном репозитории — **medium**

```console
$ grep -m1 'SCRIPT_VERSION='       ctf.sh      # readonly SCRIPT_VERSION="3.0.0"
$ grep -m1 'CTF_SCRIPT_VERSION='   ctf.bat     # set "CTF_SCRIPT_VERSION=3.1.0-win"
$ grep -m1 'Version-3'             README.md   # badge: Version-3.1.0
```

README описывает возможности 3.1.0, bash-скрипт заявляет 3.0.0. В v4 версия
хранится в одном месте — файле `VERSION`, — а `tests/test_meta.sh` и отдельная
CI-джоба `version-consistency` падают при расхождении между `VERSION`,
`ctf.sh`, `README.md` и `CHANGELOG.md`.

---

## D9, D10 · `ctf.bat`: LF и самораспаковка — **medium**

```console
$ grep -c $'\r' ctf.bat
0
$ sed -n '50,60p' ctf.bat
set "MARKER_LINE="
for /f "tokens=1 delims=:" %%L in ('findstr /n /c:"%MARKER%" "%SELF%"') do set /a MARKER_LINE=%%L
...
more +%MARKER_LINE% "%SELF%" > "%TMP_PS1%"
```

Две проблемы:

1. **LF вместо CRLF.** `cmd.exe` читает пакетный файл поблочно и при LF
   неверно обрабатывает метки и `goto`; классический симптом — «метка есть,
   а переход не работает». Файл 845 строк содержал ноль символов CR.
2. **Полиглот BAT+PowerShell.** Полезная нагрузка извлекается из самого себя
   через `more +N`. `more` — постраничный фильтр: он нормализует длинные
   строки, может добавлять переводы и портит не-ASCII. Любая правка выше
   маркера смещает номер строки, и извлечение ломается без видимой причины.
   Кроме того, PowerShell-код внутри `.bat` не проверяется ни одним линтером.

### Решение в v4

`ctf.ps1` — самостоятельный скрипт (1090 строк), `ctf.bat` — 68 строк
лаунчера: ищет `ctf.ps1` рядом с собой или в PATH, выбирает `pwsh.exe`
(PS7) с откатом на `powershell.exe` (5.1), переключает консоль в UTF-8 с
восстановлением прежней кодовой страницы, пробрасывает аргументы через `%*` и
возвращает код выхода PowerShell. Переводы строк закреплены в
`.gitattributes` (`*.bat text eol=crlf`, `*.sh text eol=lf`), и CI-джоба
`line-endings` это контролирует.

---

## D11 · Скрипт нельзя тестировать — **medium**

```console
$ tail -1 ctf.sh
main "$@"
```

Без охраны `[[ "${BASH_SOURCE[0]}" == "${0}" ]]` любой `source ctf.sh`
немедленно исполняет программу. Ни одну внутреннюю функцию нельзя проверить
изолированно. В v4 охрана есть, и `tests/` активно этим пользуется:
`is_binary`, `version_gt`, `map_lang`, `choose_fence`, `batch_scan`
тестируются прямым вызовом после `source`.

---

## D12 · Права на результат 0600 — **medium**

Документ создаётся через `mktemp` и переносится `mv`, поэтому наследует
права `0600`:

```console
-rw------- 1 root root 0 … tr2.md
```

Другой пользователь, процесс веб-сервера или сборщик артефактов CI такой файл
не прочитает. В v4 права выставляются как `0666 & ~umask` (обычно `0644`).

---

## D13–D15 · Отсутствующая функциональность — **medium**

В v3 нет ни одной из перечисленных возможностей; для инструмента, заявленного
как средство подготовки LLM-контекста, критичны первые три.

| Возможность | Зачем | В v4 |
|:---|:---|:---|
| Ограничение объёма | контекстное окно конечно | `--token-budget`, `--budget-action truncate\|drop`, оценка `ceil(bytes/4)` в шапке и сводке |
| Исключения/включения | шум `.git`/`node_modules` (D4) | `-E`, `-I`, `--exclude-dir`, дефолтный список |
| Перечисление через git | `.gitignore` как источник правды | `--git tracked\|all` |
| Машинно-читаемый вывод | CI, jq, дальнейшая обработка | `-F json`, `-F jsonl`, `--stats`, `--dry-run` (TSV) |
| Вывод в stdout | конвейеры (`\| pbcopy`, `\| jq`) | `-o -` |
| Оглавление | навигация по документу на 500 файлов | `--toc` с GitHub-якорями |
| Хеш и размер файла | аудит снимка | `--metadata`, `--dedup` |
| Нумерация строк | ссылки в ревью («строка 42») | `--line-numbers` |
| Обрезка | один огромный файл не должен съедать бюджет | `--truncate-lines` |
| BOM | артефакты Windows-редакторов | `--strip-bom` |
| Воспроизводимость | снимок можно коммитить и диффить | `--no-timestamp`, `SOURCE_DATE_EPOCH` |
| Конфигурация | повторяемые прогоны в проекте | `-c FILE`, `CTF_CONFIG`, `--print-config` |
| Диагностика | «почему файла нет в документе» | `-v`, таблица причин пропуска, `--stats` |
| Строгость | CI должен падать на пустой выборке | `--strict`, код 4 |
| Цвет | пайпы и логи | `--color auto\|always\|never`, `NO_COLOR` |
| Самообновление | распространение без git | `--check-update`, `--update`, каналы, зеркало через `CTF_UPDATE_BASE_URL` |

Отдельно про безопасность конфигурации: файл настроек v4 читается как данные —
белый список `CTF_*`, никакого `eval`, значения с `$(`, обратными кавычками,
`;`, `|`, `&`, `<`, `>` отклоняются, а файл с групповой или мировой записью
игнорируется с предупреждением (его мог подложить другой локальный
пользователь). Проверено `tests/test_safety.sh`.

---

## D16 · Инженерная обвязка — **medium**

В v3 репозиторий состоял из четырёх файлов: `ctf.sh`, `ctf.bat`, `README.md`,
`LICENSE`. Не было ни тестов, ни CI, ни CHANGELOG, ни installer, ни
`.gitignore`/`.gitattributes`/`.editorconfig`, ни Makefile.

В v4:

```
ctf.sh          2222 строк   bash 4.2+, shellcheck --severity=style: 0 замечаний
ctf.ps1         1139 строк   PowerShell 5.1+, парсер: 0 ошибок
ctf.bat          68 строк   CRLF, тонкий лаунчер
VERSION                    единственный источник версии
tests/          8 файлов, 236 проверок, без сети и без root
docs/ANALYSIS.md, docs/DECISIONS.md
install.sh, Makefile, .github/workflows/ci.yml
.gitattributes, .gitignore, .editorconfig, CHANGELOG.md
```

---

## D17 · Производительность — **low**

v3 тратил 4–8 процессов на файл: `head|od|tr|grep` (бинарность), `awk`
(fence), `tail -c 1` (последний байт), `cat` (содержимое), `wc -c` (размер).

```console
$ time bash ctf.sh js /tmp/perf/src /tmp/perf/out.md     # 2000 файлов
real 0m19.049s
```

В v4 метаданные читаются пакетами: размеры — `stat --printf` (один процесс на
200 файлов), бинарность — `grep -IlZ` (один процесс на пакет), строки/fence/сумма
байтов — один проход `awk` на пакет. Единственный процесс на файл — `cat`,
копирующий содержимое. Функции горячего пути возвращают результат через
глобальные переменные, а не через подстановку команды, поэтому под each файл
не создаётся subshell.

```console
$ time bash ctf.sh -q --no-timestamp -e js -o /tmp/perf/v4.md /tmp/perf/src
real 0m8.984s          # 2000 файлов, выдача побайтово совпадает с небатчевой
$ time bash ctf.sh -q --no-timestamp --no-default-excludes -o /tmp/big/o.md /tmp/big
real 0m2.727s          # 600 файлов, 13 МБ
```

---

## D18 · Замечания shellcheck — **low**

```console
$ shellcheck --severity=style ctf.sh
In ctf.sh line 50:
readonly BACKTICK="$(printf '\140')"
         ^------^ SC2155 (warning): Declare and assign separately to avoid masking return values.
In ctf.sh line 694:
                    printf '### `%s`\n\n' "$rel_display"
                           ^------------^ SC2016 (info): Expressions don't expand in single quotes…
```

В v4: `ctf.sh` — 0 замечаний на уровне `style`; `install.sh` — 0; тесты —
0 на уровне `warning` (политика зафиксирована в `docs/DECISIONS.md`).

---

## D19 · README — **low**

1061 строка, из них значительная часть — маркетинговые формулировки
(«battle-tested»), сравнительные таблицы с галочками и разделы вроде
«Homebrew tap» с инструкцией создать formula вручную. Справочника опций нет:
единственный источник — `--help`.

В v4 README (530 строк) содержит: установку, быстрый старт, полный справочник
всех 46 длинных опций и переменных окружения, описание форматов вывода,
семантику исключений и glob, работу с токен-бюджетом, контракт
самообновления, таблицу кодов выхода, раздел о поведении (позиционные
аргументы, бинарность, fence, воспроизводимость), разработку и замеры
производительности. `tests/test_meta.sh` механически проверяет, что каждая опция
парсера упомянута в README и наоборот.

---

## D20 · Одиночный `-` как расширение — **low**

В v3 ветка `-?*)` требует минимум два символа, поэтому `-` проваливается в
`*)` и становится расширением `-`; дальнейший `find -iname '*.-'` просто
ничего не находит, и пользователь получает пустой документ без объяснения.
В v4 `-` попадает в ту же ветку проверки расширения и отклоняется с кодом 2.

---

## Что проверено и дефектом НЕ является

Чтобы аудит не читался как список придирок, отдельно зафиксированы решения v3,
которые выглядят подозрительно, но корректны:

1. **Динамический выбор fence.** v3 ищет только строки, состоящие из символов
   fence (с отступом до 3 пробелов) — это ровно то, что по CommonMark может
   закрыть блок. Строка `has ``` backticks` блок не закрывает, поэтому fence
   из трёх кавычек здесь корректен, а не «слишком короток». Проверено на
   файлах с ```` ``` ````, ```` ````` ```` и `~~~`.
2. **Выбор более короткого fence** между кавычками и тильдами — осознанная
   оптимизация, а не ошибка: `~~~` не закрывает блок из ```` ``` ````.
3. **`LC_ALL=C`** — не «порча кодировок», а гарантия стабильной сортировки и
   побайтового поведения классов символов независимо от локали пользователя.
4. **Запись через временный файл + `mv`** — корректная атомарность; проблема
   была только в правах (D12), а не в подходе.
5. **Запись сквозь симлинк** (`cat > target`, а не `mv`) — верно: `mv`
   подменил бы симлинк обычным файлом. Сохранено в v4 и покрыто тестом.
6. **Пропуск собственного выходного файла** (`-ef`) — верная защита от
   самопоглощения при повторных прогонах. Сохранено в v4.
7. **`printf '\140'` для обратной кавычки** — оправдан: позволяет хранить
   символ в переменной и не экранировать его в десятках мест.
8. **Отказ от `du`** в пользу `wc -c`/`stat` — разумная переносимость; в v4
   размер берётся из `stat` пакетно, а `wc -c` остался резервным путём.

---

## Остаточные ограничения v4

Честный список того, что не решено:

1. **Бинарность файлов < ~1 КБ.** Файл из высокоэнтропийных данных без NUL и
   почти без управляющих байтов может пройти как текст. Вероятность мала
   (для 100 случайных байтов ≈ 10⁻⁵), но не нулевая. Средства управления:
   `--ext`, `--binary always`, `--max-size`. Задокументировано в README.
2. **UTF-16 считается бинарным** (содержит NUL). Это правильное решение для
   документа, предназначенного для чтения как UTF-8, но transcoding не
   выполняется; при необходимости используйте `iconv` до сбора.
3. **`--config` не портирован в PowerShell** (POSIX-механика прав и
   `printf -v`). На Windows настройки задаются параметрами.
4. **`-E/--exclude` на Windows** доступна только в длинной форме: PowerShell
   не различает регистр, а пара `-e/-E` в bash различает. Аналогично
   `--version` (короткая `-V` конфликтует с `-v`).
5. **Сортировка строк с не-ASCII.** bash сортирует байты (LC_ALL=C), .NET
   `StringComparer.Ordinal` — кодовые единицы UTF-16. Для BMP результаты
   совпадают; расхождения возможны только для символов вне BMP.
6. **Порядок файлов одинакового размера** детерминирован (второй ключ — путь),
   но не связан с «порядком в каталоге»; полагаться на него как на порядок
   создания файлов нельзя — для этого есть `--sort mtime`.
7. **Самообновление не проверяет подпись.** Целостность обеспечивается
   валидацией (размер, shebang, `bash -n`, версия) и резервной копией, но не
   криптографически. Для закрытых контуров используйте `CTF_UPDATE_BASE_URL`
   с собственного зеркала и распространяйте контрольные суммы вместе с ним.
