diff --git a/tests/skills/check-uuid-invariant.mjs b/tests/skills/check-uuid-invariant.mjs new file mode 100644 index 00000000..341e98bf --- /dev/null +++ b/tests/skills/check-uuid-invariant.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// Инвариант: НИ ОДНА операция meta-edit не меняет идентификатор существующей сущности — +// ни объекта, ни реквизита/измерения/ресурса/ТЧ, ни GeneratedType (TypeId/ValueId). +// Смена uuid рвёт ссылки/данные/состояние поддержки. Снапшот-тесты это НЕ ловят +// (нормализуют uuid позиционно), поэтому нужен отдельный guard. +// +// Компилирует объект, фиксирует uuid'ы, применяет широкую правку (rename+type+структурные +// свойства+свойства объекта+ТЧ+add+remove), сверяет что uuid существующих сущностей целы. +// Прогоняет оба рантайма. Выход 1 при нарушении. Запуск: node tests/skills/check-uuid-invariant.mjs [--runtime python] +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const runtimes = process.argv.includes('--runtime') + ? [process.argv[process.argv.indexOf('--runtime') + 1] === 'python' ? 'python' : 'powershell'] + : ['powershell', 'python']; + +function skill(runtime, name, args, cwd) { + const ext = runtime === 'python' ? '.py' : '.ps1'; + const p = join(ROOT, '.claude/skills', name, 'scripts', name + ext); + if (runtime === 'python') { + execFileSync(process.env.PYTHON || 'python', [p, ...args], { cwd, stdio: 'pipe' }); + } else { + execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', p, ...args], { cwd, stdio: 'pipe' }); + } +} + +// Множество ВСЕХ идентификаторов объекта: каждый uuid="..." (тип-элемент, реквизиты, ТЧ, +// формы, команды…) + GeneratedType TypeId/ValueId. Надёжнее, чем маппинг по имени +// (ТЧ имеет InternalInfo между uuid и Properties). +function collectUuids(xmlPath) { + const s = readFileSync(xmlPath, 'utf8'); + const set = new Set([...s.matchAll(/\buuid="([0-9a-f-]{36})"/g)].map(m => m[1])); + for (const m of s.matchAll(/([0-9a-f-]{36}) сразу). +function attrUuid(xmlPath, name) { + const s = readFileSync(xmlPath, 'utf8'); + const m = s.match(new RegExp(`\\s*\\s*${name}`)); + return m ? m[1] : null; +} + +let failures = 0; + +for (const runtime of runtimes) { + let work; + try { + work = mkdtempSync(join(tmpdir(), 'uuidinv-')); + // 1. Конфигурация + объект + skill(runtime, 'cf-init', ['-OutputDir', work, '-Name', 'Т'], work); + const inp = join(work, 'c.json'); + writeFileSync(inp, JSON.stringify({ + type: 'Catalog', name: 'Спр', + attributes: ['Комм: String(50)', 'Сумма: Number(15,2)', 'Удаляемый: String(10)'], + tabularSections: { 'Товары': ['Цена: Number(15,2)', 'Кол: Number(15,3)'] }, + }), 'utf8'); + skill(runtime, 'meta-compile', ['-JsonPath', inp, '-OutputDir', work], work); + const objXml = join(work, 'Catalogs', 'Спр.xml'); + + const before = collectUuids(objXml); + const removedUuid = attrUuid(objXml, 'Удаляемый'); + + // 2. Широкая правка существующих сущностей + add + remove + const edit = join(work, 'e.json'); + writeFileSync(edit, JSON.stringify({ + modify: { + properties: { CodeLength: 15, DataLockFields: ['Сумма'] }, + attributes: { + 'Комм': { name: 'Комментарий', type: 'String(200)' }, + 'Сумма': { MinValue: 0, Format: 'ЧЦ=15; ЧДЦ=2' }, + }, + tabularSections: { 'Товары': { modify: { 'Цена': { name: 'ЦенаНовая' } } } }, + }, + add: { attributes: ['НовыйРекв: Boolean'] }, + remove: { attributes: ['Удаляемый'] }, + }), 'utf8'); + skill(runtime, 'meta-edit', ['-ObjectPath', objXml, '-DefinitionFile', edit, '-NoValidate'], work); + + const after = collectUuids(objXml); + + // 3. Инвариант: каждый uuid, существовавший ДО правки (кроме намеренно удалённого), + // должен присутствовать ПОСЛЕ (переименование/смена типа/структурные свойства НЕ меняют id). + const fail = (msg) => { console.log(`[${runtime}] НАРУШЕНИЕ: ${msg}`); failures++; }; + let checked = 0; + for (const uuid of before) { + if (uuid === removedUuid) continue; // Attribute «Удаляемый» намеренно удалён + checked++; + if (!after.has(uuid)) fail(`uuid ${uuid} пропал после правки (перегенерирован?)`); + } + if (removedUuid && after.has(removedUuid)) fail(`uuid удалённого реквизита ${removedUuid} остался (не удалён?)`); + console.log(`[${runtime}] проверено ${checked} uuid (объект + GeneratedType + реквизиты/ТЧ), удалённый: ${removedUuid ? 'ок' : 'НЕ НАЙДЕН'}`); + } catch (e) { + console.log(`[${runtime}] ОШИБКА прогона: ${(e.stderr || e.message || '').toString().slice(0, 300)}`); + failures++; + } finally { + if (work) try { rmSync(work, { recursive: true, force: true }); } catch {} + } +} + +console.log(failures === 0 + ? 'OK — инвариант сохранения uuid держится (объект/сущности/GeneratedType)' + : `\n${failures} НАРУШЕНИЙ инварианта uuid.`); +process.exit(failures ? 1 : 0);