test(skills): гард согласованности карт типов метаданных со спецификацией

Прямой ответ на вопрос ишью #60. Гард inline-реализаций держал функции, но не
словари — а именно там расхождение и накапливалось молча: тип Bot существовал в
таблице спецификации и в трёх навыках, в остальных двенадцати его не было, и
заметить это было нечем.

Эталон — таблица «Порядок типов в ChildObjects» из docs/1c-configuration-spec.md
(45 типов: имя, каталог, позиция). Берём документацию, а не отдельный JSON: тогда
спека и код не расходятся молча, что и было целью ишью.

Модель двухуровневая, как и предлагалось в обсуждении: общее ядро (имя, каталог,
порядок) обязано совпадать у всех, а навык объявляет своё подмножество —
исключение с ПРИЧИНОЙ. Проверка отличает намеренное ограничение от забытого типа.
Сейчас исключение ровно одно: Language в cfe-diff, где записи в карте были бы
недостижимы.

Вокабуляры навыков (TYPE_ALIASES, TYPE_NORM_MAP, CONTENT_TYPE_MAP, TYPE_PLURAL_MAP)
проверяются слабее: полнота не требуется, но каждое каноническое имя обязано
существовать в таблице — это ловит опечатки. Пустое извлечение карты считается
ошибкой разбора, иначе непонятый формат прошёл бы вхолостую.

Проверено негативом: убранный тип, подменённый каталог и переставленный порядок
дают ERROR и exit 1.

