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

6.1 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.

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

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