diff --git a/CLAUDE.md b/CLAUDE.md index 0fbfd67..1484d97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,8 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote **Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): - `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 — Срез 2a (см. ниже), форм §12 — Срез 2b; -- `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность с - `../docs/contracts` проверяется в `make check`) — 3.1; +- `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность по правилу MAJOR (S-53), + `tests/contract_identity.py`, с `../docs/contracts` проверяется в `make check`) — 3.1; - `api/rules/` — README о назначении; сами словари `*.yaml` (в документах `rules/*.yaml` относительно `api/`) появились в Срезе 2a — 4.1; - `api/src/core/nats.py` и `api/src/core/worker.py` — подписка queue group `tabular-parser` на @@ -89,7 +89,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23). **Контракт v1.1:** `rows[].extra` (нераспознанные колонки таблицы по заголовку); `CONTRACT_VERSION = "1.1"`; копия схемы -в `api/contracts/` идентична `../docs/contracts/` (проверяется в `make check`). +в `api/contracts/` идентична `../docs/contracts/` по правилу MAJOR (S-53, `tests/contract_identity.py`; проверяется в `make check`). **Тесты:** `make check` ≈ 95 с, потому что `make test` (`-m "not integration"`) включает тесты маркера `fixtures` (golden, property, покрытие словарей); без каталога `../fixtures` они пропускаются. Полный набор — `make check-full` (+ интеграция diff --git a/api/tests/contract_identity.py b/api/tests/contract_identity.py new file mode 100644 index 0000000..4deba97 --- /dev/null +++ b/api/tests/contract_identity.py @@ -0,0 +1,109 @@ +"""Правило сверки копии контракта `api/contracts/` с `docs/contracts/` на окне перехода v1 → v2 (S-53). + +Правило — из спеки docs `recognition-contract`, требование «Копия контракта в сервисе идентична источнику»: +схема копии `<имя>.schema.json` с `$id` `…/v` сверяется с `<имя>.v.schema.json` docs, если такой файл есть, +иначе с основным `<имя>.schema.json`; пример `examples/<имя>.json` — с `examples/v/<имя>.json`, если он есть +(`M` — MAJOR схемы копии вида примера), иначе с `examples/<имя>.json`; прочие файлы копии (`.v`-схемы, +`examples/v/`) — с одноимённым файлом docs. Файл побайтно одинаков в arm-core, tabular-parser и scan-gateway +(`api/tests/contract_identity.py`); правка — во всех трёх, сверка — `shasum -a 256`. +""" + +import json +import re +from pathlib import Path + +# Порядок значим: «result-message-» начинается с «result-», берётся первый подходящий префикс. +KIND_BY_PREFIX: tuple[tuple[str, str], ...] = ( + ("request-", "recognition-request"), + ("result-message-", "recognition-result-message"), + ("result-", "recognition-result"), +) + +_EXAMPLE_DEPTH = 2 # examples/<файл>.json +_ID_MAJOR = re.compile(r"/v(\d+)$") +_WINDOW_SCHEMA = re.compile(r"\.v(\d+)\.schema\.json$") +_WINDOW_EXAMPLE = re.compile(r"^examples/v(\d+)/[^/]+\.json$") + + +def schema_major(schema_path: Path) -> int: + """MAJOR схемы из её `$id` (`…/v`).""" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + schema_id = schema.get("$id") if isinstance(schema, dict) else None + match = _ID_MAJOR.search(schema_id) if isinstance(schema_id, str) else None + if match is None: + raise ValueError(f"{schema_path.name}: в $id нет MAJOR (…/v)") + return int(match.group(1)) + + +def example_kind(file_name: str) -> str: + """Имя схемы вида примера по префиксу имени файла.""" + for prefix, kind in KIND_BY_PREFIX: + if file_name.startswith(prefix): + return kind + raise ValueError(f"{file_name}: вид примера не определён по префиксу (request-, result-message-, result-)") + + +def docs_pair(copy_rel: str, copy_dir: Path, docs_dir: Path) -> str: + """Относительный POSIX-путь файла docs, с которым сверяется файл копии `copy_rel`.""" + parts = copy_rel.split("/") + if len(parts) == 1 and copy_rel.endswith(".schema.json") and not _WINDOW_SCHEMA.search(copy_rel): + major = schema_major(copy_dir / copy_rel) + candidate = copy_rel.removesuffix(".schema.json") + f".v{major}.schema.json" + return candidate if (docs_dir / candidate).is_file() else copy_rel + if len(parts) == _EXAMPLE_DEPTH and parts[0] == "examples" and copy_rel.endswith(".json"): + kind = example_kind(parts[1]) + kind_schema = copy_dir / f"{kind}.schema.json" + if not kind_schema.is_file(): + raise ValueError(f"{copy_rel}: нет схемы {kind}.schema.json в копии") + candidate = f"examples/v{schema_major(kind_schema)}/{parts[1]}" + return candidate if (docs_dir / candidate).is_file() else copy_rel + return copy_rel + + +def held_window_majors(copy_rels: set[str]) -> set[int]: + """MAJOR, которые копия держит файлами `.v` (хотя бы один файл — держит все).""" + majors: set[int] = set() + for rel in copy_rels: + match = _WINDOW_SCHEMA.search(rel) if "/" not in rel else _WINDOW_EXAMPLE.match(rel) + if match: + majors.add(int(match.group(1))) + return majors + + +def _json_files(root: Path) -> set[str]: + return {p.relative_to(root).as_posix() for p in root.rglob("*.json")} + + +def _is_required_in_copy(docs_rel: str, held: set[int]) -> bool: + parts = docs_rel.split("/") + if len(parts) == 1 and docs_rel.endswith(".schema.json"): + match = _WINDOW_SCHEMA.search(docs_rel) + return match is None or int(match.group(1)) in held + if len(parts) == _EXAMPLE_DEPTH and parts[0] == "examples": + return True + match = _WINDOW_EXAMPLE.match(docs_rel) + return match is not None and int(match.group(1)) in held + + +def identity_problems(copy_dir: Path, docs_dir: Path) -> list[str]: + """Все расхождения копии с docs; каждое начинается с относительного пути файла. Пустой список — идентична.""" + copy_files = _json_files(copy_dir) + docs_files = _json_files(docs_dir) + problems: list[str] = [] + for copy_rel in sorted(copy_files): + try: + pair = docs_pair(copy_rel, copy_dir, docs_dir) + except ValueError as exc: + problems.append(f"{copy_rel}: {exc}") + continue + if pair not in docs_files: + problems.append(f"{copy_rel}: нет пары в docs/contracts ({pair})") + elif (copy_dir / copy_rel).read_bytes() != (docs_dir / pair).read_bytes(): + problems.append( + f"{copy_rel}: отличается от docs/contracts/{pair} — скопируйте заново (cp), не правьте руками" + ) + held = held_window_majors(copy_files) + for docs_rel in sorted(docs_files - copy_files): + if _is_required_in_copy(docs_rel, held): + problems.append(f"{docs_rel}: есть в docs/contracts, нет в api/contracts") + return sorted(problems, key=lambda p: p.split(":", 1)[0]) diff --git a/api/tests/unit/test_contract.py b/api/tests/unit/test_contract.py index 9a70731..e7cc79e 100644 --- a/api/tests/unit/test_contract.py +++ b/api/tests/unit/test_contract.py @@ -1,8 +1,9 @@ """Контракт обмена с сервисами распознавания v1.1. Спека сервиса — `tabular-recognition-service`; change `recognition-contract` лежит в `../docs`. -Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и -JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же вердикт на негативных кейсах. +Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/` по правилу MAJOR (S-53); +каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же +вердикт на негативных кейсах. """ import copy @@ -34,6 +35,7 @@ from src.contract import ( request_subject, result_msg_id, ) +from tests.contract_identity import identity_problems API_DIR = Path(__file__).resolve().parents[2] CONTRACTS_DIR = API_DIR / "contracts" @@ -116,16 +118,8 @@ def test_vendored_contracts_are_identical_to_docs() -> None: pytest.skip( "docs недоступен: тест идентичности контракта пропущен (задайте ARM_KA_DOCS или положите docs рядом)" ) - docs_files = {p.relative_to(docs_dir).as_posix() for p in docs_dir.rglob("*.json")} - own_files = {p.relative_to(CONTRACTS_DIR).as_posix() for p in CONTRACTS_DIR.rglob("*.json")} - assert own_files == docs_files, ( - f"набор файлов контракта разошёлся: только в api/contracts: {sorted(own_files - docs_files)}, " - f"только в docs/contracts: {sorted(docs_files - own_files)}" - ) - for name in sorted(docs_files): - assert (CONTRACTS_DIR / name).read_bytes() == (docs_dir / name).read_bytes(), ( - f"файл {name} в api/contracts отличается от docs/contracts: скопируйте заново (cp), не правьте руками" - ) + problems = identity_problems(CONTRACTS_DIR, docs_dir) + assert problems == [], "\n".join(problems) def test_example_table_covers_all_example_files() -> None: diff --git a/api/tests/unit/test_contract_identity.py b/api/tests/unit/test_contract_identity.py new file mode 100644 index 0000000..8a3dc1a --- /dev/null +++ b/api/tests/unit/test_contract_identity.py @@ -0,0 +1,190 @@ +"""Правило теста идентичности копии контракта (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).""" + result = [] + for name, files in (("copy", copy), ("docs", docs)): + root = tmp_path / name + root.mkdir() + for rel, text in files.items(): + target = root / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + result.append(root) + return result[0], result[1] + + +def _without(files: dict[str, str], rel: str) -> dict[str, str]: + return {k: v for k, v in files.items() if k != rel} + + +def test_step0_before_docs_merge_passes(tmp_path: Path) -> None: + c, d = _dirs(tmp_path, _v1_copy(), _v1_copy()) + assert identity_problems(c, d) == [] + assert docs_pair(f"{RES}.schema.json", c, d) == f"{RES}.schema.json" + + +def test_stale_copy_fails_naming_file(tmp_path: Path) -> None: + docs = _v1_copy() + docs[f"{RES}.schema.json"] = json.dumps({"$id": "arm-ka/recognition-result/v1", "new": 1}) + c, d = _dirs(tmp_path, _v1_copy(), docs) + problems = identity_problems(c, d) + assert len(problems) == 1 + assert problems[0].startswith(f"{RES}.schema.json") + assert "отличается" in problems[0] + + +def test_service_on_v1_after_v2_merge_passes(tmp_path: Path) -> None: + c, d = _dirs(tmp_path, _v1_copy(), _docs_window()) + assert identity_problems(c, d) == [] + assert docs_pair(f"{RES}.schema.json", c, d) == f"{RES}.v1.schema.json" + assert docs_pair(f"{MSG}.schema.json", c, d) == f"{MSG}.v1.schema.json" + + +def test_request_v1_pairs_with_main_after_v2_merge(tmp_path: Path) -> None: + c, d = _dirs(tmp_path, _v1_copy(), _docs_window()) + assert docs_pair(f"{REQ}.schema.json", c, d) == f"{REQ}.schema.json" + assert docs_pair("examples/request-image.json", c, d) == "examples/request-image.json" + + +def test_previous_major_examples_pair_with_v1_dir(tmp_path: Path) -> None: + c, d = _dirs(tmp_path, _v1_copy(), _docs_window()) + for name in ("result-statement-excel.json", "result-message-done.json"): + assert docs_pair(f"examples/{name}", c, d) == f"examples/v1/{name}" + + +def test_service_on_v2_passes_and_ignores_v1_files(tmp_path: Path) -> None: + c, d = _dirs(tmp_path, _v2_main(), _docs_window()) + assert identity_problems(c, d) == [] + assert docs_pair(f"{RES}.schema.json", c, d) == f"{RES}.schema.json" + assert docs_pair("examples/result-statement-excel.json", c, d) == "examples/result-statement-excel.json" + + +def test_core_window_copy_passes(tmp_path: Path) -> None: + c, d = _dirs(tmp_path, _docs_window(), _docs_window()) + assert identity_problems(c, d) == [] + assert docs_pair(f"{RES}.v1.schema.json", c, d) == f"{RES}.v1.schema.json" + assert docs_pair("examples/v1/result-statement-excel.json", c, d) == "examples/v1/result-statement-excel.json" + + +def test_core_window_copy_requires_every_v1_docs_file(tmp_path: Path) -> None: + copy = _without(_docs_window(), "examples/v1/result-message-done.json") + c, d = _dirs(tmp_path, copy, _docs_window()) + assert "examples/v1/result-message-done.json: есть в docs/contracts, нет в api/contracts" in identity_problems(c, d) + + +def test_window_copy_without_docs_pair_fails(tmp_path: Path) -> None: + c, d = _dirs(tmp_path, _docs_window(), _v2_main()) + problems = identity_problems(c, d) + for rel in (f"{RES}.v1.schema.json", "examples/v1/result-statement-excel.json"): + found = [p for p in problems if p.startswith(rel)] + assert found, rel + assert "нет пары" in found[0] + + +def test_top_level_docs_file_missing_in_copy_fails(tmp_path: Path) -> None: + copy = _without(_v1_copy(), "examples/request-image.json") + c, d = _dirs(tmp_path, copy, _v1_copy()) + assert "examples/request-image.json: есть в docs/contracts, нет в api/contracts" in identity_problems(c, d) + + +def test_extra_copy_file_fails(tmp_path: Path) -> None: + copy = {**_v1_copy(), "examples/result-new.json": _example(1, "result")} + c, d = _dirs(tmp_path, copy, _v1_copy()) + problems = identity_problems(c, d) + assert len(problems) == 1 + assert problems[0].startswith("examples/result-new.json") + assert "нет пары" in problems[0] + + +def test_new_top_level_docs_example_is_reported(tmp_path: Path) -> None: + docs = {**_docs_window(), "examples/result-statement-ocr.json": _example(2, "result")} + c, d = _dirs(tmp_path, _v1_copy(), docs) + problems = identity_problems(c, d) + assert len(problems) == 1 + assert problems[0].startswith("examples/result-statement-ocr.json") + + +def test_unknown_example_prefix_fails(tmp_path: Path) -> None: + files = {**_v1_copy(), "examples/foo.json": _example(1, "x")} + c, d = _dirs(tmp_path, files, files) + problems = identity_problems(c, d) + assert len(problems) == 1 + assert problems[0].startswith("examples/foo.json") + assert "вид примера" in problems[0] + with pytest.raises(ValueError, match="вид примера"): + example_kind("foo.json") + + +def test_schema_without_major_fails(tmp_path: Path) -> None: + copy = {**_v1_copy(), f"{RES}.schema.json": json.dumps({"$id": "arm-ka/recognition-result"})} + c, d = _dirs(tmp_path, copy, _v1_copy()) + problems = identity_problems(c, d) + assert any(p.startswith(f"{RES}.schema.json") and "$id" in p for p in problems) + with pytest.raises(ValueError): + schema_major(c / f"{RES}.schema.json") + + +def test_example_kind_by_longest_prefix(tmp_path: Path) -> None: + copy = { + **_v1_copy(), + f"{RES}.schema.json": _schema(RES, 2), + "examples/result-statement-excel.json": _example(2, "result"), + } + docs = {**copy, "examples/v1/result-message-done.json": _example(1, "message")} + c, d = _dirs(tmp_path, copy, docs) + assert example_kind("result-message-done.json") == MSG + assert docs_pair("examples/result-message-done.json", c, d) == "examples/v1/result-message-done.json" + assert docs_pair("examples/result-statement-excel.json", c, d) == "examples/result-statement-excel.json" diff --git a/openspec/changes/contract-identity-window/proposal.md b/openspec/changes/contract-identity-window/proposal.md new file mode 100644 index 0000000..b1e2b64 --- /dev/null +++ b/openspec/changes/contract-identity-window/proposal.md @@ -0,0 +1,22 @@ +## Why + +Окно перехода контракта v1 → v2 (docs change `unified-statement-normalization`, шаг 0 выкладки): после слияния docs PR #117 +основные схемы в `docs/contracts/` станут v2.0, а прежние v1 лягут рядом файлами `*.v1.schema.json` и `examples/v1/`. +Текущий тест идентичности копии сверяет наборы файлов целиком и покраснеет сразу после слияния. Правило S-53 спеки docs +`recognition-contract` сверяет копию с файлом docs своего MAJOR. + +## What Changes + +- Тест идентичности копии `api/contracts/` использует правило MAJOR (`api/tests/contract_identity.py`, побайтно одинаковый + файл в arm-core, tabular-parser и scan-gateway) вместо сравнения наборов файлов целиком. +- Требование «Копия контракта идентична источнику» уточнено под окно перехода. + +## Non-goals + +- Копии `api/contracts/` и pydantic-модели не меняются; переход сервиса на контракт 2.0 — шаг 5 выкладки, отдельный change. + +## Impact + +- `api/tests/` (тест идентичности, правило, сценарии), `CLAUDE.md`. +- Issue: RSHB/arm-tabular-parser#50; план — + `docs/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md`. diff --git a/openspec/changes/contract-identity-window/specs/tabular-recognition-service/spec.md b/openspec/changes/contract-identity-window/specs/tabular-recognition-service/spec.md new file mode 100644 index 0000000..cd60647 --- /dev/null +++ b/openspec/changes/contract-identity-window/specs/tabular-recognition-service/spec.md @@ -0,0 +1,22 @@ +## 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** тест идентичности падает, сообщение называет файл diff --git a/openspec/changes/contract-identity-window/tasks.md b/openspec/changes/contract-identity-window/tasks.md new file mode 100644 index 0000000..04c09d3 --- /dev/null +++ b/openspec/changes/contract-identity-window/tasks.md @@ -0,0 +1,3 @@ +## 1. Тест идентичности по правилу MAJOR + +- [x] 1.1 Выполнить задачи плана 0 (`docs`, `openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md`) для этого репозитория: правило, сценарии, реальный тест, проверка `make check` на docs main и на ветке `feat/116-unified-normalization`.