Предскан объединённых ячеек xlsx отдаёт expat каждую некаталожную запись zip: отбор «похоже на XML» по первым 64 байтам обходился листом в части без .xml с ведущими пробелами и без XML-декларации (1,9 КБ → > 3 с, у ревьюера 27 с / 1,4 ГБ). Части *.xml — строго, остальные — мягко (ошибка разбора лишь заканчивает скан части). После чтения любого формата число диапазонов листа площадью > MAX_INDEXED_AREA ограничено LARGE_MERGED_COUNT_LIMIT = 1000: Sheet.merged_span перебирает их линейно. README, design.md №39, дельта спеки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
34 KiB
Spec Delta: osv-parsing
Purpose
Разбор оборотно-сальдовых ведомостей из XLS/XLSX/XLSB по 05-domain-rules.md §1: структура определяется по содержимому,
результат — osvDocument контракта v1.1, достоверность подтверждается контрольными суммами, словари — данные.
ADDED Requirements
Requirement: Листы ОСВ находятся по содержимому
Парсер SHALL считать листом ОСВ любой лист, где ячейка в первых 30 строках листа (SHEET_MARKER_ROWS: шапка ведомости
всегда над таблицей) матчит признак ОСВ из rules/osv_headers.yaml (без учёта регистра, «ё/е», переносов строк и
дефисов). Листы без признаков ОСВ SHALL пропускаться с предупреждением
sheet=<имя> skipped: нет признаков ОСВ в metrics.warnings; лист с признаками ББ/ОФР (§12 п. 1) — с предупреждением
sheet=<имя> skipped: statement (slice 2b). Книга без единого листа ОСВ →
status = error, error.code = INVALID_DOCUMENT. Несколько листов ОСВ → несколько documents с locator = sheet:<имя>.
Имя листа MUST NOT использоваться для поиска.
Scenario: Один лист — одна ОСВ (все реальные фикстуры)
- WHEN разбирается любой файл из
fixtures/osv/** - THEN
documentsсодержит ровно одинosvDocumentсlocator = "sheet:Лист_1", статусdone
Scenario: Два листа ОСВ и посторонний лист
- WHEN разбирается синтетический файл с листами «ОСВ 60», «Справка», «ОСВ 62»
- THEN
documentsсодержит два документа в порядке листов, вwarningsесть запись о пропуске листа «Справка»
Scenario: Нет ОСВ
- WHEN разбирается книга с одним листом произвольной таблицы
- THEN
status = error,error.code = INVALID_DOCUMENT, сообщение называет число просмотренных листов
Requirement: Заголовок таблицы и колонки сумм определяются по тексту
Парсер SHALL найти блок заголовка таблицы: 1–4 подряд идущих строки, в которых (с учётом объединённых ячеек) встречаются
группы «сальдо на начало», «обороты за период», «сальдо на конец» по синонимам словаря, и под каждой группой подзаголовки
«Дебет»/«Кредит» по синонимам. Группы «обороты» и «сальдо на конец» обязательны: строка, где их нет, заголовком не
считается (R-12). Допустимы: отсутствие группы «начало» (сальдо на начало null), одна колонка в группе без
Дебет/Кредит (свёрнутое сальдо: положительное — дебет, отрицательное — кредит, предупреждение), дополнительные колонки
(«Количество», «Валюта», «ИНН», «КПП», «Код»). Колонка текста строки — первая непустая слева от сумм по строкам данных;
колонка ИНН — по заголовку ИНН или колонка, где ≥ 80 % непустых значений — ровно 10 или 12 цифр (число из 9/11 цифр
при этом выборе MUST NOT дополняться нулём: иначе числовая колонка КПП сошла бы за колонку ИНН, R-24). Правая граница таблицы —
последняя колонка «Кредит»; всё правее MUST NOT попадать в суммы. Колонки внутри таблицы, не сопоставленные с шестью
суммами и ИНН, SHALL попадать в rows[].extra по заголовку. Заголовок не найден или в найденной строке групп нет «оборотов» либо «сальдо
на конец» → status = error, error.code = INVALID_FORMAT с названием ненайденной группы, листом и шагом. В columns SHALL быть записана диагностика (буква
колонки) для каждой из шести сумм и ИНН.
Scenario: Плавающие колонки двух организаций
- WHEN разбираются
rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsxиschastl/2024-Q3/…62 за 3 квартал 2024 г..xls - THEN
columnsпервого содержитopening_debit = "E",closing_credit = "Q", второго —opening_debit = "G",closing_credit = "S",inn = "E"; суммы корневой строки совпадают со строкой «Итого»
Scenario: Мусор правее таблицы
- WHEN разбирается
schastl/2024-Q3/…62 за 3 квартал 2024 г..xls(числа в колонках T/U) - THEN эти значения не входят ни в одну из шести сумм, инварианты §1.4 выполнены
Scenario: ОСВ без сальдо на начало и со свёрнутым сальдо
- WHEN разбирается синтетическая ОСВ с группами только «Обороты» и «Сальдо на конец», где у «конца» одна колонка
- THEN
opening_* = nullу всех строк, отрицательное свёрнутое сальдо попадает вclosing_credit, вwarningsесть запись о свёрнутом сальдо, статусdone
Scenario: Неизвестная колонка внутри таблицы
- WHEN в синтетической ОСВ между «ИНН» и суммами есть колонка «КПП»
- THEN у строк контрагентов
extra["КПП"]содержит значение, суммы не сдвинуты
Requirement: Шапка документа и схема группировок
Парсер SHALL извлечь из области выше заголовка таблицы: organization_name (первая непустая текстовая строка, trim),
account_code_raw (после «по счету»/«по счёту», как в файле: «60», «58.03»), period_text (после «за» до «г.»/конца),
data_type («Выводимые данные: БУ/НУ/сумма»), currency («Валюта …: RUB/руб.» → RUB), header_variant (1 — группировки
строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). group_schema SHALL начинаться с ACCOUNT и далее
перечислять группировки в порядке вложенности по словарю rules/osv_groups.yaml; ячейка ИНН в строке группировки →
has_inn = true. period_quarter/is_cumulative SHALL вычисляться только по тексту по rules/periods.yaml (§1.5,
ASSUMPTION(OQ-02)), period_year — четыре цифры года. Отсутствие любого поля шапки, кроме обязательных по контракту, —
null и предупреждение; отсутствие organization_name/period_text — пустая строка и предупреждение (контракт требует
строку).
Scenario: Вариант 1 с тремя строками группировок
- WHEN разбирается
schastl/2024-Q3/…62 за 3 квартал 2024 г..xls - THEN
group_schema = [ACCOUNT, DEPARTMENT, COUNTERPARTY(has_inn=true)],header_variant = 1,account_code_raw = "62",period_text = "3 квартал 2024 г.",period_quarter = "Q3",is_cumulative = false,data_type = "BU",currency = "RUB"
Scenario: ОСВ по субсчёту и годовой период
- WHEN разбираются
rb/2024-Q3/сч 583_3 кв 2024.xlsxиrb/2024-FY/ОСВ_66 за 2024.xls - THEN у первого
account_code_raw = "58.03"; у второгоperiod_quarter = "Q4",is_cumulative = true
Scenario: Вариант 2
- WHEN разбирается синтетическая ОСВ с шапкой «Период: 1 квартал 2026 г.», «Детализация по субсчетам, субконто: Контрагенты, Договоры», заголовком «Субконто»
- THEN
header_variant = 2,group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT],period_quarter = "Q1"
Requirement: Иерархия строк — каскад стратегий
Парсер SHALL определять level строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки
или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево,
где у каждой строки есть предок с меньшим уровнем (кроме уровня 0). Строка «Итого» стратегиям MUST NOT передаваться:
она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна
строка) — стратегия trivial: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в metrics.warnings как
sheet=<имя> hierarchy=<имя стратегии>. parent_row_number SHALL указывать на ближайшую
предыдущую строку с меньшим уровнем.
Scenario: Outline level (реальные файлы)
- WHEN разбирается
rb/2024-FY/ОСВ_66 за 2024.xls(и любой другой реальный файл со строками данных) - THEN
hierarchy=outline, строки66(0) →66.01(1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ»parent_row_number— строка66.03
Scenario: Без outline level
- WHEN разбирается синтетическая ОСВ без группировок Excel, с отступами ячеек (
s02) - THEN
hierarchy=indent, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по кодам счетов и жирности (s03)
Scenario: Без outline и отступов
- WHEN разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (
s03) - THEN
hierarchy=account_pattern, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт
Scenario: Пустая ОСВ — тривиальное дерево
- WHEN в ОСВ нет строк данных кроме «Итого»
- THEN
hierarchy=trivial, «Итого» — уровень 0 без родителя
Requirement: Классификация строк
Каждая строка данных SHALL получить group_type: ACCOUNT — текст матчит ACCOUNT_RE (регистр учитывается, IGNORECASE
нет): две-три цифры и до двух сегментов субсчёта через точку, где сегмент — \d{1,2} или 1–3 заглавные буквы
кириллицы/латиницы («76.АВ», «76.ВА», «76.ОТ», R-16), далее опционально запятая и название: ^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$
с флагами re.ASCII | re.DOTALL (после запятой допустим любой пробел или его отсутствие, R-18; цифры — только 0–9;
название после запятой может занимать несколько строк ячейки — M-1); account_code_raw — код как в файле, без названия после
запятой. Не-счёт — группировка из group_schema по позиции (число не-счётных предков после последнего ACCOUNT + 1), при
выходе за схему — OTHER; последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL. Для COUNTERPARTY
counterparty_name = row_value, counterparty_inn — только из колонки ИНН: цифры текстовой ячейки колонки («ИНН
352819549855» → «352819549855», R-15), результат только при 10 или 12 цифрах, иначе null; число из 9/11 цифр
(ИНН регионов 01–09 с потерянным ведущим нулём) при извлечении дополняется нулём слева (R-24); ИНН внутри текста строки
(«Полиграф, 3528315710») MUST NOT извлекаться (R-17). Строки «Вал.», «Кол.», трёхбуквенного кода валюты SHALL получать
group_type = OTHER, data_type CURRENCY/QUANTITY, MUST NOT занимать позицию в group_schema и MUST NOT наследовать
counterparty_name/counterparty_inn (account_code_raw наследуется от ветки, R-19); их суммы остаются в шести полях.
Остальные строки получают data_type шапки. Все строки, включая TOTAL, SHALL присутствовать в rows
(ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро).
Scenario: Подразделение, контрагент с ИНН, физлицо
- WHEN разбирается
schastl/2024-Q3/…62 за 3 квартал 2024 г..xls - THEN «БП Аренда» → DEPARTMENT; «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» → COUNTERPARTY с
counterparty_innиз 10 цифр; «Ведомость в счет з/платы» → COUNTERPARTY сcounterparty_inn = null
Scenario: Название субсчёта в строке счёта
- WHEN в синтетической ОСВ строка «60.01, Расчеты с поставщиками»
- THEN
group_type = ACCOUNT,account_code_raw = "60.01",row_value— исходный текст
Scenario: Буквенный субсчёт
- WHEN в ОСВ 76 строка «76.АВ» (реальные файлы РБ и Счастливочной) либо «60.АВ, …» (синтетика)
- THEN
group_type = ACCOUNT, строка наследуется детьми как счёт ветки; «76.ав» (строчные) счётом не считается
Scenario: Строки Вал./Кол.
- WHEN в синтетической ОСВ под контрагентом строки «USD» и «Кол.»
- THEN
group_type = OTHER,data_type = CURRENCYиQUANTITY,counterparty_name/counterparty_innравныnull,account_code_rawсовпадает с родителем, строки исключены из проверки «сумма детей = родитель», статусdone
Requirement: Числа — Decimal, пусто — null
Суммы SHALL приводиться к Decimal с двумя знаками: числовые ячейки — через строковое представление; текстовые —
после удаления пробелов (включая неразрывные и узкие), замены запятой на точку, (x) → -x; «-», «—», «–», пустая ячейка →
null (не 0). Нечисловой текст в колонке сумм → null и предупреждение с row_number. float в результате MUST NOT
использоваться; в JSON суммы — строки ^-?\d+(\.\d{1,2})?$.
Scenario: Текстовые числа
- WHEN в синтетической ОСВ суммы записаны как «1 234 567,89», «(1 234,00)», «—»
- THEN получены
1234567.89,-1234.00,null
Requirement: Контрольные суммы определяют статус
Для каждого документа парсер SHALL проверить: по каждой строке opening_debit − opening_credit + turnover_debit − turnover_credit = closing_debit − closing_credit (±0.01, null = 0); по каждому родителю суммы = Σ детей с тем же
data_type (±0.01), причём обороты сверяются по колонкам, а сальдо (начало, конец) — по колонкам либо, если у
родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто,
а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки CURRENCY/QUANTITY в суммирование не входят и сами
родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0). Если корня нет (пустая ОСВ,
R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, total_row_matches_root = false и в
warnings — «строка «Итого» не найдена» (R-20). Результат — checks{total_row_matches_root, balance_identity_violations, parent_sum_violations}. Любое нарушение → status = needs_check всего результата и запись
причины в metrics.warnings; парсер MUST NOT подгонять структуру под сходимость.
Остаточные риски (известные слепые зоны, не ловятся проверками §1.4):
- RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом (перестановка сторон при совпавшем нетто) не проверяется.
- RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя, у которого в файле потеряна одна сторона, неотличимо от нетто-показа 1С и проходит, если нетто совпало.
- RR-3 — нетто допускается на любом уровне дерева (счёт, субсчёт, контрагент, договор), а не только на уровне «счёт → субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится.
- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже).
- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто, а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются).
- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0 (закреплено тестом как осознанное поведение R-21).
- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет
group_typeстрок ветки; суммы и проверки это не затрагивает.
Scenario: Все реальные файлы сходятся
- WHEN разбирается каждый файл
fixtures/osv/** - THEN
status = done,checks.total_row_matches_root = true, оба списка нарушений пусты
Scenario: Испорченная сумма
- WHEN в синтетической ОСВ у одного контрагента
closing_debitувеличен на 1 - THEN
status = needs_check,balance_identity_violationsсодержит егоrow_number,parent_sum_violations—row_numberродителя, вwarnings— текст с номерами строк
Scenario: Сальдо родителя свёрнуто нетто
- WHEN у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя
- THEN нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто или расхождение оборотов — нарушение
Scenario: «Итого» не найдена
- WHEN в таблице нет строки с текстом из словаря «Итого»
- THEN
total_row_matches_root = false,status = needs_check, вwarnings— «строка «Итого» не найдена»
Scenario: Пустая ОСВ
- WHEN разбираются реальные пустые ОСВ (в фикстурах две:
schastl/2025-Q2/…58 за 2 квартал 2025 г..xlsxи…66 за 2 квартал 2025 г..xlsx: заголовок и сразу «Итого») либо любой файл, где кроме «Итого» строк данных нет - THEN
status = done,rowsсодержит только строку TOTAL,metrics.rows = 0(строки данных без TOTAL),hierarchy=trivial,checks.total_row_matches_root = true(корня нет, суммы TOTAL пусты), предупреждение «нет строк данных»
Requirement: Форматы, лимиты, время
Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, formatting_info), XLSB (pyxlsb; outline level недоступен —
каскад начинается с отступа). Несоответствие file_type сигнатуре файла → попытка по сигнатуре и предупреждение.
Файл больше PARSE_MAX_BYTES (по умолчанию 50 МиБ) → error UNSUPPORTED_FORMAT с текстом про лимит. До вызова
библиотеки чтения SHALL действовать лимиты чтения (R-23) → error UNSUPPORTED_FORMAT с текстом про лимит: сумма
объявленных несжатых размеров записей zip (xlsx/xlsb) не больше ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES (10 ×);
суммарная площадь объединённых ячеек книги не больше MERGED_AREA_LIMIT (200 000 ячеек). Для xlsx — до openpyxl
потоковым разбором xml.parsers.expat всех частей архива (R-25, I-1б: каждая некаталожная запись в любом каталоге,
без отбора по имени или началу содержимого; ошибка разбора части не *.xml лишь заканчивает её скан; элемент с локальным именем mergeCell в любом пространстве имён; только атрибут ref без
префикса; ссылки на символы раскрываются; площадь — по строке ref, без перебора ячеек; отказ сразу при превышении);
для любого формата — после чтения, по диапазонам, которые индекс листа раскладывает по ячейкам (площадь не больше
grid.MAX_INDEXED_AREA; merged на весь столбец не считается); число остальных (крупных) диапазонов листа — не больше
LARGE_MERGED_COUNT_LIMIT (1000). Книга не открывается (в том числе битый XML части
*.xml, ref объединения, который openpyxl не разберёт) → UNREADABLE (лог WARNING);
превышение PARSE_TIMEOUT_SECONDS (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка
таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → TIMEOUT;
непредвиденное исключение → RECOGNITION_FAILED с одной строкой причины, стек — в лог уровня ERROR. Не ограничены (остаточный
риск, решение платформы — лимит памяти контейнера, defusedxml): объём самих ячеек, sharedStrings и стилей в
пределах лимита распакованного zip, XML-бомбы entity expansion (кроме защиты самого expat ≥ 2.4). Любой файл
fixtures/osv/** SHALL разбираться быстрее 5 с.
Scenario: XLSB
- WHEN разбирается синтетический xlsb той же ОСВ, что
s02(s13, собран тестовым писателем BIFF12, а не Excel — O-7) - THEN результат совпадает с xlsx-оригиналом во всём, кроме
locatorи предупреждения «xlsb: outline level недоступен»;hierarchy=indent
Scenario: Расширение не совпадает с содержимым
- WHEN запрос говорит
XLSX, а байты — BIFF8 - THEN файл прочитан как xls, в
warningsесть запись о несоответствии, статус по содержимому
Scenario: Превышен лимит
- WHEN передан файл в 51 МиБ
- THEN
error UNSUPPORTED_FORMAT, книга не открывается
Scenario: Бомба объединённых ячеек
- WHEN передан xlsx в несколько килобайт с
<mergeCell ref="A1:Z200000"/>(5,2 млн ячеек) - THEN
error UNSUPPORTED_FORMAT(«лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается
Scenario: Обходы скана объединённых ячеек
- WHEN тот же диапазон спрятан: лист вне
xl/worksheets/или с расширением не.xml(цель связи вworkbook.xml.rels),refзаписан ссылками на символы (A1:Z200000,A1:…), передrefстоит атрибут с префиксом (a:ref="A1"), в значении другого атрибута есть>, элемент записан с префиксом (x:mergeCell) - THEN
error UNSUPPORTED_FORMAT(«лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается; тот же вариант с малым диапазоном читается, и объединение видно в листе
Scenario: Объединение на весь столбец в xls
- WHEN в книге xls есть законный merged-диапазон на весь столбец (больше
grid.MAX_INDEXED_AREA) - THEN книга читается: после-чтения проверка его не считает (индекс листа его по ячейкам не раскладывает)
Scenario: Распакованный zip больше лимита
- WHEN zip книги распаковывается больше чем в 10 ×
PARSE_MAX_BYTES - THEN
error UNSUPPORTED_FORMAT(«распакованный размер … больше лимита»), книга не открывается
Requirement: Словари — данные, не код
Признаки листов, синонимы групп колонок и подзаголовков, колонки ИНН, строки «Итого», маркеров шапки (rules/osv_headers.yaml:
sheet_markers, statement_markers, groups, debit, credit, service_columns, total_row, header_markers),
группировок (rules/osv_groups.yaml: mapping, default), периодов (rules/periods.yaml: rules, year, end),
типов данных и кодов валют (rules/data_types.yaml: header, row, currency_codes, currency_aliases)
SHALL загружаться из YAML и валидироваться pydantic-моделью при старте (ошибка словаря — падение старта с указанием файла и
записи). В src/parser/ (кроме grid.py и readers/) MUST NOT быть строковых литералов букв колонок, адресов ячеек,
имён листов и сравнений с номерами строк — проверяется тестом по AST. Каждая запись распознавания словаря (паттерн) SHALL
срабатывать хотя бы на одной фикстуре или синтетике (тест покрытия словарей, R-22); перечень кодов валют
currency_codes (ISO 4217) — список значений, а не синонимы, и в покрытие не входит.
Scenario: Новый синоним без релиза кода
- WHEN в
osv_headers.yamlдобавлен синоним «Нач. остаток» и дана синтетическая ОСВ с таким заголовком - THEN разбор проходит без изменений в
src/; без этого синонима заголовок «Нач. остаток» не распознаётся (колонки начала уходят вextra, результатneeds_check)
Scenario: Литерал координаты
- WHEN в
detect.pyпоявляется строка"E9" - THEN тест-линтер падает с указанием файла и строки
Scenario: Мёртвая запись словаря
- WHEN в словарь добавлена запись, не срабатывающая ни на одном реальном или синтетическом файле
- THEN тест покрытия падает с указанием файла, ключа и паттерна
Requirement: Золотые снимки и property-тесты
Для каждого файла fixtures/osv/** SHALL существовать снимок fixtures/golden/expected_osv_parse/<зеркальный путь>.json
(= файл результата без job_id, file_id, service.version, metrics.duration_ms); тест сравнивает разбор со снимком,
обновление — только явной командой (make golden-update) с просмотром diff. Property-тест по всем файлам SHALL проверять
инварианты независимо от снимков (статус ≠ error, контрольные суммы, корень = счёт шапки, период распознан). Без
каталога fixtures оба теста пропускаются с причиной (ARM_KA_FIXTURES).
Scenario: Регрессия
- WHEN правка парсера меняет
levelодной строки в любом реальном файле - THEN golden-тест падает с diff по этому файлу
Scenario: Новый файл без снимка
- WHEN в
fixtures/osv/добавлен файл без снимка - THEN property-тест его разбирает, golden-тест сообщает «нет снимка: выполните make golden-update»