From aad9956e12af018b7da6be8ce33e02886e6370d4 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Fri, 21 Aug 2026 10:51:06 +0300 Subject: [PATCH] =?UTF-8?q?test(skills):=20=D0=B3=D0=B0=D1=80=D0=B4=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=83=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Держит три инварианта form-add: состав видов сходится со спецификацией, свойство «основная форма» у назначения есть у этого вида по спецификации, таблицы PS и PY совпадают между собой. Проверено на регрессии: если вернуть журналу DefaultListForm, гард падает дважды — по спецификации и по паритету портов. Co-Authored-By: Claude Opus 5 --- tests/skills/README.md | 1 + tests/skills/check-all.mjs | 1 + tests/skills/check-form-purposes.mjs | 163 +++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 tests/skills/check-form-purposes.mjs diff --git a/tests/skills/README.md b/tests/skills/README.md index d99db0c5e..f32c0ef1f 100644 --- a/tests/skills/README.md +++ b/tests/skills/README.md @@ -65,6 +65,7 @@ node tests/skills/check-inline-drift.mjs --list # реестр: семья → | `check-inline-drift.mjs` | копии общих inline-утилит совпадают с эталонами своих вариантов | | `check-type-maps.mjs` | карты типов метаданных согласованы с таблицей `ChildObjects` из `docs/1c-configuration-spec.md` | | `check-format-versions.mjs` | проверенный диапазон версий формата: границы одинаковы во всех навыках и сходятся с лестницей из `docs/1c-configuration-spec.md` | +| `check-form-purposes.mjs` | таблица назначений форм в `form-add`: состав видов, свойство «основная форма» и оба порта сходятся с `docs/1c-form-spec.md` | | `check-positional-binding.mjs` | read-only навыки (`*-info` / `*-validate` / `cfe-diff`): позиционным остаётся только путь ко входу, лишний позиционный аргумент не перезаписывает указанный файл | | `check-agent-portability.mjs` | исходники навыков не привязаны к конкретному AI-агенту: единственная разрешённая форма — плейсхолдер `${CLAUDE_SKILL_DIR}/`, который разворачивает `scripts/switch.py` | diff --git a/tests/skills/check-all.mjs b/tests/skills/check-all.mjs index 2a5d823b5..e9e16bcf0 100644 --- a/tests/skills/check-all.mjs +++ b/tests/skills/check-all.mjs @@ -14,6 +14,7 @@ const GUARDS = [ ['check-inline-drift.mjs', 'общие inline-реализации: копии совпадают с эталонами'], ['check-type-maps.mjs', 'карты типов метаданных: согласованы со спецификацией'], ['check-format-versions.mjs', 'проверенный диапазон версий формата: согласован со спецификацией'], + ['check-form-purposes.mjs', 'назначения форм в form-add: согласованы со спецификацией и между портами'], ['check-positional-binding.mjs', 'read-only навыки: позиционным остаётся только путь ко входу'], ['check-agent-portability.mjs', 'исходники навыков: без привязки к конкретному AI-агенту'], ]; diff --git a/tests/skills/check-form-purposes.mjs b/tests/skills/check-form-purposes.mjs new file mode 100644 index 000000000..671febed0 --- /dev/null +++ b/tests/skills/check-form-purposes.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +// Анти-дрейф таблицы назначений форм в form-add. +// +// Знание «какие формы бывают у вида объекта» раньше было размазано по четырём независимым +// спискам внутри form-add (supportedTypes, objectLikeTypes, processorLikeTypes, attrTypeMap) плюс +// switch по свойствам DefaultForm. Списки разошлись молча: DocumentJournal попал в поддерживаемые, +// но не в карту типов, и в форму уходило `cfg:.Журнал` — платформа такую выгрузку не принимает +// («Исключение XDTO при чтении файла»), а навык рапортовал успех. +// +// Эталон — таблицы «Свойства DefaultForm по типам объектов» и «Главный реквизит формы по +// назначению» из docs/1c-form-spec.md. Берём документацию, а не отдельный JSON: тогда спека и код +// не расходятся молча. +// +// Держит три инварианта: +// 1. Каждый вид из таблицы видов form-add объявлен в спецификации, и наоборот. +// 2. Свойство «основная форма» у назначения совпадает со спекой (журналу — DefaultForm, а не +// DefaultListForm, которого у него нет). +// 3. Таблицы PS и PY совпадают между собой: вид, набор назначений, тип главного реквизита, слот. +// +// Запуск: node tests/skills/check-form-purposes.mjs [--list] +// Выход 1 при ERROR. +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-form-spec.md'); + +const errors = []; +const listMode = process.argv.includes('--list'); + +function read(path) { + return existsSync(path) ? readFileSync(path, 'utf8') : null; +} + +// ─── Эталон: таблица слотов из спецификации ───────────────────────────────── +// Строки вида: | DocumentJournal | DefaultForm | +function parseSpecSlots(text) { + const map = new Map(); + const section = text.split(/^#### Свойства DefaultForm по типам объектов$/m)[1]; + if (!section) { + errors.push('spec: не найдена таблица «Свойства DefaultForm по типам объектов»'); + return map; + } + const body = section.split(/^#### /m)[0]; + for (const line of body.split('\n')) { + const m = /^\|\s*([A-Za-z*]+)\s*\|\s*([^|]+?)\s*\|/.exec(line); + if (!m) continue; + const kind = m[1]; + if (kind === 'Тип' || /^-+$/.test(kind)) continue; + const slots = m[2].split(',').map(s => s.trim()).filter(s => /^Default\w+$/.test(s)); + map.set(kind, new Set(slots)); + } + return map; +} + +// ─── Таблица видов из скрипта навыка ──────────────────────────────────────── +// Таблица плоская по устройству: одна строка на (вид, назначение). Разбор поэтому тривиален — +// если он вдруг усложняется, это признак, что таблицу в навыке перестали писать явно. +function parseSkillTable(text, port) { + const isPs = port === 'ps'; + const table = new Map(); + const tableRe = isPs + ? /^\$formKinds\s*=\s*@\{([\s\S]*?)^\}/m + : /^ form_kinds\s*=\s*\{([\s\S]*?)^ \}/m; + const tm = tableRe.exec(text); + if (!tm) { + errors.push(`${port}: не найдена таблица видов form-add`); + return table; + } + const kindRe = isPs + ? /^ "(\w+)" = @\{\n([\s\S]*?)^ \}/gm + : /^ "(\w+)": \{\n([\s\S]*?)^ \},/gm; + const ruleRe = isPs + ? /"(\w+)"\s*=\s*@\{ MainAttr = (\$null|"[^"]*"); AttrName = (?:\$null|"[^"]*"); Slot = (\$null|"[^"]*")/g + : /"(\w+)": \{"main_attr": (None|"[^"]*"), "attr_name": (?:None|"[^"]*"),\s*"slot": (None|"[^"]*")/g; + + for (const km of tm[1].matchAll(kindRe)) { + const rules = new Map(); + ruleRe.lastIndex = 0; + for (const rm of km[2].matchAll(ruleRe)) { + const unq = (v) => (v === '$null' || v === 'None') ? null : v.slice(1, -1); + rules.set(rm[1], { mainAttr: unq(rm[2]), slot: unq(rm[3]) }); + } + if (rules.size === 0) errors.push(`${port}: у вида ${km[1]} не разобрано ни одного назначения`); + table.set(km[1], rules); + } + return table; +} + +// ─── Проверки ─────────────────────────────────────────────────────────────── +const psText = read(join(SKILLS, 'form-add', 'scripts', 'form-add.ps1')); +const pyText = read(join(SKILLS, 'form-add', 'scripts', 'form-add.py')); +const specText = read(SPEC); +if (!psText || !pyText || !specText) { + console.error('[ERROR] не найдены исходники form-add или спецификация'); + process.exit(1); +} + +const specSlots = parseSpecSlots(specText); +const psTable = parseSkillTable(psText, 'ps'); +const pyTable = parseSkillTable(pyText, 'py'); + +// 1. Состав видов: код ↔ спека. Виды без собственных форм в таблице не участвуют. +const noOwnForms = new Set(['Constant', 'CommonForm']); +for (const kind of psTable.keys()) { + if (!specSlots.has(kind)) errors.push(`spec: вид ${kind} есть в form-add, но не описан в 1c-form-spec.md`); +} +for (const kind of specSlots.keys()) { + if (noOwnForms.has(kind) || kind.includes('*')) continue; + if (!psTable.has(kind)) errors.push(`form-add: вид ${kind} описан в спецификации, но не поддержан`); +} + +// 2. Слоты: каждый непустой слот назначения обязан быть у вида в спеке. +for (const [kind, rules] of psTable) { + const allowed = specSlots.get(kind); + if (!allowed) continue; + for (const [purpose, rule] of rules) { + if (!rule.slot) continue; + if (!allowed.has(rule.slot)) { + errors.push(`form-add: ${kind}/${purpose} пишет ${rule.slot}, которого у вида нет по спецификации ` + + `(есть: ${[...allowed].join(', ') || 'нет'})`); + } + } +} + +// 3. Паритет портов. +for (const kind of new Set([...psTable.keys(), ...pyTable.keys()])) { + const a = psTable.get(kind); + const b = pyTable.get(kind); + if (!a || !b) { errors.push(`паритет: вид ${kind} есть только в ${a ? 'PS' : 'PY'}`); continue; } + const purposes = new Set([...a.keys(), ...b.keys()]); + for (const p of purposes) { + const ra = a.get(p); + const rb = b.get(p); + if (!ra || !rb) { errors.push(`паритет: ${kind}/${p} есть только в ${ra ? 'PS' : 'PY'}`); continue; } + if ((ra.slot || null) !== (rb.slot || null)) { + errors.push(`паритет: ${kind}/${p} слот PS=${ra.slot} PY=${rb.slot}`); + } + const norm = (v) => (v || '').replace(/\{0\}|\{1\}/g, ''); + if (norm(ra.mainAttr) !== norm(rb.mainAttr)) { + errors.push(`паритет: ${kind}/${p} главный реквизит PS=${ra.mainAttr} PY=${rb.mainAttr}`); + } + } +} + +if (listMode) { + console.log('Таблица назначений form-add (PS):'); + for (const kind of [...psTable.keys()].sort()) { + const rules = psTable.get(kind); + const parts = [...rules.entries()].sort().map(([p, r]) => `${p}→${r.slot || '—'}`); + console.log(` ${kind.padEnd(28)} ${parts.join(', ')}`); + } + console.log(''); +} + +if (errors.length) { + for (const e of errors) console.error(`[ERROR] ${e}`); + console.error(`\ncheck-form-purposes: ${errors.length} расхождений`); + process.exit(1); +} +console.log(`check-form-purposes: OK (${psTable.size} видов, спека и оба порта сходятся)`);