feat(scripts): помощник statements_check.py — зависимости пакета и коды словаря имён против перечня (#120)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 19:06:56 +04:00
parent d4f5453980
commit ddf6927332
3 changed files with 237 additions and 4 deletions

View File

@ -1,18 +1,18 @@
# Команды проверки репозитория arm-ka-docs. Запуск из корня репозитория. # Команды проверки репозитория arm-ka-docs. Запуск из корня репозитория.
# Сервисных целей (lint/test/up...) здесь нет: описание общих целей сервисов — dev/checks.md. # Сервисных целей (lint/test/up...) здесь нет: описание общих целей сервисов — dev/checks.md.
VENV := .venv VENV := .venv
# Если окружение создано (`make venv`) — берём его интерпретатор, иначе системный python3 (в нём должен быть jsonschema) # Если окружение создано (`make venv`) — берём его интерпретатор, иначе системный python3 (в нём должны быть jsonschema и PyYAML)
PY := $(if $(wildcard $(VENV)/bin/python),$(CURDIR)/$(VENV)/bin/python,python3) PY := $(if $(wildcard $(VENV)/bin/python),$(CURDIR)/$(VENV)/bin/python,python3)
CHANGE ?= slice-0-skeleton CHANGE ?= slice-0-skeleton
# Текущая версия модели данных — по ней рисуются диаграммы (make diagrams) # Текущая версия модели данных — по ней рисуются диаграммы (make diagrams)
DBML ?= model/data-model-v5.18.dbml 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: venv:
uv venv --python 3.11 --allow-existing $(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 # Примеры contracts/examples/ против трёх схем JSON Schema 2020-12
validate-contracts: validate-contracts:
@ -30,6 +30,10 @@ test-snapshot:
test-sync-web: test-sync-web:
scripts/test_sync_web.sh 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" # Синхронизация arm-core/web с arm-web@test одной командой (worktree, issue, PR); SYNC_ARGS="--check" или "--dry-run"
sync-web: sync-web:
scripts/sync-web.sh $(SYNC_ARGS) scripts/sync-web.sh $(SYNC_ARGS)

125
scripts/statements_check.py Executable file
View File

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Помощник sync-statements.sh: проверки пакета arm_statements против хоста (arm-core, tabular-parser, scan-gateway).
Команды:
deps <pyproject.toml> <requirements.txt>
зависимости пакета (`[project].dependencies`), которых нет в requirements хоста;
lines <statement_lines.yaml> <statement_code_orders.yaml>
коды словаря имён строк, которых нет в перечне кодов копии пакета.
Коды возврата: 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 <pyproject.toml> <requirements.txt>\n"
" statements_check.py lines <statement_lines.yaml> <statement_code_orders.yaml>",
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:]))

104
scripts/test_sync_statements.sh Executable file
View File

@ -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 <rc-ожидаемый> <описание> <аргументы…>: вывод — в $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 — все сценарии пройдены"