diff --git a/Makefile b/Makefile index fe25938..5ccd6d2 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,18 @@ # Команды проверки репозитория arm-ka-docs. Запуск из корня репозитория. # Сервисных целей (lint/test/up...) здесь нет: описание общих целей сервисов — dev/checks.md. VENV := .venv -# Если окружение создано (`make venv`) — берём его интерпретатор, иначе системный python3 (в нём должен быть jsonschema) +# Если окружение создано (`make venv`) — берём его интерпретатор, иначе системный python3 (в нём должны быть jsonschema и PyYAML) PY := $(if $(wildcard $(VENV)/bin/python),$(CURDIR)/$(VENV)/bin/python,python3) CHANGE ?= slice-0-skeleton # Текущая версия модели данных — по ней рисуются диаграммы (make diagrams) DBML ?= model/data-model-v5.18.dbml -.PHONY: venv validate-contracts validate-openspec test-snapshot test-sync-web sync-web check diagrams +.PHONY: venv validate-contracts validate-openspec test-snapshot test-sync-web test-sync-statements sync-web check diagrams -# Единственная зависимость скриптов проверки контракта — jsonschema +# Зависимости скриптов проверки контракта — jsonschema и PyYAML (перечни кодов форм contracts/statement-code-orders.yaml) venv: uv venv --python 3.11 --allow-existing $(VENV) - uv pip install --python $(VENV)/bin/python jsonschema + uv pip install --python $(VENV)/bin/python jsonschema pyyaml # Примеры contracts/examples/ против трёх схем JSON Schema 2020-12 validate-contracts: @@ -30,6 +30,10 @@ test-snapshot: test-sync-web: scripts/test_sync_web.sh +# sync-statements.sh на синтетических репозиториях: помощник проверок, копирование пакета, отказы +test-sync-statements: + STATEMENTS_PY=$(PY) scripts/test_sync_statements.sh + # Синхронизация arm-core/web с arm-web@test одной командой (worktree, issue, PR); SYNC_ARGS="--check" или "--dry-run" sync-web: scripts/sync-web.sh $(SYNC_ARGS) diff --git a/scripts/statements_check.py b/scripts/statements_check.py new file mode 100755 index 0000000..5bf3afa --- /dev/null +++ b/scripts/statements_check.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Помощник sync-statements.sh: проверки пакета arm_statements против хоста (arm-core, tabular-parser, scan-gateway). + +Команды: + deps + зависимости пакета (`[project].dependencies`), которых нет в requirements хоста; + lines + коды словаря имён строк, которых нет в перечне кодов копии пакета. + +Коды возврата: 0 — находок нет, 20 — есть находки, 1 — файл не читается или не той формы, 2 — неверные аргументы. +Спека: openspec statement-normalization (единая нормализация ББ/ОФР, issue #116). Только stdlib и PyYAML. +""" +from __future__ import annotations + +import re +import sys +import tomllib + +import yaml + +_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*") + + +def normalize_name(name: str) -> str: + """Имя пакета по PEP 503.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def requirement_name(line: str) -> str | None: + """Имя пакета из строки требования; пустая строка, комментарий и опции (`-r`, `--index-url`) дают None.""" + line = line.strip() + if not line or line.startswith("#") or line.startswith("-"): + return None + match = _NAME_RE.match(line) + return match.group(0) if match else None + + +def _pinned_version(line: str) -> str | None: + match = re.search(r"==\s*([^\s;,#]+)", line) + return match.group(1) if match else None + + +def missing_dependencies(pyproject_text: str, requirements_text: str) -> tuple[list[str], list[str]]: + """(зависимости пакета, которых нет среди requirements; примечания о различии версий).""" + project = tomllib.loads(pyproject_text).get("project") + deps = project.get("dependencies") if isinstance(project, dict) else None + if not deps: + return [], [] + host: dict[str, str] = {} + for line in requirements_text.splitlines(): + name = requirement_name(line) + if name: + host[normalize_name(name)] = line.split("#", 1)[0].strip() + missing: list[str] = [] + notes: list[str] = [] + for dep in deps: + name = requirement_name(dep) + if not name: + continue + host_line = host.get(normalize_name(name)) + if host_line is None: + missing.append(dep.strip()) + continue + pkg_version, host_version = _pinned_version(dep), _pinned_version(host_line) + if pkg_version and host_version and pkg_version != host_version: + notes.append(f"версии различаются: пакет {dep.strip()}, хост {host_line}") + return missing, notes + + +def codes_outside_orders(lines_doc: object, orders_doc: object) -> list[str]: + """Коды словаря имён, которых нет в объединении вариантов full и simplified перечня.""" + if not isinstance(lines_doc, dict) or not isinstance(lines_doc.get("editions"), list): + raise ValueError("словарь имён: нет списка editions") + if not isinstance(orders_doc, dict) or not isinstance(orders_doc.get("editions"), dict): + raise ValueError("перечень кодов: нет словаря editions") + problems: list[str] = [] + for edition in lines_doc["editions"]: + if not isinstance(edition, dict) or "id" not in edition or not isinstance(edition.get("forms"), dict): + raise ValueError("словарь имён: запись редакции без id или forms") + ed_id = str(edition["id"]) + ed_orders = orders_doc["editions"].get(ed_id) + for form, entries in edition["forms"].items(): + allowed: set[str] = set() + if isinstance(ed_orders, dict) and isinstance(ed_orders.get(form), dict): + for variant in ("full", "simplified"): + allowed.update(str(c) for c in ed_orders[form].get(variant) or []) + for entry in entries or []: + code = str(entry["code"]) if isinstance(entry, dict) and "code" in entry else None + if code is None: + raise ValueError(f"словарь имён: запись без code в редакции {ed_id}, форма {form}") + if code not in allowed: + problems.append(f"код {code} формы {form} редакции {ed_id} нет в перечне копии пакета") + return problems + + +def _read(path: str) -> str: + with open(path, encoding="utf-8") as fh: + return fh.read() + + +def main(argv: list[str]) -> int: + if len(argv) != 3 or argv[0] not in ("deps", "lines"): + print("использование: statements_check.py deps \n" + " statements_check.py lines ", + file=sys.stderr) + return 2 + try: + if argv[0] == "deps": + missing, notes = missing_dependencies(_read(argv[1]), _read(argv[2])) + findings = [f"зависимость пакета {dep} не найдена в {argv[2]}" for dep in missing] + else: + findings = codes_outside_orders(yaml.safe_load(_read(argv[1])), yaml.safe_load(_read(argv[2]))) + notes = [] + except (OSError, ValueError, yaml.YAMLError, tomllib.TOMLDecodeError) as exc: + print(f"ошибка: {exc}", file=sys.stderr) + return 1 + for finding in findings: + print(finding) + for note in notes: + print(f"примечание: {note}") + return 20 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/test_sync_statements.sh b/scripts/test_sync_statements.sh new file mode 100755 index 0000000..c736330 --- /dev/null +++ b/scripts/test_sync_statements.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Тест sync-statements.sh на синтетических репозиториях (bare origin + клон), без tea и сети. +# Разделы: помощник statements_check.py; дальше — сценарии самого скрипта (дописываются по задачам плана). +# Запуск: make test-sync-statements (или напрямую из корня docs; интерпретатор — STATEMENTS_PY, по умолчанию python3). +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PY=${STATEMENTS_PY:-python3} +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t + +if ! "$PY" -c 'import tomllib, yaml' 2>/dev/null; then + echo "нужен Python >= 3.11 с PyYAML: выполните make venv (или задайте STATEMENTS_PY)" >&2 + exit 1 +fi + +fail=0 +check() { if [ "$1" = 0 ]; then echo "ok: $2"; else echo "FAIL: $2" >&2; fail=1; fi; } + +put() { mkdir -p "$(dirname "$1")"; printf '%s\n' "${2:-x}" > "$1"; } +# shellcheck disable=SC2317 +commit_push() { git -C "$1" add -A; git -C "$1" commit -qm "$2"; git -C "$1" push -q origin "HEAD:$3" 2>/dev/null; git -C "$1" fetch -q origin; } + +# --- помощник statements_check.py --------------------------------------------------------------------------------- +H="$WORK/h"; mkdir -p "$H" +CHK="$HERE/statements_check.py" +# run <описание> <аргументы…>: вывод — в $OUT +run() { + local want=$1 desc=$2 rc=0; shift 2 + OUT=$("$PY" "$CHK" "$@" 2>&1) || rc=$? + check "$([ "$rc" = "$want" ] && echo 0 || echo 1)" "$desc (rc=$rc, ожидался $want)" +} +has() { grep -q -- "$1" <<<"$OUT"; } + +put "$H/pp.toml" $'[project]\ndependencies = ["PyYAML==6.0.3"]' +put "$H/r1.txt" $'aiohttp==3.14.3\nPyYAML==6.0.3' +run 0 "deps: есть" deps "$H/pp.toml" "$H/r1.txt" +check "$([ -z "$OUT" ] && echo 0 || echo 1)" "deps: есть — вывод пустой" +put "$H/r2.txt" $'aiohttp==3.14.3\npyyaml==6.0.3' +run 0 "deps: регистр" deps "$H/pp.toml" "$H/r2.txt" +put "$H/pp_u.toml" $'[project]\ndependencies = ["Py_YAML==6.0.3"]' +put "$H/r2u.txt" $'py-yaml==6.0.3' +run 0 "deps: подчёркивание и дефис равны" deps "$H/pp_u.toml" "$H/r2u.txt" +put "$H/r3.txt" $'aiohttp==3.14.3\n# PyYAML==6.0.3\n-r other.txt' +run 20 "deps: нет" deps "$H/pp.toml" "$H/r3.txt" +check "$(has PyYAML && echo 0 || echo 1)" "deps: нет — в выводе PyYAML" +put "$H/r4.txt" $'PyYAML==6.0.4' +run 0 "deps: другая версия" deps "$H/pp.toml" "$H/r4.txt" +check "$(has 'примечание:' && has 6.0.4 && echo 0 || echo 1)" "deps: другая версия — примечание с 6.0.4" +put "$H/pp_none.toml" $'[tool.ruff]\nline-length = 120' +run 0 "deps: без [project]" deps "$H/pp_none.toml" "$H/r1.txt" +put "$H/pp_jwt.toml" $'[project]\ndependencies = ["PyJWT==2.8.0"]' +put "$H/r5.txt" $'PyJWT[crypto]==2.8.0' +run 0 "deps: экстры хоста" deps "$H/pp_jwt.toml" "$H/r5.txt" + +put "$H/orders.yaml" 'version: 1 +repeats: {} +editions: + 66n: + BALANCE: {full: ["1110", "1100"], simplified: ["1100"]} + INCOME: {full: ["2110"], simplified: ["2110"]} + fsbu4: + BALANCE: {full: ["1110", "1100"], simplified: ["1100"]} + INCOME: {full: ["2110"], simplified: ["2110"]}' +put "$H/lines.yaml" 'editions: + - id: 66n + forms: + BALANCE: + - {code: "1110", name: "Основные средства"} + - {code: "1100", name: "Итого"} + - id: fsbu4 + forms: + BALANCE: + - {code: "1110", name: "Основные средства"}' +run 0 "lines: все коды есть" lines "$H/lines.yaml" "$H/orders.yaml" +put "$H/lines_x.yaml" 'editions: + - id: 66n + forms: + BALANCE: + - {code: "1110", name: "а"} + - id: fsbu4 + forms: + BALANCE: + - {code: "1110", name: "а"} + - {code: "1120", name: "б"}' +run 20 "lines: код вне перечня" lines "$H/lines_x.yaml" "$H/orders.yaml" +check "$(has 1120 && has fsbu4 && echo 0 || echo 1)" "lines: код вне перечня — в выводе 1120 и fsbu4" +put "$H/orders_s.yaml" 'version: 1 +editions: + fsbu4: + BALANCE: {full: ["1110"], simplified: ["1100"]}' +put "$H/lines_s.yaml" 'editions: + - id: fsbu4 + forms: + BALANCE: + - {code: "1100", name: "а"}' +run 0 "lines: код только в simplified" lines "$H/lines_s.yaml" "$H/orders_s.yaml" +put "$H/bad.yaml" 'editions: [' +run 1 "битый YAML" lines "$H/bad.yaml" "$H/orders.yaml" +run 2 "аргументы" + +[ "$fail" = 0 ] || exit 1 +echo "ok: sync-statements.sh — все сценарии пройдены"