Тест идентичности копии контракта по MAJOR (шаг 0 docs#116) #51
@ -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` (+ интеграция
|
||||
|
||||
109
api/tests/contract_identity.py
Normal file
109
api/tests/contract_identity.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""Правило сверки копии контракта `api/contracts/` с `docs/contracts/` на окне перехода v1 → v2 (S-53).
|
||||
|
||||
Правило — из спеки docs `recognition-contract`, требование «Копия контракта в сервисе идентична источнику»:
|
||||
схема копии `<имя>.schema.json` с `$id` `…/v<M>` сверяется с `<имя>.v<M>.schema.json` docs, если такой файл есть,
|
||||
иначе с основным `<имя>.schema.json`; пример `examples/<имя>.json` — с `examples/v<M>/<имя>.json`, если он есть
|
||||
(`M` — MAJOR схемы копии вида примера), иначе с `examples/<имя>.json`; прочие файлы копии (`.v<N>`-схемы,
|
||||
`examples/v<N>/`) — с одноимённым файлом 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<M>`)."""
|
||||
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<M>)")
|
||||
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<N>` (хотя бы один файл — держит все)."""
|
||||
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])
|
||||
@ -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:
|
||||
|
||||
190
api/tests/unit/test_contract_identity.py
Normal file
190
api/tests/unit/test_contract_identity.py
Normal file
@ -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"
|
||||
22
openspec/changes/contract-identity-window/proposal.md
Normal file
22
openspec/changes/contract-identity-window/proposal.md
Normal file
@ -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`.
|
||||
@ -0,0 +1,22 @@
|
||||
## 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** тест идентичности падает, сообщение называет файл
|
||||
3
openspec/changes/contract-identity-window/tasks.md
Normal file
3
openspec/changes/contract-identity-window/tasks.md
Normal file
@ -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`.
|
||||
Loading…
x
Reference in New Issue
Block a user