mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-14 15:33:20 +03:00
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:
co-authored by
Claude Opus 5
parent
beaa75c742
commit
06f21ab3d1
@@ -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;
|
||||
|
||||
@@ -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}).`,
|
||||
);
|
||||
@@ -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'] },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user