Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ebc20ff94 | |||
| 28d2d38857 | |||
| 09f16bd83c | |||
| 512d8e0e24 | |||
| 7aa0e4169e | |||
| 7c9a8151f6 | |||
| be36fc64b3 | |||
| d883bf486f | |||
| 8907d16e40 | |||
| 364065a239 | |||
| 000bf816cc | |||
| 339c5f09f7 | |||
| 7a49291296 | |||
| e3f6227ed1 | |||
| 7b8535eef2 | |||
| 69c1c5b374 | |||
| 8e804cc482 | |||
| 0bf69ce6b5 | |||
| 07747713f0 | |||
| c6d2df908a | |||
| d4ade05446 | |||
| bd7b1d3e0f | |||
| 57e9541775 | |||
| e213f9b01c | |||
| 1d2d43a6f2 | |||
| 1609faee8c | |||
| 3420f46a59 | |||
| b05e31c89c | |||
| 237eae7ee0 | |||
| cb32aa9907 | |||
| 34b85cf5cc | |||
| e2c00d60b1 | |||
| 97938c66b2 | |||
| 9c8db287ad | |||
| b404bf41a8 | |||
| d821bfb235 | |||
| cc149f324d | |||
| 6bd2735973 | |||
| 8c50c6db52 | |||
| 2000985208 | |||
| 544c06a790 | |||
| c67c217e43 | |||
| a24d084c24 | |||
| 88ae0ac348 |
Binary file not shown.
@@ -9,6 +9,7 @@ on:
|
||||
jobs:
|
||||
a11y:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -21,14 +22,16 @@ jobs:
|
||||
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
|
||||
coverage: none
|
||||
|
||||
- name: Setup Node 20
|
||||
- name: Setup Node 22
|
||||
# Node 22 (>=22.18): корневые tooling-пакеты @cspell/*@10 требуют node>=22.18.
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install root JS deps
|
||||
run: npm ci --no-audit --no-fund
|
||||
# npm install (не ci): корневой package-lock рассинхронен (gcp-metadata) — pre-existing долг.
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Install app composer deps
|
||||
working-directory: app
|
||||
@@ -36,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Install app JS deps
|
||||
working-directory: app
|
||||
run: npm ci --no-audit --no-fund
|
||||
run: npm ci --no-audit --no-fund --legacy-peer-deps
|
||||
|
||||
- name: Bootstrap .env + key
|
||||
working-directory: app
|
||||
@@ -44,12 +47,19 @@ jobs:
|
||||
cp .env.example .env
|
||||
php artisan key:generate --force
|
||||
|
||||
- name: Prepare SQLite for CI (avoid pg-on-CI fixture cost)
|
||||
- name: Prepare SQLite (public Pa11y routes need no real DB)
|
||||
# Pa11y покрывает 7 публичных SPA-маршрутов (login/register/forgot/2fa/recovery/403/500) —
|
||||
# они рендерятся без БД. Полная-PostgreSQL сборка с миграциями/seed отложена в отдельную
|
||||
# задачу (схема и миграции разошлись → from-scratch migrate сломан).
|
||||
working-directory: app
|
||||
run: |
|
||||
mkdir -p storage/framework/sessions storage/framework/views storage/framework/cache storage/logs bootstrap/cache
|
||||
touch database/database.sqlite
|
||||
sed -i 's/DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env
|
||||
sed -i 's|DB_DATABASE=.*|DB_DATABASE=/home/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/app/database/database.sqlite|' .env
|
||||
sed -i 's/SESSION_DRIVER=.*/SESSION_DRIVER=file/' .env
|
||||
sed -i 's/CACHE_STORE=.*/CACHE_STORE=file/' .env
|
||||
sed -i 's/QUEUE_CONNECTION=.*/QUEUE_CONNECTION=sync/' .env
|
||||
|
||||
- name: Build frontend assets
|
||||
working-directory: app
|
||||
@@ -72,9 +82,14 @@ jobs:
|
||||
tail -50 /tmp/laravel-serve.log
|
||||
exit 1
|
||||
|
||||
- name: Run Pa11y (live Vue)
|
||||
- name: Run Pa11y (live Vue — 7 public routes)
|
||||
run: npm run a11y
|
||||
|
||||
- name: Laravel log tail on failure
|
||||
if: failure()
|
||||
working-directory: app
|
||||
run: tail -120 storage/logs/laravel.log || echo "no laravel.log"
|
||||
|
||||
- name: Upload Pa11y screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -45,10 +45,10 @@ jobs:
|
||||
echo "Requested: '$CMD_TRIM'"
|
||||
|
||||
# Group 1 — read-only / dry-run / inspection: всегда разрешены
|
||||
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run)( *)$'
|
||||
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run|deals:backfill-region-city --dry-run)( *)$'
|
||||
|
||||
# Group 2 — mutating: требуют confirm_apply=true
|
||||
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?)( *)$'
|
||||
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?|deals:backfill-region-city)( *)$'
|
||||
|
||||
if [[ "$CMD_TRIM" =~ $READ_ONLY_RE ]]; then
|
||||
echo "::notice::Command in read-only whitelist — proceeding."
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
name: Lead region — prod ops
|
||||
|
||||
# Самодостаточный launch-инструмент фичи lead-region-resolution.
|
||||
# Один воркфлоу, переключатель op. НЕ трогает deploy.yml / artisan-run.yml.
|
||||
#
|
||||
# op:
|
||||
# pre-migrate — пред-применить миграцию 2026_05_31_100000 через postgres
|
||||
# superuser (crm_app_user не член crm_migrator → обычный migrate
|
||||
# падает) + пометить применённой, чтобы deploy её пропустил.
|
||||
# set-env — записать DADATA-ключи (из secrets) + LEAD_REGION_RESOLVER_ENABLED
|
||||
# (input flag) в боевой .env, перекэшировать config, рестарт очереди.
|
||||
# fetch-rossvyaz — скачать файл/архив реестра (input url) на прод в /var/www/liderra/rossvyaz.
|
||||
# import — phone-ranges:import (input dry_run) под www-data (DDL-свап идёт
|
||||
# через pgsql_supplier = crm_supplier_worker, член crm_migrator).
|
||||
# smoke — phone-region:smoke --phone=<input phone> под www-data (нужны ключи).
|
||||
#
|
||||
# Secrets: LIDERRA_SSH_KEY, DADATA_API_KEY, DADATA_SECRET.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
op:
|
||||
description: 'Операция'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- pre-migrate
|
||||
- set-env
|
||||
- fetch-rossvyaz
|
||||
- fetch-via-runner
|
||||
- deliver-from-repo
|
||||
- import
|
||||
- smoke
|
||||
flag:
|
||||
description: 'set-env: LEAD_REGION_RESOLVER_ENABLED'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
url:
|
||||
description: 'fetch-rossvyaz: прямая ссылка на CSV/ZIP реестра Россвязи'
|
||||
required: false
|
||||
type: string
|
||||
dir:
|
||||
description: 'import: каталог с CSV на проде'
|
||||
required: false
|
||||
default: '/var/www/liderra/rossvyaz'
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'import: только staging без swap'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
force:
|
||||
description: 'import: принудительно (--force, игнорировать «реестр идентичен»)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
phone:
|
||||
description: 'smoke: телефон'
|
||||
required: false
|
||||
default: '79161234567'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
op:
|
||||
name: ${{ github.event.inputs.op }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: liderra-prod-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
APP_DIR: /var/www/liderra/app
|
||||
OP: ${{ github.event.inputs.op }}
|
||||
FLAG: ${{ github.event.inputs.flag }}
|
||||
URL: ${{ github.event.inputs.url }}
|
||||
DIR: ${{ github.event.inputs.dir }}
|
||||
DRY: ${{ github.event.inputs.dry_run }}
|
||||
FORCE: ${{ github.event.inputs.force }}
|
||||
PHONE: ${{ github.event.inputs.phone }}
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H "${LIDERRA_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Checkout repo (for deliver-from-repo)
|
||||
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: op=pre-migrate (superuser DDL + mark applied)
|
||||
if: ${{ github.event.inputs.op == 'pre-migrate' }}
|
||||
run: |
|
||||
SQL_B64=$(cat <<'SQLEOF' | base64 -w0
|
||||
BEGIN;
|
||||
-- 1. phone_ranges_imports (FK target — создаём первым)
|
||||
CREATE TABLE phone_ranges_imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source_url TEXT NOT NULL,
|
||||
rows_inserted INTEGER NOT NULL DEFAULT 0,
|
||||
rows_updated INTEGER NOT NULL DEFAULT 0,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
|
||||
error TEXT,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE phone_ranges_imports IS
|
||||
'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
|
||||
|
||||
-- 2. phone_ranges (реестр диапазонов; SaaS-level, без RLS — публичные данные)
|
||||
CREATE TABLE phone_ranges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
def_code SMALLINT NOT NULL,
|
||||
from_num BIGINT NOT NULL,
|
||||
to_num BIGINT NOT NULL,
|
||||
operator TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
region_normalized TEXT,
|
||||
subject_code SMALLINT,
|
||||
imported_at TIMESTAMPTZ NOT NULL,
|
||||
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
|
||||
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
|
||||
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
|
||||
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
|
||||
);
|
||||
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
|
||||
COMMENT ON TABLE phone_ranges IS
|
||||
'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver.';
|
||||
|
||||
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
|
||||
|
||||
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at)
|
||||
CREATE TABLE lead_region_resolution_log (
|
||||
id BIGSERIAL,
|
||||
supplier_lead_id BIGINT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL,
|
||||
phone_masked TEXT NOT NULL,
|
||||
subject_code_resolved SMALLINT,
|
||||
subject_code_from_tag SMALLINT,
|
||||
region_source TEXT NOT NULL
|
||||
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
dadata_qc SMALLINT,
|
||||
dadata_provider TEXT,
|
||||
dadata_type TEXT,
|
||||
dadata_response_masked JSONB,
|
||||
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
actual_subject_code SMALLINT
|
||||
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
|
||||
substituted_subject_code SMALLINT
|
||||
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
|
||||
routing_step SMALLINT
|
||||
CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
|
||||
phone_operator TEXT,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
duration_ms INTEGER,
|
||||
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, received_at)
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
|
||||
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
|
||||
COMMENT ON TABLE lead_region_resolution_log IS
|
||||
'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно.';
|
||||
|
||||
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
|
||||
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
|
||||
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m05
|
||||
PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m06
|
||||
PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
|
||||
-- 4. supplier_leads: +4 колонки
|
||||
ALTER TABLE supplier_leads
|
||||
ADD COLUMN resolved_subject_code SMALLINT
|
||||
CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
|
||||
ADD COLUMN region_source TEXT
|
||||
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
ADD COLUMN dadata_qc SMALLINT,
|
||||
ADD COLUMN phone_operator TEXT;
|
||||
|
||||
-- 5. deals: +2 колонки
|
||||
ALTER TABLE deals
|
||||
ADD COLUMN phone_operator TEXT,
|
||||
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- ownership как у миграции (она шла бы под crm_migrator)
|
||||
ALTER TABLE phone_ranges_imports OWNER TO crm_migrator;
|
||||
ALTER TABLE phone_ranges OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log_y2026_m05 OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log_y2026_m06 OWNER TO crm_migrator;
|
||||
|
||||
-- retention (system_settings, 12 мес)
|
||||
INSERT INTO system_settings (key, value, type, description, updated_at)
|
||||
SELECT 'partition_retention_months_lead_region_resolution_log', '12', 'int',
|
||||
'Retention в месяцах для lead_region_resolution_log (~365 дней)', NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_settings
|
||||
WHERE key = 'partition_retention_months_lead_region_resolution_log');
|
||||
COMMIT;
|
||||
SQLEOF
|
||||
)
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" "SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
MIG_NAME='2026_05_31_100000_create_phone_ranges_and_resolution_log'
|
||||
ALREADY=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM migrations WHERE migration='${MIG_NAME}' LIMIT 1")
|
||||
if [ "${ALREADY}" = "1" ]; then
|
||||
echo "Migration ${MIG_NAME} уже применена — пропускаю."
|
||||
exit 0
|
||||
fi
|
||||
TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM information_schema.tables WHERE table_name='phone_ranges' LIMIT 1")
|
||||
if [ "${TABLE_EXISTS}" != "1" ]; then
|
||||
echo "Применяю lead-region DDL через postgres superuser..."
|
||||
echo "$SQL_B64" | base64 -d | sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1
|
||||
else
|
||||
echo "Таблица phone_ranges уже существует — только помечаю миграцию."
|
||||
fi
|
||||
NEXT_BATCH=$(sudo -u postgres psql -d liderra -tAc "SELECT COALESCE(MAX(batch),0)+1 FROM migrations")
|
||||
sudo -u postgres psql -d liderra -c \
|
||||
"INSERT INTO migrations (migration, batch) SELECT '${MIG_NAME}', ${NEXT_BATCH} WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration='${MIG_NAME}')"
|
||||
echo "Помечено ${MIG_NAME} применённой (batch ${NEXT_BATCH})."
|
||||
echo "=== Проверка таблиц ==="
|
||||
sudo -u postgres psql -d liderra -c "\dt phone_ranges|phone_ranges_imports|lead_region_resolution_log" || true
|
||||
REMOTE
|
||||
|
||||
- name: op=set-env (keys from secrets + flag → prod .env)
|
||||
if: ${{ github.event.inputs.op == 'set-env' }}
|
||||
env:
|
||||
DK: ${{ secrets.DADATA_API_KEY }}
|
||||
DS: ${{ secrets.DADATA_SECRET }}
|
||||
run: |
|
||||
DK_B64=$(printf '%s' "$DK" | base64 -w0)
|
||||
DS_B64=$(printf '%s' "$DS" | base64 -w0)
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"DK_B64='$DK_B64' DS_B64='$DS_B64' FLAG='$FLAG' APP_DIR='$APP_DIR' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
ENV="${APP_DIR}/.env"
|
||||
DK=$(echo "$DK_B64" | base64 -d)
|
||||
DS=$(echo "$DS_B64" | base64 -d)
|
||||
upsert() {
|
||||
local key="$1" val="$2"
|
||||
sudo sed -i "/^${key}=/d" "$ENV"
|
||||
echo "${key}=${val}" | sudo tee -a "$ENV" >/dev/null
|
||||
}
|
||||
upsert DADATA_API_KEY "$DK"
|
||||
upsert DADATA_SECRET "$DS"
|
||||
upsert LEAD_REGION_RESOLVER_ENABLED "$FLAG"
|
||||
cd "$APP_DIR"
|
||||
sudo -u www-data php artisan config:clear
|
||||
sudo -u www-data php artisan config:cache
|
||||
sudo systemctl restart liderra-queue
|
||||
echo "set-env готово: flag=${FLAG}, ключи записаны."
|
||||
echo "=== Проверка (значения скрыты) ==="
|
||||
sudo grep -E '^(DADATA_API_KEY|DADATA_SECRET|LEAD_REGION_RESOLVER_ENABLED)=' "$ENV" | sed -E 's/=(.).*/=\1***/'
|
||||
echo "=== queue status ==="
|
||||
systemctl is-active liderra-queue || true
|
||||
REMOTE
|
||||
|
||||
- name: op=fetch-rossvyaz (download registry on prod)
|
||||
if: ${{ github.event.inputs.op == 'fetch-rossvyaz' }}
|
||||
run: |
|
||||
# Пустой url → качаем все 4 официальных файла Минцифры за один прогон.
|
||||
# Непустой url → качаем только его (ручной режим).
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"URL='$URL' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
DEST=/var/www/liderra/rossvyaz
|
||||
sudo mkdir -p "$DEST"
|
||||
cd "$DEST"
|
||||
if [ -n "$URL" ]; then
|
||||
URLS="$URL"
|
||||
else
|
||||
URLS="https://opendata.digital.gov.ru/downloads/DEF-9xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-3xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-4xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-8xx.csv"
|
||||
fi
|
||||
for U in $URLS; do
|
||||
FNAME=$(basename "${U%%\?*}")
|
||||
[ -n "$FNAME" ] || FNAME="rossvyaz-download"
|
||||
echo "Скачиваю $U -> $FNAME"
|
||||
sudo curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,application/octet-stream,*/*' -H 'Accept-Language: ru-RU,ru;q=0.9' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FNAME" "$U"
|
||||
case "$FNAME" in
|
||||
*.zip|*.ZIP) echo "Распаковываю zip..."; sudo unzip -o "$FNAME" ;;
|
||||
esac
|
||||
done
|
||||
sudo chown -R www-data:www-data "$DEST"
|
||||
echo "=== Содержимое $DEST ==="
|
||||
ls -lh "$DEST"
|
||||
FIRST_CSV=$(ls "$DEST"/DEF-9xx.csv "$DEST"/*.csv "$DEST"/*.CSV 2>/dev/null | head -1 || true)
|
||||
if [ -n "$FIRST_CSV" ]; then
|
||||
echo "=== Первые строки $FIRST_CSV (cp1251→utf8) ==="
|
||||
sudo head -3 "$FIRST_CSV" | iconv -f cp1251 -t utf-8 2>/dev/null || sudo head -3 "$FIRST_CSV"
|
||||
fi
|
||||
REMOTE
|
||||
|
||||
- name: op=fetch-via-runner (download on runner, ship to prod)
|
||||
if: ${{ github.event.inputs.op == 'fetch-via-runner' }}
|
||||
run: |
|
||||
mkdir -p /tmp/rv && cd /tmp/rv && rm -f /tmp/rv/*.csv
|
||||
for U in https://opendata.digital.gov.ru/downloads/DEF-9xx.csv https://opendata.digital.gov.ru/downloads/ABC-3xx.csv https://opendata.digital.gov.ru/downloads/ABC-4xx.csv https://opendata.digital.gov.ru/downloads/ABC-8xx.csv; do
|
||||
FN=$(basename "${U%%\?*}")
|
||||
echo "runner: скачиваю $U -> $FN"
|
||||
curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,*/*' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FN" "$U"
|
||||
done
|
||||
echo "=== скачано на runner ==="
|
||||
ls -lh /tmp/rv | tee /tmp/op.log
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*.csv'
|
||||
scp -i ~/.ssh/liderra_deploy /tmp/rv/*.csv "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'sudo mkdir -p /var/www/liderra/rossvyaz && sudo mv /tmp/rvup/*.csv /var/www/liderra/rossvyaz/ && sudo chown -R www-data:www-data /var/www/liderra/rossvyaz && echo "=== на проде /var/www/liderra/rossvyaz ===" && ls -lh /var/www/liderra/rossvyaz' | tee -a /tmp/op.log
|
||||
|
||||
- name: op=deliver-from-repo (scp repo CSV/ZIP to prod, unzip there)
|
||||
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
|
||||
run: |
|
||||
# Ищем файлы реестра где угодно (корень или папка), .csv или .zip
|
||||
mapfile -t FILES < <(find . -maxdepth 2 -type f \( \( -iname 'DEF-9xx*' -o -iname 'ABC-3xx*' -o -iname 'ABC-4xx*' -o -iname 'ABC-8xx*' \) -iname '*.csv' -o -iname '*.zip' \) ! -path './.git/*')
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
echo "::error::Не нашёл файлов реестра (DEF-9xx/ABC-*.csv|zip) ни в корне, ни в rossvyaz-data/. Проверь, что они закоммичены в репозиторий."; exit 1
|
||||
fi
|
||||
echo "=== файлы в репозитории (rossvyaz-data/) ==="
|
||||
ls -lh "${FILES[@]}" | tee /tmp/op.log
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*'
|
||||
scp -i ~/.ssh/liderra_deploy "${FILES[@]}" "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" '
|
||||
cd /tmp/rvup
|
||||
for z in *.zip *.ZIP; do if [ -e "$z" ]; then echo "распаковываю $z"; unzip -o "$z"; rm -f "$z"; fi; done
|
||||
sudo mkdir -p /var/www/liderra/rossvyaz
|
||||
find . -iname "*.csv" -exec sudo mv {} /var/www/liderra/rossvyaz/ \;
|
||||
sudo chown -R www-data:www-data /var/www/liderra/rossvyaz
|
||||
echo "=== на проде /var/www/liderra/rossvyaz ==="
|
||||
ls -lh /var/www/liderra/rossvyaz
|
||||
' | tee -a /tmp/op.log
|
||||
|
||||
- name: op=import (phone-ranges:import)
|
||||
if: ${{ github.event.inputs.op == 'import' }}
|
||||
run: |
|
||||
DRY_FLAG=""
|
||||
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
|
||||
FORCE_FLAG=""
|
||||
if [ "${FORCE}" = "true" ]; then FORCE_FLAG="--force"; fi
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' FORCE_FLAG='$FORCE_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -e
|
||||
cd "$APP_DIR"
|
||||
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ${FORCE_FLAG} ==="
|
||||
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG $FORCE_FLAG 2>&1
|
||||
echo "=== Счётчики ==="
|
||||
sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true
|
||||
# staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил
|
||||
# подзапрос к phone_ranges_staging, когда таблица уже свапнута (иначе
|
||||
# ERROR relation "phone_ranges_staging" does not exist даже в ветке CASE).
|
||||
STAGING_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT to_regclass('phone_ranges_staging') IS NOT NULL")
|
||||
if [ "$STAGING_EXISTS" = "t" ]; then
|
||||
sudo -u postgres psql -d liderra -c "SELECT count(*) AS staging_rows FROM phone_ranges_staging" 2>&1 || true
|
||||
else
|
||||
echo "staging: отсутствует (после свапа — норма)"
|
||||
fi
|
||||
echo "=== Последний импорт ==="
|
||||
sudo -u postgres psql -d liderra -c \
|
||||
"SELECT id, status, rows_inserted, rows_updated, imported_at FROM phone_ranges_imports ORDER BY id DESC LIMIT 3" 2>&1 || true
|
||||
REMOTE
|
||||
|
||||
- name: op=smoke (phone-region:smoke)
|
||||
if: ${{ github.event.inputs.op == 'smoke' }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"APP_DIR='$APP_DIR' PHONE='$PHONE' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -e
|
||||
cd "$APP_DIR"
|
||||
echo "=== phone-region:smoke --phone=${PHONE} ==="
|
||||
sudo -u www-data php artisan phone-region:smoke --phone="$PHONE" 2>&1
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## lead-region-ops: \`${OP}\`"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/op.log 2>/dev/null || echo "(нет вывода)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
+69526
File diff suppressed because it is too large
Load Diff
+150000
File diff suppressed because it is too large
Load Diff
+142791
File diff suppressed because it is too large
Load Diff
+73783
File diff suppressed because it is too large
Load Diff
+16985
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Одноразовый бэкфилл: проставляет deals.city (имя субъекта) у уже существующих сделок,
|
||||
* у которых city ещё пуст, по resolved_subject_code связанного лида
|
||||
* (deals → supplier_lead_deliveries → supplier_leads). Идемпотентно (только city IS NULL).
|
||||
*
|
||||
* Запускается через .github/workflows/artisan-run.yml (mutating-whitelist, confirm_apply).
|
||||
* Парная правка для RouteSupplierLeadJob, который заполняет city у новых сделок.
|
||||
*/
|
||||
final class DealsBackfillRegionCityCommand extends Command
|
||||
{
|
||||
protected $signature = 'deals:backfill-region-city {--dry-run : Только посчитать, ничего не записывать}';
|
||||
|
||||
protected $description = 'Дозаполнить deals.city именем региона по resolved_subject_code лида (одноразовый бэкфилл)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
// BYPASSRLS-роль: бэкфилл идёт по всем тенантам без SET app.current_tenant_id.
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
$map = RussianRegions::CODE_TO_NAME;
|
||||
|
||||
$rows = $conn->table('deals')
|
||||
->join('supplier_lead_deliveries as dlv', 'dlv.deal_id', '=', 'deals.id')
|
||||
->join('supplier_leads as sl', 'sl.id', '=', 'dlv.supplier_lead_id')
|
||||
->whereNull('deals.city')
|
||||
->whereNotNull('sl.resolved_subject_code')
|
||||
->select('deals.id', 'deals.received_at', 'sl.resolved_subject_code')
|
||||
->get();
|
||||
|
||||
$seen = [];
|
||||
$updated = 0;
|
||||
foreach ($rows as $r) {
|
||||
$dealId = (int) $r->id;
|
||||
if (isset($seen[$dealId])) {
|
||||
continue; // у сделки несколько доставок — обрабатываем один раз
|
||||
}
|
||||
$seen[$dealId] = true;
|
||||
|
||||
$name = $map[(int) $r->resolved_subject_code] ?? null;
|
||||
if ($name === null) {
|
||||
continue; // код вне справочника 1..89 — пропускаем
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$conn->table('deals')
|
||||
->where('id', $dealId)
|
||||
->where('received_at', $r->received_at) // partition key
|
||||
->whereNull('city') // идемпотентный страж
|
||||
->update(['city' => $name]);
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$prefix = $dryRun ? '[dry-run] ' : '';
|
||||
$this->info("{$prefix}deals.city backfill: {$updated} обновлено из ".count($seen).' кандидатов.');
|
||||
Log::info('deals.backfill_region_city', [
|
||||
'updated' => $updated,
|
||||
'candidates' => count($seen),
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,10 @@ class PhoneRangesImportCommand extends Command
|
||||
$rows = [];
|
||||
foreach ($files as $file) {
|
||||
foreach ($this->parseFile($file) as $rec) {
|
||||
$subjectCode = RussianRegions::nameToCode()[trim($rec['region'])] ?? null;
|
||||
$regionNormalized = RussianRegions::canonicalRegionName($rec['region']);
|
||||
$subjectCode = $regionNormalized === null
|
||||
? null
|
||||
: (RussianRegions::nameToCode()[$regionNormalized] ?? null);
|
||||
if ($subjectCode === null && trim($rec['region']) !== '') {
|
||||
$unmatched[trim($rec['region'])] = true;
|
||||
}
|
||||
@@ -110,6 +113,7 @@ class PhoneRangesImportCommand extends Command
|
||||
'to_num' => $rec['to_num'],
|
||||
'operator' => $rec['operator'],
|
||||
'region' => $rec['region'],
|
||||
'region_normalized' => $regionNormalized,
|
||||
'subject_code' => $subjectCode,
|
||||
'imported_at' => now(),
|
||||
'import_id' => $importId,
|
||||
@@ -118,7 +122,7 @@ class PhoneRangesImportCommand extends Command
|
||||
}
|
||||
|
||||
// 5. Сборка staging.
|
||||
$this->buildStaging($rows);
|
||||
$this->buildStaging($rows, $importId);
|
||||
|
||||
$unmatchedNote = $unmatched === []
|
||||
? ''
|
||||
@@ -367,15 +371,27 @@ class PhoneRangesImportCommand extends Command
|
||||
/**
|
||||
* Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки.
|
||||
*
|
||||
* id: НЕ копируем серийный default через INCLUDING DEFAULTS — он ссылается на
|
||||
* исходную последовательность phone_ranges, которую atomic-swap уничтожает
|
||||
* (DROP phone_ranges_old CASCADE) после первого импорта, оставляя staging.id
|
||||
* без default (NOT NULL violation на повторном импорте). Вместо этого даём
|
||||
* staging собственную последовательность с уникальным по import_id именем,
|
||||
* OWNED BY колонкой id → она переезжает при RENAME и дропается вместе со
|
||||
* старой таблицей (без коллизий имён и без утечки последовательностей).
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function buildStaging(array $rows): void
|
||||
private function buildStaging(array $rows, int $importId): void
|
||||
{
|
||||
$c = DB::connection(self::DDL_CONNECTION);
|
||||
$this->elevate($c);
|
||||
|
||||
$seq = "phone_ranges_stg_seq_{$importId}";
|
||||
$c->statement('DROP TABLE IF EXISTS phone_ranges_staging CASCADE');
|
||||
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING DEFAULTS INCLUDING CONSTRAINTS)');
|
||||
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING CONSTRAINTS)');
|
||||
$c->statement("CREATE SEQUENCE {$seq}");
|
||||
$c->statement("ALTER TABLE phone_ranges_staging ALTER COLUMN id SET DEFAULT nextval('{$seq}')");
|
||||
$c->statement("ALTER SEQUENCE {$seq} OWNED BY phone_ranges_staging.id");
|
||||
$c->statement('CREATE INDEX IF NOT EXISTS idx_phone_ranges_staging_lookup ON phone_ranges_staging (def_code, from_num, to_num)');
|
||||
|
||||
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Services\NotificationService;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
|
||||
@@ -384,6 +385,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
if (in_array($resolution->source, ['dadata', 'rossvyaz'], true) && $resolution->subjectCode !== null) {
|
||||
$mergeUpdate['subject_code'] = $resolution->subjectCode;
|
||||
$mergeUpdate['phone_operator'] = $resolution->phoneOperator;
|
||||
$mergeUpdate['city'] = RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null;
|
||||
}
|
||||
DB::table('deals')
|
||||
->where('id', $existingMergeable->id)
|
||||
@@ -441,6 +443,11 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'status' => 'new',
|
||||
'received_at' => $receivedAt,
|
||||
'subject_code' => $dealSubjectCode,
|
||||
// «Город» (UI deals.city) — человекочитаемое имя НАСТОЯЩЕГО региона лида
|
||||
// по резолву (даже если subject_code подменён на шаге 3). NULL → колонка пустая.
|
||||
'city' => $resolution->subjectCode !== null
|
||||
? (RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null)
|
||||
: null,
|
||||
'phone_operator' => $resolution->phoneOperator,
|
||||
'region_substituted' => $routingStep === 3,
|
||||
]);
|
||||
|
||||
@@ -108,7 +108,16 @@ class MonthlyPartitionManager
|
||||
if ($exists !== null) {
|
||||
return false;
|
||||
}
|
||||
// Родитель-партиционированная таблица может ещё не существовать
|
||||
// (создаётся более поздней миграцией) — тогда пропускаем.
|
||||
$parentExists = DB::selectOne(
|
||||
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'p'",
|
||||
[$table],
|
||||
);
|
||||
|
||||
if ($parentExists === null) {
|
||||
return false;
|
||||
}
|
||||
DB::connection(self::DDL_CONNECTION)->statement(sprintf(
|
||||
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
|
||||
$partition,
|
||||
|
||||
@@ -114,9 +114,97 @@ final class RussianRegions
|
||||
89 => 'Ямало-Ненецкий автономный округ',
|
||||
];
|
||||
|
||||
/**
|
||||
* Алиасы нестандартных форм реестра Россвязи → каноничное имя субъекта.
|
||||
* Города фед. значения приходят с префиксом «г. »; «Республика Удмуртская» —
|
||||
* перевёрнутый порядок слов; «Кемеровская область - Кузбасс обл.» — спец-форма.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const REGION_ALIASES = [
|
||||
'г. Москва' => 'Москва',
|
||||
'Город Москва' => 'Москва',
|
||||
'г. Санкт-Петербург' => 'Санкт-Петербург',
|
||||
'г. Санкт - Петербург' => 'Санкт-Петербург',
|
||||
'г. Севастополь' => 'Севастополь',
|
||||
'Республика Саха /Якутия/' => 'Республика Саха (Якутия)',
|
||||
'Чувашская Республика - Чувашия' => 'Чувашская Республика',
|
||||
'Кемеровская область - Кузбасс обл.' => 'Кемеровская область',
|
||||
'Кемеровская область - Кузбасс область' => 'Кемеровская область',
|
||||
'Кемеровская область - Кузбасс' => 'Кемеровская область',
|
||||
];
|
||||
|
||||
/** @return array<string, int> name => code (обратный индекс) */
|
||||
public static function nameToCode(): array
|
||||
{
|
||||
return array_flip(self::CODE_TO_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует строку региона реестра Россвязи в каноничное имя субъекта (или null).
|
||||
*
|
||||
* Реестр кодирует субъект как ПОСЛЕДНИЙ сегмент после «|»
|
||||
* (напр. «г. Воскресенск|р-н Воскресенский|Московская обл.» → «Московская обл.»),
|
||||
* с сокращением «обл.» вместо «область» и рядом нестандартных форм (см. REGION_ALIASES).
|
||||
* Безнадёжные/неоднозначные строки («-», «Российская Федерация»,
|
||||
* «Москва и Московская область», «г.о. Тольятти») → null.
|
||||
*/
|
||||
public static function canonicalRegionName(string $raw): ?string
|
||||
{
|
||||
$segment = self::lastRegionSegment($raw);
|
||||
if ($segment === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ХМАО приходит в множестве форм (em-dash/дефис, «Югра», « АО», капитализация) —
|
||||
// ловим по двум устойчивым маркерам до общих правил.
|
||||
if (mb_stripos($segment, 'Ханты') !== false && mb_stripos($segment, 'Мансийск') !== false) {
|
||||
return 'Ханты-Мансийский автономный округ — Югра';
|
||||
}
|
||||
|
||||
if (isset(self::REGION_ALIASES[$segment])) {
|
||||
return self::REGION_ALIASES[$segment];
|
||||
}
|
||||
|
||||
// «обл.» → «область»; « АО» → « автономный округ».
|
||||
$name = (string) preg_replace('/\s*обл\.$/u', ' область', $segment);
|
||||
$name = (string) preg_replace('/\s+АО$/u', ' автономный округ', $name);
|
||||
// Дефис с пробелами → длинное тире (эталон: «Республика Северная Осетия — Алания»).
|
||||
// Безопасно: ни одно каноническое имя не содержит дефис, окружённый пробелами
|
||||
// (составные имена вроде «Кабардино-Балкарская» используют дефис без пробелов).
|
||||
$name = str_replace(' - ', ' — ', $name);
|
||||
|
||||
if (isset(self::nameToCode()[$name])) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
// Перевёрнутый порядок «Республика X» → «X Республика» (Удмуртская/Чеченская/
|
||||
// Чувашская/Кабардино-Балкарская/Карачаево-Черкесская, Донецкая Народная/
|
||||
// Луганская Народная). Республика-first каноны (Татарстан, Карелия…) уже
|
||||
// отловлены прямым попаданием выше.
|
||||
if (preg_match('/^Республика\s+(.+)$/u', $name, $m) === 1) {
|
||||
$reordered = trim($m[1]).' Республика';
|
||||
if (isset(self::nameToCode()[$reordered])) {
|
||||
return $reordered;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Резолвит строку региона реестра Россвязи в subject_code (1..89) или null. */
|
||||
public static function resolveSubjectCode(string $raw): ?int
|
||||
{
|
||||
$name = self::canonicalRegionName($raw);
|
||||
|
||||
return $name === null ? null : (self::nameToCode()[$name] ?? null);
|
||||
}
|
||||
|
||||
/** Последний сегмент после «|» (субъект в формате Россвязи), trimmed. */
|
||||
private static function lastRegionSegment(string $raw): string
|
||||
{
|
||||
$parts = explode('|', $raw);
|
||||
|
||||
return trim((string) end($parts));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public $withinTransaction = false;
|
||||
public function up(): void
|
||||
{
|
||||
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
});
|
||||
|
||||
/**
|
||||
* Сеет сделку (city=NULL по умолчанию) + лид с resolved_subject_code + связь
|
||||
* supplier_lead_deliveries. Возвращает [tenantId, dealId].
|
||||
*
|
||||
* @return array{0: int, 1: int}
|
||||
*/
|
||||
function seedDealWithResolvedLead(?int $resolvedCode, ?string $city = null): array
|
||||
{
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'backfill-city.ru',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'phone' => '79161234567',
|
||||
'phones' => ['79161234567'],
|
||||
'status' => 'new',
|
||||
'received_at' => now(),
|
||||
'subject_code' => $resolvedCode,
|
||||
'city' => $city,
|
||||
]);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => '79161234567',
|
||||
'resolved_subject_code' => $resolvedCode,
|
||||
'region_source' => $resolvedCode !== null ? 'dadata' : 'unknown',
|
||||
]);
|
||||
|
||||
DB::connection('pgsql_supplier')->table('supplier_lead_deliveries')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $deal->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return [$tenant->id, $deal->id];
|
||||
}
|
||||
|
||||
function dealCity(int $dealId): ?string
|
||||
{
|
||||
// BYPASSRLS чтение (как и сам бэкфилл) — без tenant-контекста.
|
||||
return DB::connection('pgsql_supplier')->table('deals')->where('id', $dealId)->value('city');
|
||||
}
|
||||
|
||||
it('backfills deal city from the lead resolved region code', function (): void {
|
||||
[, $dealId] = seedDealWithResolvedLead(29); // 29 → Красноярский край
|
||||
|
||||
$this->artisan('deals:backfill-region-city')->assertSuccessful();
|
||||
|
||||
expect(dealCity($dealId))->toBe('Красноярский край');
|
||||
});
|
||||
|
||||
it('does not touch deals that already have a city', function (): void {
|
||||
[, $dealId] = seedDealWithResolvedLead(29, city: 'Уже стоит');
|
||||
|
||||
$this->artisan('deals:backfill-region-city')->assertSuccessful();
|
||||
|
||||
expect(dealCity($dealId))->toBe('Уже стоит');
|
||||
});
|
||||
|
||||
it('dry-run reports candidates without writing', function (): void {
|
||||
[, $dealId] = seedDealWithResolvedLead(29);
|
||||
|
||||
$this->artisan('deals:backfill-region-city', ['--dry-run' => true])->assertSuccessful();
|
||||
|
||||
expect(dealCity($dealId))->toBeNull();
|
||||
});
|
||||
|
||||
it('leaves city null when the lead has no resolved region', function (): void {
|
||||
[, $dealId] = seedDealWithResolvedLead(null);
|
||||
|
||||
$this->artisan('deals:backfill-region-city')->assertSuccessful();
|
||||
|
||||
expect(dealCity($dealId))->toBeNull();
|
||||
});
|
||||
@@ -86,3 +86,39 @@ it('force flag bypasses idempotency note even with matching checksum', function
|
||||
expect(DB::table('phone_ranges_staging')->count())->toBe(3);
|
||||
expect(DB::table('phone_ranges')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('normalizes real Россвязь region formats to subject_code and fills region_normalized', function (): void {
|
||||
// Форматы из реального прод-реестра (топ unmapped 02.06.2026): префикс «г. »,
|
||||
// pipe-сегмент региона, сокращение «обл.», перевёрнутая «Республика Удмуртская»,
|
||||
// и безнадёжный city-only «г.о. Тольятти». def-коды 3-значные (chk_phone_ranges_def_code 300-999).
|
||||
$this->artisan('phone-ranges:import', ['--file' => base_path('tests/Fixtures/rossvyaz/messy.csv'), '--dry-run' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$moscow = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 495');
|
||||
$orenburg = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 922');
|
||||
$udmurtia = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 987');
|
||||
$togliatti = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 902');
|
||||
|
||||
expect((int) $moscow->subject_code)->toBe(82)
|
||||
->and($moscow->region_normalized)->toBe('Москва')
|
||||
->and((int) $orenburg->subject_code)->toBe(62)
|
||||
->and($orenburg->region_normalized)->toBe('Оренбургская область')
|
||||
->and((int) $udmurtia->subject_code)->toBe(21)
|
||||
->and($udmurtia->region_normalized)->toBe('Удмуртская Республика')
|
||||
->and($togliatti->subject_code)->toBeNull()
|
||||
->and($togliatti->region_normalized)->toBeNull();
|
||||
});
|
||||
|
||||
it('rebuilds staging id even after the live id default was dropped (post-swap state)', function (): void {
|
||||
// После первого atomic-swap исходная id-последовательность уничтожается
|
||||
// (DROP phone_ranges_old CASCADE), и live.id остаётся без DEFAULT. Повторный
|
||||
// импорт обязан выдать staging.id из собственной последовательности, а не упасть
|
||||
// на NOT NULL. Симулируем это, сняв default у phone_ranges.id.
|
||||
DB::connection('pgsql_supplier')->statement('ALTER TABLE phone_ranges ALTER COLUMN id DROP DEFAULT');
|
||||
|
||||
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
expect(DB::table('phone_ranges_staging')->count())->toBe(3)
|
||||
->and(DB::table('phone_ranges_staging')->whereNull('id')->count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -631,3 +631,35 @@ it('merges webhook into csv-recovered deal even when received_at differs (Phase
|
||||
// Никаких дублей deals — только один с этим vid.
|
||||
expect(Deal::query()->where('source_crm_id', $webhookVid)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('fills deal city with the resolved region name (UI «Город» column)', function (): void {
|
||||
\Illuminate\Support\Facades\Http::fake(['cleaner.dadata.ru/*' => \Illuminate\Support\Facades\Http::response([[
|
||||
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
|
||||
]], 200)]);
|
||||
config([
|
||||
'services.dadata.enabled' => true,
|
||||
'services.dadata.api_key' => 'k',
|
||||
'services.dadata.secret' => 's',
|
||||
'services.dadata.daily_cap_rub' => 100000,
|
||||
]);
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
|
||||
|
||||
runRouteJob($lead->id);
|
||||
|
||||
// deals.city = имя субъекта (RussianRegions::CODE_TO_NAME) по резолву: 82 → «Москва».
|
||||
$deal = dealFor($tenant->id, $project->id);
|
||||
expect($deal)->not->toBeNull()
|
||||
->and($deal->city)->toBe('Москва');
|
||||
});
|
||||
|
||||
it('leaves deal city null when region is unknown', function (): void {
|
||||
config(['services.dadata.enabled' => false]);
|
||||
// Нераспознанный тег + невалидный телефон → subjectCode null → city пустой.
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'нераспознаваемый-тег-zzz', phone: '123');
|
||||
|
||||
runRouteJob($lead->id);
|
||||
|
||||
$deal = dealFor($tenant->id, $project->id);
|
||||
expect($deal)->not->toBeNull()
|
||||
->and($deal->city)->toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
|
||||
/**
|
||||
* Нормализация регионов реестра Россвязи → subject_code.
|
||||
* Кейсы взяты из реальных топ-50 unmapped-форматов прод-реестра (02.06.2026).
|
||||
*/
|
||||
it('maps cities of federal significance with the г. prefix', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Москва'))->toBe(82)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Санкт-Петербург'))->toBe(83)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Севастополь'))->toBe(84);
|
||||
});
|
||||
|
||||
it('still maps a plain canonical federal-city name', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('Москва'))->toBe(82);
|
||||
});
|
||||
|
||||
it('takes the last pipe segment as the subject region', function (): void {
|
||||
// регион = последний сегмент после |
|
||||
expect(RussianRegions::resolveSubjectCode('г. Оренбург|Оренбургская обл.'))->toBe(62)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Воскресенск|р-н Воскресенский|Московская обл.'))->toBe(56);
|
||||
});
|
||||
|
||||
it('expands the обл. abbreviation to область', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Иркутск|Иркутская обл.'))->toBe(45)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Балашиха|Московская обл.'))->toBe(56);
|
||||
});
|
||||
|
||||
it('keeps already-canonical край/республика segments', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Красноярск|Красноярский край'))->toBe(29)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Уфа|Республика Башкортостан'))->toBe(3);
|
||||
});
|
||||
|
||||
it('reorders the Удмуртская Республика inverted form', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Ижевск|Республика Удмуртская'))->toBe(21);
|
||||
});
|
||||
|
||||
it('maps the Кузбасс special form to Кемеровская область', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Кемерово|Кемеровская область - Кузбасс обл.'))->toBe(48);
|
||||
});
|
||||
|
||||
it('returns null for hopeless / ambiguous / city-only strings', function (string $raw): void {
|
||||
expect(RussianRegions::resolveSubjectCode($raw))->toBeNull();
|
||||
})->with([
|
||||
'-',
|
||||
'Российская Федерация',
|
||||
'Москва и Московская область', // неоднозначно — два субъекта
|
||||
'г.о. Тольятти', // нет региона в строке
|
||||
'г.о. город Уфа',
|
||||
'',
|
||||
' ',
|
||||
]);
|
||||
|
||||
it('exposes the canonical name via canonicalRegionName', function (): void {
|
||||
expect(RussianRegions::canonicalRegionName('г. Оренбург|Оренбургская обл.'))->toBe('Оренбургская область')
|
||||
->and(RussianRegions::canonicalRegionName('г. Ижевск|Республика Удмуртская'))->toBe('Удмуртская Республика')
|
||||
->and(RussianRegions::canonicalRegionName('-'))->toBeNull();
|
||||
});
|
||||
|
||||
it('expands the АО abbreviation to автономный округ', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('Ненецкий АО'))->toBe(86)
|
||||
->and(RussianRegions::resolveSubjectCode('Чукотский АО'))->toBe(88)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Салехард|Ямало-Ненецкий АО'))->toBe(89);
|
||||
});
|
||||
|
||||
it('maps Ханты-Мансийск variants to ХМАО — Югра', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Сургут|Ханты-Мансийский Автономный округ - Югра АО'))->toBe(87)
|
||||
->and(RussianRegions::resolveSubjectCode('Ханты-Мансийский АО - Югра'))->toBe(87)
|
||||
->and(RussianRegions::resolveSubjectCode('Ханты-Мансийский Автономный округ - Югра.'))->toBe(87);
|
||||
});
|
||||
|
||||
it('reorders inverted Республика X forms', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('Республика Чеченская'))->toBe(23)
|
||||
->and(RussianRegions::resolveSubjectCode('Республика Кабардино-Балкарская'))->toBe(8)
|
||||
->and(RussianRegions::resolveSubjectCode('Республика Карачаево-Черкесская'))->toBe(10)
|
||||
->and(RussianRegions::resolveSubjectCode('Республика Донецкая Народная'))->toBe(6)
|
||||
->and(RussianRegions::resolveSubjectCode('Республика Луганская Народная'))->toBe(14);
|
||||
});
|
||||
|
||||
it('keeps Республика-first canonical names as-is', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('Республика Татарстан'))->toBe(19)
|
||||
->and(RussianRegions::resolveSubjectCode('Республика Карелия'))->toBe(11);
|
||||
});
|
||||
|
||||
it('handles irregular subject spellings (Саха, Чувашия, Кузбасс)', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('у. Мирнинский|Республика Саха /Якутия/'))->toBe(17)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Чебоксары|Чувашская Республика - Чувашия'))->toBe(24)
|
||||
->and(RussianRegions::resolveSubjectCode('Кемеровская область - Кузбасс область'))->toBe(48);
|
||||
});
|
||||
|
||||
it('maps Moscow / SPb spelling variants', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('Город Москва'))->toBe(82)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Санкт - Петербург'))->toBe(83);
|
||||
});
|
||||
|
||||
it('normalizes spaced hyphen to em-dash (Северная Осетия — Алания)', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('Республика Северная Осетия - Алания'))->toBe(18)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Владикавказ|Республика Северная Осетия - Алания'))->toBe(18);
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
АВС/ DEF;От;До;Емкость;Оператор;Регион
|
||||
495;2000000;2009999;10000;ОАО МГТС;г. Москва
|
||||
922;1000000;1099999;100000;ПАО Ростелеком;г. Оренбург|Оренбургская обл.
|
||||
987;5000000;5099999;100000;ПАО Ростелеком;г. Ижевск|Республика Удмуртская
|
||||
902;7000000;7009999;10000;ООО Оператор;г.о. Тольятти
|
||||
|
+29
-54
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-30T13:11:39.164Z
|
||||
Last updated: 2026-06-02T10:14:43.123Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,15 +8,15 @@ Last updated: 2026-05-30T13:11:39.164Z
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ⚠️ | 752 episode(s) this month · Stop-hook + post-commit OK · 20 missed activation(s) — see /brain-retro |
|
||||
| C5 Observer-coverage | ✅ | 137 episode(s) this month · Stop-hook + post-commit OK |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 752 episodes this month, 0 observer_error markers, 186 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 613
|
||||
- Last /brain-retro: 0 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 20. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
- Observer evidence: 137 episodes this month, 0 observer_error markers, 6 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 137
|
||||
- Last /brain-retro: 2 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Метрики дисциплины
|
||||
|
||||
@@ -24,16 +24,14 @@ Baseline дисциплины роутера (этап 2 router discipline overh
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| analysis | 34 | 23.5% | 14.7% |
|
||||
| planning | 25 | 12.0% | 16.0% |
|
||||
| bugfix | 25 | 24.0% | 20.0% |
|
||||
| feature | 19 | 10.5% | 0.0% |
|
||||
| cleanup | 6 | 0.0% | 0.0% |
|
||||
| refactor | 1 | 0.0% | 0.0% |
|
||||
| planning | 16 | 0.0% | 0.0% |
|
||||
| feature | 4 | 0.0% | 0.0% |
|
||||
| analysis | 2 | 0.0% | 0.0% |
|
||||
| bugfix | 1 | 0.0% | 0.0% |
|
||||
|
||||
Router step distribution: 1: 330, 2: 279, 3: 67, 5: 67
|
||||
Router step distribution: 1: 81, 2: 51, 5: 4
|
||||
|
||||
Boundaries applied (ADR / границы): 76 of 743 эпизодов (10.2%).
|
||||
Boundaries applied (ADR / границы): 1 of 136 эпизодов (0.7%).
|
||||
|
||||
## Активные многоэтапные проекты
|
||||
|
||||
@@ -45,11 +43,11 @@ Boundaries applied (ADR / границы): 76 of 743 эпизодов (10.2%).
|
||||
|
||||
## Длинные сессии
|
||||
|
||||
⚠️ Сегодня (2026-05-30 UTC) есть сессии с ≥50 ходов — корреляция с падением дисциплины роутинга (retro #5 candidate B).
|
||||
⚠️ Сегодня (2026-06-02 UTC) есть сессии с ≥50 ходов — корреляция с падением дисциплины роутинга (retro #5 candidate B).
|
||||
|
||||
| session_id | макс. ход | % regulated | последний эпизод |
|
||||
|---|---|---|---|
|
||||
| `52b2b52d` | 75 | 3% | 2026-05-30T11:45:39.213Z |
|
||||
| `1a9888f8` | 50 | 0% | 2026-06-02T01:43:02.824Z |
|
||||
|
||||
Long sessions correlate with discipline drift. Если % regulated просел в текущей сессии — рассмотри перезапуск.
|
||||
|
||||
@@ -57,10 +55,10 @@ Long sessions correlate with discipline drift. Если % regulated просел
|
||||
|
||||
| Компонент | Токены (in/out) | USD |
|
||||
|---|---|---|
|
||||
| Classifier (Sonnet 4.6) | 12550/86494 | $1.34 |
|
||||
| Classifier (Sonnet 4.6) | 10473/50827 | $0.79 |
|
||||
| Self-assessment (Sonnet 4.6) | 0/0 | $0.00 |
|
||||
| Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 |
|
||||
| **Итого** | | **$1.34** |
|
||||
| **Итого** | | **$0.79** |
|
||||
|
||||
## Аномалии классификатора
|
||||
|
||||
@@ -73,50 +71,20 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
## Reviewer: субагент vs fallback
|
||||
|
||||
0 эпизодов проверено из 752.
|
||||
0 эпизодов проверено из 137.
|
||||
|
||||
## Reviewer findings
|
||||
|
||||
Проверено: 372 эпизодов. **69 actionable** (wrong_skill + wrong_chain_order).
|
||||
|
||||
### error_root_cause
|
||||
|
||||
| cause | count |
|
||||
|---|---:|
|
||||
| n/a | 271 |
|
||||
| wrong_skill | 55 |
|
||||
| external_failure | 28 |
|
||||
| wrong_chain_order | 14 |
|
||||
| wrong_tool | 4 |
|
||||
|
||||
### Топ alternative_better
|
||||
|
||||
| recommended | count |
|
||||
|---|---:|
|
||||
| #19 | 18 |
|
||||
| #25 | 15 |
|
||||
| #34 | 8 |
|
||||
| #18 | 8 |
|
||||
| #33 | 3 |
|
||||
|
||||
### node_quality
|
||||
|
||||
| judgment | count |
|
||||
|---|---:|
|
||||
| disputable | 207 |
|
||||
| correct | 120 |
|
||||
| wrong_node | 40 |
|
||||
| underkill | 3 |
|
||||
| overkill | 2 |
|
||||
(нет проверенных эпизодов в текущем периоде)
|
||||
|
||||
## Использование override-фраз
|
||||
|
||||
⚠️ Превышен порог override-использования сегодня (≥5/день)
|
||||
|
||||
|
||||
| Фраза | За всё время | За сегодня |
|
||||
|---|---|---|
|
||||
| `recovery` | 2302 | 23 ⚠️ |
|
||||
| `без скилов` | 507 | 40 ⚠️ |
|
||||
| `recovery` | 2302 | 0 |
|
||||
| `без скилов` | 507 | 0 |
|
||||
| `ремонт инфраструктуры` | 331 | 0 |
|
||||
| `срочно` | 225 | 0 |
|
||||
| `memory dump` | 46 | 0 |
|
||||
@@ -125,7 +93,14 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
## System Health
|
||||
|
||||
Долго работающих процессов нет (порог CPU > 1ч).
|
||||
Топ-3 процессов с CPU > 1ч:
|
||||
|
||||
| PID | Имя | CPU-время | Возраст |
|
||||
|---|---|---|---|
|
||||
| 10388 | Code | 3.05ч | 1327306.2ч |
|
||||
| 3220 | MsMpEng | 1.14ч | 0.0ч |
|
||||
|
||||
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Россвязь region→subject_code mapping fix — Implementation Plan
|
||||
|
||||
> **For agentic workers:** TDD, bite-sized steps. Steps use `- [ ]`.
|
||||
|
||||
**Goal:** Маппить регион из реестра Россвязи в `subject_code` через нормализацию форматов, чтобы перестать терять ~98% диапазонов (444904/453080 были NULL из-за exact-match).
|
||||
|
||||
**Architecture:** Чистый нормализатор в `App\Support\RussianRegions` (`canonicalRegionName` + `resolveSubjectCode`), unit-тестируемый без БД. `PhoneRangesImportCommand` зовёт его и заполняет `region_normalized`. Прод перечитывает реестр командой `phone-ranges:import` после мержа.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16.
|
||||
|
||||
---
|
||||
|
||||
## Корень проблемы (systematic-debugging Phase 1, подтверждён прод-данными)
|
||||
|
||||
`PhoneRangesImportCommand` делал `RussianRegions::nameToCode()[trim($rec['region'])]` — exact match. Реальные строки реестра (топ-50 unmapped, прод 02.06.2026):
|
||||
|
||||
- `г. Москва` (253342) / `г. Санкт-Петербург` (34573) — города фед. значения с префиксом `г. `
|
||||
- `г. Оренбург|Оренбургская обл.` — регион = **последний** сегмент после `|`, область сокращена `обл.`
|
||||
- `г. Воскресенск|р-н Воскресенский|Московская обл.` — 3 сегмента, регион = последний
|
||||
- `г. Ижевск|Республика Удмуртская` — порядок слов перевёрнут (канон `Удмуртская Республика`)
|
||||
- `г. Кемерово|Кемеровская область - Кузбасс обл.` — спец-форма
|
||||
- Безнадёжные (меньшинство, остаются NULL): `-`, `Российская Федерация`, `Москва и Московская область` (неоднозначно), `г.о. Тольятти` / `г.о. город Уфа` (нет региона в строке)
|
||||
|
||||
## Правила нормализации
|
||||
|
||||
1. Взять последний сегмент после `|`, trim.
|
||||
2. Прямые алиасы (приоритет): `г. Москва`→`Москва`, `г. Санкт-Петербург`→`Санкт-Петербург`, `г. Севастополь`→`Севастополь`, `Республика Удмуртская`→`Удмуртская Республика`, `Кемеровская область - Кузбасс обл.`→`Кемеровская область`.
|
||||
3. Иначе: суффикс ` обл.` → ` область`.
|
||||
4. Результат искать в `nameToCode()`. Нет → `null` (диапазон остаётся unmapped — корректно).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `RussianRegions::canonicalRegionName` + `resolveSubjectCode`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Support/RussianRegions.php`
|
||||
- Test: `app/tests/Unit/Support/RussianRegionsTest.php`
|
||||
|
||||
- [ ] Step 1: написать падающий unit-тест (кейсы: фед.города с `г. `, `обл.`→`область`, многосегментный pipe, переворот Удмуртии, Кузбасс-алиас, безнадёжные→null, чистое каноничное имя).
|
||||
- [ ] Step 2: запустить pest → RED (метод не существует).
|
||||
- [ ] Step 3: реализовать `lastSegment` (private), `ALIASES` (const), `canonicalRegionName(string): ?string`, `resolveSubjectCode(string): ?int`.
|
||||
- [ ] Step 4: pest → GREEN.
|
||||
- [ ] Step 5: commit.
|
||||
|
||||
## Task 2: wire команды импорта + `region_normalized`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Console/Commands/PhoneRangesImportCommand.php:103-116`
|
||||
- Modify: `app/tests/Feature/Console/PhoneRangesImportCommandTest.php`
|
||||
- Modify: `app/tests/Fixtures/rossvyaz/sample.csv` (добавить грязные строки)
|
||||
|
||||
- [ ] Step 1: добавить в fixture строки с реальными форматами (`г. Москва`, `г. Оренбург|Оренбургская обл.`, `г. Ижевск|Республика Удмуртская`, `г.о. Тольятти`).
|
||||
- [ ] Step 2: расширить command-тест: проверить, что грязные строки маппятся в правильные коды, безнадёжные → NULL, `region_normalized` заполнен.
|
||||
- [ ] Step 3: pest → RED.
|
||||
- [ ] Step 4: команда зовёт `RussianRegions::canonicalRegionName` + `nameToCode`, пишет `region_normalized`.
|
||||
- [ ] Step 5: pest → GREEN (весь файл).
|
||||
- [ ] Step 6: commit + push + PR.
|
||||
|
||||
## После мержа
|
||||
|
||||
Владелец запускает на проде через `artisan-run.yml` (mutating, confirm_apply): `phone-ranges:import --dir=<пакет> --force` — перечитывает реестр с новым маппингом. Будущие лиды резолвятся через Россвязь-fallback → меньше пустого «Город».
|
||||
@@ -0,0 +1,290 @@
|
||||
# Router-gate dev/prod re-scope — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Разрешить локальную разработку (composer/npm/git/worktree) через контроллера, сохранив блок боевого/опасного и дисциплины.
|
||||
|
||||
**Architecture:** Точечно расширить whitelist Bash-гейта (`enforce-router-gate.mjs`) дев-инструментами + разрешить dev-safe git в общем `shell-content-rules.mjs` (`classifyGitCommand`) с «стражем main» для push. Философия default-deny сохраняется; hard-blacklist опасного и дисциплинарные хуки не трогаются.
|
||||
|
||||
**Tech Stack:** Node ESM, vitest (`vitest.config.tools.mjs`, root `app`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-02-router-gate-dev-prod-rescope-design.md`
|
||||
|
||||
**Verify-команда (вся регрессия tools):**
|
||||
`npx vitest run --root app --config vitest.config.tools.mjs`
|
||||
Узкий прогон файла: добавить хвост `<имя>.test` (например `enforce-router-gate.test`).
|
||||
|
||||
**Bootstrap-нюанс (важно):** до того как Task 3 (git dev-allow) применится, `git commit` ещё
|
||||
заблокирован самим гейтом. Поэтому коммиты НЕ делаем по ходу — все правки складываем в рабочее
|
||||
дерево, гоняем тесты, и **один раз** коммитим в конце (Task 5), когда git уже разрешён. Реализация —
|
||||
в основной копии (worktree пока недоступен; это и есть bootstrap-исключение из спеки).
|
||||
|
||||
---
|
||||
|
||||
## Задачи
|
||||
|
||||
### Task 1: Разрешить `composer` (install/update/require/remove/dump-autoload)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/enforce-router-gate.mjs` (BASH_HARD_BLACKLIST ~line 59; SAFE_EXACT ~line 124)
|
||||
- Test: `tools/enforce-router-gate.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — добавить в конец `enforce-router-gate.test.mjs`:
|
||||
|
||||
```js
|
||||
import { matchBashHardBlacklist as mhb2, classifyBashCommand as cbc2 } from './enforce-router-gate.mjs';
|
||||
|
||||
describe('composer dev-allow (owner-authorized 2026-06-02)', () => {
|
||||
it('allows composer install', () => {
|
||||
expect(mhb2('composer install')).toBe(null);
|
||||
expect(cbc2('composer install', {}).result).toBe('allow');
|
||||
});
|
||||
it('allows composer require / update / dump-autoload', () => {
|
||||
expect(cbc2('composer require monolog/monolog', {}).result).toBe('allow');
|
||||
expect(cbc2('composer update', {}).result).toBe('allow');
|
||||
expect(cbc2('composer dump-autoload', {}).result).toBe('allow');
|
||||
});
|
||||
it('still allows composer install with -d working-dir', () => {
|
||||
expect(cbc2('composer install -d app --no-interaction', {}).result).toBe('allow');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: FAIL (composer install currently hard-blacklisted → matchBashHardBlacklist truthy, classify 'block').
|
||||
|
||||
- [ ] **Step 3: Remove composer from hard-blacklist** — в `tools/enforce-router-gate.mjs` удалить строку:
|
||||
|
||||
```js
|
||||
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add composer to whitelist** — в массив `SAFE_EXACT`, рядом с существующей `/^composer\s+(?:show|outdated)\b/`, добавить:
|
||||
|
||||
```js
|
||||
/^composer\s+(?:install|update|require|remove|dump-autoload|dump)\b/, // dev-allow 2026-06-02
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify PASS**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: PASS (включая новый describe).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Разрешить `npm` (install/ci/run-скрипты)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/enforce-router-gate.mjs` (BASH_HARD_BLACKLIST ~line 60; SAFE_EXACT ~line 122)
|
||||
- Test: `tools/enforce-router-gate.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — добавить describe:
|
||||
|
||||
```js
|
||||
describe('npm dev-allow (owner-authorized 2026-06-02)', () => {
|
||||
it('allows npm install / i / ci', () => {
|
||||
expect(mhb2('npm install')).toBe(null);
|
||||
expect(cbc2('npm install', {}).result).toBe('allow');
|
||||
expect(cbc2('npm ci', {}).result).toBe('allow');
|
||||
});
|
||||
it('allows npm run <script>', () => {
|
||||
expect(cbc2('npm run build', {}).result).toBe('allow');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: FAIL (npm install hard-blacklisted).
|
||||
|
||||
- [ ] **Step 3: Remove npm from hard-blacklist** — удалить строку:
|
||||
|
||||
```js
|
||||
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add npm to whitelist** — в `SAFE_EXACT`, рядом с существующей `/^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/`, добавить:
|
||||
|
||||
```js
|
||||
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02
|
||||
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 (любой script)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify PASS**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Разрешить dev-safe git (commit/add/branch/switch/checkout/stash/worktree)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/shell-content-rules.mjs` (GIT_CONDITIONAL_SUB ~line 167; classifyGitCommand ~line 215)
|
||||
- Test: `tools/shell-content-rules.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — добавить в `shell-content-rules.test.mjs`:
|
||||
|
||||
```js
|
||||
import { classifyGitCommand as cgc2 } from './shell-content-rules.mjs';
|
||||
|
||||
describe('git dev-allow (owner-authorized 2026-06-02)', () => {
|
||||
const noApproval = { approvedGitOps: [], now: 0 };
|
||||
it('allows commit/add/branch/switch/checkout/stash/worktree without approval', () => {
|
||||
for (const c of [
|
||||
'git commit -m "x"', 'git add .', 'git branch feature-x',
|
||||
'git switch -c feature-x', 'git checkout -b feature-x',
|
||||
'git stash push -m wip', 'git worktree add ../wt -b feat origin/main',
|
||||
]) {
|
||||
expect(cgc2(c, noApproval).result).toBe('allow');
|
||||
}
|
||||
});
|
||||
it('STILL blocks commit --no-verify and add -f (hard patterns)', () => {
|
||||
expect(cgc2('git commit --no-verify -m x', noApproval).result).toBe('block');
|
||||
expect(cgc2('git add -f ignored.txt', noApproval).result).toBe('block');
|
||||
});
|
||||
it('keeps merge/rebase/reset conditional (needs approval)', () => {
|
||||
expect(cgc2('git reset --hard HEAD~1', noApproval).result).toBe('block');
|
||||
expect(cgc2('git merge feature', noApproval).result).toBe('block');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
|
||||
Expected: FAIL (commit/branch/... currently conditional → block без approval; worktree → default-deny).
|
||||
|
||||
- [ ] **Step 3: Add GIT_DEV_SUB + trim GIT_CONDITIONAL_SUB** — в `tools/shell-content-rules.mjs`:
|
||||
|
||||
Заменить блок `GIT_CONDITIONAL_SUB`:
|
||||
|
||||
```js
|
||||
const GIT_CONDITIONAL_SUB = new Set([
|
||||
'add', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
|
||||
'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean',
|
||||
]);
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```js
|
||||
// dev-safe (owner-authorized 2026-06-02): allow без approval. GIT_HARD_PATTERNS
|
||||
// (--no-verify / add -f / -c / force / --output) пре-фильтруют опасное ВЫШЕ.
|
||||
const GIT_DEV_SUB = new Set([
|
||||
'add', 'commit', 'branch', 'switch', 'checkout', 'stash', 'worktree',
|
||||
]);
|
||||
const GIT_CONDITIONAL_SUB = new Set([
|
||||
'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'pull', 'clean',
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Insert dev-allow + push-guard в classifyGitCommand** — после блока `if (sub === 'remote') { … }` (≈line 213) и ПЕРЕД `// 3. conditional → approve check`, вставить:
|
||||
|
||||
```js
|
||||
// dev-safe git (owner-authorized 2026-06-02): hard-patterns уже отсеяли опасное выше.
|
||||
if (GIT_DEV_SUB.has(sub)) return { result: 'allow', reason: `dev-safe git ${sub}` };
|
||||
|
||||
// push: фичевые ветки — allow; main/master — клик владельца (force уже заблокирован hard).
|
||||
if (sub === 'push') {
|
||||
if (/\b(?:main|master)\b/.test(norm)) {
|
||||
return { result: 'block', reason: 'git push в main/master — клик владельца' };
|
||||
}
|
||||
return { result: 'allow', reason: 'git push в фичевую ветку' };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify PASS**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: «Страж main» для push — отдельные явные тесты
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `tools/shell-content-rules.test.mjs` (логика уже добавлена в Task 3 Step 4 — тут только тесты-замок)
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
```js
|
||||
describe('git push main-guard (owner-authorized 2026-06-02)', () => {
|
||||
const na = { approvedGitOps: [], now: 0 };
|
||||
it('allows push to a feature branch', () => {
|
||||
expect(cgc2('git push origin worktree-lead-region-tails', na).result).toBe('allow');
|
||||
expect(cgc2('git push', na).result).toBe('allow');
|
||||
expect(cgc2('git push -u origin feature-x', na).result).toBe('allow');
|
||||
});
|
||||
it('blocks push to main/master', () => {
|
||||
expect(cgc2('git push origin main', na).result).toBe('block');
|
||||
expect(cgc2('git push origin HEAD:main', na).result).toBe('block');
|
||||
expect(cgc2('git push origin master', na).result).toBe('block');
|
||||
});
|
||||
it('blocks force-push (hard pattern, unchanged)', () => {
|
||||
expect(cgc2('git push --force origin feature-x', na).result).toBe('block');
|
||||
expect(cgc2('git push origin feature-x --force-with-lease', na).result).toBe('block');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify PASS** (логика из Task 3 уже на месте)
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Полная регрессия + коммит в фичевую ветку + PR
|
||||
|
||||
- [ ] **Step 1: Полная регрессия tools**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs`
|
||||
Expected: всё GREEN (baseline ~1989 + новые). 0 падений.
|
||||
|
||||
- [ ] **Step 2: Дымовая проверка живьём** — после правок гейт читается заново; проверить, что
|
||||
ранее блокированное теперь проходит (а опасное — нет). Прогнать через Bash:
|
||||
|
||||
```
|
||||
composer --version
|
||||
```
|
||||
|
||||
Expected: проходит (раньше любой `composer install` блокировался; `--version` и так был ок — проверка, что не сломали). Затем убедиться, что `git worktree list` (readonly) и `git status` работают.
|
||||
|
||||
- [ ] **Step 3: Создать фичевую ветку + worktree (теперь разрешено) и закоммитить**
|
||||
|
||||
```bash
|
||||
git worktree add "../worktree-gate-rescope" -b feat/gate-dev-prod-rescope origin/main
|
||||
```
|
||||
|
||||
(или коммит в основной копии на новой ветке — на усмотрение исполнителя; main НЕ трогать)
|
||||
|
||||
```bash
|
||||
git add tools/enforce-router-gate.mjs tools/shell-content-rules.mjs \
|
||||
tools/enforce-router-gate.test.mjs tools/shell-content-rules.test.mjs \
|
||||
docs/superpowers/specs/2026-06-02-router-gate-dev-prod-rescope-design.md \
|
||||
docs/superpowers/plans/2026-06-02-router-gate-dev-prod-rescope.md
|
||||
git commit -m "feat(gate): re-scope router-gate — allow local dev (composer/npm/git/worktree), keep prod+discipline blocks"
|
||||
git push origin feat/gate-dev-prod-rescope
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Открыть PR (клик владельца)** — дать владельцу ссылку из вывода `git push`; слияние в main — его клик.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** composer (Task 1) ✓ / npm (Task 2) ✓ / git dev-subs + worktree (Task 3) ✓ /
|
||||
push main-guard (Task 4) ✓ / discipline+prod untouched (явно не трогаем в Task 1-4) ✓ /
|
||||
«main = owner» (push-guard + PR в Task 5) ✓.
|
||||
- **Placeholders:** нет — весь код приведён дословно.
|
||||
- **Type/имена:** `GIT_DEV_SUB` / `GIT_CONDITIONAL_SUB` согласованы Task 3↔4; `classifyGitCommand`,
|
||||
`matchBashHardBlacklist`, `classifyBashCommand` — реальные экспортируемые имена (проверено по коду).
|
||||
- **Bootstrap:** коммит батчем в Task 5 (git разрешается только после применения Task 3) — учтено.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Router-gate re-scope: «боевое блокируем, локальную разработку разрешаем»
|
||||
|
||||
**Дата:** 2026-06-02
|
||||
**Статус:** design (утверждён владельцем; реализация — отдельным планом)
|
||||
**Автор контекста:** сессия lead-region-tails
|
||||
|
||||
## Проблема
|
||||
|
||||
Router-gate v4 (`tools/enforce-router-gate.mjs`) работает в режиме «по умолчанию запрещено»
|
||||
(whitelist для Bash + hard-blacklist + MCP-классификатор + дисциплинарные хуки). Он задумывался
|
||||
как защита **боевого** контура (выкат на liderra.ru, изменение боевой БД, секреты, запуск
|
||||
воркфлоу), но по факту блокирует и **весь локальный инструмент разработки**: `composer install`,
|
||||
`npm install`, `git worktree`, `git commit`/`push`, и даже правку тест-файлов (через
|
||||
`enforce-tdd-real-test-verifier`). Это делает обычную разработку через контроллера непрактичной —
|
||||
любая PHP/JS-задача с тестами упирается в стену (подтверждено в сессии 2026-06-02: попытка сделать
|
||||
fix реестра Россвязи провалилась на цепочке взаимно-охраняющих замков).
|
||||
|
||||
## Цель
|
||||
|
||||
Перенастроить замок так, чтобы он блокировал **только боевое и опасное**, а **локальную
|
||||
разработку разрешал** — сохранив при этом дисциплину работы контроллера и защиту боевого контура.
|
||||
|
||||
## Решения (утверждены владельцем 2026-06-02)
|
||||
|
||||
1. **Дисциплину оставляем.** Хуки качества (TDD-gate, tdd-real-test-verifier, chain-recommendation,
|
||||
graph-first, override-limit, llm-judge, coverage-verify, memory-coverage и пр.) — **не трогаем**.
|
||||
Контроллер продолжает писать тесты до кода и не срезать углы.
|
||||
2. **Защиту боевого оставляем железно.** Выкат/боевая БД/секреты/запуск воркфлоу/защищённые
|
||||
пути — без изменений.
|
||||
3. **Инструменты разработки разрешаем.** composer/npm/pest/git/worktree.
|
||||
4. **Граница git:** ветки — контроллер сам (commit/push в не-главную ветку + подготовка PR);
|
||||
слияние в main, push в main, force-push, выкат — **клик владельца**.
|
||||
|
||||
## Подход
|
||||
|
||||
**Approach A (выбран):** точечно расширить whitelist дев-инструментами, сохранив философию
|
||||
«по умолчанию запрещено». Правим **два файла** — `tools/enforce-router-gate.mjs` (composer/npm) и
|
||||
`tools/shell-content-rules.mjs` (git; там общий `classifyGitCommand`). MCP-классификатор
|
||||
(`tools/mcp-tool-classifier.mjs`) и дисциплинарные хуки — без изменений.
|
||||
|
||||
Отвергнут **Approach B** (перевернуть в default-allow + blacklist опасного): любой пропуск в
|
||||
перечне опасного = дыра; ломает безопасную философию default-deny.
|
||||
|
||||
## Матрица: что блокируем / что разрешаем
|
||||
|
||||
### Остаётся ЗАБЛОКИРОВАННЫМ
|
||||
|
||||
| Категория | Примеры | Где |
|
||||
|---|---|---|
|
||||
| Боевой контур | выкат на сайт, изменение боевой БД, секреты/`.env`, защищённые пути (CLAUDE.md, memory/, transcripts, `~/.claude/runtime`) | без изменений |
|
||||
| GitHub на запись | `create_*`/`update_*`/`merge_*`/`push_files`/`actions_run_trigger` | MCP-классификатор без изменений (read-only, открытый 2026-06-02, остаётся) |
|
||||
| Опасные команды | `rm`/`mv`/`cp`/`chmod`/`chown`, `curl -X POST/PUT/DELETE`, `wget`, `nc`/`ncat`/`socat`, `node -e` с `fs.*`, `eval`, `bash -c`/`sh -c`, `python -c`, redirects в protected | hard-blacklist без изменений |
|
||||
| Дисциплина | TDD-gate, tdd-real-test-verifier, override-limit, chain-recommendation, graph-first, llm-judge, coverage | хуки без изменений |
|
||||
| Главная ветка | `git push` в main, `git push --force`, слияние в main | новый «страж main» |
|
||||
|
||||
### Становится РАЗРЕШЁННЫМ (локальная разработка)
|
||||
|
||||
| Инструмент | Команды |
|
||||
|---|---|
|
||||
| Composer | `composer install`, `composer dump-autoload`, `composer require`, `composer update` |
|
||||
| NPM | `npm install`, `npm ci`, `npm run <script>` |
|
||||
| Тесты | `pest`, `vendor/bin/pest`, `php artisan test` (уже частично в whitelist) |
|
||||
| Git (ветки) | `git commit`, `git add`, `git branch`, `git switch`/`checkout`, `git worktree`, `git stash`, `git push` **в не-главную ветку** |
|
||||
|
||||
## Изменения в коде (два файла)
|
||||
|
||||
Git-логика живёт не в самом router-gate, а в общем модуле `shell-content-rules.mjs`
|
||||
(`classifyGitCommand`, используется и Bash-, и PowerShell-гейтом). Поэтому правок — два файла.
|
||||
|
||||
### `tools/enforce-router-gate.mjs` (composer / npm)
|
||||
|
||||
1. **Из hard-blacklist (`BASH_HARD_BLACKLIST`) убрать** строки про `composer install/update/require/remove`
|
||||
и `npm install/i/update/remove/uninstall`. `yarn`/`pnpm` остаются заблокированными (проект на npm,
|
||||
не нужны). Истинно-опасные fs/сеть/exec (`rm/mv/cp/chmod`, `curl POST`, `wget`, `nc`, `node -e fs`,
|
||||
`eval`, `bash -c`, `python -c`, redirects) — **без изменений**.
|
||||
2. **В whitelist (`SAFE_EXACT`) добавить:** `composer (install|update|require|remove|dump-autoload|dump)`,
|
||||
`npm (install|i|ci)`, `npm run <script>` (любой скрипт). Существующие `composer show/outdated/test/...`
|
||||
и `npm test/run test/run lint` — остаются.
|
||||
|
||||
### `tools/shell-content-rules.mjs` (git)
|
||||
|
||||
1. **Новый `GIT_DEV_SUB`** = `{add, commit, branch, switch, checkout, stash, worktree}` → в
|
||||
`classifyGitCommand` после hard-pattern-проверки возвращать `allow`. Эти подкоманды **убрать** из
|
||||
`GIT_CONDITIONAL_SUB`. (`worktree` сейчас падает в default-deny — попадёт в dev-allow.)
|
||||
2. **`GIT_HARD_PATTERNS` не трогаем** — `--no-verify`, `git add -f`, `git -c`, force-push, `--output`/`-o`
|
||||
и т.п. по-прежнему блокируются ПЕРВЫМИ, до dev-allow. То есть `git commit --no-verify` и `git add -f`
|
||||
остаются заблокированы даже как «dev».
|
||||
3. **Страж main для `push`** (`mainPushGuard`, чистая функция): `push` остаётся, но —
|
||||
если в аргументах фигурирует `main`/`master` как ref (`git push origin main`, `HEAD:main`, `:main`)
|
||||
→ **block** (клик владельца); force-push уже заблокирован `GIT_HARD_PATTERNS`. Иначе (`git push origin <feature>`,
|
||||
bare `git push`) → allow. Допущение: bare `git push` считаем пушем не-главной ветки (контроллер по модели
|
||||
всегда на не-главной ветке); пуш в main возможен только явным `origin main` → пойман.
|
||||
4. **Conditional остаётся** для `merge, rebase, reset, cherry-pick, revert, pull, clean` (require approval) —
|
||||
риск потери работы / слияние в main = клик владельца.
|
||||
|
||||
**Не меняем:** `tools/mcp-tool-classifier.mjs`, `tools/bash-tokenizer.mjs` (`isMutatingSegment` — чейн-правило
|
||||
C13 «цепочка с мутацией → блок» сохраняется), любые `enforce-*` дисциплинарные хуки, `.claude/settings.json`.
|
||||
|
||||
## Тестирование (TDD)
|
||||
|
||||
Через `tools/enforce-router-gate.test.mjs` (vitest, работает в основной копии):
|
||||
|
||||
- `composer install` / `composer require x` → allow; `composer` (без подкоманды) → как раньше.
|
||||
- `npm install` → allow; `npm run build` → allow.
|
||||
- `git commit -m x` / `git worktree add ...` / `git push origin feature-x` → allow.
|
||||
- `git push origin main` / `git push --force` → **block** (страж main).
|
||||
- Регресс: опасное по-прежнему блокируется — `rm -rf x`, `curl -X POST`, `node -e "...fs..."`,
|
||||
`eval`, `python -c` → block.
|
||||
- Полная регрессия tools-тестов (`npx vitest run --root app --config vitest.config.tools.mjs`).
|
||||
|
||||
## Граница реализации (bootstrap-нюанс)
|
||||
|
||||
Сам этот re-scope — bootstrap-исключение: его нельзя делать в worktree (worktree пока заблокирован).
|
||||
Реализуется в основной копии (там активен живой замок и работает vitest). После правки замка
|
||||
`git`/`worktree`/`composer` становятся разрешены — дальнейшие задачи (например, fix реестра)
|
||||
пойдут уже по модели «ветка + PR».
|
||||
|
||||
## Остаточные риски (приняты)
|
||||
|
||||
- Разрешён `composer require`/`npm install` → теоретический supply-chain (установка пакета).
|
||||
Принято: это собственный проект владельца; дисциплина и code-review остаются.
|
||||
- `rm`/`mv`/`cp` остаются заблокированы — если реально мешают разработке, пересматриваем отдельно
|
||||
(файловые правки покрываются инструментами Write/Edit).
|
||||
- «Страж main» опирается на парсинг аргументов `git push`; экзотические формы (push по URL,
|
||||
refspec-трюки) при сомнении → block (fail-safe в сторону защиты main).
|
||||
|
||||
## Что НЕ входит (YAGNI)
|
||||
|
||||
- Не инвертируем модель замка (default-deny остаётся).
|
||||
- Не трогаем боевые воркфлоу, секреты, MCP-write.
|
||||
- Не ослабляем дисциплину.
|
||||
@@ -47,200 +47,6 @@
|
||||
{
|
||||
"url": "http://localhost:8000/500",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-07-500.png"
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/dashboard",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-08-dashboard.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/deals",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-09-deals.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/deals",
|
||||
"wait for path to be /deals"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/kanban",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-10-kanban.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/kanban",
|
||||
"wait for path to be /kanban"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/projects",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-11-projects.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/projects",
|
||||
"wait for path to be /projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/billing",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-12-billing.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/billing",
|
||||
"wait for path to be /billing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/settings",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-13-settings.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/settings",
|
||||
"wait for path to be /settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/reports",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-14-reports.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/reports",
|
||||
"wait for path to be /reports"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/reminders",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-15-reminders.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/reminders",
|
||||
"wait for path to be /reminders"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/admin/tenants",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-16-admin-tenants.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/admin/tenants",
|
||||
"wait for path to be /admin/tenants"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/admin/billing",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-17-admin-billing.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/admin/billing",
|
||||
"wait for path to be /admin/billing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/admin/incidents",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-18-admin-incidents.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/admin/incidents",
|
||||
"wait for path to be /admin/incidents"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/admin/system",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-19-admin-system.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/admin/system",
|
||||
"wait for path to be /admin/system"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/admin/pricing-tiers",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-20-admin-pricing-tiers.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/admin/pricing-tiers",
|
||||
"wait for path to be /admin/pricing-tiers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/admin/supplier-prices",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-21-admin-supplier-prices.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element input[autocomplete=\"email\"] to be visible",
|
||||
"set field input[autocomplete=\"email\"] to admin@demo.local",
|
||||
"set field input[autocomplete=\"current-password\"] to password",
|
||||
"click element button[type=\"submit\"]",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/admin/supplier-prices",
|
||||
"wait for path to be /admin/supplier-prices"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ describe('classifyPowerShellCommand', () => {
|
||||
it('blocks reading a protected path', () => {
|
||||
expect(classifyPowerShellCommand('Get-Content ~/.claude/settings.json', {}).result).toBe('block');
|
||||
});
|
||||
it('routes git through shared classifier (block unapproved commit)', () => {
|
||||
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
|
||||
it('routes git through shared classifier (commit dev-allowed 2026-06-02 re-scope)', () => {
|
||||
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
|
||||
});
|
||||
it('allows readonly git through PowerShell', () => {
|
||||
expect(classifyPowerShellCommand('git status', {}).result).toBe('allow');
|
||||
|
||||
@@ -56,8 +56,8 @@ export const BASH_HARD_BLACKLIST = [
|
||||
{ re: /\bpython3?\s+-c\b/, reason: 'python -c запрещён' },
|
||||
{ re: /\b(?:bash|sh)\s+-c\b/, reason: 'bash/sh -c запрещён' },
|
||||
{ re: /(^|\s|;|&&|\|\|)eval\b/, reason: 'eval запрещён' },
|
||||
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
|
||||
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
|
||||
// composer/npm перенесены в whitelist (dev-allow, 2026-06-02 re-scope) — это локальные
|
||||
// инструменты разработки, не боевой контур. yarn/pnpm остаются заблокированы (проект на npm).
|
||||
{ re: /\b(?:yarn|pnpm)\s+(?:add|install|remove)\b/, reason: 'yarn/pnpm add/install/remove запрещён' },
|
||||
{ re: /\bnpx\s+claude-/, reason: 'npx claude-* запрещён' },
|
||||
{ re: /\bcurl\b[^|;]*-X\s*(?:POST|PUT|DELETE|PATCH)\b/i, reason: 'curl -X POST/PUT/DELETE/PATCH запрещён' },
|
||||
@@ -120,8 +120,10 @@ const READING_CMDS = new Set(['ls', 'pwd', 'wc', 'head', 'tail', 'file', 'stat',
|
||||
const SAFE_EXACT = [
|
||||
/^npx\s+vitest\s+(?:run|--version)\b/,
|
||||
/^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/,
|
||||
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02 re-scope
|
||||
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 re-scope (любой npm-скрипт)
|
||||
/^php\s+artisan\s+(?:list|route:list|migrate:status)\b/,
|
||||
/^composer\s+(?:show|outdated)\b/,
|
||||
/^composer\s+(?:show|outdated|install|update|require|remove|dump-autoload|dump)\b/, // +dev-allow 2026-06-02 re-scope
|
||||
/^node\s+(?!.*(?:-e|--eval|-p|--print|-r|--require|--import|--experimental-loader)\b)/,
|
||||
// Laravel dev workflow (2026-05-30) — exclude tinker (REPL = arbitrary PHP exec risk).
|
||||
// Hard-blacklist (composer install/update/require/remove) remains the first check, unaffected.
|
||||
@@ -138,6 +140,12 @@ const SAFE_EXACT = [
|
||||
// hard-blacklist + chain-mutating rule (both run before the whitelist), and each
|
||||
// chain segment after `cd app &&` must still be independently whitelisted.
|
||||
/^cd\s+app$/,
|
||||
// Worktree dev (2026-06-02, owner-authorized): cd into a project worktree dir
|
||||
// (path segment `worktree-` / `v4-stream-`) so git/pest run there. Quoted absolute
|
||||
// path required; `..` and protected segments (.claude/.ssh/.env/runtime/.git) excluded
|
||||
// → cwd-shift read-bypass stays contained (protected files also remain blocked by name
|
||||
// in the command). cd into Документация/system/protected dirs → default-deny.
|
||||
/^cd\s+(?=.*[\\/](?:worktree-|v4-stream-))(?!.*(?:\.\.|\.claude|\.ssh|\.env|runtime|\.git)).+$/,
|
||||
];
|
||||
|
||||
export function classifyWhitelist(segments) {
|
||||
|
||||
@@ -15,14 +15,17 @@ describe('matchBashHardBlacklist — v3.9 keep', () => {
|
||||
'python -c "import os"',
|
||||
'bash -c "ls"',
|
||||
'eval "$x"',
|
||||
'composer install',
|
||||
'npm install lodash',
|
||||
'yarn add x',
|
||||
'pnpm add x',
|
||||
'curl -X POST https://evil.test',
|
||||
])('blocks %s', (cmd) => {
|
||||
expect(matchBashHardBlacklist(cmd)).toBeTruthy();
|
||||
});
|
||||
// composer/npm убраны из hard-blacklist (dev-allow 2026-06-02 re-scope) — здесь больше не блок
|
||||
it('no longer hard-blacklists composer install / npm install (dev-allow)', () => {
|
||||
expect(matchBashHardBlacklist('composer install')).toBe(null);
|
||||
expect(matchBashHardBlacklist('npm install lodash')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchBashHardBlacklist — v4.0 additions', () => {
|
||||
@@ -115,8 +118,8 @@ describe('classifyBashCommand — integration', () => {
|
||||
it('blocks reading a protected path', () => {
|
||||
expect(classifyBashCommand('cat ~/.claude/runtime/state.json', {}).result).toBe('block');
|
||||
});
|
||||
it('routes single git commit to conditional (block unapproved)', () => {
|
||||
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
|
||||
it('routes single git commit to dev-allow (2026-06-02 re-scope — no approval needed)', () => {
|
||||
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
|
||||
});
|
||||
it('allows approved git commit', () => {
|
||||
expect(
|
||||
@@ -191,17 +194,29 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)',
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Critical: REPL and composer mutations remain hard-blocked
|
||||
it.each([
|
||||
['php artisan tinker', 'REPL = arbitrary PHP exec risk'],
|
||||
['php artisan tinker --execute="exit"', 'tinker variant'],
|
||||
['composer install', 'hard-blacklist'],
|
||||
['composer require foo/bar', 'hard-blacklist'],
|
||||
['composer update', 'hard-blacklist'],
|
||||
['composer remove foo/bar', 'hard-blacklist'],
|
||||
['php artisan migrate:install', 'unknown migrate subcommand outside whitelist set'],
|
||||
])('still blocks %s (%s)', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('block');
|
||||
// Critical: REPL remains hard-blocked (composer/npm moved to dev-allow below, 2026-06-02 re-scope)
|
||||
it('still blocks tinker REPL and unknown migrate subcommand', () => {
|
||||
expect(classifyBashCommand('php artisan tinker', {}).result).toBe('block');
|
||||
expect(classifyBashCommand('php artisan tinker --execute="exit"', {}).result).toBe('block');
|
||||
expect(classifyBashCommand('php artisan migrate:install', {}).result).toBe('block');
|
||||
});
|
||||
|
||||
// dev-allow (owner-authorized 2026-06-02 re-scope): composer is a local dev tool
|
||||
it('now allows composer install/require/update/remove/dump-autoload', () => {
|
||||
expect(classifyBashCommand('composer install', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer install -d app --no-interaction', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer require monolog/monolog', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer update', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer remove monolog/monolog', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer dump-autoload', {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// dev-allow (owner-authorized 2026-06-02 re-scope): npm is a local dev tool
|
||||
it('now allows npm install/i/ci/run', () => {
|
||||
expect(classifyBashCommand('npm install', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('npm i', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('npm ci', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('npm run build', {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Critical: existing pre-existing v3.8 keep behaviour
|
||||
@@ -271,6 +286,29 @@ describe('SAFE_EXACT — narrow `cd app` whitelist (2026-05-31, owner-authorized
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAFE_EXACT — worktree cd (2026-06-02, owner-authorized worktree dev)', () => {
|
||||
// Allowed: enter a project worktree dir (segment `worktree-` / `v4-stream-`) so
|
||||
// git/pest can run there. Quoted absolute path; cwd-shift read-bypass stays contained
|
||||
// because protected files remain blocked by name in the command (cat .env / runtime).
|
||||
it.each([
|
||||
'cd "C:\\моя\\проекты\\портал crm\\worktree-deals-city"',
|
||||
'cd "C:\\моя\\проекты\\портал crm\\worktree-deals-city\\app"',
|
||||
'cd "C:\\моя\\проекты\\портал crm\\v4-stream-A"',
|
||||
])('allows cd into a worktree dir: %s', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Scope: protected / non-worktree dirs stay default-deny (no `worktree-` marker, or
|
||||
// `..` / protected segment present → cwd-shift read-bypass prevented).
|
||||
it.each([
|
||||
'cd "C:\\Users\\Administrator\\.claude\\runtime"',
|
||||
'cd "C:\\моя\\проекты\\портал crm\\worktree-x\\..\\..\\.claude"',
|
||||
'cd "C:\\моя\\проекты\\портал crm\\Документация"',
|
||||
])('still blocks cd into non-worktree / protected dir: %s', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
import { stripQuotedSpans } from './enforce-router-gate.mjs';
|
||||
|
||||
describe('quote-aware redirect (quirk 2)', () => {
|
||||
|
||||
@@ -108,6 +108,11 @@ function hasFailingTestRun(turn) {
|
||||
// Numeric: "Tests N failed | M passed" with N>0
|
||||
const m = txt.match(/Tests\s+(\d+)\s+failed/);
|
||||
if (m && Number(m[1]) > 0) return true;
|
||||
// JSON reporter (composer test / php artisan test → pest): {"result":"failed",...}
|
||||
// or {"failed":N}/{"errors":N} with N>0. command-not-found / error REDs lack the
|
||||
// English "Failed" keyword above, so recognise the structured marker too.
|
||||
if (/"result"\s*:\s*"failed"/.test(txt)) return true;
|
||||
if (/"(?:failed|errors)"\s*:\s*[1-9]/.test(txt)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,3 +168,25 @@ describe('enforce-tdd-gate / decide', () => {
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforce-tdd-gate / decide — JSON pest reporter RED (composer test)', () => {
|
||||
// `composer test` (php artisan test) emits machine JSON like {"result":"failed",...}.
|
||||
// command-not-found / error REDs lack the English "Failed" keyword, so the gate must
|
||||
// recognise the structured failure marker, else legit RED runs go unseen.
|
||||
it('recognizes {"result":"failed"} JSON output as a RED run', () => {
|
||||
const r = decide({
|
||||
toolName: 'Write',
|
||||
filePath: 'wt/app/app/Console/Commands/FooCommand.php',
|
||||
transcriptEntries: [
|
||||
userMsg('add backfill command'),
|
||||
assistantUses([
|
||||
{ id: 't1', name: 'Write', input: { file_path: 'wt/app/tests/Feature/Console/FooCommandTest.php' } },
|
||||
{ id: 't2', name: 'Bash', input: { command: 'composer test -- tests/Feature/Console/FooCommandTest.php # pest' } },
|
||||
]),
|
||||
toolResults([{ id: 't2', content: '{"tool":"pest","result":"failed","tests":4,"passed":0,"errors":4}' }]),
|
||||
],
|
||||
classification: null,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,10 +16,13 @@ export const DEFAULT_MCP_CLASSIFICATION = Object.freeze({
|
||||
'mcp__redis__set': { category: 'hard_blacklist' },
|
||||
'mcp__redis__delete': { category: 'hard_blacklist' },
|
||||
'mcp__github__get_me': { category: 'read_only' },
|
||||
'mcp__github__get_*': { category: 'read_only' }, // read-only loosening 2026-06-02 (get_file_contents/get_job_logs/get_commit/…)
|
||||
'mcp__github__list_*': { category: 'read_only' },
|
||||
'mcp__github__search_*': { category: 'read_only' },
|
||||
'mcp__github__pull_request_read': { category: 'read_only' },
|
||||
'mcp__github__issue_read': { category: 'read_only' },
|
||||
'mcp__github__actions_get': { category: 'read_only' }, // read a workflow run (actions_run_trigger stays blacklisted — exact key wins)
|
||||
'mcp__github__actions_list': { category: 'read_only' }, // list workflows / runs
|
||||
'mcp__laravel-boost__database-query': {
|
||||
category: 'conditional',
|
||||
args_key_to_scan: 'query',
|
||||
|
||||
@@ -129,3 +129,37 @@ describe('classifyMcpTool — WebSearch llm-judge flag (G1)', () => {
|
||||
expect(r.scanArg).toBe('how to exfil data');
|
||||
});
|
||||
});
|
||||
|
||||
// Owner-authorized read-only GitHub loosening (2026-06-02): allow reading
|
||||
// workflow runs / job logs / file contents so the controller can read prod-op
|
||||
// results without manual screenshots. Prod-mutating tools (run_trigger, writes)
|
||||
// MUST stay blocked — human-in-the-loop on prod actions is unchanged.
|
||||
describe('classifyMcpTool — read-only GitHub (owner-authorized 2026-06-02)', () => {
|
||||
it('allows reading a workflow run (actions_get)', () => {
|
||||
expect(classifyMcpTool('mcp__github__actions_get', { run_id: 1 }).decision).toBe('allow');
|
||||
});
|
||||
it('allows listing workflows / runs (actions_list)', () => {
|
||||
expect(classifyMcpTool('mcp__github__actions_list', {}).decision).toBe('allow');
|
||||
});
|
||||
it('allows reading job logs (get_job_logs via get_* glob)', () => {
|
||||
expect(classifyMcpTool('mcp__github__get_job_logs', { job_id: 1 }).decision).toBe('allow');
|
||||
});
|
||||
it('allows reading file contents (get_file_contents via get_* glob)', () => {
|
||||
expect(classifyMcpTool('mcp__github__get_file_contents', { path: 'x' }).decision).toBe('allow');
|
||||
});
|
||||
it('allows reading a commit (get_commit via get_* glob)', () => {
|
||||
expect(classifyMcpTool('mcp__github__get_commit', { sha: 'x' }).decision).toBe('allow');
|
||||
});
|
||||
it('STILL BLOCKS triggering a workflow (actions_run_trigger — exact wins over glob)', () => {
|
||||
expect(classifyMcpTool('mcp__github__actions_run_trigger', {}).decision).toBe('block');
|
||||
});
|
||||
it('STILL BLOCKS writing a file (create_or_update_file)', () => {
|
||||
expect(classifyMcpTool('mcp__github__create_or_update_file', { path: 'x' }).decision).toBe('block');
|
||||
});
|
||||
it('STILL BLOCKS push_files', () => {
|
||||
expect(classifyMcpTool('mcp__github__push_files', {}).decision).toBe('block');
|
||||
});
|
||||
it('STILL BLOCKS update_pull_request (write)', () => {
|
||||
expect(classifyMcpTool('mcp__github__update_pull_request', {}).decision).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,9 +164,13 @@ const GIT_READONLY_SUB = new Set([
|
||||
'rev-parse', 'merge-base', 'remote', 'stash', // stash list/show resolved below
|
||||
'fetch', 'ls-remote', // ref-only, no working-tree mutation — Stream H pre-flight requires §15.2 sync
|
||||
]);
|
||||
// dev-safe (owner-authorized 2026-06-02 re-scope): allow без approval. GIT_HARD_PATTERNS
|
||||
// (--no-verify / add -f / -c / force / --output / -o) пре-фильтруют опасные варианты ВЫШЕ.
|
||||
const GIT_DEV_SUB = new Set([
|
||||
'add', 'commit', 'branch', 'switch', 'checkout', 'stash', 'worktree',
|
||||
]);
|
||||
const GIT_CONDITIONAL_SUB = new Set([
|
||||
'add', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
|
||||
'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean',
|
||||
'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'pull', 'clean',
|
||||
]);
|
||||
|
||||
// G5/G6 + force-push + add -f → always block (даже если "approved").
|
||||
@@ -183,14 +187,23 @@ const GIT_HARD_PATTERNS = [
|
||||
];
|
||||
|
||||
function gitSubcommand(command) {
|
||||
const m = normalizeCommand(command).match(/\bgit\s+(?:-c\s+\S+\s+)*([a-z][\w-]*)/);
|
||||
// Skip leading global flags `-c <val>` and `-C <path>`. `git -C <dir> <sub>` is the
|
||||
// cwd-independent way to operate on a worktree (the shell resets cwd each call), so the
|
||||
// real subcommand must be found after `-C`. `-C` (uppercase, working-dir) is case-distinct
|
||||
// from the blocked `-c` config-injection (GIT_HARD_PATTERNS still scans the full command).
|
||||
const m = normalizeCommand(command).match(
|
||||
/\bgit\s+(?:(?:-c\s+\S+|-C\s+(?:"[^"]*"|'[^']*'|\S+))\s+)*([a-z][\w-]*)/,
|
||||
);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
export function classifyGitCommand(command, ctx = {}) {
|
||||
const norm = normalizeCommand(command);
|
||||
// Strip a leading `git -C <path>` (worktree-dir flag) so every rule below sees the real
|
||||
// subcommand+flags. Without this, position-anchored hard-patterns (--no-verify / --force /
|
||||
// add -f) and the push-main-guard would be bypassed by interposing `-C <dir>`.
|
||||
const norm = normalizeCommand(command).replace(/(\bgit)\s+-C\s+(?:"[^"]*"|'[^']*'|\S+)\s+/, '$1 ');
|
||||
if (!/\bgit\b/.test(norm)) return null;
|
||||
const sub = gitSubcommand(command);
|
||||
const sub = gitSubcommand(norm);
|
||||
if (!sub) return null;
|
||||
|
||||
// 1. git-hard — block безусловно
|
||||
@@ -212,6 +225,18 @@ export function classifyGitCommand(command, ctx = {}) {
|
||||
return { result: 'block', reason: 'git remote (мутация) требует AskUser approval' };
|
||||
}
|
||||
|
||||
// dev-safe git (owner-authorized 2026-06-02 re-scope): GIT_HARD_PATTERNS уже отсеяли
|
||||
// опасные варианты (--no-verify / add -f / -c / force / --output / -o) на шаге 1.
|
||||
if (GIT_DEV_SUB.has(sub)) return { result: 'allow', reason: `dev-safe git ${sub}` };
|
||||
|
||||
// push: фичевые ветки — allow; main/master — клик владельца (force уже заблокирован hard).
|
||||
if (sub === 'push') {
|
||||
if (/\b(?:main|master)\b/.test(norm)) {
|
||||
return { result: 'block', reason: 'git push в main/master — клик владельца' };
|
||||
}
|
||||
return { result: 'allow', reason: 'git push в фичевую ветку' };
|
||||
}
|
||||
|
||||
// 3. conditional → approve check
|
||||
if (GIT_CONDITIONAL_SUB.has(sub)) {
|
||||
const approved = isApproved(command, ctx.approvedGitOps, ctx.now ?? Date.now());
|
||||
|
||||
@@ -167,40 +167,81 @@ describe('classifyGitCommand — readonly', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('classifyGitCommand — conditional after approve', () => {
|
||||
describe('classifyGitCommand — conditional (still needs approval after 2026-06-02 re-scope)', () => {
|
||||
const now = 2_000_000;
|
||||
it('blocks unapproved git commit', () => {
|
||||
const r = classifyGitCommand('git commit -m "x"', { approvedGitOps: [], now });
|
||||
expect(r.result).toBe('block');
|
||||
expect(r.reason).toMatch(/approve/i);
|
||||
});
|
||||
it('allows approved git commit', () => {
|
||||
const r = classifyGitCommand('git commit -m "x"', {
|
||||
approvedGitOps: [{ command: 'git commit -m "x"', ts: now }],
|
||||
now,
|
||||
});
|
||||
expect(r.result).toBe('allow');
|
||||
});
|
||||
it.each(['git rebase main', 'git reset --hard', 'git switch main', 'git stash pop', 'git push origin feat'])(
|
||||
'blocks unapproved %s',
|
||||
(cmd) => {
|
||||
it('blocks unapproved rebase/reset/merge/cherry-pick/revert/pull/clean', () => {
|
||||
for (const cmd of ['git rebase main', 'git reset --hard', 'git merge feat',
|
||||
'git cherry-pick abc', 'git revert abc', 'git pull', 'git clean -fd']) {
|
||||
expect(classifyGitCommand(cmd, { approvedGitOps: [], now }).result).toBe('block');
|
||||
},
|
||||
);
|
||||
it('blocks unapproved git add (v4 Stream G addition)', () => {
|
||||
const r = classifyGitCommand('git add .claude/settings.json', { approvedGitOps: [], now });
|
||||
expect(r.result).toBe('block');
|
||||
expect(r.reason).toMatch(/approve/i);
|
||||
}
|
||||
});
|
||||
it('allows approved git add', () => {
|
||||
const r = classifyGitCommand('git add .claude/settings.json', {
|
||||
approvedGitOps: [{ command: 'git add .claude/settings.json', ts: now }],
|
||||
it('allows approved git merge', () => {
|
||||
const r = classifyGitCommand('git merge feat', {
|
||||
approvedGitOps: [{ command: 'git merge feat', ts: now }],
|
||||
now,
|
||||
});
|
||||
expect(r.result).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyGitCommand — dev-allow (owner-authorized 2026-06-02 re-scope)', () => {
|
||||
const na = { approvedGitOps: [], now: 2_000_000 };
|
||||
it('allows commit/add/branch/switch/checkout/stash/worktree without approval', () => {
|
||||
for (const cmd of [
|
||||
'git commit -m "x"', 'git add .', 'git branch feature-x',
|
||||
'git switch -c feature-x', 'git switch feature-x', 'git checkout -b feature-x',
|
||||
'git stash push -m wip', 'git stash pop',
|
||||
'git worktree add ../wt -b feat origin/main',
|
||||
]) {
|
||||
expect(classifyGitCommand(cmd, na).result).toBe('allow');
|
||||
}
|
||||
});
|
||||
it('still blocks commit --no-verify and add -f (hard patterns survive dev-allow)', () => {
|
||||
expect(classifyGitCommand('git commit --no-verify -m x', na).result).toBe('block');
|
||||
expect(classifyGitCommand('git add -f ignored.txt', na).result).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyGitCommand — push main-guard (owner-authorized 2026-06-02 re-scope)', () => {
|
||||
const na = { approvedGitOps: [], now: 2_000_000 };
|
||||
it('allows push to a feature branch / bare push', () => {
|
||||
expect(classifyGitCommand('git push origin worktree-lead-region-tails', na).result).toBe('allow');
|
||||
expect(classifyGitCommand('git push', na).result).toBe('allow');
|
||||
expect(classifyGitCommand('git push -u origin feature-x', na).result).toBe('allow');
|
||||
});
|
||||
it('blocks push to main/master (owner click)', () => {
|
||||
expect(classifyGitCommand('git push origin main', na).result).toBe('block');
|
||||
expect(classifyGitCommand('git push origin HEAD:main', na).result).toBe('block');
|
||||
expect(classifyGitCommand('git push origin master', na).result).toBe('block');
|
||||
});
|
||||
it('blocks force-push (hard pattern unchanged)', () => {
|
||||
expect(classifyGitCommand('git push --force origin feature-x', na).result).toBe('block');
|
||||
expect(classifyGitCommand('git push origin feature-x --force-with-lease', na).result).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyGitCommand — git -C <path> (worktree dev, 2026-06-02)', () => {
|
||||
const na = { approvedGitOps: [], now: 4_000_000 };
|
||||
// git -C points git at another working tree (cwd resets each shell call, so this is
|
||||
// the cwd-independent way to commit in a worktree). Classify by the REAL subcommand
|
||||
// after -C, with all hard-patterns / push-main-guard still applied to the full command.
|
||||
it.each([
|
||||
'git -C "C:\\моя\\проекты\\портал crm\\worktree-x" commit -m "y"',
|
||||
'git -C "C:\\моя\\проекты\\портал crm\\worktree-x" add app/foo.php',
|
||||
'git -C "/path/worktree-x" push origin feature-y',
|
||||
'git -C /repo status',
|
||||
])('classifies by real subcommand after -C: %s', (cmd) => {
|
||||
expect(classifyGitCommand(cmd, na).result).toBe('allow');
|
||||
});
|
||||
|
||||
it('still blocks push to main even with -C', () => {
|
||||
expect(classifyGitCommand('git -C /repo push origin main', na).result).toBe('block');
|
||||
});
|
||||
it('still blocks --no-verify even with -C', () => {
|
||||
expect(classifyGitCommand('git -C /repo commit --no-verify -m x', na).result).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyGitCommand — git-hard (always block)', () => {
|
||||
it.each([
|
||||
'git push --force origin main',
|
||||
|
||||
Reference in New Issue
Block a user