Files
cc-1c-skills/tests/skills/check-uuid-invariant.mjs
T
0442aa015a fix(tests,web-test): не отдавать rmSync/cpSync пути с не-ASCII символами
На Windows fs.rmSync и fs.cpSync молча не делают ничего, когда не-ASCII символы
есть в самом аргументе пути (nodejs/node#61067). Для аудитории проекта это боевой
сценарий: кириллическое имя пользователя даёт кириллический %TEMP%, где раннер
создаёт воркспейсы, плюс кириллические имена объектов 1С в путях внутри кейсов.

Замерено на восьми сборках. Затронуты 22.18.0–22.23.2 (последняя LTS Jod) и
24.12.0–24.14.0; исправны 22.15.1, 24.15.0+, 25.x, 26.x. Фикс приехал в fs-слой
Node 24.15 и в ветку 22.x не бэкпортирован, поэтому «обновить Node» вопрос не
закрывает. Зависимость не монотонна по версиям (24.14 чинит rmSync, но не
cpSync) — гард по номеру версии невозможен, решает только сам путь.

Симптомы: rmSync молча ничего не удаляет; cpSync с не-ASCII приёмником молча
ничего не копирует; cpSync с не-ASCII источником валит процесс нативно
(0xC0000409) мимо try/catch. Не затронуты mkdirSync, readdirSync, lstatSync,
copyFileSync (включая перезапись), unlinkSync, rmdirSync — на них стоит обход.

Что ломалось: под кириллическим %TEMP% фикстуры не доезжали до воркспейсов
(meta-info — 15 ложных падений из 26, один кейс ложно-зелёный на пустом
воркспейсе); cfe-validate/module-state-flag-without-file был красным даже при
ASCII %TEMP% (кириллица в самом deletePath); --update-snapshots молча не сносил
старый эталон, то есть портил коммитимые артефакты.

Реализация — единый fsutil в двух побайтно одинаковых копиях (tests/common/ и
внутри автономного навыка web-test), раскатанный на все 42 точки вызова в 11
файлах: tests/skills/*, tests/web-test/*, hooks/test/run.mjs, движок web-test.
Предикат судит по resolve(p), а не по строке аргумента: относительный
ASCII-аргумент при не-ASCII cwd платформа роняет так же молча. Ретраи доживают
до ручного обхода, перезапись как у cpSync с force: true, настоящие ошибки не
глотаются, выживший после удаления путь — громкая ошибка.

Гард tests/skills/check-nonascii-fs.mjs в check-all.mjs проверяет хелпер, а не
платформу (поэтому зелёный и на исправной Node), сверяет хеши обеих копий и
печатает справкой состояние текущей сборки.

Co-Authored-By: androman.pro <5669019+andromanpro@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:27:02 +03:00

148 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env 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, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
// fs.rmSync/fs.cpSync напрямую не зовём: на Windows они молча ничего не делают,
// когда в пути есть не-ASCII символы. Подробности и таблица сборок — в самом модуле.
import { removePathSync } from '../common/fsutil.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const IS_WIN = process.platform === 'win32';
// PowerShell вне Windows не исполняется — это природа платформы, а не пробел в покрытии
// (см. debug/macmini-testing.md). Отсеиваем порт по ОС, иначе гард падал бы на маке с
// «spawnSync powershell.exe ENOENT» и красил весь check-all.
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);
}
// На *nix интерпретатор зовётся python3; `python` там обычно отсутствует вовсе.
const PY = process.env.PYTHON || (IS_WIN ? 'python' : 'python3');
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(PY, [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(/<xr:(?:TypeId|ValueId)>([0-9a-f-]{36})</g)) set.add(m[1]);
return set;
}
// uuid реквизита по имени (для исключения намеренно удаляемого; реквизит имеет <Properties> сразу).
function attrUuid(xmlPath, name) {
const s = readFileSync(xmlPath, 'utf8');
const m = s.match(new RegExp(`<Attribute uuid="([0-9a-f-]{36})">\\s*<Properties>\\s*<Name>${name}</Name>`));
return m ? m[1] : null;
}
let failures = 0; // нарушения инварианта
let runErrors = 0; // прогон не состоялся: окружение, а не uuid
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'], predefined: ['(1) ПервыйПредоп', '(2) ВторойПредоп'] },
remove: { attributes: ['Удаляемый'] },
}), 'utf8');
skill(runtime, 'meta-edit', ['-ObjectPath', objXml, '-DefinitionFile', edit, '-NoValidate'], work);
const after = collectUuids(objXml);
// 3b. Инвариант для предопределённых: добавление ещё элементов не меняет id существующих <Item>.
const predefXml = join(work, 'Catalogs', 'Спр', 'Ext', 'Predefined.xml');
const predefIds = (p) => new Set([...readFileSync(p, 'utf8').matchAll(/<Item id="([0-9a-f-]{36})"/g)].map(m => m[1]));
const predefBefore = predefIds(predefXml);
const edit2 = join(work, 'e2.json');
writeFileSync(edit2, JSON.stringify({ add: { predefined: ['(3) ТретийПредоп'] } }), 'utf8');
skill(runtime, 'meta-edit', ['-ObjectPath', objXml, '-DefinitionFile', edit2, '-NoValidate'], work);
const predefAfter = predefIds(predefXml);
// 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} остался (не удалён?)`);
for (const id of predefBefore) {
if (!predefAfter.has(id)) fail(`id предопределённого элемента ${id} пропал после добавления новых (перегенерирован?)`);
}
console.log(`[${runtime}] проверено ${checked} uuid объекта + ${predefBefore.size} id предопределённых (сохранены при add)`);
} catch (e) {
const detail = (e.stderr || e.message || '').toString();
console.log(`[${runtime}] ОШИБКА прогона: ${detail.slice(0, 300)}`);
// Гард запускает НАВЫКИ, а им нужен интерпретатор с lxml. Системный python3 на маке его не
// имеет, и без подсказки ошибка читается как нарушение инварианта, а не как окружение.
if (runtime === 'python' && /ModuleNotFoundError|No module named|ENOENT/.test(detail)) {
console.log(`[python] интерпретатор: ${PY}. Если модулей нет — указать venv: PYTHON=<путь> node ${'tests/skills/check-uuid-invariant.mjs'}`);
}
runErrors++;
} finally {
if (work) try { removePathSync(work); } catch {}
}
}
// Ошибку прогона от нарушения инварианта отличаем в выводе: «N НАРУШЕНИЙ инварианта uuid»
// на несобранном окружении отправляет искать баг там, где его нет.
if (failures) console.log(`\n${failures} НАРУШЕНИЙ инварианта uuid.`);
if (runErrors) console.log(`${runErrors} порт(ов) не удалось прогнать — инвариант НЕ проверен (окружение, не uuid).`);
if (!failures && !runErrors) console.log('OK — инвариант сохранения uuid держится (объект/сущности/GeneratedType)');
process.exit(failures || runErrors ? 1 : 0);