Три предыдущие статьи мини-цикла закрыли обычную форму. Сначала - как сделать бинарный Form.bin читаемым через распаковщик. Потом - как вытащить дерево элементов из elem.json. Потом - как собрать все формы в один реестр scan_forms и следить за его дрейфом. И всё это время в углу тихо сидел второй тип формы, про который я предпочитал не думать: управляемая.
Гипотеза у меня была готовая и, как мне казалось, очевидная. Обычная форма - это бинарник Form.bin, значит, управляемая должна лежать текстом, в Form.xml. Отсюда и план работ: научиться отличать управляемую форму от обычной, написать чистый парсер Form.xml, отделить его от сканера выгрузки, собрать из XML компактную выжимку и считать по ней дрейф. План выглядел стройно ровно до одного шага, который я почему-то отложил на потом: посмотреть, что реально лежит в распакованной выгрузке.
А когда посмотрел - Form.xml там не было. Ни у одной формы, ни в каком режиме распаковщика. И обычная, и управляемая форма материализовались одним и тем же артефактом - *.elem.json. Вся стройная ветка про XML-парсер оказалась решением несуществующей задачи.
Эта статья - про то, как исходная гипотеза не подтвердилась и что из этого вышло на практике: как управляемая форма попадает в тот же конвейер, что и обычная, через *.elem.json, и почему это обошлось без единого нового парсера.
Коротко: что делать
Всё описанное ниже уже реализовано в пакете v8unpack-agent и слито в основную ветку. Ветка про Form.xml (отдельный XML-парсер, классификатор типа формы) - отклонена и в код не вошла; почему - в первом же разделе.
- Проверить артефакт до проектирования парсера. Используемый распаковщик не отдаёт Form.xml: и обычная, и управляемая форма распаковываются в
*.elem.json. Form.xml существует только в другом сценарии платформы («Выгрузить конфигурацию в файлы») и в этот пайплайн не входит. - Обнаруживать форму по наличию
*.elem.jsonв её директории, а не по типу и не по имени модуля. Признак единый для обоих типов форм. - Не заводить отдельный реестр и отдельный парсер под управляемую форму. Оба типа кладутся в общий
FormScanIndex; у elem-формы заполняется путьelem_json_path, у обычных и внешних он пустой. - Не вводить ярлык «обычная/управляемая» в реестр. Отдельный классификатор типа самостоятельной ценности не дал и был отклонён: выжимка и дрейф строятся одинаково для любой формы с
*.elem.json. - Строить компактную выжимку
FormSummaryповерх штатногоparse_elem_json, а не через отдельный слой-адаптер: реквизиты, команды, элементы, события, связи - без идентификаторов и оформления. - Контроль дрейфа считать по уже существующему структурному хэшу
elem_sha256(хэш нормализованной структуры), а не по сырому*.elem.json: иначе смена идентификаторов и layout-кодов при пересборке даст ложный дрейф. - Тесты держать полностью синтетическими: генератор
*.elem.json-фикстур без единого реального контейнера.
Диаграмма конвейера:

