openspec(#116): план 0 — тест идентичности копий контракта на окне перехода v1 → v2 (arm-core, tabular-parser, scan-gateway)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 11:53:09 +04:00
parent cecb5dea2a
commit 0bb33e88c2

View File

@ -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/<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` проверку не задевают:
```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<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`.
```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/<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 не нужен). Построители и наборы:
```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>)`):
| Тест | Копия / 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**
```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(#<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`:
```markdown
## 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`:
```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 <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: Копии правила совпадают.**
```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/<repo>`), в каждом — «Closes #<N>», ссылка на 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).