Files
cc-1c-skills/tests/skills/check-inline-drift.mjs
T
Nick ShirokovandClaude Opus 5 fadda6a287 refactor(meta-validate,web-publish): свести format_rank и db-обвязку к эталонам
format_rank в meta-validate отличался только именем параметра, _version_key в
web-publish инлайнил каталог версии вместо общего _version_dir (и знал только
Windows-раскладку .../bin/exe). Обе копии приведены к эталону db-create/
meta-compile; для Windows-навыка web-publish поведение не меняется.

В реестр добавлены семьи _version_dir и _find_project_v8path.

Экстрактор PS1 в гарде и сканере научен видеть вложенные определения: db-load-git
объявляет Find-ProjectV8Path внутри if, и поиск по `function` с нулевой позиции
считал функцию отсутствующей. Конец тела теперь ищется по закрывающей скобке на
отступе самого слова function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 21:13:50 +03:00

488 lines
26 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// Анти-дрейф общих inline-реализаций. Навыки автономны (общих runtime-модулей нет, утилиты
// копируются в каждый .ps1/.py — docs/python-porting-guide.md), поэтому нужен гард от расхождения
// копий. Реестр семей держим здесь же: реестр про дрейф не должен дрейфовать относительно проверки.
//
// Семья хранит не одно эталонное тело, а список вариантов: расхождение бывает и законным. У такого
// варианта обязано быть поле `why`; вариант без `why` — необоснованный, идёт в список долга (WARN).
//
// Важно не путать «вариант» с «разными задачами под одним именем»: esc_xml и esc_xml_text — это
// ДВЕ семьи, а не два варианта одной. Платформа в тексте элемента экранирует только & < >, а в
// значении атрибута добавляет &quot;, поэтому им нужны разные функции с говорящими именами, каждая
// со своим единственным эталоном. Свести такое в один вариант с флагом — значит спрятать разницу.
//
// Запуск: node tests/skills/check-inline-drift.mjs [--list]
// Выход 1 при ERROR, 0 при WARN. Кандидатов в реестр искать: node debug/inline-utils/scan-dupes.mjs
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const SKILLS = join(ROOT, '.claude', 'skills');
// ─── Реестр семей ───────────────────────────────────────────────────────────
// name — как называть семью в отчёте
// py/ps1 — имя функции в соответствующем порте (null, если в порте её нет)
// variants[].authority — навык-эталон варианта: правку вносим в него и копируем в consumers
// variants[].why — обоснование, почему вариант отличается от остальных. Нет why → долг.
// variants[].consumersPs1 / consumersPy — потребители, которые есть только в этом порте
// variants[].port — вариант существует только в указанном порте (расхождение одного порта)
// Заготовку по новой семье печатает: node debug/inline-utils/scan-dupes.mjs --stub <py>:<ps1>
const FAMILIES = [
// ─── support-guard: запрет правки объекта на поддержке ───────────────────
{
name: 'support-guard: assert_edit_allowed', py: 'assert_edit_allowed', ps1: 'Assert-EditAllowed',
variants: [
{ id: 'full', authority: 'cf-edit',
consumers: ['form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile',
'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit',
'subsystem-compile', 'subsystem-edit', 'template-add'] },
{ id: 'xdto-compile-own', authority: 'xdto-compile', consumers: [] },
{ id: 'xdto-edit-own', authority: 'xdto-edit', consumers: [] },
// Итог: xdto-* несут свою урезанную реализацию guard-а вместо общей — см. FINDINGS.md.
],
},
{
name: 'support-guard: get_edit_mode', py: '_sg_get_edit_mode', ps1: 'Get-EditMode',
variants: [
{ id: 'full', authority: 'cf-edit',
consumers: ['form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile',
'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit',
'subsystem-compile', 'subsystem-edit', 'template-add'] },
// В PY xdto-* держат ту же логику встроенной в assert_edit_allowed, отдельной функции нет.
{ id: 'xdto-own', authority: 'xdto-compile', consumers: [], consumersPs1: ['xdto-edit'], port: 'ps1' },
],
},
{
name: 'support-guard: is_external_root', py: '_sg_is_external_root', ps1: 'Test-ExternalObjectRoot',
variants: [
{ id: 'full', authority: 'cf-edit',
consumers: ['form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile',
'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit',
'subsystem-compile', 'subsystem-edit', 'template-add'],
// *-info навыки несут хелпер только в PS1-порте — см. debug/inline-utils/FINDINGS.md.
consumersPs1: ['form-info', 'meta-info', 'mxl-info', 'role-info', 'skd-info'] },
{ id: 'xdto-own', authority: 'xdto-compile', consumers: [], consumersPs1: ['xdto-edit'], port: 'ps1' },
],
},
{
name: 'support-guard: find_v8project', py: '_sg_find_v8project', ps1: 'Find-V8Project',
variants: [
{ id: 'full', authority: 'cf-edit',
consumers: ['form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile',
'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit',
'subsystem-compile', 'subsystem-edit', 'template-add'],
consumersPs1: ['xdto-compile', 'xdto-edit'] },
],
},
{
name: 'support-guard: root_uuid', py: '_sg_root_uuid', ps1: 'Get-RootUuid',
variants: [
{ id: 'full', authority: 'cf-edit',
consumers: ['form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile',
'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit',
'subsystem-compile', 'subsystem-edit', 'template-add'],
consumersPs1: ['support-edit', 'xdto-compile'] },
],
},
// ─── Версия формата выгрузки ─────────────────────────────────────────────
{
name: 'detect_format_version', py: 'detect_format_version', ps1: 'Detect-FormatVersion',
variants: [
// Ветка автономной EPF/ERF безвредна для конфигурационных навыков: она срабатывает только
// если <каталог>.xml — корень ExternalDataProcessor/ExternalReport, чего в дереве
// конфигурации не бывает. Поэтому вариант один на всех, переключателя не нужно.
{ id: 'base', authority: 'form-compile',
consumers: ['cfe-borrow', 'form-add', 'help-add', 'interface-edit', 'meta-compile',
'mxl-compile', 'role-compile', 'subsystem-compile', 'template-add', 'xdto-compile'] },
],
},
{
name: 'format_rank', py: 'format_rank', ps1: 'Get-FormatRank',
variants: [
{ id: 'base', authority: 'meta-compile',
consumers: ['cfe-borrow', 'cfe-init', 'form-add', 'form-compile', 'meta-validate',
'mxl-compile', 'role-compile', 'subsystem-compile', 'subsystem-edit', 'template-add',
'xdto-compile'],
consumersPy: ['epf-init', 'erf-init'] },
],
},
// ─── Прощающий ввод: разбор строки типа ──────────────────────────────────
{
name: 'resolve_type_str', py: 'resolve_type_str', ps1: 'Resolve-TypeStr',
// Тело одинаково во всех навыках, а словари синонимов разные — навык объявляет алиас
// TYPE_SYNONYMS / $script:typeSynonyms на свой локальный словарь.
variants: [
{ id: 'base', authority: 'meta-compile',
consumers: ['form-compile', 'form-edit', 'meta-edit', 'skd-compile', 'skd-edit'] },
],
},
// ─── Экранирование XML ───────────────────────────────────────────────────
// Платформа в ТЕКСТЕ элемента экранирует только & < > (кавычка и апостроф остаются сырыми —
// проверено раундтрипом через базу), а в ЗНАЧЕНИИ АТРИБУТА добавляет &quot;: внутри "..."
// литеральная кавычка невалидна. Отсюда две функции, а не одна с переключателем.
{
name: 'esc_xml (значение атрибута)', py: 'esc_xml', ps1: 'Esc-Xml',
variants: [
{ id: 'attr-with-quot', authority: 'meta-compile',
consumers: ['form-compile', 'form-edit', 'meta-edit', 'mxl-compile', 'role-compile',
'skd-compile', 'skd-edit', 'subsystem-compile', 'subsystem-edit'] },
],
},
{
name: 'esc_xml_text (текст элемента)', py: 'esc_xml_text', ps1: 'Esc-XmlText',
variants: [
{ id: 'text-no-quot', authority: 'meta-compile',
consumers: ['cf-init', 'cfe-init', 'epf-init', 'erf-init', 'form-compile', 'form-edit',
'meta-edit', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit',
'subsystem-compile', 'subsystem-edit'] },
],
},
// ─── Сохранение стиля XML при round-trip (#44/#46/#47) ───────────────────
{
name: 'detect_xml_style', py: '_detect_xml_style', ps1: null,
variants: [
{ id: 'base', authority: 'cf-edit',
consumers: ['cfe-borrow', 'form-add', 'form-remove', 'help-add', 'interface-edit', 'meta-edit',
'meta-remove', 'subsystem-edit', 'template-add', 'template-remove'] },
],
},
{
name: 'finalize_xml_bytes', py: '_finalize_xml_bytes', ps1: null,
variants: [
{ id: 'base', authority: 'cf-edit',
consumers: ['cfe-borrow', 'form-add', 'form-remove', 'help-add', 'interface-edit', 'meta-edit',
'meta-remove', 'subsystem-edit', 'template-add', 'template-remove'] },
],
},
// ─── Обвязка вызова платформы 1С (db-* и потребители) ────────────────────
{
name: 'platform: resolve_extra_args', py: 'resolve_extra_args', ps1: 'Resolve-ExtraArgs',
variants: [
{ id: 'base', authority: 'db-create',
consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git',
'db-load-xml', 'db-run', 'db-update', 'epf-build', 'epf-dump'] },
],
},
{
name: 'platform: run_v8', py: 'run_v8', ps1: 'Invoke-PlatformProcess',
variants: [
// db-run запускает Предприятие и не ждёт процесс — общей обвязки запуска не использует.
{ id: 'base', authority: 'db-create',
consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git',
'db-load-xml', 'db-update', 'epf-build', 'epf-dump'] },
],
},
{
name: 'platform: decode_platform_bytes', py: 'decode_platform_bytes', ps1: 'ConvertFrom-PlatformBytes',
variants: [
{ id: 'base', authority: 'db-create',
consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git',
'db-load-xml', 'db-update', 'epf-build', 'epf-dump'] },
],
},
{
name: 'platform: redact', py: '_redact', ps1: 'Protect-Secrets',
variants: [
{ id: 'base', authority: 'db-dump-cf',
consumers: ['db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git',
'db-load-xml', 'db-run', 'db-update', 'epf-build', 'epf-dump'] },
],
},
{
name: 'platform: version_key', py: '_version_key', ps1: null,
variants: [
{ id: 'base', authority: 'db-create',
consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git',
'db-load-xml', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'web-publish'] },
],
},
{
name: 'platform: version_dir', py: '_version_dir', ps1: null,
variants: [
{ id: 'base', authority: 'db-create',
consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git',
'db-load-xml', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'web-publish'] },
],
},
{
name: 'platform: find_project_v8path', py: '_find_project_v8path', ps1: 'Find-ProjectV8Path',
variants: [
{ id: 'base', authority: 'db-create',
consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git',
'db-load-xml', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'web-publish'] },
],
},
];
// ─── Семьи, разъехавшиеся целиком ───────────────────────────────────────────
// Перечислять «вариант на каждую копию» бессмысленно — эталона у них сейчас нет. Держим храповик:
// число групп не должно расти. Фаза 2 сводит семью к одному телу и переносит её в FAMILIES.
const DRIFTED = [
{ name: 'emit_mltext', py: 'emit_mltext', ps1: 'Emit-MLText', maxVariants: { py: 5, ps1: 5 } },
{ name: 'get_ml_text', py: 'get_ml_text', ps1: 'Get-MLText', maxVariants: { py: 7, ps1: 6 } },
{ name: 'validate: report_error', py: null, ps1: 'Report-Error', maxVariants: { ps1: 3 } },
{ name: 'validate: report_warn', py: null, ps1: 'Report-Warn', maxVariants: { ps1: 3 } },
{ name: 'validate: report_ok', py: null, ps1: 'Report-OK', maxVariants: { ps1: 3 } },
{ name: 'import_fragment', py: 'import_fragment', ps1: 'Import-Fragment', maxVariants: { py: 5, ps1: 4 } },
{ name: 'get_child_indent', py: 'get_child_indent', ps1: 'Get-ChildIndent', maxVariants: { py: 4, ps1: 3 } },
{ name: 'write_xml_file', py: 'write_xml_file', ps1: null, maxVariants: { py: 3 } },
];
// ─── Извлечение тел функций ─────────────────────────────────────────────────
// PY: от `def name(` до первой строки без отступа.
// PS1: от `function Name` до первой строки, равной ровно `}` — так отформатирован репозиторий.
// Балансировка фигурных скобок в PS1 НЕ работает: скобки внутри строк и хэштейблов дают ложные
// расхождения (18 вариантов из 18 копий Assert-EditAllowed там, где реально 3).
function extractPy(text) {
const lines = text.split('\n');
const out = new Map();
for (let i = 0; i < lines.length; i++) {
const m = /^def ([A-Za-z_]\w*)\(/.exec(lines[i]);
if (!m) continue;
const body = [lines[i]];
let j = i + 1;
for (; j < lines.length; j++) {
const l = lines[j];
if (l.trim() === '' || l[0] === ' ' || l[0] === '\t') { body.push(l); continue; }
break;
}
out.set(m[1], body);
i = j - 1;
}
return out;
}
function extractPs1(text) {
const lines = text.split('\n');
const out = new Map();
for (let i = 0; i < lines.length; i++) {
// Определение может быть вложенным (db-load-git объявляет Find-ProjectV8Path внутри if),
// поэтому конец ищем по закрывающей скобке НА ТОМ ЖЕ отступе, что и слово function.
const m = /^(\s*)function\s+([A-Za-z][\w-]*)/.exec(lines[i]);
if (!m) continue;
const closing = m[1] + '}';
const body = [lines[i]];
let j = i + 1;
for (; j < lines.length; j++) {
body.push(lines[j]);
if (lines[j].replace(/\s+$/, '') === closing) break;
}
if (!out.has(m[2])) out.set(m[2], body);
i = j;
}
return out;
}
// Docstring-и снимаем наравне с комментариями: одинаковый код с разным описанием — не расхождение.
function stripDocstrings(lines) {
const out = [];
let closing = null;
for (const l of lines) {
const t = l.trim();
if (closing) {
if (t.endsWith(closing) && t.length >= closing.length) closing = null;
continue;
}
const m = /^(?:[rубf]*)("""|''')/i.exec(t);
if (m) {
const q = m[1];
const rest = t.slice(t.indexOf(q) + q.length);
if (!rest.endsWith(q) || rest.length < q.length) closing = q;
continue;
}
out.push(l);
}
return out;
}
function hashBody(body, lang) {
let lines = body.slice(1);
if (lang === 'py') lines = stripDocstrings(lines);
const sig = lines.map((l) => l.trim()).filter((l) => l !== '' && !l.startsWith('#'));
return createHash('md5').update(sig.join(' ').replace(/\s+/g, ' ')).digest('hex').slice(0, 8);
}
// ─── Индекс: навык+порт → функции ───────────────────────────────────────────
function buildIndex() {
const index = new Map(); // `${skill}|${lang}` -> Map(name -> body)
for (const skill of readdirSync(SKILLS)) {
const dir = join(SKILLS, skill, 'scripts');
if (!existsSync(dir)) continue;
for (const file of readdirSync(dir)) {
const lang = file.endsWith('.py') ? 'py' : file.endsWith('.ps1') ? 'ps1' : null;
if (!lang) continue;
const text = readFileSync(join(dir, file), 'utf8').replace(/^/, '');
const fns = lang === 'py' ? extractPy(text) : extractPs1(text);
const key = `${skill}|${lang}`;
const acc = index.get(key) || new Map();
for (const [n, b] of fns) if (!acc.has(n)) acc.set(n, b);
index.set(key, acc);
}
}
return index;
}
// ─── Проверка ───────────────────────────────────────────────────────────────
const index = buildIndex();
const errors = [];
const warns = [];
function membersOf(v, lang) {
const extra = lang === 'ps1' ? v.consumersPs1 : v.consumersPy;
return [v.authority, ...v.consumers, ...(extra || [])];
}
// Варианты, применимые к порту: общие + объявленные именно для него. Навыки, захваченные
// портовым вариантом, из общих вариантов этого порта исключаются.
function variantsFor(family, lang) {
const applicable = family.variants.filter((v) => !v.port || v.port === lang);
const claimed = new Set(applicable.filter((v) => v.port === lang).flatMap((v) => membersOf(v, lang)));
return applicable.map((v) => {
if (v.port === lang) return { v, members: membersOf(v, lang) };
return { v, members: membersOf(v, lang).filter((s) => !claimed.has(s)) };
}).filter((e) => e.members.length);
}
for (const family of FAMILIES) {
for (const lang of ['py', 'ps1']) {
const fnName = family[lang];
if (!fnName) continue;
const effective = variantsFor(family, lang);
const declared = new Set(effective.flatMap((e) => e.members));
// 1. Каждый объявленный потребитель содержит функцию, и внутри варианта тела совпадают.
const hashByVariant = new Map();
for (const { v, members } of effective) {
const hashes = new Map();
for (const skill of members) {
const body = index.get(`${skill}|${lang}`)?.get(fnName);
if (!body) {
errors.push(`${family.name} [${lang}]: ${skill} объявлен в варианте '${v.id}', но функции ${fnName} в нём нет`);
continue;
}
hashes.set(skill, hashBody(body, lang));
}
const authorityHash = hashes.get(v.authority);
if (authorityHash === undefined) continue;
hashByVariant.set(v.id, authorityHash);
for (const [skill, h] of hashes) {
if (h !== authorityHash) {
errors.push(`${family.name} [${lang}]: ${skill} разошёлся с эталоном ${v.authority} (вариант '${v.id}')`);
}
}
}
// 2. Разные варианты обязаны различаться телом — иначе реестр протух и их пора слить.
const seen = new Map();
for (const [id, h] of hashByVariant) {
if (seen.has(h)) {
errors.push(`${family.name} [${lang}]: варианты '${seen.get(h)}' и '${id}' объявлены разными, но тела совпадают — слить в реестре`);
}
seen.set(h, id);
}
// 3. Навык содержит функцию, но в реестре не объявлен — новая копия проехала мимо.
for (const [key, fns] of index) {
const [skill, l] = key.split('|');
if (l !== lang || declared.has(skill) || !fns.has(fnName)) continue;
errors.push(`${family.name} [${lang}]: ${skill} содержит ${fnName}, но в реестре не объявлен`);
}
}
// 4. Долг: отклоняющийся вариант без обоснования. Базовым считаем самый массовый — ему
// обоснование не нужно, оно нужно тем, кто от него отклонился.
if (family.variants.length > 1) {
const sized = family.variants.map((v) => ({
v, members: [...new Set([...membersOf(v, 'py'), ...membersOf(v, 'ps1')])],
})).sort((a, b) => b.members.length - a.members.length);
for (const { v, members } of sized.slice(1)) {
if (v.why) continue;
warns.push(`${family.name}: вариант '${v.id}' (${members.join(', ')}) без обоснования`);
}
}
}
// Храповик по разъехавшимся семьям: число групп не должно расти.
const drift = [];
for (const family of DRIFTED) {
for (const lang of ['py', 'ps1']) {
const fnName = family[lang];
if (!fnName) continue;
const groups = new Map();
for (const [key, fns] of index) {
const [skill, l] = key.split('|');
if (l !== lang || !fns.has(fnName)) continue;
const h = hashBody(fns.get(fnName), lang);
if (!groups.has(h)) groups.set(h, []);
groups.get(h).push(skill);
}
if (!groups.size) continue;
const limit = family.maxVariants[lang];
drift.push({ family: family.name, lang, actual: groups.size, limit, groups });
if (limit === undefined) {
errors.push(`${family.name} [${lang}]: семья не имеет предела вариантов в реестре`);
} else if (groups.size > limit) {
errors.push(`${family.name} [${lang}]: вариантов стало ${groups.size} при пределе ${limit} — новая копия разошлась`);
}
}
}
// ─── Вывод ──────────────────────────────────────────────────────────────────
if (process.argv.includes('--list')) {
for (const family of FAMILIES) {
const ports = [family.py && `py:${family.py}`, family.ps1 && `ps1:${family.ps1}`].filter(Boolean).join(' ');
console.log(`\n${family.name} (${ports})`);
const sized = family.variants.map((v) => ({
v, members: [...new Set([...membersOf(v, 'py'), ...membersOf(v, 'ps1')])],
})).sort((a, b) => b.members.length - a.members.length);
for (const [i, { v, members }] of sized.entries()) {
const tag = i === 0 ? 'базовый' : v.why || 'БЕЗ ОБОСНОВАНИЯ';
console.log(` [${v.id}] эталон: ${v.authority}${tag}`);
const copies = members.filter((s) => s !== v.authority);
if (copies.length) console.log(` копии: ${copies.join(', ')}`);
}
}
console.log('\nРазъехавшиеся семьи (эталона нет, храповик на число вариантов):');
for (const d of DRIFTED) {
const limits = ['py', 'ps1'].filter((l) => d[l]).map((l) => `${l}${d.maxVariants[l]}`).join(', ');
console.log(` ${d.name} (${limits})${d.note ? ` — ${d.note}` : ''}`);
}
process.exit(0);
}
const copies = FAMILIES.reduce((n, f) => n + f.variants.reduce(
(m, v) => m + new Set([...membersOf(v, 'py'), ...membersOf(v, 'ps1')]).size, 0), 0);
console.log(`Семей с эталоном: ${FAMILIES.length} (объявленных копий: ${copies}), разъехавшихся: ${DRIFTED.length}`);
if (drift.length) {
console.log('\nРазъехавшиеся семьи (храповик — число вариантов не должно расти):');
for (const d of drift) console.log(` ${d.family} [${d.lang}]: ${d.actual}/${d.limit}`);
}
if (warns.length) {
console.log(`\nДолг — необоснованные варианты (${warns.length}):`);
for (const w of warns) console.log(` [WARN] ${w}`);
}
if (errors.length) {
console.log(`\n${errors.length} РАСХОЖДЕНИЙ:`);
for (const e of errors) console.log(` [ERROR] ${e}`);
console.log('\nПравка общей утилиты: меняем эталон и копируем во всех потребителей варианта.');
process.exit(1);
}
console.log('\nOK — все копии совпадают с эталонами своих вариантов.');