From 0bb33e88c2231a6a464443b6a79de99a7082546d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 11:53:09 +0400 Subject: [PATCH] =?UTF-8?q?openspec(#116):=20=D0=BF=D0=BB=D0=B0=D0=BD=200?= =?UTF-8?q?=20=E2=80=94=20=D1=82=D0=B5=D1=81=D1=82=20=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=D1=87=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BF=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B0=20=D0=BD=D0=B0=20=D0=BE=D0=BA=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=B0=20v1=20?= =?UTF-8?q?=E2=86=92=20v2=20(arm-core,=20tabular-parser,=20scan-gateway)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../2026-10-18-plan-0-contract-identity.md | 506 ++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100644 openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md new file mode 100644 index 0000000..3d981b9 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md @@ -0,0 +1,506 @@ +# План 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/-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/-contract-identity-window`; Gitea — + `tea … --login home --repo RSHB/` (`RSHB/arm-core`, `RSHB/arm-tabular-parser`, `RSHB/arm-scan-gateway`). +- Правило сравнения — дословно из спеки `recognition-contract`: схема копии `<имя>.schema.json` с `$id` `…/v` — + с `docs/contracts/<имя>.v.schema.json`, **если такой файл есть**, иначе с основным `<имя>.schema.json`; пример + `examples/<имя>.json` — вид по префиксу (`request-` — запрос, `result-message-` — конверт, `result-` — файл результата), + `M` — MAJOR копии схемы этого вида, пара — `examples/v/<имя>.json`, если он есть, иначе `examples/<имя>.json`; прочие + файлы копии (`<имя>.v.schema.json`, `examples/v/*.json`) — с одноимённым файлом docs, нет его — расхождение; + набор: каждой схеме верхнего уровня docs (`contracts/*.schema.json` без `.v`) и каждому примеру + `contracts/examples/*.json` соответствует файл копии; файлы docs `*.v.schema.json` и `examples/v/` требуются в + копии, только если копия держит этот MAJOR; файлы другого MAJOR не сверяются. У `recognition-request` файла `.v1` нет. +- `$id` схем в docs имеют вид `arm-ka/<имя>/v` (`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 ` и + `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.`, `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` проверку не задевают: + +```bash +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.schema.json` или `examples/v/*.json`. + Тогда все файлы docs `*.v.schema.json` и `examples/v/*.json` обязательны в копии. Цена: ядро, положившее один + файл `.v1`, обязано положить все. +- **P0-3.** Неопределимая пара — ошибка теста с именем файла, а не пропуск: пример с неизвестным префиксом, схема без + `$id` вида `…/v`, пример, вида которого нет среди схем копии. Цена: новый вид примера в 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`**: падение с именем файла, а не исключение без контекста или молчаливое + сравнение с основным файлом; тест `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)"`, если `$id` нет или он не того вида; + - `example_kind(file_name: str) -> str` — имя схемы вида; `ValueError` `": вид примера не определён по + префиксу (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` (P0-2); + - `identity_problems(copy_dir: Path, docs_dir: Path) -> list[str]` — все расхождения, каждое начинается с + относительного пути файла; пустой список — копия идентична. + - Тексты расхождений (на них опираются тесты): `": отличается от docs/contracts/ — скопируйте заново + (cp), не правьте руками"`; `": нет пары в docs/contracts ()"`; `": есть в docs/contracts, + нет в api/contracts"`; при `ValueError` от `docs_pair` — `": <текст исключения>"`. + +- [ ] **Step 1: Зеркальный workspace, issue, ветка, индекс.** cwd — `/Users/andrey.epifancev/projects/awp-ca`. + +```bash +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/-contract-identity-window +cd $WT/arm-core && codegraph init && make venv +``` + +Expected: `git -C $WT/arm-core branch --show-current` → `feat/-contract-identity-window`; `ls $WT/docs/contracts` +без `*.v1.schema.json`. `` — номер созданной 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 не нужен). Построители и наборы: + +```python +"""Правило теста идентичности копии контракта (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 | Утверждения | +|---|---|---| +| `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.schema.json` или `examples/v/<файл>.json` → он же; `<имя>.schema.json` в корне + → `M = schema_major(copy_dir / copy_rel)`, кандидат `<имя>.v.schema.json`, если `(docs_dir / кандидат).is_file()`, + иначе `copy_rel`; `examples/<файл>.json` (один уровень) → вид `example_kind(<файл>)`, схема вида + `copy_dir / f"{вид}.schema.json"` (нет — `ValueError`), `M = schema_major(…)`, кандидат `examples/v/<файл>`, если + он есть в docs, иначе `copy_rel`; любой другой путь → он же. + - `identity_problems`: наборы `rglob("*.json")` обоих каталогов (относительные POSIX-пути); для каждого файла копии по + порядку — пара (`ValueError` → `": <текст>"` в список, файл дальше не сверяется), пары нет в docs → «нет пары», байты различаются → «отличается»; + затем обязательный набор docs: схемы корня без `.v`, `examples/*.json` одного уровня, для каждого `N` из + `held_window_majors(<набор копии>)` — `*.v.schema.json` и `examples/v/*.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.schema.json`, `examples/v/`, + если есть), правило — `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** + +```bash +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(#): тест идентичности копии контракта по MAJOR (S-53, шаг 0 docs#116)" \ + -m "Co-Authored-By: Claude Opus 5.5 +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`: + +```markdown +## MODIFIED Requirements + +### Requirement: Копия контракта идентична источнику +Каждый файл `api/contracts/**/*.json` SHALL быть байт в байт равен своей паре в `../docs/contracts/` (или в `contracts` +каталога из `ARM_KA_DOCS`) по правилу спеки docs `recognition-contract`, требование «Копия контракта в сервисе +идентична источнику»: схема с `$id` `…/v` — с `<имя>.v.schema.json`, если такой файл есть в docs, иначе с +основным; пример — с `examples/v/<имя>.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(#): …`). + +--- + +### 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`: + +```python +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` слиянием): + +```bash +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 +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: Копии правила совпадают.** + +```bash +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/`), в каждом — «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).