test(skills): гард проверенного диапазона версий формата

Допустимый список версий был независимым литералом в десяти файлах, и сверять его
было не с чем. Именно так волна 2.21 прошла по четырём валидаторам и молча обошла
пятый — form-validate остался на 2.17–2.20 (issue #63).

check-format-versions.mjs держит три инварианта: границы диапазона одинаковы во всех
навыках и на обоих портах; дефолт -FormatVersion у *-init лежит внутри диапазона;
верхняя граница совпадает с последней ЗАМЕРЕННОЙ ступенью таблицы §7.1 из
1c-configuration-spec.md — так расхождение спеки и кода падает здесь, а не на чужой
выгрузке. Отдельно ловится возврат ValidateSet/choices в *-init.

Get-FormatRank разъехался бы по восьми новым копиям — они внесены в реестр семьи
format_rank в check-inline-drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-13 14:21:07 +03:00
co-authored by Claude Opus 5
parent beaa75c742
commit 06f21ab3d1
3 changed files with 215 additions and 4 deletions
+1
View File
@@ -13,6 +13,7 @@ const GUARDS = [
['check-uuid-invariant.mjs', 'сохранение uuid объекта и сущностей при правке'],
['check-inline-drift.mjs', 'общие inline-реализации: копии совпадают с эталонами'],
['check-type-maps.mjs', 'карты типов метаданных: согласованы со спецификацией'],
['check-format-versions.mjs', 'проверенный диапазон версий формата: согласован со спецификацией'],
];
let failed = 0;
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env node
// Анти-дрейф проверенного диапазона версий формата выгрузки.
//
// Навыки автономны, и допустимый диапазон версий раньше был независимым литеральным списком в
// каждом валидаторе. Сверять его было не с чем — поэтому волна 2.21 прошла по четырём валидаторам
// и молча обошла пятый: form-validate остался на 2.17-2.20 и ругался на форму, которую сам же
// создавал через epf-init 2.21 → form-add (issue #63).
//
// Эталон границ — таблица «Лестница версий» из docs/1c-configuration-spec.md (§7.1). Берём
// документацию, а не отдельный JSON: тогда спека и код не расходятся молча.
//
// Держит три инварианта:
// 1. Границы диапазона одинаковы во всех навыках-потребителях и на обоих портах.
// 2. Дефолт -FormatVersion у *-init лежит внутри диапазона.
// 3. Верхняя граница = последняя ЗАМЕРЕННАЯ ступень лестницы: добавили платформу в спеку и
// забыли поднять код (или наоборот) — падаем здесь, а не на чужой выгрузке.
//
// Запуск: node tests/skills/check-format-versions.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-configuration-spec.md');
// Навыки, объявляющие проверенный диапазон. file — базовое имя скрипта в scripts/.
const RANGE_CONSUMERS = [
{ skill: 'cf-validate', file: 'cf-validate' },
{ skill: 'cfe-validate', file: 'cfe-validate' },
{ skill: 'epf-validate', file: 'epf-validate' },
{ skill: 'form-validate', file: 'form-validate' },
{ skill: 'meta-validate', file: 'meta-validate' },
{ skill: 'cf-init', file: 'cf-init', hasDefault: true },
{ skill: 'epf-init', file: 'init', hasDefault: true },
{ skill: 'erf-init', file: 'init', hasDefault: true },
];
const errors = [];
const listMode = process.argv.includes('--list');
function read(path) {
return existsSync(path) ? readFileSync(path, 'utf8') : null;
}
function rank(ver) {
const m = /^(\d+)\.(\d+)$/.exec(ver || '');
return m ? Number(m[1]) * 100 + Number(m[2]) : 0;
}
// ─── Эталон: лестница из спецификации ───────────────────────────────────────
// Строки вида: | 8.3.24 | `2.17` | да |
function parseLadder(text) {
const rows = [];
const section = text.split(/^### 7\.1\./m)[1];
if (!section) return rows;
const body = section.split(/^###? /m)[0];
for (const line of body.split('\n')) {
const m = /^\|\s*([\d.]+)\s*\|\s*`?(\d+\.\d+)`?\s*\|\s*([^|]+?)\s*\|/.exec(line);
if (m) rows.push({ platform: m[1], version: m[2], measured: m[3].trim() });
}
return rows;
}
const specText = read(SPEC);
if (!specText) {
console.error(`ERROR: спецификация не найдена: ${SPEC}`);
process.exit(1);
}
const ladder = parseLadder(specText);
if (ladder.length === 0) {
errors.push(`Таблица лестницы версий не разобрана из ${SPEC} (§7.1) — изменился формат таблицы?`);
}
// Ступени, помеченные как незамеренные, эталоном верхней границы быть не могут.
const measured = ladder.filter((r) => r.measured.toLowerCase() !== 'нет');
const lastMeasured = measured.length ? measured[measured.length - 1] : null;
// Лестница обязана идти по возрастанию: немонотонность = ошибка в самой таблице.
for (let i = 1; i < ladder.length; i++) {
if (rank(ladder[i].version) <= rank(ladder[i - 1].version)) {
errors.push(
`Лестница в спеке немонотонна: ${ladder[i - 1].platform}${ladder[i - 1].version}, ` +
`затем ${ladder[i].platform}${ladder[i].version}`,
);
}
}
// ─── Границы в скриптах ─────────────────────────────────────────────────────
const found = [];
for (const c of RANGE_CONSUMERS) {
for (const [port, ext] of [['ps1', '.ps1'], ['py', '.py']]) {
const path = join(SKILLS, c.skill, 'scripts', c.file + ext);
const text = read(path);
if (text === null) {
errors.push(`${c.skill} (${port}): файл не найден: ${path}`);
continue;
}
const minRe = port === 'ps1' ? /\$formatVerifiedMin\s*=\s*"([\d.]+)"/ : /FORMAT_VERIFIED_MIN\s*=\s*"([\d.]+)"/;
const maxRe = port === 'ps1' ? /\$formatVerifiedMax\s*=\s*"([\d.]+)"/ : /FORMAT_VERIFIED_MAX\s*=\s*"([\d.]+)"/;
const min = minRe.exec(text);
const max = maxRe.exec(text);
if (!min || !max) {
errors.push(
`${c.skill} (${port}): не найдены границы проверенного диапазона ` +
`(${port === 'ps1' ? '$formatVerifiedMin/$formatVerifiedMax' : 'FORMAT_VERIFIED_MIN/MAX'})`,
);
continue;
}
const entry = { skill: c.skill, port, min: min[1], max: max[1], def: null };
if (c.hasDefault) {
const defRe = port === 'ps1'
? /\[string\]\$FormatVersion\s*=\s*"([\d.]+)"/
: /'-FormatVersion',\s*dest='FormatVersion',\s*default='([\d.]+)'/;
const def = defRe.exec(text);
if (!def) {
errors.push(`${c.skill} (${port}): не найден дефолт -FormatVersion`);
} else {
entry.def = def[1];
}
}
// Запрет вернулся: ValidateSet/choices снова закрывают вход вместо предупреждения (issue #63).
if (c.hasDefault) {
const banned = port === 'ps1'
? /\[ValidateSet\([^)]*\)\]\s*\r?\n\s*\[string\]\$FormatVersion/
: /'-FormatVersion'[^)]*choices\s*=/s;
if (banned.test(text)) {
errors.push(
`${c.skill} (${port}): -FormatVersion снова ограничен списком. Версии вне проверенного ` +
`диапазона реальны — их место в предупреждении, а не в запрете на входе (issue #63)`,
);
}
}
found.push(entry);
}
}
// ─── Инвариант 1: границы совпадают у всех ──────────────────────────────────
const mins = new Set(found.map((f) => f.min));
const maxs = new Set(found.map((f) => f.max));
if (mins.size > 1) {
errors.push(
`Нижняя граница разъехалась: ` +
found.map((f) => `${f.skill}/${f.port}=${f.min}`).join(', '),
);
}
if (maxs.size > 1) {
errors.push(
`Верхняя граница разъехалась: ` +
found.map((f) => `${f.skill}/${f.port}=${f.max}`).join(', '),
);
}
const codeMin = found.length ? found[0].min : null;
const codeMax = found.length ? found[0].max : null;
if (codeMin && codeMax && rank(codeMin) > rank(codeMax)) {
errors.push(`Диапазон вывернут: min=${codeMin} > max=${codeMax}`);
}
// ─── Инвариант 2: дефолты внутри диапазона ──────────────────────────────────
for (const f of found) {
if (!f.def) continue;
if (rank(f.def) < rank(codeMin) || rank(f.def) > rank(codeMax)) {
errors.push(
`${f.skill} (${f.port}): дефолт -FormatVersion=${f.def} вне проверенного диапазона ${codeMin}-${codeMax}`,
);
}
}
// ─── Инвариант 3: границы сходятся с лестницей ──────────────────────────────
if (codeMax && lastMeasured && codeMax !== lastMeasured.version) {
errors.push(
`Верхняя граница в коде (${codeMax}) не совпадает с последней замеренной ступенью лестницы ` +
`(${lastMeasured.version}, платформа ${lastMeasured.platform}). Либо в спеку добавили платформу ` +
`и забыли поднять диапазон в навыках, либо наоборот.`,
);
}
if (codeMin && ladder.length && !ladder.some((r) => r.version === codeMin)) {
errors.push(`Нижняя граница в коде (${codeMin}) отсутствует в лестнице спецификации`);
}
// ─── Вывод ──────────────────────────────────────────────────────────────────
if (listMode) {
console.log('Лестница версий (docs/1c-configuration-spec.md §7.1):');
for (const r of ladder) {
console.log(` ${r.platform.padEnd(8)}${r.version} замерено: ${r.measured}`);
}
console.log('\nПроверенный диапазон в навыках:');
for (const f of found) {
console.log(` ${f.skill.padEnd(14)} ${f.port.padEnd(4)} ${f.min}-${f.max}${f.def ? ` default=${f.def}` : ''}`);
}
console.log('');
}
if (errors.length) {
for (const e of errors) console.error(`ERROR: ${e}`);
console.error(`\n${errors.length} ошибок.`);
process.exit(1);
}
console.log(
`OK — проверенный диапазон ${codeMin}-${codeMax} согласован: ` +
`${found.length} файлов, лестница ${ladder.length} ступеней (последняя замеренная — ${lastMeasured?.version}).`,
);
+4 -4
View File
@@ -102,10 +102,10 @@ const FAMILIES = [
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'] },
consumers: ['cf-init', 'cf-validate', 'cfe-borrow', 'cfe-init', 'cfe-validate', 'epf-build',
'epf-init', 'epf-validate', 'erf-init', 'form-add', 'form-compile', 'form-validate',
'meta-validate', 'mxl-compile', 'role-compile', 'subsystem-compile', 'subsystem-edit',
'template-add', 'xdto-compile'] },
],
},