arm-ka-docs/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md

45 KiB
Raw Blame History

План 0 — тест идентичности копий контракта на окне перехода v1 → v2. План реализации

Для исполнителей-агентов: обязательный навык — superpowers:subagent-driven-development или superpowers:executing-plans (в плане 5 задач: по ~/.claude/CLAUDE.md — executing-plans, Native). Шаги размечены чекбоксами (- [ ]).

Goal: тест идентичности api/contracts/ в arm-core, tabular-parser и scan-gateway сверяет каждый файл копии с файлом docs своего MAJOR (правило S-53), чтобы make check всех трёх оставался зелёным и до, и после слияния docs PR #117 (контракт v2.0).

Architecture: правило сравнения — один модуль api/tests/contract_identity.py (чистые функции над двумя каталогами, только stdlib) и один файл сценарных тестов на синтетических каталогах; оба файла побайтно одинаковы в трёх репозиториях (пакета для общего кода тестов нет, arm-statements ещё не существует). Существующий test_vendored_contracts_are_identical_to_docs в каждом репозитории сохраняет поиск docs и пропуск, а вместо сравнения rglob-наборов вызывает identity_problems. Шаг 0 выкладки (proposal.md, «Порядок выкладки»): три PR сливаются до docs PR #117.

Tech Stack: Python 3.11, pytest, ruff, mypy (make check), git worktree, tea, OpenSpec 1.13.

Spec: openspec/changes/unified-statement-normalization/ (репозиторий arm-ka-docs, ветка feat/116-unified-normalization): specs/recognition-contract/spec.md — требование «Копия контракта в сервисе идентична источнику» и его сценарии; specs/quality-checks/spec.md — make check; proposal.md — «Порядок выкладки», шаг 0; design.md — решение S-53. Справка по проверкам — dev/checks.md («Внешние ресурсы», «Проверки этого репозитория», «Известные расхождения реализаций»).