cfe-borrow/SKILL.md: убрано обещание про количество поддерживаемых типов — оно уже
протухло (было 44) и является обязательством, которое навык не проверяет.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-09 13:57:50 +03:00
co-authored by Claude Opus 5
parent 34ac9a18d0
commit 2eb25be7c5
4 changed files with 255 additions and 1 deletions
-1
View File
@@ -41,7 +41,6 @@ allowed-tools:
- `Enum.ВидыОплат` — перечисление
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
Поддерживаются все 45 типов объектов конфигурации из таблицы `ChildObjects` (включая `Bot` и `Language`).
### Заимствование форм
+7
View File
@@ -48,6 +48,7 @@ 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-type-maps.mjs` | карты типов метаданных согласованы с таблицей `ChildObjects` из `docs/1c-configuration-spec.md` |
`check-inline-drift.mjs` держит реестр семей внутри себя: у каждой семьи перечислены варианты, у
варианта — навык-эталон и список копий. Отклоняющийся вариант обязан иметь обоснование (`why`),
@@ -56,6 +57,12 @@ node tests/skills/check-inline-drift.mjs --list # реестр: семья →
Правишь общую утилиту — правишь эталон, копируешь во всех потребителей варианта, прогоняешь гард.
`check-type-maps.mjs` сверяет словари типов метаданных с таблицей «Порядок типов в ChildObjects»
из спецификации: имя типа, каталог и порядок обязаны совпадать, а тип, которого в карте нет
намеренно, объявляется исключением **с причиной**. Так проверка отличает намеренное ограничение
навыка от забытого типа — именно так тип `Bot` годами существовал в спеке и в трёх навыках из
пятнадцати, и это никем не замечалось.
## Интеграционные тесты
Помимо snapshot-кейсов есть многошаговые сценарии в `integration/<имя>.test.mjs` — цепочка навыков (init → compile → build → validate…), проверяющая что навыки работают вместе. Запуск:
+1
View File
@@ -12,6 +12,7 @@ const GUARDS = [
['check-enum-drift.mjs', 'allowlist-и перечислений: meta-compile ↔ meta-validate ↔ meta-edit'],
['check-uuid-invariant.mjs', 'сохранение uuid объекта и сущностей при правке'],
['check-inline-drift.mjs', 'общие inline-реализации: копии совпадают с эталонами'],
['check-type-maps.mjs', 'карты типов метаданных: согласованы со спецификацией'],
];
let failed = 0;
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env node
// Анти-дрейф словарей типов метаданных. Навыки автономны, карты типов продублированы, и раньше
// расхождение накапливалось молча: тип Bot существовал в спецификации и в трёх навыках, а в
// остальных его не было — никто этого не замечал, потому что сверять было не с чем.
//
// Эталон — таблица «Порядок типов в ChildObjects» из docs/1c-configuration-spec.md (45 типов:
// каноническое имя + каталог + позиция). Берём документацию, а не отдельный JSON: тогда спека и
// код не расходятся молча, что и было целью ишью #60.
//
// Модель двухуровневая: общее ядро (имя/каталог/порядок) обязано совпадать у всех, а каждый навык
// объявляет своё подмножество — исключение с ПРИЧИНОЙ. Так проверка отличает намеренное
// ограничение от забытого типа.
//
// Запуск: node tests/skills/check-type-maps.mjs [--list]
// Выход 1 при ERROR, 0 при WARN.
import { readFileSync, existsSync } from 'node:fs';
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');
const SPEC = join(ROOT, 'docs', '1c-configuration-spec.md');
// ─── Реестр карт ────────────────────────────────────────────────────────────
// kind: 'dir' — тип → каталог: ключи и значения сверяются с таблицей
// 'order' — список типов: порядок обязан совпадать с порядком таблицы
// 'keys' — ключи обязаны быть каноническими именами (значения свои)
// 'alias' — значения обязаны быть каноническими именами (ключи — вокабуляр навыка)
// exclude: { Тип: 'причина' } — тип, которого в карте нет намеренно. Без причины → WARN.
// extraTargets — не-ChildObjects имена, законные для alias-карты (вложенные сущности и т. п.)
const MAPS = [
// тип → каталог
{ skill: 'cf-edit', file: 'cf-edit', kind: 'dir', py: 'TYPE_TO_DIR', ps1: '$script:typeToDir' },
{ skill: 'cf-validate', file: 'cf-validate', kind: 'dir', py: 'CHILD_TYPE_DIR_MAP', ps1: '$childTypeDirMap' },
{
skill: 'cfe-validate', file: 'cfe-validate', kind: 'dir',
py: 'CHILD_TYPE_DIR_MAP', ps1: '$childTypeDirMap',
},
{ skill: 'cfe-borrow', file: 'cfe-borrow', kind: 'dir', py: 'CHILD_TYPE_DIR_MAP', ps1: '$childTypeDirMap' },
{
skill: 'cfe-diff', file: 'cfe-diff', kind: 'dir', py: 'CHILD_TYPE_DIR_MAP', ps1: '$childTypeDirMap',
exclude: {
Language: 'навык пропускает языки при сборе объектов (cfe-diff.py:530) — запись в карте была бы недостижима',
},
},
// порядок типов
{ skill: 'cf-edit', file: 'cf-edit', kind: 'order', py: 'TYPE_ORDER', ps1: '$script:typeOrder' },
{ skill: 'cf-info', file: 'cf-info', kind: 'order', py: 'type_order', ps1: '$typeOrder' },
{ skill: 'cf-validate', file: 'cf-validate', kind: 'order', py: 'CHILD_OBJECT_TYPES', ps1: '$childObjectTypes' },
{ skill: 'cfe-validate', file: 'cfe-validate', kind: 'order', py: 'CHILD_OBJECT_TYPES', ps1: '$childObjectTypes' },
{ skill: 'cfe-borrow', file: 'cfe-borrow', kind: 'order', py: 'TYPE_ORDER', ps1: '$script:typeOrder' },
// ключи — канонические имена
{ skill: 'cf-info', file: 'cf-info', kind: 'keys', py: 'type_ru_names', ps1: '$typeRuNames' },
// вокабуляры: значения — канонические имена, полнота не требуется
{
skill: 'role-compile', file: 'role-compile', kind: 'alias', py: 'TYPE_ALIASES', ps1: null,
extraTargets: ['Configuration', 'Attribute', 'StandardAttribute', 'TabularSection',
'Dimension', 'Resource', 'Command', 'AddressingAttribute'],
},
{ skill: 'interface-edit', file: 'interface-edit', kind: 'alias', py: 'TYPE_NORM_MAP', ps1: null },
{ skill: 'subsystem-edit', file: 'subsystem-edit', kind: 'alias', py: 'CONTENT_TYPE_MAP', ps1: null },
{ skill: 'subsystem-compile', file: 'subsystem-compile', kind: 'alias', py: 'CONTENT_TYPE_MAP', ps1: null },
{ skill: 'meta-remove', file: 'meta-remove', kind: 'keys', py: 'TYPE_PLURAL_MAP', ps1: '$typePluralMap' },
];
// ─── Эталон из спецификации ─────────────────────────────────────────────────
function readSpec() {
const text = readFileSync(SPEC, 'utf8');
const rows = [...text.matchAll(/^\|\s*(\d+)\s*\|\s*`(\w+)`\s*\|\s*`([\w/]+)`\s*\|/gm)];
if (rows.length < 40) throw new Error(`Таблица типов не распознана в ${SPEC} (строк: ${rows.length})`);
const order = [];
const dirOf = new Map();
for (const r of rows) {
const type = r[2];
order.push(type);
dirOf.set(type, r[3].replace(/\/$/, ''));
}
return { order, dirOf };
}
// ─── Извлечение карт ────────────────────────────────────────────────────────
// Определение может быть вложенным (subsystem-compile объявляет CONTENT_TYPE_MAP внутри функции),
// поэтому конец блока ищем по закрывающей скобке на отступе самого объявления.
function sliceBlock(text, startIdx, open, close) {
const lineStart = text.lastIndexOf('\n', startIdx) + 1;
const indent = text.slice(lineStart, startIdx).match(/^\s*/)[0];
const from = text.indexOf(open, startIdx);
if (from < 0) return null;
const closer = `\n${indent}${close}`;
const to = text.indexOf(closer, from);
return text.slice(from + open.length, to < 0 ? text.length : to);
}
function extractPy(text, name, kind) {
const re = new RegExp(`^\\s*${name.replace(/\$/g, '\\$')}\\s*=\\s*[{[]`, 'm');
const m = re.exec(text);
if (!m) return null;
const isList = kind === 'order';
const body = sliceBlock(text, m.index + m[0].length - 1, isList ? '[' : '{', isList ? ']' : '}');
if (body === null) return null;
if (isList) return [...body.matchAll(/['"]([\w]+)['"]/g)].map((x) => x[1]);
return [...body.matchAll(/['"]([^'"]+)['"]\s*:\s*['"]([^'"]*)['"]/g)].map((x) => [x[1], x[2]]);
}
function extractPs1(text, name, kind) {
const re = new RegExp(`^\\s*${name.replace(/[$]/g, '\\$')}\\s*=\\s*@[({]`, 'm');
const m = re.exec(text);
if (!m) return null;
const isList = kind === 'order';
const body = sliceBlock(text, m.index + m[0].length - 1, isList ? '(' : '{', isList ? ')' : '}');
if (body === null) return null;
if (isList) return [...body.matchAll(/"([\w]+)"/g)].map((x) => x[1]);
return [...body.matchAll(/"([^"]+)"\s*=\s*"([^"]*)"/g)].map((x) => [x[1], x[2]]);
}
function readSkill(skill, file, ext) {
const p = join(SKILLS, skill, 'scripts', `${file}.${ext}`);
if (!existsSync(p)) return null;
return readFileSync(p, 'utf8').replace(/^/, '');
}
// ─── Проверка ───────────────────────────────────────────────────────────────
const spec = readSpec();
const canonical = new Set(spec.order);
const errors = [];
const warns = [];
const seen = [];
for (const entry of MAPS) {
for (const lang of ['py', 'ps1']) {
const name = entry[lang];
if (!name) continue;
const text = readSkill(entry.skill, entry.file, lang === 'py' ? 'py' : 'ps1');
if (text === null) continue;
const data = lang === 'py' ? extractPy(text, name, entry.kind) : extractPs1(text, name, entry.kind);
if (data === null) {
errors.push(`${entry.skill}.${name} [${lang}]: карта не найдена — реестр протух`);
continue;
}
// Пустое извлечение = парсер не понял формат. Для alias/keys это прошло бы вхолостую,
// поэтому проверяем явно, а не полагаемся на «нет записей — нет расхождений».
if (data.length === 0) {
errors.push(`${entry.skill}.${name} [${lang}]: карта извлеклась пустой — сломан разбор формата`);
continue;
}
const tag = `${entry.skill}.${name} [${lang}]`;
const exclude = entry.exclude || {};
if (entry.kind === 'order') {
seen.push({ tag, count: data.length });
for (const t of data) {
if (!canonical.has(t)) errors.push(`${tag}: тип '${t}' отсутствует в таблице спецификации`);
}
// порядок — подпоследовательность канонического
const known = data.filter((t) => canonical.has(t));
const positions = known.map((t) => spec.order.indexOf(t));
for (let i = 1; i < positions.length; i++) {
if (positions[i] < positions[i - 1]) {
errors.push(`${tag}: '${known[i]}' стоит после '${known[i - 1]}', в таблице — наоборот`);
break;
}
}
checkMissing(tag, new Set(known), exclude);
continue;
}
if (entry.kind === 'dir') {
seen.push({ tag, count: data.length });
for (const [type, dir] of data) {
if (!canonical.has(type)) {
errors.push(`${tag}: тип '${type}' отсутствует в таблице спецификации`);
continue;
}
const want = spec.dirOf.get(type);
if (dir !== want) errors.push(`${tag}: '${type}' → '${dir}', в таблице '${want}'`);
}
checkMissing(tag, new Set(data.map((d) => d[0])), exclude);
continue;
}
if (entry.kind === 'keys') {
seen.push({ tag, count: data.length });
for (const [type] of data) {
if (!canonical.has(type)) errors.push(`${tag}: ключ '${type}' не является каноническим именем типа`);
}
continue;
}
// alias: значения обязаны быть каноническими, полнота не требуется
seen.push({ tag, count: data.length });
const extra = new Set(entry.extraTargets || []);
for (const [alias, target] of data) {
if (!canonical.has(target) && !extra.has(target)) {
errors.push(`${tag}: алиас '${alias}' ведёт на '${target}', которого нет в таблице`);
}
}
}
}
function checkMissing(tag, present, exclude) {
for (const type of spec.order) {
if (present.has(type)) continue;
if (Object.prototype.hasOwnProperty.call(exclude, type)) {
if (!exclude[type]) warns.push(`${tag}: тип '${type}' исключён без причины`);
continue;
}
errors.push(`${tag}: тип '${type}' есть в таблице, но отсутствует в карте и не объявлен исключением`);
}
}
// ─── Вывод ──────────────────────────────────────────────────────────────────
if (process.argv.includes('--list')) {
console.log(`Эталон: ${spec.order.length} типов из docs/1c-configuration-spec.md\n`);
for (const entry of MAPS) {
const ports = [entry.py && `py:${entry.py}`, entry.ps1 && `ps1:${entry.ps1}`].filter(Boolean).join(' ');
console.log(`${entry.skill} [${entry.kind}] ${ports}`);
for (const [type, why] of Object.entries(entry.exclude || {})) {
console.log(` исключён ${type}${why || 'БЕЗ ПРИЧИНЫ'}`);
}
}
process.exit(0);
}
console.log(`Эталон: ${spec.order.length} типов. Проверено карт: ${seen.length}`);
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Эталон типов — таблица «Порядок типов в ChildObjects» в docs/1c-configuration-spec.md.');
process.exit(1);
}
console.log('\nOK — карты типов согласованы со спецификацией.');