Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
110 lines
5.7 KiB
Python
110 lines
5.7 KiB
Python
"""Правило сверки копии контракта `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])
|