Files
obsidian-vault/personal/tech/roundtrip-key-verification.md
T

12 KiB
Raw Blame History

title, namespace, type, created, updated, tags, related
title namespace type created updated tags related
🔁 Round-trip key verification — проверка нового ключа конвертера personal how-to 2026-09-17 2026-09-17
personal
how-to
testing
converters
reference
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.

6. 🔴 ПИТФОЛЛ 93 — переименовал ключ словаря → грепни ВСЕ обращения к нему

Маски objcmd перешли с полных ('1 %0') на базы до плейсхолдера ('1 '), но проверка if _OC_OPS.get(_oc_mask) is None: осталась смотреть на старое имя переменной. Ключа нет → Noneвсе 6 записей молча ушли в fallback descr+args. Круг зелёный: fallback доносит байты верно, а новый ключ просто не появляется (ровно §6-сценарий выше).

# переименовал переменную/ключ — найди КАЖДОЕ обращение, а не только определение
grep -n "_oc_mask\|_oc_base" config-to-yml.py   # словарь, обе проверки, обе сборки

Симптом, который надо узнать: артефакт показывает старую форму при зелёном круге. Это не «правка не сохранилась» — это fallback, который её перекрывает.

7. 🔴 ПИТФОЛЛ 94 — «объект есть в YAML» ≠ «энкодер его выпустит»

Тела, развёрнутые внутри другого объекта (операнды left/right, source), не лежат отдельной секцией. Индекс тел, построенный по спискам верхнего уровня с ключом raw, их не видит_raw_of() отдаёт None → объекты теряются из выхода.

Разбор был последовательным и вскрыл четыре подловушки:

Симптом Причина Решение
-5 объектов индекс не обходил scenarios рекурсивный обход вложенного
всё ещё -5 у развёрнутых тел нет raw raw клался в тело (костыль, потом снесён)
-2 вложенные операнды left/right не регистрировались рекурсия по left/right
-2 снова фильтр x in _known_ids отсекал нужные id фильтр убран, дедуп на _register

Правила:

  • Индекс тел собирать по признаку id + type, а не по наличию raw.
  • Строку собирать хелпером по полямraw в промежуточном файле не нужен вовсе.
  • _known_ids — НЕ «что уже выпущено», а «отличить литерал от ссылки». Для второго есть отдельное множество (_emitted_ids). Перепутать = самострел: пропустишь ровно то, что надо выпустить.

8. 🔴 ПИТФОЛЛ 95 — костыли в промежуточном формате видно глазом владельца

Чтобы индекс нашёл развёрнутые тела, в них временно клался raw. Круг — зелёный, но артефакт зарос 88 блоками сырья, и владелец это увидел сразу.

круг зелёный  +  артефакт зарос костылём  =  правка не принята

Правило: raw/_-хелперы в выходном файле — только на время отладки. Если костыль нужен индексу — значит индекс построен по неверному признаку, чинить индекс, а не засорять артефакт.

Когда применять

Любой двусторонний конвертер с тестом байтовой точности: .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 («записалось») — не проверка. Подменить новое значение и пересобрать; эффект обязан появиться в выходе.

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. Питфолл «две точки входа» — чтобы следующая сессия сразу проверяла обе.

🔴 Круг зелёный ≠ приёмка (2026-09-17, ZONT круг 39)

Жёсткий урок той же сессии. Round-trip байтово чистый не означает, что правка верна.

Случай: правка декодера + энкодера прошла круг 9/9, но владелец проекта посмотрел на артефакт и увидел выдуманный ключ и обёртку-список на один элемент. С точки зрения байтов всё правильно; с точки зрения человека — сломано.

круг зелёный  +  артефакт читается плохо   =   правка не принята

Правило: перед правкой формы назвать целевую форму словами владельца и только потом трогать код. Если форма не названа — не угадывать: спросить одним вопросом.

Правило трёх: третья подряд правка «вслепую» (форма не названа, гипотеза за гипотезой) — стоп. Не править код. Назвать допущение, которое может быть неверным, и задать ОДИН вопрос.

Симптом «сломано, хотя круг зелёный»: объект, у которого есть id в строке конфига, при развороте тела в YAML теряет этот id → энкодеру не из чего собрать строку, и объекты уезжают в raw-секции (в ZONT: орфаны с 46 выросли с 4 до 69). Проверять не только число объектов и строк, но и состав служебных секций до/после.

Проверка, которую стоит добавить в любой конвертер:

# число объектов в raw-фоллбэк-секциях ДО и ПОСЛЕ правки должно совпадать
grep -c '^\- id:' out.yml.before   # <секция-фоллбэк>
grep -c '^\- id:' out.yml.after

Рост этой секции = форма не разобрана, а не «данных нет».