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

507 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# План 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).