Global Constraints

  • Пути — абсолютные. Workspace: /Users/andrey.epifancev/projects/awp-ca (не git). Git-команды — только git -C <абсолютный путь worktree>; cwd оболочки никогда не стоит в основном checkout на main (хук guard-main).
  • Зеркальный workspace плана: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/ — worktree arm-core/, tabular-parser/, scan-gateway/ (ветки feat/<issue>-contract-identity-window), docs/ — отсоединённый worktree arm-ka-docs только для проверок (в нём ничего не коммитится), симлинки fixtures, reference-old-arm, CLAUDE.md, .claude на основной workspace (docs/context/00-workspace-layout.md, «Worktree вместо веток»).
  • Одна фича = одна issue = одна ветка = один PR на репозиторий; ветки feat/<issue>-contract-identity-window; Gitea — tea … --login home --repo RSHB/<repo> (RSHB/arm-core, RSHB/arm-tabular-parser, RSHB/arm-scan-gateway).
  • Правило сравнения — дословно из спеки recognition-contract: схема копии <имя>.schema.json с $id …/v<M> — с docs/contracts/<имя>.v<M>.schema.json, если такой файл есть, иначе с основным <имя>.schema.json; пример examples/<имя>.json — вид по префиксу (request- — запрос, result-message- — конверт, result- — файл результата), M — MAJOR копии схемы этого вида, пара — examples/v<M>/<имя>.json, если он есть, иначе examples/<имя>.json; прочие файлы копии (<имя>.v<N>.schema.json, examples/v<N>/*.json) — с одноимённым файлом docs, нет его — расхождение; набор: каждой схеме верхнего уровня docs (contracts/*.schema.json без .v<N>) и каждому примеру contracts/examples/*.json соответствует файл копии; файлы docs *.v<N>.schema.json и examples/v<N>/ требуются в копии, только если копия держит этот MAJOR; файлы другого MAJOR не сверяются. У recognition-request файла .v1 нет.
  • $id схем в docs имеют вид arm-ka/<имя>/v<M> (arm-ka/recognition-result/v1, …/v2).
  • Сверяются только *.json (как сейчас); contracts/statement-code-orders.yaml в копии сервисов не лежит.
  • Поиск docs: каталог из ARM_KA_DOCS, иначе соседний docs рядом с репозиторием; нет каталога contracts — pytest.skip с текстом docs недоступен: тест идентичности контракта пропущен (задайте ARM_KA_DOCS или положите docs рядом).
  • Файлы api/tests/contract_identity.py и сценарного теста (api/tests/test_contract_identity.py в arm-core, api/tests/unit/test_contract_identity.py в tabular-parser и scan-gateway) побайтно одинаковы в трёх репозиториях; импорт — from tests.contract_identity import … (работает во всех трёх, rootdir — api/).
  • Линтер одинаков в трёх репозиториях: ruff line-length = 120, select = ["E", "F", "I", "UP", "B", "PL"]; PLR2004 игнорируется в тестах только у scan-gateway — сравнений с числовыми литералами в новых файлах нет.
  • Копии api/contracts/** в этом плане не меняются (они побайтно равны docs main и .v1-файлам ветки feat/116-unified-normalization — сверено 18.10.2026).
  • make check не требует Docker, сети и запущенной инфраструктуры (quality-checks).
  • Язык сообщений теста, комментариев, коммитов, issue и PR — русский. Коммиты заканчиваются строками Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> и Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W; PR — строкой 🤖 Generated with [Claude Code](https://claude.com/claude-code).
  • Слияние — только по команде человека; три PR сливаются до docs PR #117. Описание PR #117 не трогать.
  • Находки и решения несут маркеры (T<n>.<k>, C-n/I-n/M-n, R-n, O-n); решения этого плана — P0-n (ниже).

Проверка на двух состояниях docs

Состояние A — docs main (контракт 1.x, файлов .v1 нет); состояние B — ветка feat/116-unified-normalization (основные схемы v2.0, *.v1.schema.json, examples/v1/). Переключается отсоединённый worktree /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/docs, так что все три репозитория видят его как ../docs без переменных окружения, а правки других агентов в .wt/normalization/docs проверку не задевают:

git -C /Users/andrey.epifancev/projects/awp-ca/docs fetch origin
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/docs checkout --detach origin/main                            # A
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/docs checkout --detach origin/feat/116-unified-normalization  # B

После проверки B worktree всегда возвращается в A. Запасной путь для B без переключения — ARM_KA_DOCS=<каталог> make check (arm-core и tabular-parser — сейчас, scan-gateway — после Task 3). Признак настоящей проверки, а не пропуска: cd api && .venv/bin/pytest -rs -k vendored_contracts <файл test_contract.py> печатает PASSED, а не SKIPPED.

Решения плана, принятые без обсуждения (переносятся в design.md задачей 4)

  • P0-1. Правило живёт копией в трёх репозиториях (api/tests/contract_identity.py + сценарный тест), а не общим пакетом и не скриптом docs. Эталон — копия arm-core (пишется первой); расхождение копий ловит шаг сверки shasum задачи 5 и ревью; соседние репозитории в make check не читаются. Цена ошибки: правка правила — три PR; разошедшиеся копии до сверки не видны.
  • P0-2. «Копия держит MAJOR N» — в копии есть хотя бы один файл *.v<N>.schema.json или examples/v<N>/*.json. Тогда все файлы docs *.v<N>.schema.json и examples/v<N>/*.json обязательны в копии. Цена: ядро, положившее один файл .v1, обязано положить все.
  • P0-3. Неопределимая пара — ошибка теста с именем файла, а не пропуск: пример с неизвестным префиксом, схема без $id вида …/v<M>, пример, вида которого нет среди схем копии. Цена: новый вид примера в docs требует правки правила.
  • P0-4. scan-gateway получает поиск docs через ARM_KA_DOCS в тесте идентичности (спека требования называет ARM_KA_DOCS); поиск fixtures в scan-gateway не меняется. Цена: расхождение в dev/checks.md сужается до fixtures.
  • P0-5. tabular-parser и scan-gateway получают OpenSpec change contract-identity-window с MODIFIED требования «Копия контракта идентична источнику» (их текущий текст — «байт в байт равны ../docs/contracts/» и «модели валидируют примеры docs» — на окне неверен). arm-core change не получает: его спеки тест идентичности не описывают, правило — в спеке docs recognition-contract. Цена: у arm-core при закрытии нет openspec archive.

Review Focus

  • Новый пример верхнего уровня в docs v2 без пары в examples/v1/ (например, отдельный пример OCR в PR #117): копия сервиса v1 его не имеет — по правилу набора тест сервиса краснеет на окне, вопреки сценарию «посторонний PR не краснеет» (O-1). Ожидание — сообщение называет файл; тест test_new_top_level_docs_example_is_reported (задача 1); задача 5 повторяет проверку B на последнем коммите ветки перед слиянием #117.
  • docs удалил .v1 раньше, чем ядро — свои копии (нарушение порядка шага 6): тест ядра падает и называет файл .v1; тест test_window_copy_without_docs_pair_fails (задача 1).
  • Схема копии без $id или с $id без /v<M>: падение с именем файла, а не исключение без контекста или молчаливое сравнение с основным файлом; тест test_schema_without_major_fails (задача 1).
  • Префикс result-message- начинается с result-: конверт и файл результата разных MAJOR не должны спутаться; тест test_example_kind_by_longest_prefix (задача 1).
  • docs не найден или ARM_KA_DOCS указывает не туда: пропуск с причиной, зелёный make check без ложной «идентичности»; в scan-gateway до задачи 3 ARM_KA_DOCS игнорировался — тесты test_docs_contracts_dir_uses_arm_ka_docs и test_docs_contracts_dir_defaults_to_sibling_docs (задача 3).

Task 1: arm-core — правило S-53 и сценарии (эталон копий)

Модель исполнителя: sonnet (новый код).

Files:

  • Create: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/arm-core/api/tests/contract_identity.py
  • Create: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/arm-core/api/tests/test_contract_identity.py
  • Modify: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/arm-core/api/tests/test_contract.py:100-124 (раздел «идентичность вендоренных копий»; _docs_contracts_dir() через default_dbml_path() остаётся)
  • Modify: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/arm-core/CLAUDE.md:155-157 (строка о копиях контракта)

Interfaces:

  • Consumes: —

  • Produces (tests/contract_identity.py, используют задачи 2, 3 и план 3 ядра):

    • KIND_BY_PREFIX: tuple[tuple[str, str], ...] = (("request-", "recognition-request"), ("result-message-", "recognition-result-message"), ("result-", "recognition-result")) — порядок значим, первый подходящий префикс;
    • schema_major(schema_path: Path) -> int — MAJOR из $id (re /v(\d+)$); ValueError с текстом "<имя файла>: в $id нет MAJOR (…/v<M>)", если $id нет или он не того вида;
    • example_kind(file_name: str) -> str — имя схемы вида; ValueError "<file_name>: вид примера не определён по префиксу (request-, result-message-, result-)";
    • docs_pair(copy_rel: str, copy_dir: Path, docs_dir: Path) -> str — относительный POSIX-путь файла docs, с которым сверяется файл копии copy_rel; ValueError с именем файла, если пара неопределима (в т. ч. «нет схемы <вид>.schema.json в копии»);
    • held_window_majors(copy_rels: set[str]) -> set[int] — MAJOR, которые копия держит файлами .v<N> (P0-2);
    • identity_problems(copy_dir: Path, docs_dir: Path) -> list[str] — все расхождения, каждое начинается с относительного пути файла; пустой список — копия идентична.
    • Тексты расхождений (на них опираются тесты): "<copy_rel>: отличается от docs/contracts/<pair> — скопируйте заново (cp), не правьте руками"; "<copy_rel>: нет пары в docs/contracts (<pair>)"; "<docs_rel>: есть в docs/contracts, нет в api/contracts"; при ValueError от docs_pair — "<copy_rel>: <текст исключения>".
  • Step 1: Зеркальный workspace, issue, ветка, индекс. cwd — /Users/andrey.epifancev/projects/awp-ca.

WT=/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window
mkdir -p $WT && cd $WT
ln -s ../../fixtures fixtures; ln -s ../../reference-old-arm reference-old-arm; ln -s ../../CLAUDE.md CLAUDE.md; ln -s ../../.claude .claude
git -C /Users/andrey.epifancev/projects/awp-ca/docs fetch origin
git -C /Users/andrey.epifancev/projects/awp-ca/docs worktree add --detach $WT/docs origin/main
git -C /Users/andrey.epifancev/projects/awp-ca/arm-core fetch origin
git -C /Users/andrey.epifancev/projects/awp-ca/arm-core worktree add -b tmp/contract-window $WT/arm-core origin/main
tea issues create --login home --repo RSHB/arm-core \
  --title "Тест идентичности копии контракта по MAJOR (шаг 0 docs#116)" \
  --description "Правило S-53 спеки recognition-contract (docs change unified-statement-normalization): копия сверяется с файлом docs своего MAJOR, если он есть, иначе с основным. Шаг 0 выкладки: слить до docs PR #117. План: openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md"
git -C $WT/arm-core branch -m feat/<N>-contract-identity-window
cd $WT/arm-core && codegraph init && make venv

Expected: git -C $WT/arm-core branch --show-current → feat/<N>-contract-identity-window; ls $WT/docs/contracts без *.v1.schema.json. <N> — номер созданной issue.

  • Step 2: Красный базовый прогон текущего теста на состоянии B. Переключить docs в B («Проверка на двух состояниях docs»), затем:

Run: cd $WT/arm-core/api && .venv/bin/pytest tests/test_contract.py -k vendored_contracts -rs Expected: FAIL набор файлов контракта разошёлся: … только в docs/contracts: ['examples/v1/result-error.json', …, 'recognition-result.v1.schema.json']. Вернуть docs в A.

  • Step 3: Сценарные тесты в api/tests/test_contract_identity.py (синтетические каталоги в tmp_path, реальный docs не нужен). Построители и наборы:
"""Правило теста идентичности копии контракта (S-53, спека recognition-contract). Файл одинаков в arm-core,
tabular-parser и scan-gateway; правка — сразу во всех трёх."""

import json
from pathlib import Path

import pytest

from tests.contract_identity import docs_pair, example_kind, identity_problems, schema_major

REQ, MSG, RES = "recognition-request", "recognition-result-message", "recognition-result"


def _schema(name: str, major: int) -> str:
    return json.dumps({"$id": f"arm-ka/{name}/v{major}"})


def _example(major: int, mark: str) -> str:
    return json.dumps({"schema_version": f"{major}.0", "mark": mark})


def _v1_copy() -> dict[str, str]:  # копия сервиса сегодня = docs main до #117
    return {
        f"{REQ}.schema.json": _schema(REQ, 1),
        f"{MSG}.schema.json": _schema(MSG, 1),
        f"{RES}.schema.json": _schema(RES, 1),
        "examples/request-image.json": _example(1, "request"),
        "examples/result-message-done.json": _example(1, "message"),
        "examples/result-statement-excel.json": _example(1, "result"),
    }


def _v2_main() -> dict[str, str]:  # копия сервиса после шага 5 = docs после #117 без файлов окна; запрос остаётся v1
    return {
        f"{REQ}.schema.json": _schema(REQ, 1),
        f"{MSG}.schema.json": _schema(MSG, 2),
        f"{RES}.schema.json": _schema(RES, 2),
        "examples/request-image.json": _example(1, "request"),
        "examples/result-message-done.json": _example(2, "message"),
        "examples/result-statement-excel.json": _example(2, "result"),
    }


def _docs_window() -> dict[str, str]:  # docs после #117 = копия ядра после шага 3
    v1 = _v1_copy()
    return {
        **_v2_main(),
        f"{MSG}.v1.schema.json": v1[f"{MSG}.schema.json"],
        f"{RES}.v1.schema.json": v1[f"{RES}.schema.json"],
        "examples/v1/result-message-done.json": v1["examples/result-message-done.json"],
        "examples/v1/result-statement-excel.json": v1["examples/result-statement-excel.json"],
    }


def _dirs(tmp_path: Path, copy: dict[str, str], docs: dict[str, str]) -> tuple[Path, Path]:
    """Пишет файлы в tmp_path/copy и tmp_path/docs (подкаталоги создаёт), возвращает (copy_dir, docs_dir)."""

Тесты и их утверждения (c, d = _dirs(tmp_path, <копия>, <docs>)):

Тест Копия / docs Утверждения
test_step0_before_docs_merge_passes _v1_copy() / _v1_copy() identity_problems(c, d) == []; docs_pair(f"{RES}.schema.json", c, d) == f"{RES}.schema.json"
test_stale_copy_fails_naming_file _v1_copy() / _v1_copy() с RES-схемой json.dumps({"$id": "arm-ka/recognition-result/v1", "new": 1}) одно расхождение; начинается с f"{RES}.schema.json", содержит "отличается"
test_service_on_v1_after_v2_merge_passes _v1_copy() / _docs_window() []; пара RES-схемы — f"{RES}.v1.schema.json", MSG-схемы — f"{MSG}.v1.schema.json"
test_request_v1_pairs_with_main_after_v2_merge _v1_copy() / _docs_window() пара f"{REQ}.schema.json" — она же; пара "examples/request-image.json" — она же
test_previous_major_examples_pair_with_v1_dir _v1_copy() / _docs_window() пары examples/result-statement-excel.json и examples/result-message-done.json — examples/v1/<то же имя>
test_service_on_v2_passes_and_ignores_v1_files _v2_main() / _docs_window() []; пара RES-схемы — f"{RES}.schema.json"; пара examples/result-statement-excel.json — она же
test_core_window_copy_passes _docs_window() / _docs_window() []; пары f"{RES}.v1.schema.json" и examples/v1/result-statement-excel.json — они же
test_core_window_copy_requires_every_v1_docs_file _docs_window() без examples/v1/result-message-done.json / _docs_window() расхождение "examples/v1/result-message-done.json: есть в docs/contracts, нет в api/contracts"
test_window_copy_without_docs_pair_fails _docs_window() / _v2_main() (docs удалил .v1 раньше ядра) расхождения начинаются с f"{RES}.v1.schema.json" и "examples/v1/result-statement-excel.json", содержат "нет пары"
test_top_level_docs_file_missing_in_copy_fails _v1_copy() без examples/request-image.json / _v1_copy() "examples/request-image.json: есть в docs/contracts, нет в api/contracts" в списке
test_extra_copy_file_fails _v1_copy() + examples/result-new.json / _v1_copy() расхождение начинается с "examples/result-new.json", содержит "нет пары"
test_new_top_level_docs_example_is_reported _v1_copy() / _docs_window() + examples/result-statement-ocr.json (v2, без пары в v1/) ровно одно расхождение, начинается с "examples/result-statement-ocr.json" (O-1)
test_unknown_example_prefix_fails _v1_copy() + examples/foo.json / то же расхождение начинается с "examples/foo.json" и содержит "вид примера"; pytest.raises(ValueError, match="вид примера") для example_kind("foo.json")
test_schema_without_major_fails _v1_copy() с RES-схемой json.dumps({"$id": "arm-ka/recognition-result"}) / _v1_copy() есть расхождение, начинающееся с f"{RES}.schema.json" и содержащее "$id"; pytest.raises(ValueError) у schema_major
test_example_kind_by_longest_prefix копия: _v1_copy() с RES-схемой v2 и examples/result-statement-excel.json = _example(2, "result"); docs: та же копия + examples/v1/result-message-done.json = _example(1, "message") example_kind("result-message-done.json") == MSG; пара examples/result-message-done.json — examples/v1/result-message-done.json; пара examples/result-statement-excel.json — она же
  • Step 4: Убедиться, что сценарии красные.

Run: cd $WT/arm-core/api && .venv/bin/pytest tests/test_contract_identity.py -q Expected: ошибка сбора ModuleNotFoundError: No module named 'tests.contract_identity'.

  • Step 5: Реализовать api/tests/contract_identity.py (интерфейс — блок Produces). Шапка-докстринг: правило S-53, ссылка на спеку recognition-contract, «файл побайтно одинаков в arm-core, tabular-parser и scan-gateway (api/tests/contract_identity.py); правка — во всех трёх, сверка — shasum -a 256». Только stdlib (json, re, pathlib). Алгоритм:

    • docs_pair: путь вида <имя>.v<N>.schema.json или examples/v<N>/<файл>.json → он же; <имя>.schema.json в корне → M = schema_major(copy_dir / copy_rel), кандидат <имя>.v<M>.schema.json, если (docs_dir / кандидат).is_file(), иначе copy_rel; examples/<файл>.json (один уровень) → вид example_kind(<файл>), схема вида copy_dir / f"{вид}.schema.json" (нет — ValueError), M = schema_major(…), кандидат examples/v<M>/<файл>, если он есть в docs, иначе copy_rel; любой другой путь → он же.
    • identity_problems: наборы rglob("*.json") обоих каталогов (относительные POSIX-пути); для каждого файла копии по порядку — пара (ValueError → "<copy_rel>: <текст>" в список, файл дальше не сверяется), пары нет в docs → «нет пары», байты различаются → «отличается»; затем обязательный набор docs: схемы корня без .v<N>, examples/*.json одного уровня, для каждого N из held_window_majors(<набор копии>) — *.v<N>.schema.json и examples/v<N>/*.json; каждого нет в копии — «есть в docs/contracts, нет в api/contracts». Порядок списка — по пути (детерминированный вывод).
  • Step 6: Сценарии зелёные.

Run: cd $WT/arm-core/api && .venv/bin/pytest tests/test_contract_identity.py -q Expected: 15 passed.

  • Step 7: Перевести реальный тест на правило. В tests/test_contract.py тело test_vendored_contracts_are_identical_to_docs после проверки docs_dir.is_dir() (пропуск и его текст — без изменений) — problems = identity_problems(CONTRACTS_DIR, docs_dir); assert problems == [], "\n".join(problems). Докстринг модуля: «вендоренные схемы api/contracts/ идентичны ../docs/contracts/ по правилу MAJOR (S-53)». CLAUDE.md:155-157: дописать, что копия сверяется с файлом docs своего MAJOR (*.v<M>.schema.json, examples/v<M>/, если есть), правило — tests/contract_identity.py, одинаковый файл в трёх репозиториях.

  • Step 8: make check на A и на B.

Run (A): cd $WT/arm-core && make check, затем cd api && .venv/bin/pytest tests/test_contract.py -k vendored_contracts -rs Expected: make check — код 0; тест — PASSED. Run (B): переключить docs в B, те же две команды, вернуть docs в A. Expected: код 0; PASSED (копия v1 сверилась с .v1 и examples/v1/).

  • Step 9: Commit
git -C $WT/arm-core add api/tests/contract_identity.py api/tests/test_contract_identity.py api/tests/test_contract.py CLAUDE.md
git -C $WT/arm-core commit -m "test(#<N>): тест идентичности копии контракта по MAJOR (S-53, шаг 0 docs#116)" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W"

Task 2: tabular-parser — копия правила, реальный тест, OpenSpec change

Модель исполнителя: haiku (код готов в задаче 1, текст спеки — в плане).

Files:

  • Create: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/tabular-parser/api/tests/contract_identity.py (побайтная копия файла arm-core из задачи 1)
  • Create: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/tabular-parser/api/tests/unit/test_contract_identity.py (побайтная копия arm-core/api/tests/test_contract_identity.py)
  • Modify: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/tabular-parser/api/tests/unit/test_contract.py:104-128
  • Modify: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/tabular-parser/CLAUDE.md:45-46,92
  • Create: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/tabular-parser/openspec/changes/contract-identity-window/{proposal.md,tasks.md,specs/tabular-recognition-service/spec.md}

Interfaces:

  • Consumes: identity_problems(copy_dir: Path, docs_dir: Path) -> list[str] и остальное из задачи 1 (файл копируется, не переписывается).

  • Produces: —

  • Step 1: Worktree, issue, ветка, индекс — как задача 1, шаг 1, для tabular-parser (--repo RSHB/arm-tabular-parser, тот же заголовок и описание; симлинки и docs уже есть).

  • Step 2: Красный базовый прогон на B: cd $WT/tabular-parser/api && .venv/bin/pytest tests/unit/test_contract.py -k vendored_contracts -rs → FAIL «набор файлов контракта разошёлся … только в docs/contracts: ['examples/v1/…']». Вернуть A.

  • Step 3: Скопировать оба файла cp из $WT/arm-core/api/tests/ (сценарный тест — в tests/unit/); проверить shasum -a 256 — суммы равны суммам arm-core.

  • Step 4: Сценарии зелёные: .venv/bin/pytest tests/unit/test_contract_identity.py -q → 15 passed.

  • Step 5: Реальный тест — как задача 1, шаг 7 (_docs_contracts_dir() с ARM_KA_DOCS остаётся). CLAUDE.md: в строках о копиях (45-46, 92) — «идентичность по правилу MAJOR (S-53), tests/contract_identity.py».

  • Step 6: OpenSpec change contract-identity-window. proposal.md — Why (окно перехода v1 → v2, шаг 0 proposal.md docs change unified-statement-normalization), What Changes, Non-goals (копии api/contracts/ и модели не меняются; переход на v2.0 — шаг 5, отдельный change), Impact (api/tests/). tasks.md — один пункт со ссылкой на этот план. Дельта specs/tabular-recognition-service/spec.md:

## MODIFIED Requirements

### Requirement: Копия контракта идентична источнику
Каждый файл `api/contracts/**/*.json` SHALL быть байт в байт равен своей паре в `../docs/contracts/` (или в `contracts`
каталога из `ARM_KA_DOCS`) по правилу спеки docs `recognition-contract`, требование «Копия контракта в сервисе
идентична источнику»: схема с `$id` `…/v<M>` — с `<имя>.v<M>.schema.json`, если такой файл есть в docs, иначе с
основным; пример — с `examples/v<M>/<имя>.json`, если он есть, иначе с `examples/<имя>.json`; файлы docs другого
`MAJOR` не сверяются. pydantic-модели SHALL валидировать все примеры копии `api/contracts/examples/*.json` и порождать
JSON, проходящий схему копии. Проверка входит в `make check`.

#### Scenario: `make check`
- **WHEN** выполняются тесты контракта при доступном `../docs` (или `ARM_KA_DOCS`)
- **THEN** идентичность подтверждена, примеры валидны; при недоступном docs тест идентичности пропущен с предупреждением

#### Scenario: Окно перехода на контракт 2.0
- **WHEN** в docs основной `recognition-result.schema.json` уже v2.0, рядом `recognition-result.v1.schema.json` и
  `examples/v1/`, а копия сервиса — v1.2
- **THEN** копия сверяется с файлами `.v1` и `examples/v1/` (запрос — с основным файлом), тест проходит

#### Scenario: Копия устарела
- **WHEN** в docs изменён файл, с которым сверяется копия, а копия — нет
- **THEN** тест идентичности падает, сообщение называет файл

Run: cd $WT/tabular-parser && openspec validate contract-identity-window --strict Expected: Change 'contract-identity-window' is valid.

  • Step 7: make check на A и на B — как задача 1, шаг 8 (файл tests/unit/test_contract.py). Expected: код 0 и PASSED в обоих состояниях.
  • Step 8: Commit — как задача 1, шаг 9 (файлы задачи; сообщение test(#<N>): …).

Task 3: scan-gateway — ARM_KA_DOCS, копия правила, реальный тест, OpenSpec change

Модель исполнителя: haiku (новый код — три строки поиска docs, его тесты даны).

Files:

  • Create: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/scan-gateway/api/tests/contract_identity.py (побайтная копия)
  • Create: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/scan-gateway/api/tests/unit/test_contract_identity.py (побайтная копия)
  • Modify: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/scan-gateway/api/tests/unit/test_contract.py:113-134
  • Modify: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/scan-gateway/CLAUDE.md:87-88
  • Create: /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/scan-gateway/openspec/changes/contract-identity-window/{proposal.md,tasks.md,specs/image-recognition-gateway/spec.md}

Interfaces:

  • Consumes: файлы задачи 1.

  • Produces: _docs_contracts_dir() -> Path в tests/unit/test_contract.py — Path(os.environ["ARM_KA_DOCS"]) / "contracts", если переменная задана и непуста, иначе API_DIR.parent.parent / "docs" / "contracts" (как в tabular-parser).

  • Step 1: Worktree, issue, ветка, индекс — как задача 1, шаг 1 (--repo RSHB/arm-scan-gateway).

  • Step 2: Красный базовый прогон на B: cd $WT/scan-gateway/api && .venv/bin/pytest tests/unit/test_contract.py -k vendored_contracts -rs → FAIL «набор файлов контракта разошёлся …». Вернуть A.

  • Step 3: Тесты поиска docs в tests/unit/test_contract.py:

def test_docs_contracts_dir_uses_arm_ka_docs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    monkeypatch.setenv("ARM_KA_DOCS", str(tmp_path))
    assert _docs_contracts_dir() == tmp_path / "contracts"


def test_docs_contracts_dir_defaults_to_sibling_docs(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.delenv("ARM_KA_DOCS", raising=False)
    assert _docs_contracts_dir() == API_DIR.parent.parent / "docs" / "contracts"

Run: .venv/bin/pytest tests/unit/test_contract.py -k docs_contracts_dir -q Expected: test_docs_contracts_dir_uses_arm_ka_docs FAIL (возвращается соседний docs).

  • Step 4: Поиск docs через ARM_KA_DOCS (import os; интерфейс — Produces); текст пропуска — общий из Global Constraints (с «задайте ARM_KA_DOCS»). Run тот же → 2 passed.
  • Step 5: Скопировать оба файла правила, сверить shasum, сценарии зелёные — как задача 2, шаги 3–4.
  • Step 6: Реальный тест — как задача 1, шаг 7. CLAUDE.md:87-88: «идентичность по правилу MAJOR (S-53, tests/contract_identity.py); docs — ../docs или ARM_KA_DOCS».
  • Step 7: OpenSpec change contract-identity-window — как задача 2, шаг 6, дельта specs/image-recognition-gateway/spec.md с тем же текстом требования и сценариями; в сценарии make check — «при доступном ../docs (или ARM_KA_DOCS)» и «при недоступном docs тест идентичности пропущен с предупреждением». Run: cd $WT/scan-gateway && openspec validate contract-identity-window --strict → valid.
  • Step 8: make check на A и на B — как задача 1, шаг 8; дополнительно B через переменную без переключения: cd $WT/scan-gateway && ARM_KA_DOCS=/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs make check → код 0, тест идентичности PASSED.
  • Step 9: Commit — как задача 1, шаг 9.

Task 4: docs — dev/checks.md и решения P0-n в design.md

Модель исполнителя: haiku. Репозиторий — worktree /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs, ветка feat/116-unified-normalization (входит в PR #117; описание PR не трогать).

Files:

  • Modify: /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/dev/checks.md — «Известные расхождения реализаций», пункт ARM_KA_DOCS/ARM_KA_FIXTURES; абзац о правиле теста идентичности в «Проверки этого репозитория»
  • Modify: /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/design.md — конец раздела ## Decisions made without discussion

Interfaces:

  • Consumes: решения P0-1…P0-5 (раздел «Решения плана»), результат задачи 3 (scan-gateway читает ARM_KA_DOCS в тесте идентичности).

  • Produces: —

  • Step 1: dev/checks.md. В пункте ARM_KA_DOCS/ARM_KA_FIXTURES: scan-gateway учитывает ARM_KA_DOCS в тесте идентичности контракта (с шага 0 #116), ARM_KA_FIXTURES — нет; фраза «docs/fixtures нужно класть рядом» — только про fixtures. В абзаце о правиле: «правило реализовано файлом api/tests/contract_identity.py, побайтно одинаковым в трёх репозиториях; правка — сразу во всех трёх».

  • Step 2: design.md. Подраздел ### Решения при планировании (18.10.2026) в конце ## Decisions made without discussion; P0-1…P0-5 — следующими свободными номерами списка (на 18.10.2026 последний — 97, S-74; перед записью перечитать — параллельные планы дописывают тот же раздел), маркер P0-n в тексте сохраняется, формат как у S-n: дата, решение, обоснование, цена ошибки.

  • Step 3: Проверка: cd /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs && make check → код 0.

  • Step 4: Commit и push (git -C … pull --rebase перед push; при запрете хука — git pull слиянием):

git -C /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs add dev/checks.md openspec/changes/unified-statement-normalization/design.md
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs commit -m "docs(#116): тест идентичности копии контракта — ARM_KA_DOCS в scan-gateway, решения плана 0 (P0-1…P0-5)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W"
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs pull --rebase
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs push

Task 5: сверка трёх копий и итоговая проверка на A и B

Модель исполнителя: haiku. Кода не пишет; результат — таблица для описаний трёх PR.

Files: — (только чтение и прогоны)

Interfaces:

  • Consumes: ветки задач 1–3.

  • Produces: таблица «репозиторий × состояние docs → код make check, число тестов, пропуски» для PR.

  • Step 1: Копии правила совпадают.

shasum -a 256 /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/{arm-core,tabular-parser,scan-gateway}/api/tests/contract_identity.py
shasum -a 256 /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/arm-core/api/tests/test_contract_identity.py \
  /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/{tabular-parser,scan-gateway}/api/tests/unit/test_contract_identity.py

Expected: в каждой из двух групп три одинаковые суммы.

  • Step 2: A — docs main. make check в трёх worktree; тест идентичности в каждом — PASSED, не SKIPPED. Expected: три кода 0.
  • Step 3: B — последний коммит origin/feat/116-unified-normalization (после fetch). То же. Expected: три кода 0. Красный тест с расхождением «есть в docs/contracts, нет в api/contracts» по новому примеру верхнего уровня — это O-1: остановиться и поднять владельцу, правило не ослаблять. Вернуть docs в A.
  • Step 4: Записать таблицу в отчёт исполнителя; дальше — superpowers:finishing-a-development-branch: три PR (tea pulls create --login home --repo RSHB/<repo>), в каждом — «Closes #», ссылка на docs#116 и этот план, «слить до docs PR #117 (шаг 0 выкладки)», таблица шага 2–3, список рулингов. Перед слиянием #117 шаг 3 повторяется на его последнем коммите.

Самопроверка плана

  • Покрытие спеки: правило схем, примеров, прочих файлов и набора — задача 1 (шаг 5) и сценарии шага 3; сценарии требования «Копия устарела», «Сервис ещё пишет 1.x», «Сервис перешёл на 2.0», «Запрос v1.0 после слияния v2.0», «Примеры прежнего MAJOR», «Шаг 0 до слияния docs», «Ядро на окне перехода» — тесты таблицы задачи 1; ../docs или ARM_KA_DOCS, пропуск с предупреждением — задачи 1–3 (scan-gateway — задача 3); «всех трёх потребителей до слияния v2.0» — задачи 1–3, порядок — задача 5, шаг 4; удаление MAJOR 1 «сначала ядро» — test_window_copy_without_docs_pair_fails; quality-checks — make check задач 1–3, 5; dev/checks.md — задача 4.
  • Интерфейсы: имена identity_problems, docs_pair, example_kind, schema_major, held_window_majors, KIND_BY_PREFIX одинаковы в задачах 1–3 и в Review Focus; сценарный тест импортирует только docs_pair, example_kind, identity_problems, schema_major.
  • Счёт тестов сценариев — 15 (таблица задачи 1), он же в ожиданиях шагов 6 (задача 1) и 4 (задача 2).