arm-ka-docs/scripts/prepare-contour-snapshot.sh
Andrey Epifancev 0574ba402f feat(scripts): снимок для контура — во web/ только зависимости сборки (#112)
prepare-contour-snapshot.sh урезает web/package.json arm-core по разрешённому списку
(devDependencies: @rsbuild/core, @rsbuild/plugin-react, tailwindcss, daisyui; optional: raisa-*;
scripts: build, start, preview), сокращает web-check до npm install + npm run build, пишет убранное
в SNAPSHOT.md и сам собирает урезанный web/ во временной копии: не собралось — снимка нет.
Гайд: раздел 3.2 и правило «web/package.json из контура обратно не переносить».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:47:35 +04:00

303 lines
19 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Снимок сервисного репозитория АРМ КА для переноса в контур банка (dev/contour-transfer-guide.md).
#
# Что делает: берёт дерево коммита (`git archive`, без истории), вырезает то, чего в контуре нет или что ему не нужно
# (AI-инструментарий, планы, Dockerfile для локальной сборки), переводит `make venv` с uv на `python -m venv` + pip через
# Nexus (как `setup/setup.sh` шаблона), убирает цель docker-build, заменяет ссылки на внешний Gitea, пишет SNAPSHOT.md с
# провенансом и проверяет результат. Репозиторий-источник не меняется.
#
# Запуск из workspace: docs/scripts/prepare-contour-snapshot.sh [--web-as-is] <каталог репозитория> <каталог вывода> [ref]
# ref по умолчанию — origin/main (снимок всегда с сервера, не с рабочего дерева).
# Пример: docs/scripts/prepare-contour-snapshot.sh tabular-parser /tmp/contour
# Результат: <каталог вывода>/<имя репозитория>/ — готовое дерево для rsync в клон репозитория контура.
#
# Для arm-core перед снимком проверяется, не отстаёт ли встроенный фронт web/ от RSHB/arm-web (sync-web.sh --check).
# Если отстаёт — вопрос «перенести как есть?»: «y» — снимок с отставанием (оно записывается в SNAPSHOT.md); «n» —
# запускается синхронизация (sync-web.sh: worktree, issue, PR) и перенос останавливается (код 3) до merge PR и закрытия
# issue. Без терминала вопрос не задаётся: нужен флаг --web-as-is, иначе код 3. Расхождение (прямые правки web/ в arm-core) —
# всегда стоп. arm-web ищется рядом с репозиторием (../arm-web) или в $ARM_WEB; если его нет — предупреждение, снимок делается.
# $SYNC_WEB_ARGS — дополнительные аргументы sync-web.sh (для теста: --dry-run --wt-root …).
# $SNAPSHOT_SKIP_WEB_BUILD=1 — не проверять сборку web/ (для тестов без сети); по умолчанию проверка включена.
#
# Встроенный фронт arm-core в контуре только собирается (npm install через Nexus + rsbuild build), а зеркало npm банка не
# содержит свежих версий инструментов разработки. Поэтому в снимке web/package.json урезается по РАЗРЕШЁННОМУ списку
# (решение владельца 16.10.2026); всё остальное — eslint, prettier, biome, typescript, vitest, @types/* — разработка
# и проверки идут снаружи. Список — единственное место, которое правят, если сборке понадобится ещё пакет.
WEB_DEV_ALLOW=(@rsbuild/core @rsbuild/plugin-react tailwindcss daisyui) # devDependencies, нужные сборке
WEB_OPTIONAL_KEEP_PREFIX="raisa-" # optionalDependencies: платформенные пакеты
WEB_SCRIPTS_KEEP=(build start preview) # scripts, работающие без инструментов разработки
set -euo pipefail
usage() { echo "usage: $0 [--web-as-is] <repo-dir> <out-dir> [ref]" >&2; exit 2; }
WEB_AS_IS=0
if [ "${1:-}" = "--web-as-is" ]; then WEB_AS_IS=1; shift; fi
[ $# -ge 2 ] || usage
REPO=$(cd "$1" && pwd)
mkdir -p "$2"
OUT=$(cd "$2" && pwd)
REF=${3:-origin/main}
NAME=$(basename "$REPO")
DEST="$OUT/$NAME"
case "$NAME" in
arm-core|tabular-parser|scan-gateway) ;;
*) echo "ошибка: $NAME — не сервисный репозиторий (ожидаются arm-core, tabular-parser, scan-gateway); docs и fixtures в контур не переносятся" >&2; exit 2 ;;
esac
if [ -e "$DEST" ]; then
echo "ошибка: $DEST уже существует — удалите или укажите другой каталог вывода" >&2
exit 2
fi
# --- 0. arm-core: встроенный фронт web/ не должен отставать от arm-web -----------------------------------------------
WEB_LAG=""
if [ "$NAME" = arm-core ]; then
SYNC_WEB="$(cd "$(dirname "$0")" && pwd)/sync-web.sh"
ARM_WEB_DIR=${ARM_WEB:-$(dirname "$REPO")/arm-web}
# shellcheck disable=SC2206
SYNC_ARGS=(--arm-core "$REPO" --arm-web "$ARM_WEB_DIR" --core-ref "$REF" ${SYNC_WEB_ARGS:-})
if [ ! -e "$ARM_WEB_DIR/.git" ]; then
echo "предупреждение: arm-web не найден ($ARM_WEB_DIR) — отставание web/ от arm-web не проверено" >&2
else
rc=0; "$SYNC_WEB" --check "${SYNC_ARGS[@]}" || rc=$?
case "$rc" in
0) ;;
10)
if [ "$WEB_AS_IS" = 1 ]; then
answer=y
elif [ -t 0 ] || [ -p /dev/stdin ] || [ -f /dev/stdin ]; then
read -r -p "web/ в arm-core отстаёт от arm-web. Перенести в контур как есть? [y/N] " answer || answer=n
else
answer=n
echo "нет терминала и нет --web-as-is: перенос как есть не подтверждён" >&2
fi
case "$answer" in
y|Y|д|Д) WEB_LAG="отстаёт от arm-web (перенесён как есть по решению оператора, см. sync-web.sh --check)" ;;
*)
echo "запускается синхронизация arm-core/web с arm-web (sync-web.sh)…" >&2
"$SYNC_WEB" "${SYNC_ARGS[@]}" || true
echo "перенос в контур остановлен до полного завершения синхронизации: merge PR и закрытие issue (чеклист «закрывай»), затем повторите снимок." >&2
exit 3 ;;
esac ;;
20) echo "ошибка: web/ в arm-core расходится с arm-web (прямые правки) — снимок не делается, разберитесь вручную" >&2; exit 1 ;;
*) echo "ошибка: sync-web.sh --check завершился с кодом $rc" >&2; exit 1 ;;
esac
fi
fi
git -C "$REPO" fetch -q origin
HASH=$(git -C "$REPO" rev-parse "$REF")
DATE=$(git -C "$REPO" show -s --format=%cs "$REF")
SUBJECT=$(git -C "$REPO" show -s --format=%s "$REF")
mkdir -p "$DEST"
git -C "$REPO" archive "$REF" | tar -x -C "$DEST"
# --- 1. Вырезать то, чего в контуре нет ------------------------------------------------------------------------
# .claude/, CLAUDE.md, openspec/config.yaml — инструкции и контекст AI-агента (config.yaml: блоки context/rules с путями
# ../docs и ../fixtures; openspec CLI в контуре нет, specs/ и design.md читаются без него); docs/superpowers/ — планы и
# ledger (в них локальные пути); Dockerfile/.dockerignore — локальная сборка образа, платформа собирает образ своим CI.
# openspec/specs/ и openspec/changes/archive/ остаются: это спецификация проекта и решения (design.md).
# Артефакты агента вырезаются на любой глубине: фронт перенесён в arm-core/web/ из arm-web со своими .claude/, CLAUDE.md,
# openspec/config.yaml, docs/superpowers/ и .codegraph/. Dockerfile/.dockerignore — только в корне (образ сервиса).
rm -rf "$DEST/Dockerfile" "$DEST/.dockerignore"
find "$DEST" \( \( -type d \( -name .claude -o -name .codegraph -o -path '*/docs/superpowers' \) \) \
-o \( -type f \( -name CLAUDE.md -o -path '*/openspec/config.yaml' \) \) \) -prune -exec rm -rf {} +
find "$DEST" -type d -name docs -empty -delete
# --- 2. Makefile: venv через python -m venv + pip с Nexus, без uv; без docker-build --------------------------------
python3 -I - "$DEST/Makefile" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
lines = path.read_text(encoding="utf-8").splitlines()
out: list[str] = []
i = 0
venv_patched = False
while i < len(lines):
line = lines[i]
# рецепт venv: все строки с uv заменяются одним рецептом на pip
if line.startswith("venv:"):
comment_start = len(out)
while comment_start > 0 and out[comment_start - 1].startswith("#"):
comment_start -= 1
del out[comment_start:]
out.append("# Контур банка: Python 3.11 платформы, pip через Nexus (setup/pip.conf), как в setup/setup.sh; dev-зависимости")
out.append("# тянут requirements.txt. PYTHON переопределяется: make venv PYTHON=/usr/bin/python3.11")
out.append("PYTHON ?= python3.11")
out.append("venv:")
out.append("\tcd api && $(PYTHON) -m venv .venv")
out.append("\tcp setup/pip.conf api/.venv/pip.conf")
out.append("\tcd api && .venv/bin/pip install -r requirements-dev.txt")
i += 1
while i < len(lines) and lines[i].startswith("\t"):
i += 1
venv_patched = True
continue
# цель docker-build и комментарий над ней удаляются
if line.startswith("docker-build:"):
comment_start = len(out)
while comment_start > 0 and out[comment_start - 1].startswith("#"):
comment_start -= 1
del out[comment_start:]
i += 1
while i < len(lines) and lines[i].startswith("\t"):
i += 1
if i < len(lines) and lines[i] == "" and out and out[-1] == "":
i += 1
continue
# web-check в контуре: только npm install и npm run build (biome/tsc убраны вместе с пакетами)
if line.startswith("web-check:"):
out.append(line)
i += 1
while i < len(lines) and lines[i].startswith("\t"):
if "npx " not in lines[i]:
out.append(lines[i])
i += 1
continue
if line.startswith("# Фронт:"):
line = line.replace("biome, типы, ", "")
if line.startswith("check-full:"):
line = re.sub(r"\s+docker-build\b", "", line)
if line.startswith("\t$(MAKE) docker-build"):
i += 1
continue
if line.startswith(".PHONY:"):
line = re.sub(r"\s+docker-build\b", "", line)
if line.startswith('\t@echo "docker-build'):
i += 1
continue
line = line.replace(" + docker-build", "")
out.append(line)
i += 1
if not venv_patched:
sys.exit("Makefile: цель venv не найдена — скрипт устарел относительно репозитория")
path.write_text("\n".join(out) + "\n", encoding="utf-8")
PY
# --- 2a. arm-core: web/package.json только с зависимостями сборки ---------------------------------------------------------
WEB_REMOVED=""
if [ -f "$DEST/web/package.json" ]; then
WEB_REMOVED=$(python3 -I - "$DEST/web/package.json" "$WEB_OPTIONAL_KEEP_PREFIX" "${WEB_DEV_ALLOW[*]}" "${WEB_SCRIPTS_KEEP[*]}" <<'PY'
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
optional_prefix = sys.argv[2]
dev_allow = sys.argv[3].split()
scripts_keep = sys.argv[4].split()
pkg = json.loads(path.read_text(encoding="utf-8"))
missing = [d for d in dev_allow if d not in pkg.get("devDependencies", {})]
if missing:
sys.exit(f"web/package.json: в devDependencies нет {', '.join(missing)} из разрешённого списка — скрипт устарел относительно репозитория")
dev = pkg.get("devDependencies", {})
opt = pkg.get("optionalDependencies", {})
scripts = pkg.get("scripts", {})
removed_dev = [n for n in dev if n not in dev_allow]
removed_opt = [n for n in opt if not n.startswith(optional_prefix)]
removed_scripts = [n for n in scripts if n not in scripts_keep]
pkg["devDependencies"] = {n: v for n, v in dev.items() if n in dev_allow}
if "optionalDependencies" in pkg:
pkg["optionalDependencies"] = {n: v for n, v in opt.items() if n.startswith(optional_prefix)}
pkg["scripts"] = {n: v for n, v in scripts.items() if n in scripts_keep}
path.write_text(json.dumps(pkg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print("devDependencies: " + (", ".join(removed_dev) or "—"))
print("optionalDependencies: " + (", ".join(removed_opt) or "—"))
print("scripts: " + (", ".join(removed_scripts) or "—"))
PY
)
fi
# --- 3. Ссылки на внешний Gitea → имя репозитория и номер ---------------------------------------------------------
# В openspec-архивах и README встречаются ссылки вида https://git.aepif.ru/RSHB/<repo>/issues/5 и pulls/7.
while IFS= read -r file; do
[ -n "$file" ] || continue
python3 -I - "$file" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
text = path.read_text(encoding="utf-8")
text = re.sub(r"https?://git\.aepif\.ru/(RSHB/[\w.-]+)/(issues|pulls)/(\d+)", r"\1#\3", text)
text = re.sub(r"https?://git\.aepif\.ru/?", "внешний Gitea проекта", text)
path.write_text(text, encoding="utf-8")
PY
done < <(grep -rIl "git.aepif.ru" "$DEST" 2>/dev/null || true)
# --- 4. Провенанс ---------------------------------------------------------------------------------------------------
cat > "$DEST/SNAPSHOT.md" <<EOF
# Снимок $NAME для контура
Источник: внешний репозиторий проекта \`RSHB/$NAME\`, коммит \`$HASH\` ($DATE, «${SUBJECT}»).
Снимок без истории; подготовлен \`prepare-contour-snapshot.sh\` ($(date +%Y-%m-%d)).
Чего здесь нет по сравнению с источником: \`.claude/\`, \`.codegraph/\`, \`CLAUDE.md\`, \`openspec/config.yaml\`, \`docs/superpowers/\`
на любой глубине, в т.ч. в \`web/\` (инструментарий, контекст агента и планы разработки снаружи), \`Dockerfile\`/\`.dockerignore\`
в корне (образ собирает платформа). Соседних репозиториев \`docs\` и
\`fixtures\` в контуре нет: тесты идентичности контракта и тесты с маркером \`fixtures\` пропускаются — это ожидаемо.
Копия контракта — \`api/contracts/\`, спецификация — \`openspec/specs/\`, решения — \`openspec/changes/archive/*/design.md\`.
Разработка ведётся снаружи; правки в контуре возвращаются в источник вручную (см. dev/contour-transfer-guide.md в docs).
EOF
if [ -n "$WEB_REMOVED" ]; then
{
printf '\nВстроенный фронт `web/`: `package.json` урезан до зависимостей сборки (в контуре фронт только собирается: '
printf '`npm install` через Nexus + `rsbuild build`; зеркало npm банка не содержит свежих инструментов разработки).\n'
printf 'Оставлены `dependencies` целиком, devDependencies `%s`, optionalDependencies `%s*`, scripts `%s`.\n' \
"${WEB_DEV_ALLOW[*]}" "$WEB_OPTIONAL_KEEP_PREFIX" "${WEB_SCRIPTS_KEEP[*]}"
printf 'Убрано:\n\n'
printf '%s\n' "$WEB_REMOVED" | sed 's/^/- /'
printf '\nЦель `make web-check` — `npm install` и `npm run build`. `web/package.json` из контура обратно не переносить: он урезан.\n'
} >> "$DEST/SNAPSHOT.md"
fi
if [ -n "$WEB_LAG" ]; then
printf '\nВстроенный фронт \`web/\` на момент снимка %s.\n' "$WEB_LAG" >> "$DEST/SNAPSHOT.md"
fi
# --- 5. Проверки ----------------------------------------------------------------------------------------------------
fail=0
for p in Dockerfile .dockerignore; do
if [ -e "$DEST/$p" ]; then echo "ошибка: в снимке остался $p" >&2; fail=1; fi
done
left=$(cd "$DEST" && find . \( -name .claude -o -name .codegraph -o -name CLAUDE.md -o -path '*/openspec/config.yaml' \
-o -path '*/docs/superpowers' \) -print)
if [ -n "$left" ]; then echo "ошибка: в снимке остались артефакты агента:" >&2; echo "$left" >&2; fail=1; fi
if grep -rIn "/Users/\|/home/[a-z]" "$DEST" >/dev/null 2>&1; then
echo "ошибка: в снимке есть локальные пути:" >&2; grep -rIn "/Users/\|/home/[a-z]" "$DEST" >&2; fail=1
fi
if grep -rIn "git.aepif.ru\|uv venv\|uv pip" "$DEST" >/dev/null 2>&1; then
echo "ошибка: остались ссылки на внешний Gitea или uv:" >&2; grep -rIn "git.aepif.ru\|uv venv\|uv pip" "$DEST" >&2; fail=1
fi
for p in raisa_config.yml setup/setup.sh setup/pip.conf api/run_server.py api/requirements.txt api/.env.test api/contracts; do
if [ ! -e "$DEST/$p" ]; then echo "ошибка: платформенный файл $p отсутствует" >&2; fail=1; fi
done
# Сборка урезанного web/ собирается в ВРЕМЕННОЙ копии: снимок остаётся без node_modules/dist/package-lock.json, а упавшая
# проверка не оставляет полуготовых файлов. Нужен npm и доступ к реестру (в контуре — Nexus, снаружи — публичный npm;
# raisa-* опциональны и снаружи пропускаются). Падение сборки почти всегда значит: пакет, нужный сборке, не в WEB_DEV_ALLOW.
if [ -f "$DEST/web/package.json" ] && [ "${SNAPSHOT_SKIP_WEB_BUILD:-0}" != 1 ]; then
echo "проверка сборки урезанного web/ (npm install + npm run build)…" >&2
BUILD_TMP=$(mktemp -d)
cp -R "$DEST/web" "$BUILD_TMP/web"
if ! { (cd "$BUILD_TMP/web" && npm install --no-audit --no-fund && npm run build) >"$BUILD_TMP/build.log" 2>&1; }; then
tail -n 30 "$BUILD_TMP/build.log" >&2
rm -rf "$BUILD_TMP" "$DEST"
echo "ошибка: сборка урезанного web/ не прошла — пакет, нужный сборке, не в разрешённом списке (WEB_DEV_ALLOW в $(basename "$0"))." >&2
echo "снимок не выдан." >&2
exit 1
fi
rm -rf "$BUILD_TMP"
fi
[ "$fail" = 0 ] || exit 1
echo "снимок готов: $DEST"
echo "источник: $NAME@${HASH:0:7} ($DATE); файлов: $(find "$DEST" -type f | wc -l | tr -d ' ')"