From 97ba40bf8e0b725d2ebba10cdb772e34b81ffc4f Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 17 Sep 2026 20:02:47 +0600 Subject: [PATCH] [2026-09-17] eagle: personal/projects/zont-config-compiler.md personal/tech/roundtrip-key-verification.md --- personal/projects/zont-config-compiler.md | 142 ++++++++++++++------ personal/tech/roundtrip-key-verification.md | 102 ++++++++++++++ 2 files changed, 202 insertions(+), 42 deletions(-) create mode 100644 personal/tech/roundtrip-key-verification.md diff --git a/personal/projects/zont-config-compiler.md b/personal/projects/zont-config-compiler.md index 68486a0e..247c392e 100644 --- a/personal/projects/zont-config-compiler.md +++ b/personal/projects/zont-config-compiler.md @@ -3,7 +3,7 @@ title: ⚙️ ZONT Config Compiler — конвертеры .txt ⇄ .yml namespace: personal type: how-to created: '2026-09-17' -updated: '2026-09-17d' +updated: '2026-09-17e' tags: - personal - zont @@ -154,7 +154,7 @@ python3 test_roundtrip.py zont_config/config_X.txt `1, 3, 4, 5, 6, 7, 9, 10, 11, 14, 16, 20, 24, 25, 27, 28, 42, 45, 46, 49, 51, 52, 53, 57` ➕ **`0`** (дискретные датчики) и **`36`** (их вложенные конфиги) — **25 типов**. -➕ конструкции логики **`47` / `48` / `50` / `59`**. +➕ конструкции логики **`47` / `48` / `50` / `59`** (тела 59 — `set var` / `puts` / `storeev` / `objcmd`, §5.10). 📌 `README_converters.md` перечисляет **23** — пропускает `0` и `36`. --- @@ -206,7 +206,8 @@ python3 test_roundtrip.py zont_config/config_X.txt | `45` | пауза | `[45, ms]` | `wait: ms` | | `47` | сравнение | `[47, op, left, value|right]` | `{op, left, value}` | | `48` | группа | `[48, logic, [ids]]` | `{group, children}` | -| `59` | мини-скрипт | `[59, '<код>', arg1, arg2, flag]` | `{descr, args}` | +| `59` | мини-скрипт | `[59, '<код>', arg1, arg2, flag]` | `{descr, args[, set_var]}` | +| — | **тела 59** | `set var` → `set_var` ‖ `puts` ‖ `storeev I/A` ‖ `objcmd` (+`target`/`value`) | §5.10 | | `5` | действие над выходом | `[5, '', output_ref, value, …]` | `{descr, target, value, params?}` | | `9` | команда (реле/контур/режим) | `[9, '', target, '']` | `{descr, target, value}` | @@ -360,9 +361,11 @@ enabled = not (f5 & 8) | `target_name`, `target_type`, `raw_value` | дорисовка парсера, в строке конфига их нет | | `_step_id`, `_trigger_id`, `_then` | выдуманные служебные ключи | | `group`/`op`/`condition`/`operator` как ключи **сценария** | их в конфиге нет | +| `set_var_name` / имя целевого объекта вместо id | **подстановка имени из другого объекта** — у ссылки только `id` (§5.10, §10.1) | ✅ **РАЗРЕШЕНО:** `trigger:` (выводится из тела — один шаг 46), `if`/`then`/`else`/`flag` — **реальные поля -записи 46**, `action` — имя списка у шага с поднятым условием. +записи 46**, `action` — имя списка у шага с поднятым условием, `set_var` — **id цели записи** из `args[0]` +тела `set var` (§10.1). Служебные `_`-ключи — **только** на нестандартных случаях (`_op` при операторе ≠ 1). > 📌 **Общий принцип:** YAML-ключ обязан соответствовать полю строки конфига либо выводиться из тела. @@ -414,7 +417,7 @@ enabled = not (f5 & 8) | Тело (`descr`) | Кол-во | Что это | Где живёт значение | |---|---|---|---| -| `set var1` | 12 | **запись значения объекту** | `args[0]` = id целевого объекта (`8844`, `8831`, `8429`) | +| `set var1` | **10** | **запись значения объекту** | `args[0]` = id целевого объекта (`8829`, `8831`, `8844`…); у `#Z8472` = `0` (цели нет) | | `puts "…"` | 5 | **print log** — вывод текста | args = `0,0,0` (текст в самом `descr`) | | `storeev I "…"` / `storeev A "…"` | 5 | **журнал событий**: `I` = инфо, `A` = alert | args = `0,0,0` | | `objcmd "fmt"` | 3 | команда объекту (в т.ч. с хвостом `;#a` / `;#h`) | `args[0]` = id/операнд | @@ -427,15 +430,17 @@ enabled = not (f5 & 8) `#Z8598=59,'storeev I "инфо событие в пн, ср, чт, пт, сб"',0,0,0` · `#Z8818=59,'objcmd 8700 "1 %0"',14.5,0,1` · `#Z8821=59,'expr "%0 + %1"',8819,8820,0` -**В YAML сейчас:** `{id, descr: 'set var1', args: [8829, 0, 0]}` — verbatim, без семантики. -`target`/`value` дорисовываются только для тел, начинающихся с `objcmd `. +**В YAML:** `{id, descr: 'set var1', args: [8829, 0, 0], set_var: 8829}` — цель записи вынесена +ключом `set_var` (§10.1, коммит `87e315c`). `target`/`value` дорисовываются только для тел, +начинающихся с `objcmd `. > 🔴 **Alarm — отдельного типа НЕТ.** Сигнализация/события выражаются телом `storeev` (журнал > событий) и условием на битовую маску. Не искать «тип alarm» в конфиге. -> 🔴 **Дыра в читаемости:** 12 записей `set var1` **переиспользуют** один и тот же `descr` — по YAML -> не видно, что они разные. А несущие объекты (`#Z8830` → `#Z8829=49,8450,1,0`; маски `#Z8844=50,1,0,0,123`) -> лежат в `scenario_orphans` как raw-склад и в теле сценария **не развёрнуты**. +> 🔴 **Дыра в читаемости (частично закрыта):** 10 записей `set var1` **переиспользуют** один и тот же +> `descr` — по YAML не видно, что они разные. Теперь различаются ключом `set_var` (§10.1). А несущие +> объекты (`#Z8830` → `#Z8829=49,8450,1,0`; маски `#Z8844=50,1,0,0,123`) по-прежнему лежат в +> `scenario_orphans` как raw-склад и в тело сценария **не развёрнуты** (вариант B не выбирался). > ⚠️ **Артефакт парсера:** `#Z8195` (SMS) в `steps` → `raw: *id002` — PyYAML-анкор на секцию > `sms_notifications`. Два анкора в файле (`&id001` sensors, `&id002` SMS) — **законные**, это не @@ -475,6 +480,27 @@ diff A.txt B.txt # пусто = чисто > ⚠️ **`>` затирает целевой файл ещё до старта питона** — пустой `.yml` рядом с непустым `.txt` означает > **падение** конвертера: смотреть stderr. +### 🔴 Проверка НОВОГО ключа — тест на подмену, а не round-trip + +Round-trip остаётся зелёным, даже если энкодер **полностью игнорирует** новый ключ: он сравнивает +то, что положил парсер. Для каждого нового YAML-ключа обязателен один тест **эффекта**: + +```bash +cd /tmp && rm -rf zt && mkdir zt && cd zt +cp /Users/admin/Automation/HA-ZONT-Modbus/zont_config/<снимок>.yml t.yml +# подменить ОДНО значение нового ключа и пересобрать .txt +/usr/bin/python3 /Users/admin/Automation/HA-ZONT-Modbus/yml-to-config.py t.yml > out.txt +iconv -f cp1251 -t utf-8 out.txt | tr -d '\r' | grep '^#Z=<тип>' +# ✅ новое значение на месте ❌ старое = правка не доехала (искать ВТОРУЮ точку входа) +``` + +Реальный случай 2026-09-17 (`set_var`, §10.1): правка была внесена только в `emit_action`, +инлайн-ветка `emit_step` её игнорировала → round-trip 🟢, значение на выходе **старое**. + +> 📌 Правило «проверка — результат, а не факт записи» действует и здесь: `read back` YAML = «записалось», +> подмена + пересборка = «работает». Сравнивать `diff` против оригинала — должно измениться +> **ровно ожидаемое число строк**. + --- ## 7. Питфоллы @@ -490,7 +516,8 @@ diff A.txt B.txt # пусто = чисто | 5 | 🔴 **Гонять конвертер в ЦЕЛЕВОЙ файл, а не в `/tmp`** | Проверка в `/tmp` не проверяет артефакт. Alex видел `type: trigger` в файле, который я не перегенерировал | | 6 | 🔴 **Не отдавать артефакт, не прочитав его самому** | Round-trip «байты сходятся» ≠ «читаемо». Форма `blocks` проходила round-trip, но `8456` превращался в список цифр — выявил Alex | | 7 | 🔴 **Артефакты — в проект, не в `/tmp`** | «качай доки в папку в проекте а не в темп» | -| 8 | 🔴 **Бэкапы кода — только git** | «какой нахуй бэкап скриптов — там в гите все» | +| 8 | 🔴 **Бэкапы кода — только git. Не создавать копии «на всякий»** | «какой нахуй бэкап скриптов — там в гите все» (2026-09-17: «какой нах бэкап. у нас гит»). Коммит-чекпойнт ДО правки = бэкап; откат = `git checkout `. Копии файлов в проект **не плодить**. Исключение — **доки Obsidian перед УДАЛЕНИЕМ** (MCP-удаление необратимо) | +| 8a | ⚠️ **`git commit` на отсутствующих изменениях → exit 1** | Если дерево чистое, «коммит ДО» делать нечего — уже закоммичено. Проверить `git log -1`, не считать exit 1 провалом | | 9 | 🔴 **`read_file` возвращает контент с номерами строк — не patch-ить им vault** | Обсидиан — только через obsidian-MCP | | 10 | 🔴 **Коммит до проверки Alex** | Порядок: коммит ДО → правка → заливка → **проверка Alex** → коммит ПОСЛЕ | | 11 | ⚠️ `grep -n "id: N"` по YAML даёт **несколько** совпадений | Объект живёт и в своей секции, и внутри сценария. Номер строки меняется между генерациями | @@ -516,8 +543,9 @@ diff A.txt B.txt # пусто = чисто | 26 | 🔴 **Правка комментария — не правка кода** | Три ответа подряд «готово, зелёный», при том что горячий блок стоял выше моего нового. Мёртвый новый код = ложный зелёный | | 27 | ⚠️ **`>` в шелле затирает `.yml` до старта питона** | Проверять exit-код | | 28 | ⚠️ **Один шаг может принадлежать нескольким сценариям** | Хелпер `_register()` — обновляет запись по id, не добавляет дубль | -| 28a | 🔴 **12 разных `set var1` выглядят в YAML одинаково** | `descr` у всех один, различие — только в `args[0]`. Не «оптимизировать» их обратно в одну запись. Смысл теряется: см. §5.10, план §10.1 | +| 28a | 🔴 **10 разных `set var1` выглядят в YAML одинаково** | `descr` у всех один, различие — в `set_var` (+ `args[0]`). Не «оптимизировать» их обратно в одну запись. §5.10, §10.1 | | 28b | ⚠️ **Несущие объекты 49/50 живут в `scenario_orphans`, не в теле шага** | `#Z8830` → `#Z8829=49,8450,1,0`. Sweep (§7.4) докидывает их только как raw-склад. Раскрытие = отдельное решение (вариант B §10.1), не «попутная починка» | +| 28c | 🔴 **Две точки входа энкодера: правка в одной = потеря правки при зелёном round-trip** | `emit_action` (скрипты из списков/`scenario_orphans`) и инлайн-ветка `emit_step` (`descr`+`args`) — **форк**. Новый ключ вводить в **обе**; проверять тестом на **подмену значения** (§10.1) | ### 7.3. Проверка гипотез @@ -616,10 +644,10 @@ https://lk.zont-online.ru/download/firmwares/H2000_PRO____.zip |---|---| | Round-trip | 🟢 **ЗЕЛЁНЫЙ, байт-в-байт** — `661 → 661`, `686 → 686`, `diff` = 0 (проверен на `19-53-21`) | | Форма сценария | ✅ закрыта (§5), оба конвертера переведены | -| Тела типа 59 | 📖 таксономия собрана (§5.10) · ⏳ `set_var` не развёрнут — **план §10.1, ждём ответа** | -| Коммит | `823fabd` на `main` — «Scenario YAML shape: bare action ids, no anchors» (3 файла, +200/−1000) | -| Последний | `b75c51f` — «Drop stale YAML snapshots in old scenario shape» | -| Предыдущий | `1cc010a` — **содержит сломанные версии** (анкоры, лишний `if`, `then` вместо `action`); закрыт §9.1 | +| Тела типа 59 | ✅ **разобраны (§5.10)** · `set_var` развёрнут (§10.1, коммит `87e315c`); несущие условия 49/50 — нет | +| Коммит | `87e315c` — «Type 59: decode set-var target into 'set_var' (parser + encoder, both entry points)» (4 файла) | +| Предыдущий | `b75c51f` — «Drop stale YAML snapshots in old scenario shape» | +| Ранее | `823fabd` — форма сценария §5; `1cc010a` — **содержит сломанные версии**; закрыт §9.1 | | Документация | ✅ **три дока сведены в один** — `personal/projects/zont-config-compiler.md` (§9.2) | | Push | ❌ **не сделан** | @@ -646,6 +674,9 @@ git commit -m "Scenario YAML shape: bare action ids, no anchors" > 📌 **`--amend` не понадобился** — история фиксирует обе итерации формы, откат возможен по SHA. > В коммит вошли **только 3 файла** — 10 скриптов разбора и 2 папки остались untracked намеренно. +> 🔴 **Бэкап-папка `backups_before_scripts_*` УДАЛЕНА** — Alex: «какой нах бэкап. у нас гит». +> Путь отката — только git-история (`b75c51f` → `87e315c` → …). Не воссоздавать (питфолл 8). + ### 9.2. Мерж трёх доков в один (2026-09-17) Документация конвертеров жила в **трёх** доках с наложенными слоями правок (§5b→§5c→§5j→§5k→§6→§8) @@ -681,39 +712,67 @@ git commit -m "Scenario YAML shape: bare action ids, no anchors" 1. **Тип 50** — маска дней недели: `#Z8548=50,1,0,0,109`, где `109 = 0b1101101` = пн, ср, чт, сб, вс. Alex: «это выбор дней недели, то же самое что ставится в значение var1». Сейчас `raw`. -2. **`set var1` с `args: [8844, 0, 0]`** — внутри объекта `8844` лежит та же маска дней недели, - объект не раскрыт: «что какого-то хуя уехало вовне сценария вообще — только там пн, вт, чт, пт, сб, вс». - → **см. §10.1, план в работе.** +2. **Несущие объекты `set var1`** — `set_var` даёт **id цели** (`8844`), но тело цели (`#Z8844=50,1,0,0,123` — + та же маска дней недели; `#Z8829=49,8450,1,0` — условие) лежит в `scenario_orphans`, в тело сценария + не развёрнуто. Alex: «что какого-то хуя уехало вовне сценария вообще — только там пн, вт, чт, пт, сб, вс». + → **вариант B, §10.1 (не выбирался).** 3. **`unresolved: true`** у `#Z8860`, `#Z8864`, `#Z8601` — этих объектов нет в конфиге. 4. **Тип 3** (SMS `8195`) — тело лежит якорем в `sms_notifications`, не раскрыто. 5. **Тип 11 внутри `steps` другого сценария** — ссылка на сценарий голым `id` (`#Z8456` держит `11109`). 6. **Старые снапшоты `14-16-35.yml` / `16-02-18.yml`** — по 2 вхождения старого `type:`, не перегенерированы. -### 10.1. 📋 ПЛАН: развернуть `set var` / `print log` / alarm (2026-09-17, ОЖИДАЕТ ОТВЕТА) +### 10.1. ✅ ВЫПОЛНЕНО: `set_var` в записи 59 (2026-09-17) **Запрос Alex:** «Теперь разверни set var, print log и alarm нотификации». -**Разведка (сделана):** снят живой конфиг `config_local_2026-09-17_19-53-21.txt` (686 строк, -661 `#Z`), round-trip 🟢 чистый, таксономия §5.10. Три конструкции = тела **типа 59**. +**Итог:** все три — тела **типа 59** (§5.10). Развёрнут `set_var`; `puts`/`storeev` уже +самодостаточны (`descr` + args), отдельного типа alarm нет. Сделано **вариантом A**. -| Шаг | Что | Файл | -|---|---|---| -| 1 | ✅ **Бэкап** | `zont_config/backups_before_scripts_20260917_195411/` (оба конвертера + `test_roundtrip.py` + txt) | -| 2 | В `dump` типа 59: если `descr` начинается с `set var` → добавить `set_var: ` (голый id; резолв имени **запрещён** §5.7) | `config-to-yml.py` | -| 3 | В `emit_action`: `set_var` → собрать `[59, 'set var', set_var, 0, 0]` | `yml-to-config.py` | -| 4 | ⚠️ Развернуть 12 несущих условий (`#Z8829`/`8831`/…/`8847`, типы 49/50) из `scenario_orphans` | оба | -| 5 | Round-trip + правка §4/§5.7 + снять п.2 этого списка | док | +| Шаг | Что | Файл | Статус | +|---|---|---|---| +| 1 | Коммит ДО (бэкап не нужен — git, питфолл 8) | `b75c51f` | ✅ | +| 2 | `dump` типа 59: `descr` начинается с `set var` **и** `args[0]` — непустой int (не `bool`/`float`) → `set_var: ` | `config-to-yml.py` | ✅ | +| 3 | `emit_action` **и** инлайн-ветка `emit_step`: `set_var` переопределяет arg 1, тип проверяется (не int → exit 2) | `yml-to-config.py` | ✅ | +| 4 | Развернуть несущие условия (`#Z8829`/`8831`/…/`8847`) | — | ❌ не делалось (вариант A) | +| 5 | Round-trip + док | док | ✅ | -**Открытый вопрос (задан Alex, ответа нет):** делать ли шаг 4. +**Коммит ПОСЛЕ:** `87e315c` — «Type 59: decode set-var target into 'set_var' (parser + encoder, +both entry points)», 4 файла (2 конвертера + снимок `19-53-21` `.txt`/`.yml`). -- **Вариант A (рекомендован)** — только `set_var` в 59 (шаги 2, 3, 5). Минимум риска, - round-trip гарантированно зелёный. Шаг 4 не трогаем. -- **Вариант B** — A + развернуть несущие условия 49/50. Полный смысл, но **меняет форму `8456`** - (сейчас та байт-совместима через `scenario_orphans`). +#### 🔴 Питфолл, поймавший реальный баг: круг «правка в шаге не доезжает» -**Порядок работ (правило Alex):** коммит ДО → правка → заливка → проверка Alex → коммит ПОСЛЕ. +Две точки входа — **разные**. `emit_action` (скрипты в `scenario_orphans`/списках) и +**инлайн-ветка `emit_step`** (`if 'descr' in step and 'args' in step`) — форк: правка только в +`emit_action` даёт **зелёный round-trip** и **незамеченную потерю правки**. -**Текущее состояние:** разведка + бэкап сделаны, **правок кода НЕТ**. Ждём «A» или «B». +Проверка, которая это вскрыла (проверять не `read back`, а **эффект**): + +```bash +cd /tmp && rm -rf zt && mkdir zt && cd zt +cp /Users/admin/Automation/HA-ZONT-Modbus/zont_config/config_local_2026-09-17_19-53-21.yml t.yml +# подменить ОДИН set_var: 8829 -> 7777 (в блоке args descr: set var1) +/usr/bin/python3 /Users/admin/Automation/HA-ZONT-Modbus/yml-to-config.py t.yml > out.txt +iconv -f cp1251 -t utf-8 out.txt | tr -d '\r' | grep '^#Z8830=59' +# ❌ 8829 = правка не доехала ✅ 7777 = доехала +``` + +> 📌 **Правило:** round-trip зелёный ≠ правка работает. Round-trip читает то, что положил парсер; +> если энкодер проигнорировал ключ — байты сходятся. Один тест на **подмену значения** обязателен +> для каждого нового ключа. После починки инлайн-ветки: `#Z8830=59,'set var1',7777,0,0`, +> `diff` vs оригинал = **ровно 1 строка**. (Питфолл 25 — тот же корень: объект отображается в +> двух местах, править надо **все**.) + +#### Факт по данным + +- `set var1` в конфиге **10**, не 12 (в плане было 12 — ошибка счёта). Формы: 9 с ненулевым + `args[0]` → получили `set_var`; `#Z8472=59,'set var1',0,0,0` (arg1 = 0) → **без** `set_var` + (запись ничего не пишет, разворачивать нечего). +- `set var1` — 0 не влезает в предикат намеренно: `0` = «нет цели», не id. + +**Что НЕ сделано (сознательно):** несущие условия `#Z8830 → #Z8829=49,8450,1,0` и маски +`#Z8844=50,1,0,0,123` остались в `scenario_orphans` как raw-склад — разворачивание их в тело +сценария меняет форму `8456` (вариант B, Alex не выбрал). Видно по `set_var: 8829` — цель есть, +тело цели лежит рядом в `scenario_orphans`. --- @@ -721,18 +780,17 @@ git commit -m "Scenario YAML shape: bare action ids, no anchors" | Файл | Статус | |---|---| -| `config-to-yml.py` | ✅ форма §5: `trigger:` подъём через `pop('if')`, шаг = `{id, [flag], action}` или `{id, [flag], if, then, [else]}` · ⏳ тип 59 без `set_var` (§10.1) | -| `yml-to-config.py` | ✅ `emit_step` читает `then`/`action`/`else`/`flag`; `f5` из `trigger:`/`interval_ms` + бит 8 · ⏳ тип 59 без `set_var` (§10.1) | +| `config-to-yml.py` | ✅ форма §5: `trigger:` подъём через `pop('if')`, шаг = `{id, [flag], action}` или `{id, [flag], if, then, [else]}` · ✅ тип 59 даёт `set_var` (§10.1) | +| `yml-to-config.py` | ✅ `emit_step` читает `then`/`action`/`else`/`flag`; `f5` из `trigger:`/`interval_ms` + бит 8 · ✅ `set_var` в **обеих** точках (`emit_action` + инлайн `emit_step`) | | `test_roundtrip.py` | ✅ без изменений (в коммите `199f2b1`) | -| `zont_config/config_local_2026-09-17_19-53-21.{txt}` | 🆕 **актуальный** снимок с прибора (686 строк, 661 `#Z`) | +| `zont_config/config_local_2026-09-17_19-53-21.{txt,yml}` | 🆕 **актуальный** снимок с прибора (686 строк, 661 `#Z`) — в коммите `87e315c` | | `zont_config/config_local_2026-09-17_18-43-24.{txt,yml}` | предыдущий снимок, форма §5 | -| `zont_config/backups_before_scripts_20260917_195411/` | 🆕 бэкап **перед** правками типа 59 (оба конвертера + `test_roundtrip.py` + txt) | | `zont_config/config_local_2026-09-17_{17-45-00,16-13-28,16-02-18,14-16-35}.*` | исторические снапшоты | | `zont_config/config_0FA7C33CC89F_…_12-12-28.txt` | боевой конфиг (598 `#Z`, 25 `#S`) | | `zont_config/archive/` | `-2`/`-3`/`-4` — закоммичены (`7ae0e32`) | -> 📌 **Бэкап кода — только git** (питфолл 8), но **перед правкой типа 59** Alex просил бэкап -> рабочей копии — он лежит в `backups_before_scripts_/`, не в `/tmp`. +> 📌 **Бэкап кода — только git** (питфолл 8). Alex 2026-09-17: «какой нах бэкап. у нас гит». +> Копии файлов перед правкой **не делать**, рабочий откат = `git checkout `. **Не относится к конвертерам** (исторический TrueNAS-стек, **декомиссирован**): `INFRASTRUCTURE.md`, `docker-compose.yml`, `docker run.txt`, `modbus_*_bridge.py`, diff --git a/personal/tech/roundtrip-key-verification.md b/personal/tech/roundtrip-key-verification.md new file mode 100644 index 00000000..7f724329 --- /dev/null +++ b/personal/tech/roundtrip-key-verification.md @@ -0,0 +1,102 @@ +--- +title: 🔁 Round-trip key verification — проверка нового ключа конвертера +namespace: personal +type: how-to +created: '2026-09-17' +updated: '2026-09-17' +tags: + - personal + - how-to + - testing + - converters + - reference +related: + - '[[personal/projects/zont-config-compiler]]' +--- + +# 🔁 Round-trip key verification + +> Зеркало навыка `roundtrip-key-verification` (категория `software-development`, Hermes). +> Появился из реального бага в ZONT-конвертере 2026-09-17 (коммит `87e315c`). +> Разбор в проекте — [[personal/projects/zont-config-compiler]] §10.1. + +## Когда применять + +Любой двусторонний конвертер с тестом байтовой точности: `.txt ⇄ .yml`, сериализатор с +decode + encode, dump/undump AST, DB ⇄ ORM. + +**Триггер:** добавил **новый ключ** в декодированный вывод и round-trip зелёный. + +## 🔴 Ловушка + +Байтовый round-trip доказывает, что **вывод парсера == вход**. Про энкодер он не говорит +ничего — энкодер получает ровно то, что парсер положил. + +Если энкодер **игнорирует** новый ключ, тест всё равно зелёный: + +``` +original.txt → parse → YAML(с новым ключом) → encode(ключ игнорируется) → original.txt ✅ diff = 0 + ▲ ключ добавлен ▲ ключ потерян, старое поле доносит байты +``` + +Потеря невидима: старое поле (`args`) рядом и по-прежнему собирает байты. + +**Реальный случай:** ключ `set_var` добавлен в парсер типа 59, энкодер поправлен **только** +в `emit_action`. Вторая точка входа — инлайн-ветка `emit_step` (`'descr' in step and 'args' in step`) — +ключ игнорировала. Round-trip 🟢, правка пользователя молча терялась. + +## Обязательный шаг: тест подменой (mutation) + +`read back` («записалось») — **не** проверка. Подменить новое значение и пересобрать; эффект +обязан появиться в выходе. + +```bash +cd /tmp && rm -rf t && mkdir t && cd t +cp <реальный-артефакт>.yml t.yml +python3 - <<'EOF' +s = open('t.yml', encoding='utf-8').read() +old = "...блок со старым значением нового ключа..." +new = "...тот же блок, значение заменено на маркер (напр. 7777)..." +assert s.count(old) == 1, s.count(old) # assert уникальности — без случайных мультиправок +open('t.yml', 'w', encoding='utf-8').write(s.replace(old, new)) +EOF +python3 <проект>/encode.py t.yml > out.txt +diff <(iconv -f <вх> -t utf-8 <оригинал> | tr -d '\r' | sort) \ + <(iconv -f <вых> -t utf-8 out.txt | tr -d '\r' | sort) +# ✅ изменилась РОВНО одна строка, в ней маркер +# ❌ ноль изменений = энкодер ключ проигнорировал → искать ДРУГУЮ ветку кода +``` + +Проверять **обе** половины: +1. **Парсер** — ключ есть в промежуточном файле, на ожидаемом месте. +2. **Энкодер** — подмена доезжает до выхода, `diff` = ровно 1 строка. + +## Найти ВСЕ точки входа + +У конвертеров их обычно две и больше: рекурсивный обход и инлайн/ссылочная ветка, либо +проход по реестру типов и явный цикл. + +- `grep -n "def emit_\|def dump_" *.py` — выписать **всех** вызывающих. +- Не править одну и полагать, что покрыта другая. Дублированные ветки — норма. +- Симптом пропущенной ветки: в тесте подменой остаётся старое значение. + +В **каждой** ветке ставить проверку типа, чтобы плохое значение падало громко (`exit 2`), +а не тихо уходило сырым полем. + +## Смежные питфоллы + +- **Проверять тип производного ключа.** Парсер, сохраняющий whole-float как float (`14.5`, `1.0`), + даст не-int для ключа-идентификатора. Guard: + `isinstance(v, int) and not isinstance(v, bool) and not isinstance(v, float) and v != 0`. + `0` = «цели нет», а не валидный id — пропускать, не выдумывать. +- **Один объект в двух секциях** → ключ править во **всех** местах отображения, иначе одна + проекция показывает новую семантику, другая старую. Round-trip и тут зелёный. +- **Считать вхождения по данным**, не по плану: «12 элементов» из плана оказались 10 в конфиге. +- **Не резолвить имя из другого объекта.** Производный ключ несёт сырой id; `target_name` + вместо `target` — запрещённая подстановка. + +## Что записать после проверки + +1. Новый ключ, где эмитится, какие точки входа поправлены. +2. Команду подмены и наблюдённый результат («1 строка изменилась, маркер на месте»). +3. Питфолл «две точки входа» — чтобы следующая сессия сразу проверяла обе.