"""Правило сверки копии контракта `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])