Гипотеза Form.xml и что оказалось в распаковке
Начну с ошибки, потому что она здесь центральная и поучительная.
Рабочая гипотеза звучала так: раз обычная форма лежит бинарём, то управляемая наверняка лежит текстом - в Form.xml, откуда её и надо парсить. Я даже успел расписать под это архитектуру: чистый парсер XML на вход-содержимое, отдельный от него сканер выгрузки, классификатор типа формы по артефакту. Всё это было сформулировано как задачи и выглядело разумно - но не проверено. Первый же реальный шаг - открыть распакованную выгрузку и посмотреть, что там лежит рядом с формами, - гипотезу опроверг. Используемый распаковщик не отдаёт Form.xml ни в каком режиме. И обычная, и управляемая форма при распаковке контейнера материализуются одинаково - файлом *.elem.json плюс сопутствующие артефакты. Form.xml в природе существует, но в другом сценарии: это результат платформенной операции «Выгрузить конфигурацию в файлы», а не распаковки контейнера. В конвейер поверх распаковщика он не попадает вообще.
Последствие простое и приятное: почти вся «управляемая» ветка схлопнулась. Не нужен отдельный XML-парсер - структуру формы и так читает штатный разборщик elem.json. Не нужен классификатор типа - и обычная, и управляемая форма обнаруживаются по одному признаку и обрабатываются одинаково. Две отдельные задачи (чистый парсер Form.xml и классификатор «обычная/управляемая») были закрыты как неактуальные ещё до реализации.
| Что планировалось под Form.xml | Что оказалось на самом деле |
|---|---|
| Управляемая форма - текстовый Form.xml | Управляемая форма - тот же *.elem.json, что и обычная |
| Нужен отдельный XML-парсер | Достаточно штатного parse_elem_json |
| Нужен классификатор типа формы | Тип формы для конвейера не нужен - признак один |
| Отдельный реестр или отдельное поле под XML | Одно поле пути elem_json_path в общем реестре |
Обидно ли было выкинуть проработанный план? Нет. Дешевле выкинуть план на бумаге, чем поддерживать парсер формата, которого в пайплайне не существует.
Обнаружение формы: признак - наличие *.elem.json
discover_elem_forms находит любую форму, в чьей директории есть *.elem.json - и обычную, и управляемую. Назовём её «elem-формой» по этому единственному признаку; остальные файлы в директории - опциональные спутники.
from pathlib import Path
from v8unpack_agent.managed_forms import discover_elem_forms, ElemFormEntry
forms: list[ElemFormEntry] = discover_elem_forms(Path("cf_export/"))
# ElemFormEntry.elem_json_path: Path # обязательный носитель структуры
# ElemFormEntry.meta_json_path: Path | None # сопутствующие метаданные
# ElemFormEntry.id_json_path: Path | None
# ElemFormEntry.bsl_path: Path | None # модуль формы, если он есть
# ElemFormEntry.descent_artifacts: list[...] # наборы файлов по значению --descent
# ElemFormEntry.extra_warnings: list[str]
Имя discover_elem_forms выбрано намеренно нейтральным - оно про elem-форму, а не про «управляемую».
Неочевидная деталь - имена сопутствующих файлов. Основной артефакт всегда называется предсказуемо: <Контейнер>.elem.json. А вот у спутников имя зависит от параметра распаковщика --descent. Если параметр не задан, в имени используется литерал id:
CatalogForm.elem.json
CatalogForm.id.json
CatalogForm.obj.id.bsl
При --descent 10:
CatalogForm.elem.json
CatalogForm.10.json
CatalogForm.obj.10.bsl
Значение бывает и четырёхкомпонентным - полной версией платформы вида <major>.<minor>.<build>.<patch>, - тогда суффикс спутников такой же. Поэтому обнаружение не завязано на конкретный суффикс: обязателен только <Контейнер>.elem.json, а спутники подбираются по фактическому descent-суффиксу, каким бы он ни был. Модуль формы (.obj.<descent>.bsl) при этом необязателен: у части форм его просто нет, и это не мешает считать директорию формой.
Раскладку каталогов обнаружение поддерживает во всех вариантах, которые встречаются в распаковке: контейнеры по типу объекта (CatalogForm, DocumentForm, ReportForm и другие имена, оканчивающиеся на Form), общие формы (CommonForm), а также внешние обработки и отчёты (EPF - External Data Processor File, файл внешней обработки; ERF - External Report File, файл внешнего отчёта). Пути в результат пишутся относительные, от корня распаковки. Полная таблица раскладок вынесена в docs/managed_forms_structure.md репозитория.
Практическое правило: признак формы - это наличие структурного файла в её директории, а не тип формы и не наличие модуля. Не завязывайте обнаружение на суффикс сопутствующих файлов: он зависит от параметров распаковщика и меняется, а основной артефакт - нет.
Единый реестр: elem_json_path вместо второго индекса
Первым соблазном было завести отдельный реестр под управляемые формы: мол, они другие, пусть живут отдельно. Второй, более тонкий соблазн - оставить один реестр, но добавить в запись поле-ярлык «тип формы». От обоих я отказался, и оба отказа оказались правильными.
Реестр форм в пайплайне отвечает на один вопрос: «какие формы вообще есть и где они лежат». Агенту, который спрашивает «покажи форму заказа клиента», всё равно, обычная она или управляемая, - ему нужен адрес. Разводить формы по двум реестрам значит дублировать и маршрутизатор, и детект дрейфа, а любой обзорный запрос гонять по двум источникам. Лишняя связность на ровном месте.
Поэтому обычные, внешние и elem-формы лежат в одном FormScanIndex. Отличие elem-формы - одно поле пути:
Один парсер структуры вместо второго
Вот прямое следствие проверки артефакта: раз структура управляемой формы лежит в *.elem.json, её читает тот же разборщик, что и структуру обычной формы, - parse_elem_json из предыдущей статьи цикла. Второй парсер не нужен.
Это стоит проговорить, потому что на пути к финальному решению я успел сделать лишний крюк. Промежуточная попытка была такой: реальный *.elem.json структурно не совпадал с той нормализованной моделью, которую я держал в голове для выжимки, поэтому напрашивался слой-адаптер «сырой elem.json -> удобный промежуточный вид -> выжимка». Я его даже начал. А потом убрал: адаптер оказался дублирующим парсером - он повторно разбирал то, что уже умеет разбирать parse_elem_json. Правильная граница прошла не «сырой JSON -> адаптер -> выжимка», а «parse_elem_json -> выжимка». Один разборщик структуры на весь пакет.
Семантика формы в реальном *.elem.json лежит не в готовых секциях attributes/elements, как я сначала предполагал, а в служебных секциях: рекурсивная иерархия элементов, список реквизитов формы, узлы с путями к данным. Плюс много шума в тех же узлах: идентификаторы, GUID (Globally Unique Identifier - глобально уникальный идентификатор), числовые layout-коды, координаты и оформление. Задача выжимки - вытащить смысл и отбросить шум, и делать это поверх уже разобранной структуры, а не поверх сырого файла.
Практическое правило: прежде чем писать второй парсер под «другой» источник, проверьте, не разбирает ли его уже существующий. Адаптер, который повторно парсит то же самое, - это дублирующий парсер под другим именем, и его стоимость всплывёт при первом же изменении формата.
from pathlib import Path
from v8unpack_agent.scan_forms import scan_forms, FormScanIndex, FormEntry
index: FormScanIndex = scan_forms(Path("cf_export/"))
# У формы с *.elem.json заполнен путь к структурному файлу:
# FormEntry.elem_json_path: Path | None # relative-to-root; None у обычных/внешних
# FormEntry.elem_sha256: str | None # хэш нормализованной структуры (см. дрейф)
Ключевое решение здесь - что реестр хранит только путь, а не разобранную структуру. Разбор *.elem.json по требованию делает parse_elem_json; сам реестр парсер структуры не импортирует и второй раз ничего не парсит. Это ровно та дисциплина, что и в предыдущей статье: реестр отвечает «где форма», а не «что внутри».
А вот отдельного поля-ярлыка «обычная/управляемая» в реестре нет, и это осознанно. Изначально планировался классификатор типа формы, который проставлял бы такой ярлык. На практике оказалось, что ярлык ни на что не влияет: выжимка строится для любой формы с *.elem.json одинаково, дрейф считается одинаково, маршрутизация одинаковая. Отдельный классификатор не давал самостоятельной ценности и был закрыт как непланируемый. Тип формы для конвейера просто не нужен - нужен факт наличия *.elem.json.
Обратную совместимость реестр держит через дефолты: старый индекс без поля elem_json_path читается штатно (значение по умолчанию пустое), а если в старом реестре когда-то фигурировало поле под путь к XML, оно при чтении молча игнорируется. Никакой миграции старых индексов не требуется.
Практическое правило: не заводите ни второй реестр под второй тип формы, ни поле-ярлык типа, если тип ни на что не влияет. Один реестр, одно поле пути к структуре, общий ключ уникальности. Ярлык, который не меняет обработку, - это лишнее поле, которое потом придётся тащить в каждой ветке кода.
Компактная выжимка: FormSummary поверх elem.json
Цель выжимки - свести структуру формы к компактному семантическому виду для агента: оставить смысл, выбросить косметику и технические идентификаторы. Тот же принцип, что и для дерева элементов обычной формы в прошлой статье, только источник теперь единый для обоих типов.
Выжимка строится функцией build_form_summary:
from pathlib import Path
from v8unpack_agent.form_summary import build_form_summary, to_normalized_json, FormSummary
summary: FormSummary = build_form_summary(Path("cf_export/Catalog/Банки/CatalogForm/ФормаЭлемента"))
# FormSummary.attributes: list[dict] # реквизиты формы
# FormSummary.commands: list[dict] # команды формы
# FormSummary.elements: list[dict] # элементы формы (имя, тип, вложенность)
# FormSummary.events: list[dict] # обработчики событий
# FormSummary.relations: list[dict] # связи «элемент -> данные/команда»
# FormSummary.warnings: list[str] # честные предупреждения, где связь не выводится
normalized_json: str = to_normalized_json(summary)
Имя типа - FormSummary, а не что-то с префиксом про управляемую форму. Это результат сознательного переименования: выжимка строится для любой elem-формы, обычной и управляемой, и префикс вводил в заблуждение.
Что выжимка делает с исходной структурой:
- Идентификаторы, GUID и числовые layout-коды не попадают в результат - они не адресуют для агента ничего и меняются от выгрузки к выгрузке.
- Оформление (цвета, размеры, шрифты, координаты) отбрасывается целиком.
- Смысловые коллекции сортируются детерминированно по стабильным ключам, чтобы косметические перестановки не меняли результат.
- Связи вытащены явными полями: привязка элемента к данным, связь кнопки с командой, имя процедуры-обработчика у события. Именно связи и делают выжимку полезной - голый список элементов отвечает на «что на форме есть», а связи на «к какому реквизиту привязано поле и какая процедура вызывается».
Сериализация детерминирована намеренно: to_normalized_json даёт для одного и того же смыслового содержимого один и тот же JSON (JavaScript Object Notation - текстовый формат обмена данными), с устойчивым порядком ключей. Это условие следующего шага - контроля дрейфа: если сериализация «плавает», хэш по ней бессмысленен.
Честная граница достоверности. Часть связей не всегда выводится из *.elem.json - там, где семантика в файле не представлена, выжимка не додумывает её, а пишет предупреждение в warnings. Это тот же принцип, что и с вложенностью групп обычной формы в прошлой статье: восстанавливаем достоверное, честно отмечаем недостающее, ничего не реконструируем по догадке.
Практическое правило: выжимка - это реквизиты, команды, элементы, события и связи, приведённые к именам без идентификаторов и без оформления. Где связь из структуры не выводится - предупреждение, а не реконструкция. Детерминированная сериализация здесь не украшение, а условие корректного дрейфа.
Контроль дрейфа: тот же elem_sha256 для всех форм
Реестр без детекта изменений протухает после первой же правки конфигурации. Для обычных форм дрейф уже считался в прошлой статье: хэш содержимого кода (bsl_sha256) и хэш нормализованной структуры (elem_sha256), разведённые на два независимых сигнала modified и structure_modified. Задача с управляемыми формами свелась не к тому, чтобы придумать для них новый механизм, а к тому, чтобы убедиться: они попадают в уже существующий.
И они попадают. Раз структура elem-формы читается тем же parse_elem_json, то и структурный хэш у неё считается тем же способом - по нормализованной структуре, а не по сырому файлу. Это критично. Сырой *.elem.json на девять десятых состоит из идентификаторов, GUID и layout-кодов, которые текут от одной распаковки к другой без единой смысловой правки. Хэш сырого файла дал бы ложный структурный дрейф на каждой пересборке. Поэтому хэшируется нормализованная структура: из неё оставлены только смысловые ключи (имя, тип, путь, родитель, страница, привязка к данным, обработчик), а весь шум отброшен ещё до хэширования. Отдельный «сырой» хэш *.elem.json заводить прямо запрещено - только существующий структурный elem_sha256.
Раскладка по сценариям получается единой для всех форм:
Действие modified structure_modified
Правка процедуры в модуле формы да нет
Добавление реквизита/команды/элемента нет да
Правка модуля и добавление элемента да да
Смена идентификаторов/layout-кодов при пересборке нет нет
Повторная распаковка без содержательных правок нет нет
Последние две строки - ровно то, ради чего дрейф считается по нормализованной структуре, а не по сырому артефакту.
Отдельно про elem-only формы - те, у которых есть *.elem.json, но нет модуля. Их надо аккуратно исключать из «протухших извлечений» и «удалённых»: отсутствие модуля у такой формы - это норма, а не признак того, что распаковка что-то потеряла. Иначе детект дрейфа честно, но ошибочно сигналил бы, что все elem-only формы «исчезли».
Была и отдельная поломка на внешних формах, которую стоит назвать прямо, потому что она поучительна. Детект дрейфа снимает снимок текущего состояния диска своей функцией, и эта функция изначально умела обходить только раскладку конфигурации. При проверке дрейфа внешних обработок и отчётов (режим external) снимок диска получался пустым для их раскладки - и все внешние формы немедленно уходили в «удалённые» сразу после создания baseline, хотя на диске ничего не менялось. Ложный дрейф на пустом месте. Чинить пришлось несколько часов.
Лечение вышло не «добавить вторую ветку обхода», а «перестать дублировать обход». Функция снимка теперь не ходит по диску сама, а делегирует это тому же scan_forms, передавая ему режим:
from pathlib import Path
from v8unpack_agent.drift_checker import check_drift, DriftReport
report: DriftReport = check_drift(
cf_export_root=Path("external_export/"),
index_path=Path("forms_scan_index.json"),
mode="external", # тот же режим, что и у scan_forms
)
# report.structure_modified: list[str] # изменилась нормализованная структура
Параметр mode проходит насквозь: и в снимок диска, и в скан для структурного хэша. Раскладка внешних форм больше не теряется, а логика обхода живёт в одном месте - scan_forms, - а не дублируется в двух.
Практическое правило: дрейф формы считайте по нормализованной структуре, а не по сырому
*.elem.json. И не дублируйте обход выгрузки: если детект дрейфа снимает состояние диска сам, он рано или поздно разойдётся с основным сканером по поддержке раскладок. Делегируйте снимок тому же сканеру и прокидывайте режим насквозь.
Тесты - только синтетические
Отдельно про тесты, потому что тут легко срезать угол и притащить в фикстуры кусок реальной формы. Делать этого нельзя, и не только из соображений обезличивания: на реальной форме тест ещё и хрупкий, потому что тащит за собой версию платформы и версию распаковщика.
Весь тестовый набор проекта синтетический. Под elem-формы сделан общий генератор фикстур, чтобы они не расползлись по тест-файлам с разной структурой:
make_managed_form_elem_json(...)собирает payload в том же формате, что читает штатный разборelem.json: секции с деревом элементов, реквизитами, командами и путями к данным. В payload намеренно вложен реалистичный «шум» - идентификаторы, GUID-подобные значения, координаты и оформление, - чтобы проверять, что выжимка их отсекает.write_managed_form_elem(...)раскладывает*.elem.jsonи опциональные спутники по директориям формы, во всех поддерживаемых вариантах контейнера.
Генератор детерминирован: одинаковые входные параметры дают побайтово одинаковый вывод. Это важно именно для тестов дрейфа - иначе «дрейф» ловился бы на самой фикстуре. Ни живой платформы, ни реальной выгрузки для тестов не требуется; весь смысл покрытия - проверить логику обнаружения, выжимки и дрейфа на выдуманных ЗаказКлиента, ФормаЗаказа, Банки.
Практическое правило: фикстура формы - это выдуманная минимальная структура, а не урезанная реальная. И генератор фикстур должен быть детерминированным: тесты дрейфа проверяют изменение смысла, а недетерминированная фикстура сама даст ложный дрейф и замаскирует настоящие баги.
Что бы я сделал иначе
- Проверил бы артефакт до проектирования. Это главная ошибка всего эпизода: я расписал архитектуру под Form.xml, не открыв ни одной распакованной формы. Десять минут проверки сэкономили бы день проектирования парсера, классификатора и «чистой границы» под формат, которого в пайплайне нет.
- Не заводил бы слой-адаптер поверх штатного парсера. Промежуточная попытка «сырой elem.json -> адаптер -> выжимка» была дублирующим парсером. Правильная граница - «
parse_elem_json-> выжимка», один разборщик структуры на пакет. Адаптер я в итоге убрал, но лучше было не писать его вовсе. - Не планировал бы поле-ярлык типа формы. Классификатор «обычная/управляемая» казался очевидным шагом, а на деле ярлык ни на что не влиял: и выжимка, и дрейф одинаковы для любой формы с
*.elem.json. Лишнее поле, которое пришлось бы тащить в каждой ветке кода, - хорошо, что отклонили до реализации.
Чек-лист для применения
Все пункты опираются на реализованное и слитое в основную ветку поведение.
- До проектирования парсера открыть распакованную выгрузку и проверить фактический артефакт формы. Не предполагать формат по типу формы.
- Обнаруживать форму по наличию
*.elem.jsonв её директории (discover_elem_forms); модуль и спутники считать опциональными. - Не завязывать обнаружение на descent-суффикс спутников (литерал
id, простое значение10или полная версия платформы): обязателен только<Контейнер>.elem.json. - Складывать обычные, внешние и elem-формы в один
FormScanIndex; у elem-формы заполнятьelem_json_path, у остальных оставлять пустым. - Не вводить в реестр ярлык типа формы, если он не меняет обработку: тип для конвейера не нужен.
- Читать структуру формы единственным разборщиком
parse_elem_json; не заводить второй парсер и не писать адаптер, дублирующий разбор. - Строить выжимку
build_form_summaryповерх разобранной структуры: реквизиты, команды, элементы, события, связи; идентификаторы, GUID и оформление отбрасывать. - Сериализовать выжимку детерминированно (
to_normalized_json): устойчивый порядок ключей - условие корректного дрейфа. - Контроль дрейфа считать по существующему структурному
elem_sha256(хэш нормализованной структуры), а не по сырому*.elem.json; второй/сырой хэш не вводить. - Исключать elem-only формы (без модуля) из «протухших» и «удалённых»; при проверке дрейфа внешних форм прокидывать
mode="external"насквозь и делегировать снимок диска сканеру. - Тесты держать на синтетических детерминированных фикстурах
*.elem.json; реальные выгрузки в тесты не тащить.
Что дальше
Когда управляемые формы попадают в тот же реестр, что и обычные, и дают такую же компактную выжимку, у агента впервые появляется единый слой по всем формам конфигурации независимо от их типа. Дальше логично надстроить над этим слоем контекст формы: собрать из модуля, выжимки и метаданных единый объект и подготовить его для LLM - это тема следующей статьи цикла про FormContext.
Ссылки
- Обычные формы 1С в агентном пайплайне: пошаговая распаковка - статья 02 цикла, распаковка
Form.bin. - СКД и дерево элементов обычной формы 1С - статья 03 цикла, разбор
elem.json. - Реестр форм 1С для агента: scan_forms и первый агрегат поверх распаковки - статья 04 цикла, реестр форм и контроль дрейфа.
- v8unpack-agent - Python-надстройка над v8unpack: обнаружение elem-форм, единый реестр, выжимка и контроль дрейфа - в основной ветке. MIT (лицензия Массачусетского технологического института).
- saby-integration/v8unpack - open-source распаковщик контейнеров 1С, Python, MIT.
Вступайте в нашу телеграмм-группу Инфостарт