From c8144a01e5106b90094ff8ff6ff4571f1f53ee1e Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 8 Aug 2026 17:22:33 +0300 Subject: [PATCH] =?UTF-8?q?test(skills):=20=D1=80=D0=B5=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=80=20=D0=B8=20=D0=B3=D0=B0=D1=80=D0=B4=20=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B8=D1=85=20inline-=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Навыки автономны, общие утилиты копируются в каждый .ps1/.py, но нигде не было зафиксировано, какая копия эталонная и какие расхождения законны. Ревизия по #60 показала два класса семей: одни держатся побайтово (support-guard — 16 копий, db-обвязка — 12), другие разъехались без эталона (resolve_type_str — 6 копий и 6 вариантов, get_ml_text — 7 и 7). check-inline-drift.mjs держит реестр семей внутри себя: вариант → эталон → копии. Копия обязана совпадать с эталоном своего варианта; отклоняющийся вариант обязан иметь обоснование, иначе печатается как долг. Часть расхождений законна (esc_xml без " в form-* ради раундтрипа), поэтому модель хранит варианты, а не одно эталонное тело. Разъехавшиеся целиком семьи стоят на храповике maxVariants. Извлечение тел: PS1 — до строки ровно `}` (балансировка скобок даёт ложные 18 вариантов из 18 копий Assert-EditAllowed); PY — с обязательным снятием docstring-ов (иначе одинаковый код с разным описанием читается как расхождение). check-all.mjs — единая точка входа: check-enum-drift и check-uuid-invariant были рабочими, но не упоминались в README и никем не запускались. Co-Authored-By: Claude Opus 5 (1M context) --- docs/python-porting-guide.md | 33 +- tests/skills/README.md | 25 ++ tests/skills/check-all.mjs | 28 ++ tests/skills/check-inline-drift.mjs | 455 ++++++++++++++++++++++++++++ 4 files changed, 530 insertions(+), 11 deletions(-) create mode 100644 tests/skills/check-all.mjs create mode 100644 tests/skills/check-inline-drift.mjs diff --git a/docs/python-porting-guide.md b/docs/python-porting-guide.md index 1fa7ddf2..8269b7e7 100644 --- a/docs/python-porting-guide.md +++ b/docs/python-porting-guide.md @@ -222,16 +222,27 @@ PS1 молча возвращает `$null` при обращении к нес При доработке `.ps1`: 1. Применить аналогичные изменения в `.py` -2. Если затронуты inline-утилиты — обновить во всех скриптах: `grep -r "def esc_xml" .claude/skills/` +2. Если затронуты inline-утилиты — править **эталон** и копировать во всех потребителей варианта + (см. ниже), затем прогнать `node tests/skills/check-inline-drift.mjs` -## Inline-утилиты — полный список +## Inline-утилиты — реестр -| Функция | Где используется | -|---------|-----------------| -| `esc_xml()` | compile, init, edit, add скрипты | -| `emit_mltext()` | compile, init, add скрипты | -| `new_uuid()` | init, add, compile скрипты | -| `read_utf8()` | все скрипты | -| `write_utf8_bom()` | все скрипты с записью | -| `paginate()` | info скрипты | -| `split_camelcase()` | info скрипты | +Списка «на глаз» здесь нет: он протухает молча. Источник истины — реестр внутри гарда: + +```bash +node tests/skills/check-inline-drift.mjs --list # семья → эталон → список копий +node tests/skills/check-inline-drift.mjs # проверить, что копии не разошлись +node debug/inline-utils/scan-dupes.mjs # найти кандидатов, которых в реестре ещё нет +``` + +Модель: у семьи есть один или несколько **вариантов**, у варианта — навык-**эталон** и список +копий. Копия обязана совпадать с эталоном своего варианта побайтово (с точностью до комментариев, +docstring-ов и пробелов). + +Несколько вариантов — нормально, если различие содержательное, и тогда у варианта обязано быть +поле `why`. Пример: `esc_xml` в form-* не экранирует кавычки (платформа их в тексте элемента не +экранирует, `"` ломает раундтрип), а в init-навыках экранирует — там он применяется к +значениям атрибутов. Вариант без `why` считается долгом и печатается как WARN. + +Добавил новую общую утилиту — заведи семью в реестре, иначе гард сообщит, что навык содержит +функцию, но в реестре не объявлен. diff --git a/tests/skills/README.md b/tests/skills/README.md index a1cf5f94..bc1f5246 100644 --- a/tests/skills/README.md +++ b/tests/skills/README.md @@ -31,6 +31,31 @@ node tests/skills/verify-snapshots.mjs --help # полный Перепрогоняет навык из DSL кейса и грузит результат в 1С — отлавливает случаи, когда снапшоты обновили, но платформа уже не принимает выход. +## Гарды-инварианты + +Проверяют не вывод навыков, а инварианты исходников — то, что снапшотами не ловится. Навыки +автономны, общие словари и утилиты намеренно продублированы, поэтому нужна защита от расхождения +копий. В `runner.mjs` не входят, запускаются одной командой: + +```bash +node tests/skills/check-all.mjs # все гарды сразу +node tests/skills/check-inline-drift.mjs # только один +node tests/skills/check-inline-drift.mjs --list # реестр: семья → эталон → потребители +``` + +| Гард | Что держит | +|---|---| +| `check-enum-drift.mjs` | allowlist-ы значений перечислений: meta-compile (авторитет) ↔ meta-validate ↔ meta-edit | +| `check-uuid-invariant.mjs` | uuid объекта и сущностей не перегенерируются при правке | +| `check-inline-drift.mjs` | копии общих inline-утилит совпадают с эталонами своих вариантов | + +`check-inline-drift.mjs` держит реестр семей внутри себя: у каждой семьи перечислены варианты, у +варианта — навык-эталон и список копий. Отклоняющийся вариант обязан иметь обоснование (`why`), +иначе попадает в список долга. Семьи, разъехавшиеся целиком, стоят на храповике `maxVariants`: +число вариантов не должно расти. Кандидатов в реестр ищет `node debug/inline-utils/scan-dupes.mjs`. + +Правишь общую утилиту — правишь эталон, копируешь во всех потребителей варианта, прогоняешь гард. + ## Интеграционные тесты Помимо snapshot-кейсов есть многошаговые сценарии в `integration/<имя>.test.mjs` — цепочка навыков (init → compile → build → validate…), проверяющая что навыки работают вместе. Запуск: diff --git a/tests/skills/check-all.mjs b/tests/skills/check-all.mjs new file mode 100644 index 00000000..63010b3f --- /dev/null +++ b/tests/skills/check-all.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +// Прогон всех гардов-инвариантов одной командой. Гарды не входят в snapshot-прогон runner.mjs: +// они проверяют не вывод навыков, а инварианты исходников. Выход 1, если упал хотя бы один. +// Запуск: node tests/skills/check-all.mjs +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const GUARDS = [ + ['check-enum-drift.mjs', 'allowlist-и перечислений: meta-compile ↔ meta-validate ↔ meta-edit'], + ['check-uuid-invariant.mjs', 'сохранение uuid объекта и сущностей при правке'], + ['check-inline-drift.mjs', 'общие inline-реализации: копии совпадают с эталонами'], +]; + +let failed = 0; +for (const [script, title] of GUARDS) { + console.log(`\n${'='.repeat(70)}\n${script} — ${title}\n${'='.repeat(70)}`); + const r = spawnSync(process.execPath, [join(HERE, script)], { stdio: 'inherit' }); + if (r.status !== 0) failed++; +} + +console.log(`\n${'='.repeat(70)}`); +console.log(failed === 0 + ? `OK — все гарды прошли (${GUARDS.length}).` + : `${failed} из ${GUARDS.length} гардов упали.`); +process.exit(failed ? 1 : 0); diff --git a/tests/skills/check-inline-drift.mjs b/tests/skills/check-inline-drift.mjs new file mode 100644 index 00000000..66e06b74 --- /dev/null +++ b/tests/skills/check-inline-drift.mjs @@ -0,0 +1,455 @@ +#!/usr/bin/env node +// Анти-дрейф общих inline-реализаций. Навыки автономны (общих runtime-модулей нет, утилиты +// копируются в каждый .ps1/.py — docs/python-porting-guide.md), поэтому нужен гард от расхождения +// копий. Реестр семей держим здесь же: реестр про дрейф не должен дрейфовать относительно проверки. +// +// Часть расхождений ЗАКОННА (например esc_xml без " в form-* ради раундтрипа), поэтому семья +// хранит не одно эталонное тело, а список вариантов. У законного варианта обязано быть поле `why`; +// вариант без `why` — необоснованный, попадает в список долга (WARN). +// +// Запуск: 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 : + +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: [ + { id: 'config-only', authority: 'meta-compile', + consumers: ['cfe-borrow', 'interface-edit', 'role-compile', 'subsystem-compile', 'xdto-compile'] }, + { id: 'with-external-root', authority: 'form-compile', + why: 'EPF/ERF не имеет своего Configuration.xml — версию несёт корень самой обработки', + consumers: ['form-add', 'mxl-compile', 'template-add'], consumersPy: ['help-add'] }, + // Тот же алгоритм, отличается только именем переменной — редакторский дрейф PS1-копии. + { id: 'with-external-root-help-add-ps1', authority: 'help-add', consumers: [], port: 'ps1' }, + ], + }, + { + name: 'format_rank', py: 'format_rank', ps1: 'Get-FormatRank', + variants: [ + { id: 'base', authority: 'meta-compile', + consumers: ['cfe-borrow', 'cfe-init', 'form-add', 'form-compile', 'mxl-compile', 'role-compile', + 'subsystem-compile', 'subsystem-edit', 'template-add', 'xdto-compile'], + consumersPy: ['epf-init', 'erf-init'] }, + { id: 'meta-validate-own', authority: 'meta-validate', consumers: [] }, + ], + }, + + // ─── Экранирование XML ─────────────────────────────────────────────────── + { + name: 'esc_xml', py: 'esc_xml', ps1: 'Esc-Xml', + variants: [ + { id: 'text-no-quot', authority: 'form-compile', + why: 'экранирование ТЕКСТА элемента: платформа кавычки в тексте не экранирует, " ломает раундтрип', + consumers: ['form-edit', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit'], + consumersPy: ['subsystem-compile', 'subsystem-edit'] }, + { id: 'text-no-quot-subsystem-ps1', authority: 'subsystem-compile', + consumers: [], consumersPs1: ['subsystem-edit'], port: 'ps1' }, + { id: 'attr-with-quot', authority: 'cf-init', port: 'py', + why: 'экранирование ЗНАЧЕНИЯ АТРИБУТА: там " обязателен (init-навыки, PS1-порт функции не имеет)', + consumers: ['cfe-init', 'epf-init', 'erf-init'] }, + { id: 'meta-attr-with-quot', authority: 'meta-compile', + why: 'парный esc_xml_text экранирует текст, сам esc_xml применяется только к значениям атрибутов', + consumers: ['meta-edit'] }, + { id: 'meta-edit-own-py', authority: 'meta-edit', consumers: [], port: 'py' }, + ], + }, + // ─── Сохранение стиля 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'] }, + { id: 'web-publish-own', authority: 'web-publish', consumers: [] }, + ], + }, + +]; + +// ─── Семьи, разъехавшиеся целиком ─────────────────────────────────────────── +// Перечислять «вариант на каждую копию» бессмысленно — эталона у них сейчас нет. Держим храповик: +// число групп не должно расти. Фаза 2 сводит семью к одному телу и переносит её в FAMILIES. + +const DRIFTED = [ + { name: 'resolve_type_str', py: 'resolve_type_str', ps1: 'Resolve-TypeStr', maxVariants: { py: 6, ps1: 4 }, + note: 'алгоритм один, отличия редакторские; отдельно — срезание префикса cfg: только в form-compile' }, + { 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++) { + const m = /^function\s+([A-Za-z][\w-]*)/.exec(lines[i]); + if (!m) continue; + const body = [lines[i]]; + let j = i + 1; + for (; j < lines.length; j++) { + body.push(lines[j]); + if (lines[j].replace(/\s+$/, '') === '}') break; + } + out.set(m[1], 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 — все копии совпадают с эталонами своих вариантов.');