diff --git a/scripts/sync-statements.sh b/scripts/sync-statements.sh new file mode 100755 index 0000000..7a1c978 --- /dev/null +++ b/scripts/sync-statements.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# Синхронизация копии пакета arm_statements (репозиторий RSHB/arm-statements) с хостами: arm-core, tabular-parser, +# scan-gateway. Копия лежит в <хост>/api/arm_statements/ и принадлежит источнику: правят только в arm-statements. +# +# Точка синхронизации — файл <копия>/VERSION: полный хэш коммита arm-statements, из которого сделана копия. +# Файлы копии должны совпасть с `git archive arm_statements` (без VERSION и кэшей). +# +# Запуск из workspace: docs/scripts/sync-statements.sh [--check|--in-place|--dry-run] (--host <имя> | --host-dir ) +# [--ref ] [--host-ref ] [--source ] [--docs ] +# [--core-dir ] [--wt-root ] +# --check только проверить рабочее дерево хоста (незакоммиченная правка тоже расхождение): +# 0 — копия синхронна, 10 — предупреждение (копия отстала или перечень кодов в docs отличается), +# 20 — расхождение (прямая правка копии, чужая версия, нет копии, нет зависимости, код вне перечня) +# --in-place разложить копию в --host-dir (режим пока не реализован) +# --dry-run worktree хоста без issue и PR (режим пока не реализован); вместе с --in-place — код 2 +# --host arm-core | tabular-parser | scan-gateway; по умолчанию — по origin хоста (arm-core, arm-tabular-parser, +# arm-scan-gateway), иначе по имени каталога +# --host-dir каталог хоста (корень репозитория) +# --ref ветка источника, по умолчанию origin/main; --host-ref — база worktree хоста, по умолчанию origin/main +# --source каталог arm-statements, по умолчанию /arm-statements; --docs — каталог docs +# STATEMENTS_PY — интерпретатор помощника (Python >= 3.11 с PyYAML); иначе docs/.venv, хост api/.venv, python3 +# --check важнее --dry-run и --in-place при любом порядке аргументов. +# Итог --check: «statements-check <хост>: <код> — …»; код возврата равен коду в строке; 2 — неверные аргументы или окружение. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# Корень workspace — где рядом лежит arm-statements. Скрипт запускают и из docs/ (корень = ../..), и из worktree +# .wt/<задача>/docs (корень = ../../../..); внешний кандидат проверяется первым — там основные checkout'ы. +WS="$(cd "$HERE/../.." && pwd)" +for cand in "$HERE/../../../.." "$HERE/../.."; do + if [ -e "$cand/arm-statements/.git" ]; then WS="$(cd "$cand" && pwd)"; break; fi +done +SRC="$WS/arm-statements" +DOCS="$(cd "$HERE/.." && pwd)" +CORE_DIR="$WS/arm-core" +WT_ROOT="$WS/.wt" +REF=origin/main +HOST_REF=origin/main +HOST_NAME="" +HOST_DIR="" +MODE="" +CHECK=0 +IN_PLACE=0 +DRY=0 + +die() { echo "sync-statements: $1" >&2; exit "${2:-1}"; } # die <сообщение> [код] + +while [ $# -gt 0 ]; do + case "$1" in + --check) CHECK=1 ;; # приоритет над --dry-run и --in-place независимо от порядка + --in-place) IN_PLACE=1 ;; + --dry-run) DRY=1 ;; + --host) [ $# -ge 2 ] || die "--host требует значение" 2; HOST_NAME=$2; shift ;; + --host-dir) [ $# -ge 2 ] || die "--host-dir требует значение" 2; HOST_DIR=$2; shift ;; + --ref) [ $# -ge 2 ] || die "--ref требует значение" 2; REF=$2; shift ;; + --host-ref) [ $# -ge 2 ] || die "--host-ref требует значение" 2; HOST_REF=$2; shift ;; + --source) [ $# -ge 2 ] || die "--source требует значение" 2; SRC=$2; shift ;; + --docs) [ $# -ge 2 ] || die "--docs требует значение" 2; DOCS=$2; shift ;; + --core-dir) [ $# -ge 2 ] || die "--core-dir требует значение" 2; CORE_DIR=$2; shift ;; + --wt-root) [ $# -ge 2 ] || die "--wt-root требует значение" 2; WT_ROOT=$2; shift ;; + -h|--help) sed -n "2,23p" "$0"; exit 0 ;; + *) die "неизвестный аргумент $1" 2 ;; + esac + shift +done +if [ "$CHECK" = 1 ]; then MODE=check +elif [ "$IN_PLACE" = 1 ] && [ "$DRY" = 1 ]; then die "--in-place несовместим с --dry-run" 2 +elif [ "$IN_PLACE" = 1 ]; then MODE=in-place +elif [ "$DRY" = 1 ]; then MODE=dry +else MODE=sync; fi + +[ -n "$HOST_NAME" ] || [ -n "$HOST_DIR" ] || die "укажите --host <имя> или --host-dir " 2 +case "$HOST_NAME" in ""|arm-core|tabular-parser|scan-gateway) ;; *) die "неизвестный хост: $HOST_NAME (arm-core | tabular-parser | scan-gateway)" 2 ;; esac +[ -d "$SRC/.git" ] || [ -f "$SRC/.git" ] || [ -d "$SRC/objects" ] || die "arm-statements не найден: $SRC (--source )" 2 +SRC=$(cd "$SRC" && pwd) +[ -d "$DOCS" ] || die "docs не найден: $DOCS (--docs )" 2 +DOCS=$(cd "$DOCS" && pwd) + +if [ -z "$HOST_DIR" ]; then + case "$HOST_NAME" in + arm-core) HOST_DIR=$CORE_DIR ;; + *) HOST_DIR="$WS/$HOST_NAME" ;; + esac +fi +[ -d "$HOST_DIR" ] || die "каталог хоста не найден: $HOST_DIR" 2 +HOST_DIR=$(cd "$HOST_DIR" && pwd) + +if [ -z "$HOST_NAME" ]; then + ORIGIN=$(git -C "$HOST_DIR" remote get-url origin 2>/dev/null || true) + base=$(basename "${ORIGIN:-$HOST_DIR}"); base=${base%.git} + case "$base" in + arm-core) HOST_NAME=arm-core ;; + arm-tabular-parser|tabular-parser) HOST_NAME=tabular-parser ;; + arm-scan-gateway|scan-gateway) HOST_NAME=scan-gateway ;; + *) base=$(basename "$HOST_DIR") + case "$base" in + arm-core|tabular-parser|scan-gateway) HOST_NAME=$base ;; + *) die "не удалось определить хост по origin и имени каталога: укажите --host" 2 ;; + esac ;; + esac +fi + +# --- интерпретатор помощника --------------------------------------------------------------------------------------- +PY="" +for cand in "${STATEMENTS_PY:-}" "$DOCS/.venv/bin/python" "$HOST_DIR/api/.venv/bin/python" python3; do + [ -n "$cand" ] || continue + if "$cand" -c 'import tomllib, yaml' 2>/dev/null; then PY=$cand; break; fi +done +[ -n "$PY" ] || die "нужен Python >= 3.11 с PyYAML: выполните make -C docs venv (или задайте STATEMENTS_PY)" 2 +CHK="$HERE/statements_check.py" + +# --- источник ------------------------------------------------------------------------------------------------------ +FETCH_OK=1 +if ! git -C "$SRC" fetch -q origin 2>/dev/null; then + FETCH_OK=0 + echo "sync-statements: fetch не удался, сравниваю с локальным $REF" >&2 +fi +REF_SHA=$(git -C "$SRC" rev-parse -q --verify "$REF^{commit}") || die "в arm-statements нет $REF" 2 +REF7=${REF_SHA:0:7} + +TMPD=$(mktemp -d) +trap 'rm -rf "$TMPD"' EXIT + +# helper <аргументы…>: вывод помощника в $HELPER_OUT, код в $HELPER_RC; код 1 — выход скрипта с кодом 1 +HELPER_OUT=""; HELPER_RC=0 +helper() { + HELPER_RC=0 + HELPER_OUT=$("$PY" "$CHK" "$@" 2>&1) || HELPER_RC=$? + case "$HELPER_RC" in + 0|20) ;; + *) echo "$HELPER_OUT" >&2; exit 1 ;; + esac +} + +# --- evaluate ------------------------------------------------------------------------------------------------------ +# evaluate +# Печатает находки, выставляет EVAL_CODE (0/10/20) и EVAL_WARNS (причины предупреждения через «; »). +EVAL_CODE=0 +EVAL_WARNS="" +evaluate() { + local copy=$1 reqs=$2 lines=$3 host=$4 + local version="" v7="" archive="$TMPD/archive" known=1 line + EVAL_CODE=0; EVAL_WARNS="" + bad() { echo "$1"; EVAL_CODE=20; } + warn() { echo "$1"; [ "$EVAL_CODE" = 20 ] || EVAL_CODE=10; EVAL_WARNS="${EVAL_WARNS:+$EVAL_WARNS; }$2"; } + + if [ ! -d "$copy" ]; then + bad "копии нет: разложите sync-statements.sh --in-place --host-dir ($copy)" + return 0 + fi + if [ -f "$copy/VERSION" ]; then version=$(tr -d ' \t\r\n' < "$copy/VERSION"); fi + v7=${version:0:7} + if ! printf '%s' "$version" | grep -Eq '^[0-9a-f]{40}$' \ + || ! git -C "$SRC" cat-file -e "$version^{commit}" 2>/dev/null; then + known=0 + bad "VERSION ${version:-(нет файла)} нет в истории arm-statements$([ "$FETCH_OK" = 1 ] || echo '; fetch не удался — сделайте fetch в arm-statements и повторите')" + fi + + if [ "$known" = 1 ]; then + # 3. версия должна быть из истории ref + if ! git -C "$SRC" merge-base --is-ancestor "$version" "$REF_SHA" 2>/dev/null; then + bad "чужая версия: $v7 не из истории $REF" + fi + # 4. файлы копии против git archive + rm -rf "$archive"; mkdir -p "$archive" + git -C "$SRC" archive "$version" arm_statements | tar -x -C "$archive" + local diffs + diffs=$(diff -rq -x VERSION -x __pycache__ -x '*.pyc' -x .pytest_cache -x .mypy_cache -x .ruff_cache -x .DS_Store \ + "$archive/arm_statements" "$copy" 2>&1 || true) + if [ -n "$diffs" ]; then + bad "прямая правка копии (файлы относительно arm_statements/):" + while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in + "Files "*" differ") + line=${line#Files }; line=${line%% and *} + echo " изменён: ${line#"$archive/arm_statements"/}" ;; + "Only in "*) + line=${line#Only in } + local d=${line%%: *} n=${line#*: } + if [ "${d#"$copy"}" != "$d" ]; then + d=${d#"$copy"}; echo " только в копии: ${d#/}${d:+/}$n" | sed 's#: /#: #; s#//#/#g' + else + d=${d#"$archive/arm_statements"}; echo " только в источнике: ${d#/}${d:+/}$n" | sed 's#: /#: #; s#//#/#g' + fi ;; + *) echo " $line" ;; + esac + done <<<"$diffs" + fi + # 5. зависимости пакета в requirements хоста + git -C "$SRC" show "$version:pyproject.toml" > "$TMPD/pyproject.toml" 2>/dev/null || : > "$TMPD/pyproject.toml" + helper deps "$TMPD/pyproject.toml" "$reqs" + if [ "$HELPER_RC" = 20 ]; then bad "$HELPER_OUT"; elif [ -n "$HELPER_OUT" ]; then echo "$HELPER_OUT"; fi + fi + + # 6. коды словаря имён строк (только у хоста со словарём) + if [ -n "$lines" ]; then + helper lines "$lines" "$copy/rules/statement_code_orders.yaml" + if [ "$HELPER_RC" = 20 ]; then bad "$HELPER_OUT"; fi + fi + # 7. перечень кодов в docs + if [ ! -f "$DOCS/contracts/statement-code-orders.yaml" ]; then + warn "перечня кодов нет в docs: contracts/statement-code-orders.yaml" "перечня кодов нет в docs" + elif ! cmp -s "$DOCS/contracts/statement-code-orders.yaml" "$copy/rules/statement_code_orders.yaml"; then + warn "перечень кодов в docs отличается от копии: contracts/statement-code-orders.yaml" "перечень кодов в docs отличается от копии" + fi + # 8. отставание от ref + if [ "$known" = 1 ] && [ "$version" != "$REF_SHA" ]; then + warn "копия отстала: $v7, $REF — $REF7" "копия отстала: $v7, $REF — $REF7" + git -C "$SRC" log --format=' %h %s' "$version..$REF_SHA" 2>/dev/null || true + fi + VERSION7=$v7 + return 0 +} +VERSION7="" + +# --- режимы -------------------------------------------------------------------------------------------------------- +if [ "$MODE" = check ]; then + LINES="" + if [ "$HOST_NAME" = arm-core ] && [ -f "$HOST_DIR/api/rules/statement_lines.yaml" ]; then + LINES="$HOST_DIR/api/rules/statement_lines.yaml" + fi + evaluate "$HOST_DIR/api/arm_statements" "$HOST_DIR/api/requirements.txt" "$LINES" "$HOST_NAME" + case "$EVAL_CODE" in + 0) echo "statements-check $HOST_NAME: 0 — копия синхронна с arm-statements@$REF ($VERSION7)" ;; + 10) echo "statements-check $HOST_NAME: 10 — ПРЕДУПРЕЖДЕНИЕ: $EVAL_WARNS" ;; + *) echo "statements-check $HOST_NAME: 20 — расхождение, копию не принимать" ;; + esac + exit "$EVAL_CODE" +fi + +die "режим не реализован" 1 diff --git a/scripts/test_sync_statements.sh b/scripts/test_sync_statements.sh index c736330..f8568ce 100755 --- a/scripts/test_sync_statements.sh +++ b/scripts/test_sync_statements.sh @@ -100,5 +100,135 @@ put "$H/bad.yaml" 'editions: [' run 1 "битый YAML" lines "$H/bad.yaml" "$H/orders.yaml" run 2 "аргументы" +# --- sync-statements.sh --check ----------------------------------------------------------------------------------- +SYNC="$HERE/sync-statements.sh" +export STATEMENTS_PY="$PY" +SRC="$WORK/arm-statements" +git init -q --bare "$WORK/arm-statements.git" +git clone -q "$WORK/arm-statements.git" "$SRC" 2>/dev/null +git -C "$SRC" checkout -q -b main +put "$SRC/pyproject.toml" $'[project]\nname = "arm-statements"\ndependencies = ["PyYAML==6.0.3"]' +put "$SRC/README.md" "пакет" +put "$SRC/arm_statements/__init__.py" "# init" +put "$SRC/arm_statements/steps.py" "def step(): return 1" +mkdir -p "$SRC/arm_statements/rules"; cp "$H/orders.yaml" "$SRC/arm_statements/rules/statement_code_orders.yaml" +put "$SRC/arm_statements/tests/__init__.py" "# t" +put "$SRC/arm_statements/tests/test_steps.py" "def test_step(): pass" +commit_push "$SRC" "S1" main +S1=$(git -C "$SRC" rev-parse HEAD) +mkdir -p "$WORK/docs/contracts" "$WORK/docs-empty" "$WORK/wt" +cp "$H/orders.yaml" "$WORK/docs/contracts/statement-code-orders.yaml" + +# make_host <каталог> <имя origin>: bare + клон с копией S1 в api/arm_statements, requirements и (для arm-core) словарём +make_host() { + local dir=$1 name=$2 + git init -q --bare "$WORK/$name.git" + git clone -q "$WORK/$name.git" "$dir" 2>/dev/null + git -C "$dir" checkout -q -b main + put "$dir/api/requirements.txt" $'aiohttp==3.14.3\nPyYAML==6.0.3' + mkdir -p "$dir/api" + git -C "$SRC" archive "$S1" arm_statements | tar -x -C "$dir/api" + printf '%s\n' "$S1" > "$dir/api/arm_statements/VERSION" +} +make_host "$WORK/arm-core" arm-core +mkdir -p "$WORK/arm-core/api/rules"; cp "$H/lines.yaml" "$WORK/arm-core/api/rules/statement_lines.yaml" +commit_push "$WORK/arm-core" "copy S1" main +make_host "$WORK/tabular-parser" arm-tabular-parser +commit_push "$WORK/tabular-parser" "copy S1" main +make_host "$WORK/scan-gateway" arm-scan-gateway +mkdir -p "$WORK/scan-gateway/api/rules" +put "$WORK/scan-gateway/api/rules/statement_lines.yaml" $'editions:\n - id: fsbu4\n forms:\n BALANCE:\n - {code: "9999", name: "х"}' +commit_push "$WORK/scan-gateway" "copy S1" main +COMMON=(--source "$WORK/arm-statements" --docs "$WORK/docs" --wt-root "$WORK/wt" --core-dir "$WORK/arm-core") +AC="$WORK/arm-core/api" +reset_hosts() { local h; for h in arm-core tabular-parser scan-gateway; do git -C "$WORK/$h" checkout -q -- . && git -C "$WORK/$h" clean -fdq; done; } +# sync <описание> <аргументы…>: вывод — в $OUT +sync() { + local want=$1 desc=$2 rc=0; shift 2 + OUT=$("$SYNC" "${COMMON[@]}" "$@" 2>&1) || rc=$? + check "$([ "$rc" = "$want" ] && echo 0 || echo 1)" "$desc (rc=$rc, ожидался $want)" +} + +sync 0 "check: синхронно" --check --host-dir "$WORK/arm-core" +check "$(has 'statements-check arm-core: 0' && echo 0 || echo 1)" "check: синхронно — итоговая строка" +sync 0 "check: имя по origin, словарь не у scan-gateway" --check --host-dir "$WORK/scan-gateway" +check "$(has 'scan-gateway' && ! has 9999 && echo 0 || echo 1)" "check: scan-gateway — в выводе имя, нет 9999" +OUT=$(cd "$WORK/tabular-parser" && "$SYNC" --check --host-dir . "${COMMON[@]}" 2>&1) && rc=0 || rc=$? +check "$([ "$rc" = 0 ] && has 'statements-check tabular-parser: 0' && echo 0 || echo 1)" "check: --host-dir . из каталога хоста (rc=$rc)" +sync 0 "check: --host вместо origin" --check --host scan-gateway --host-dir "$WORK/scan-gateway" +sync 2 "check: неизвестное имя --host" --check --host foo --host-dir "$WORK/arm-core" + +TP="$WORK/tabular-parser/api/arm_statements" +mkdir -p "$TP/__pycache__" "$TP/tests/.pytest_cache" "$TP/.mypy_cache" +: > "$TP/__pycache__/steps.cpython-311.pyc"; : > "$TP/tests/.pytest_cache/x"; : > "$TP/.mypy_cache/x"; : > "$TP/.DS_Store" +sync 0 "check: кэши в копии игнорируются" --check --host-dir "$WORK/tabular-parser" +reset_hosts + +echo "# правка" >> "$AC/arm_statements/steps.py" +sync 20 "check: изменён файл" --check --host-dir "$WORK/arm-core" +check "$(has steps.py && has 'прямая правка' && echo 0 || echo 1)" "check: изменён файл — steps.py и «прямая правка»" +reset_hosts +put "$AC/arm_statements/extra.py" "x = 1" +sync 20 "check: добавлен файл" --check --host-dir "$WORK/arm-core" +check "$(has extra.py && echo 0 || echo 1)" "check: добавлен файл — extra.py" +reset_hosts +rm "$AC/arm_statements/tests/test_steps.py" +sync 20 "check: удалён файл" --check --host-dir "$WORK/arm-core" +check "$(has test_steps.py && echo 0 || echo 1)" "check: удалён файл — test_steps.py" +reset_hosts +rm "$AC/arm_statements/VERSION" +sync 20 "check: нет VERSION" --check --host-dir "$WORK/arm-core" +reset_hosts +printf '%s\n' 0000000000000000000000000000000000000000 > "$AC/arm_statements/VERSION" +sync 20 "check: неизвестный VERSION" --check --host-dir "$WORK/arm-core" +reset_hosts +rm -rf "$AC/arm_statements" +sync 20 "check: нет копии" --check --host-dir "$WORK/arm-core" +check "$(has 'копии нет' && echo 0 || echo 1)" "check: нет копии — сообщение" +reset_hosts + +git -C "$SRC" checkout -q -b feat/x +echo "# F" >> "$SRC/arm_statements/steps.py" +commit_push "$SRC" "F" feat/x +F=$(git -C "$SRC" rev-parse HEAD) +git -C "$SRC" checkout -q main +git -C "$SRC" archive "$F" arm_statements | tar -x -C "$AC" +printf '%s\n' "$F" > "$AC/arm_statements/VERSION" +sync 20 "check: чужая версия" --check --host-dir "$WORK/arm-core" +check "$(has 'чужая версия' && echo 0 || echo 1)" "check: чужая версия — сообщение" +reset_hosts + +put "$AC/requirements.txt" "aiohttp==3.14.3" +sync 20 "check: нет зависимости" --check --host-dir "$WORK/arm-core" +check "$(has PyYAML && echo 0 || echo 1)" "check: нет зависимости — PyYAML" +reset_hosts +cp "$H/lines_x.yaml" "$AC/rules/statement_lines.yaml" +sync 20 "check: код словаря вне перечня" --check --host-dir "$WORK/arm-core" +check "$(has 1120 && has fsbu4 && echo 0 || echo 1)" "check: код вне перечня — 1120 и fsbu4" +reset_hosts + +echo "# новый код" >> "$WORK/docs/contracts/statement-code-orders.yaml" +sync 10 "check: перечень в docs новее" --check --host-dir "$WORK/arm-core" +check "$(has contracts/statement-code-orders.yaml && has 'statements-check arm-core: 10' && echo 0 || echo 1)" "check: перечень в docs — в выводе файл и итог 10" +cp "$H/orders.yaml" "$WORK/docs/contracts/statement-code-orders.yaml" +sync 10 "check: перечня нет в docs" --check --host-dir "$WORK/arm-core" --docs "$WORK/docs-empty" + +sync 2 "check: нет источника" --check --host-dir "$WORK/arm-core" --source "$WORK/nope" +sync 2 "check: нет хоста" --check --host-dir "$WORK/nope" +sync 2 "check: неизвестный аргумент" --check --bogus +sync 2 "check: нет --host/--host-dir" --check +sync 2 "check: --ref не разрешается" --check --host-dir "$WORK/arm-core" --ref origin/nope +sync 2 "--in-place вместе с --dry-run" --in-place --dry-run --host-dir "$WORK/arm-core" +sync 0 "check важнее --dry-run и --in-place" --in-place --dry-run --check --host-dir "$WORK/arm-core" + +# проверки с S2 — последними +put "$SRC/README.md" "пакет, правка" +commit_push "$SRC" "S2 правка README" main +sync 10 "check: отстала" --check --host-dir "$WORK/arm-core" +check "$(has 'копия отстала' && has 'S2 правка README' && echo 0 || echo 1)" "check: отстала — сообщение и тема S2" +echo "# правка" >> "$AC/arm_statements/steps.py" +sync 20 "check: правка + отставание = 20" --check --host-dir "$WORK/arm-core" +reset_hosts + [ "$fail" = 0 ] || exit 1 echo "ok: sync-statements.sh — все сценарии пройдены"