From 2bd624a66f173d296b7b928059e5b9b7669097c5 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sun, 13 Sep 2026 17:49:30 +0300 Subject: [PATCH] =?UTF-8?q?test(role-edit):=20=D0=B3=D0=B0=D1=80=D0=B4=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BC=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Снапшоты фиксируют итоговый файл целиком, поэтому лишняя перестановка узлов или переписанный соседний блок уехали бы в эталон как норма. Гард считает diff к исходному файлу: операция обязана дать ровно свои строки и ни одной чужой. Измерено заодно на живой роли типовой (БазовыеПраваБП, 4357 строк, 105 узлов): add-rights нового объекта — +11/-0, права в существующий узел — +4/-0, set-rls — +3/-0, remove-rights — +0/-4, запрет на реквизит — +11/-0. Удалённых строк нигде, кроме снятия. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FGkXwoXTuafcu1SXMsauFq --- tests/skills/check-all.mjs | 1 + tests/skills/check-minimal-diff.mjs | 100 ++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 tests/skills/check-minimal-diff.mjs diff --git a/tests/skills/check-all.mjs b/tests/skills/check-all.mjs index 78e2729eb..0d4279d94 100644 --- a/tests/skills/check-all.mjs +++ b/tests/skills/check-all.mjs @@ -12,6 +12,7 @@ const GUARDS = [ ['check-enum-drift.mjs', 'allowlist-и перечислений: meta-compile ↔ meta-validate ↔ meta-edit'], ['check-type-synonyms.mjs', 'словари типов: meta-edit не противоречит meta-compile'], ['check-uuid-invariant.mjs', 'сохранение uuid объекта и сущностей при правке'], + ['check-minimal-diff.mjs', 'правка роли: диф только там, где просили'], ['check-inline-drift.mjs', 'общие inline-реализации: копии совпадают с эталонами'], ['check-type-maps.mjs', 'карты типов метаданных: согласованы со спецификацией'], ['check-format-versions.mjs', 'проверенный диапазон версий формата: согласован со спецификацией'], diff --git a/tests/skills/check-minimal-diff.mjs b/tests/skills/check-minimal-diff.mjs new file mode 100644 index 000000000..7ac5e0101 --- /dev/null +++ b/tests/skills/check-minimal-diff.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// Инвариант: правка роли меняет в Rights.xml ровно то, что просили, и ничего больше. +// Снапшот-тесты это НЕ ловят: они фиксируют итоговый файл целиком, поэтому лишняя +// перестановка узлов или переписанный соседний блок уехали бы в эталон как норма. +// Здесь считается diff к ИСХОДНОМУ файлу: сколько строк прибавилось и убыло. +// +// Прогоняет операции по очереди на фикстуре роли и сверяет размер правки. +// Оба рантайма. Выход 1 при нарушении. Запуск: node tests/skills/check-minimal-diff.mjs [--runtime python] +import { execFileSync } from 'node:child_process'; +import { readFileSync, mkdtempSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { removePathSync, copyTreeSync } from '../common/fsutil.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const IS_WIN = process.platform === 'win32'; +const requested = process.argv.includes('--runtime') + ? [process.argv[process.argv.indexOf('--runtime') + 1] === 'python' ? 'python' : 'powershell'] + : ['powershell', 'python']; +const runtimes = requested.filter(rt => rt !== 'powershell' || IS_WIN); +if (requested.includes('powershell') && !IS_WIN) { + console.log(`[powershell] пропущен: PowerShell не исполняется на ${process.platform}`); +} +if (runtimes.length === 0) { + console.log('Нечего проверять: запрошен только powershell, а он на этой ОС не исполняется.'); + process.exit(1); +} + +const PY = process.env.PYTHON || (IS_WIN ? 'python' : 'python3'); +const FIXTURE = join(ROOT, 'tests', 'skills', 'cases', 'role-edit', 'fixtures', 'role-base'); +const RIGHTS = join('Roles', 'Менеджер', 'Ext', 'Rights.xml'); + +// Ожидаемый размер правки: +добавлено / -убрано строк. Числа — форма узлов Rights.xml: +// это 4 строки, добавляет ещё 3 (открывающий тег, имя, закрывающий), +// ограничение — 3. +const STEPS = [ + { op: 'add-rights', value: 'InformationRegister.Цены: Read', plus: 7, minus: 0, + why: 'новый узел объекта с одним правом: обёртка, имя и четыре строки права' }, + { op: 'add-rights', value: 'InformationRegister.Цены: Update', plus: 4, minus: 0, + why: 'ещё одно право в существующий узел' }, + { op: 'set-rls', value: 'InformationRegister.Цены.Read: ГДЕ ЛОЖЬ', plus: 3, minus: 0, + why: 'ограничение на существующем праве' }, + { op: 'remove-rls', value: 'InformationRegister.Цены.Read', plus: 0, minus: 3, + why: 'снятие ограничения, право остаётся' }, + { op: 'remove-rights', value: 'InformationRegister.Цены: Update', plus: 0, minus: 4, + why: 'снятие права, узел остаётся' }, +]; + +function skill(runtime, args, cwd) { + const ext = runtime === 'python' ? '.py' : '.ps1'; + const script = join(ROOT, '.claude', 'skills', 'role-edit', 'scripts', `role-edit${ext}`); + const cmd = runtime === 'python' ? PY : 'powershell.exe'; + const argv = runtime === 'python' + ? [script, ...args] + : ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, ...args]; + return execFileSync(cmd, argv, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +} + +function countDiff(before, after) { + // Сравниваем мультимножества строк: перестановка узла — тоже правка вне радиуса, + // и она даст ненулевые plus/minus, как и переписанный блок. + const tally = new Map(); + for (const line of before.split('\n')) tally.set(line, (tally.get(line) || 0) + 1); + let plus = 0; + for (const line of after.split('\n')) { + const n = tally.get(line) || 0; + if (n > 0) tally.set(line, n - 1); else plus++; + } + let minus = 0; + for (const n of tally.values()) minus += n; + return { plus, minus }; +} + +let failed = 0; +for (const runtime of runtimes) { + const work = mkdtempSync(join(tmpdir(), 'mindiff-')); + try { + copyTreeSync(FIXTURE, work); + for (const step of STEPS) { + const before = readFileSync(join(work, RIGHTS), 'utf8'); + skill(runtime, ['-RolePath', join(work, 'Roles', 'Менеджер'), '-Operation', step.op, + '-Value', step.value, '-NoValidate'], work); + const after = readFileSync(join(work, RIGHTS), 'utf8'); + const { plus, minus } = countDiff(before, after); + const ok = plus === step.plus && minus === step.minus; + if (!ok) failed++; + console.log(` [${runtime}] ${ok ? '+' : 'x'} ${step.op}: +${plus}/-${minus} строк ` + + `(ожидалось +${step.plus}/-${step.minus} — ${step.why})`); + } + } finally { + removePathSync(work); + } +} + +if (failed) { + console.log(`\n${failed} НАРУШЕНИЙ: правка задела больше, чем просили.`); + process.exit(1); +} +console.log('\nOK — правка роли меняет только то, что просили.');