mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-19 01:30:23 +03:00
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:
co-authored by
Claude Opus 5
parent
34ac9a18d0
commit
2eb25be7c5
@@ -41,7 +41,6 @@ allowed-tools:
|
|||||||
- `Enum.ВидыОплат` — перечисление
|
- `Enum.ВидыОплат` — перечисление
|
||||||
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
|
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
|
||||||
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
|
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
|
||||||
Поддерживаются все 45 типов объектов конфигурации из таблицы `ChildObjects` (включая `Bot` и `Language`).
|
|
||||||
|
|
||||||
### Заимствование форм
|
### Заимствование форм
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ node tests/skills/check-inline-drift.mjs --list # реестр: семья →
|
|||||||
| `check-enum-drift.mjs` | allowlist-ы значений перечислений: meta-compile (авторитет) ↔ meta-validate ↔ meta-edit |
|
| `check-enum-drift.mjs` | allowlist-ы значений перечислений: meta-compile (авторитет) ↔ meta-validate ↔ meta-edit |
|
||||||
| `check-uuid-invariant.mjs` | uuid объекта и сущностей не перегенерируются при правке |
|
| `check-uuid-invariant.mjs` | uuid объекта и сущностей не перегенерируются при правке |
|
||||||
| `check-inline-drift.mjs` | копии общих inline-утилит совпадают с эталонами своих вариантов |
|
| `check-inline-drift.mjs` | копии общих inline-утилит совпадают с эталонами своих вариантов |
|
||||||
|
| `check-type-maps.mjs` | карты типов метаданных согласованы с таблицей `ChildObjects` из `docs/1c-configuration-spec.md` |
|
||||||
|
|
||||||
`check-inline-drift.mjs` держит реестр семей внутри себя: у каждой семьи перечислены варианты, у
|
`check-inline-drift.mjs` держит реестр семей внутри себя: у каждой семьи перечислены варианты, у
|
||||||
варианта — навык-эталон и список копий. Отклоняющийся вариант обязан иметь обоснование (`why`),
|
варианта — навык-эталон и список копий. Отклоняющийся вариант обязан иметь обоснование (`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…), проверяющая что навыки работают вместе. Запуск:
|
Помимо snapshot-кейсов есть многошаговые сценарии в `integration/<имя>.test.mjs` — цепочка навыков (init → compile → build → validate…), проверяющая что навыки работают вместе. Запуск:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const GUARDS = [
|
|||||||
['check-enum-drift.mjs', 'allowlist-и перечислений: meta-compile ↔ meta-validate ↔ meta-edit'],
|
['check-enum-drift.mjs', 'allowlist-и перечислений: meta-compile ↔ meta-validate ↔ meta-edit'],
|
||||||
['check-uuid-invariant.mjs', 'сохранение uuid объекта и сущностей при правке'],
|
['check-uuid-invariant.mjs', 'сохранение uuid объекта и сущностей при правке'],
|
||||||
['check-inline-drift.mjs', 'общие inline-реализации: копии совпадают с эталонами'],
|
['check-inline-drift.mjs', 'общие inline-реализации: копии совпадают с эталонами'],
|
||||||
|
['check-type-maps.mjs', 'карты типов метаданных: согласованы со спецификацией'],
|
||||||
];
|
];
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|||||||
@@ -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 — карты типов согласованы со спецификацией.');
|
||||||
Reference in New Issue
Block a user