[2026-09-17] eagle: personal/projects/zont-config-compiler.md personal/tech/roundtrip-key-verification.md

This commit is contained in:
Alexey Martemyanov
2026-09-17 20:02:47 +06:00
parent 5fbf03b00f
commit 97ba40bf8e
2 changed files with 202 additions and 42 deletions
+100 -42
View File
@@ -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<N>``set_var``puts``storeev I/A``objcmd` (+`target`/`value`) | §5.10 |
| `5` | действие над выходом | `[5, '<descr>', output_ref, value, …]` | `{descr, target, value, params?}` |
| `9` | команда (реле/контур/режим) | `[9, '<descr>', target, '<value>']` | `{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<N>` (§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 <id> "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<id>=<тип>'
# ✅ новое значение на месте ❌ старое = правка не доехала (искать ВТОРУЮ точку входа)
```
Реальный случай 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 <SHA>`. Копии файлов в проект **не плодить**. Исключение — **доки 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_<HW>__<FW>_<PROFILE>.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: <args[0]>` (голый id; резолв имени **запрещён** §5.7) | `config-to-yml.py` |
| 3 | В `emit_action`: `set_var` → собрать `[59, 'set var<N>', 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: <args[0]>` | `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_<TS>/`, не в `/tmp`.
> 📌 **Бэкап кода — только git** (питфолл 8). Alex 2026-09-17: «какой нах бэкап. у нас гит».
> Копии файлов перед правкой **не делать**, рабочий откат = `git checkout <SHA>`.
**Не относится к конвертерам** (исторический TrueNAS-стек, **декомиссирован**):
`INFRASTRUCTURE.md`, `docker-compose.yml`, `docker run.txt`, `modbus_*_bridge.py`,
+102
View File
@@ -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_<x>\|def dump_<x>" *.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. Питфолл «две точки входа» — чтобы следующая сессия сразу проверяла обе.