Files
cc-1c-skills/tests/skills/check-positional-binding.mjs
T
Nick ShirokovandClaude Opus 5 0a0322faab fix(skills): пишущие навыки — запрет позиционного связывания параметров
Значение с пробелом без кавычек разрывалось на токены, и лишние молча растекались по свободным
позиционным параметрам. На документированной форме вызова (powershell -NoProfile -File):

  form-compile.ps1 -JsonPath a.json -OutputPath b.xml -Purpose Форма списка
  → Purpose=[Форма] ObjectPath=[списка], ошибки НЕТ

Навык отрабатывал успешно с усечённым значением; реальный триггер — путь с пробелом в -EmitDsl
или -ObjectPath, результат уезжал не туда молча. У навыков с парой -DefinitionFile/-Operation
осколок уезжал в -DefinitionFile, и навык жаловался на параметр, которого в команде не было.

Расхождение портов: py на том же вызове отвечает «unrecognized arguments: списка» — там все
аргументы объявлены опциями. Правка выравнивает PS по py, python не менялся.

41 навык получает [CmdletBinding(PositionalBinding=$false)] и ни одного позиционного параметра.
Без Position=0 (в отличие от read-only навыков): «главный» параметр механически не выводится —
у meta-edit первым объявлен -DefinitionFile, а путь к объекту вторым, — а ошибка выбора тестами
не ловится, раннер передаёт только именованные флаги. Позиционной формы вызова нет ни в одной из
140 строк SKILL.md, внутренние вызовы навыков друг из друга тоже именованные.

check-positional-binding.mjs расширен на пишущие навыки статической проверкой. Поведенческая
(канареечный файл) остаётся только у read-only: у web-stop и db-run все параметры
Mandatory=$false, связывание прошло бы и тело выполнилось — гард остановил бы Apache и запустил 1С.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:24:58 +03:00

180 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Инвариант: анализирующий навык (*-info, *-validate, cfe-diff) не пишет в файл, который ему
// не назвали по имени. Позиционным остаётся только путь ко входу.
//
// Без этого лишний позиционный аргумент связывается со следующим параметром по порядку
// объявления. У role-validate им был -OutFile, и вызов вида
// role-validate.ps1 "Roles/Роль" "Roles/Роль.xml"
// перезаписывал XML роли текстом валидационного отчёта. Остальных скриптов семьи спасала
// случайность типов ([int]MaxErrors/Limit, строка с [ValidateSet] — падают на конвертации),
// то есть любая перестановка параметров в param() открывала дыру заново.
// Снапшот-тесты этого не видят: они сверяют вывод, а не связывание аргументов.
//
// Две области. Read-only навыки: позиционным остаётся путь ко входу (Position=0), не более
// одного. Пишущие навыки: позиционных параметров нет вовсе — «главный» параметр там не
// выводится механически (у meta-edit первым объявлен -DefinitionFile, а путь к объекту вторым),
// и ошибка выбора не ловится тестами: раннер передаёт только именованные флаги. Молчаливое
// растекание там уже стреляло: `-Purpose Форма списка` у form-compile отдавало
// Purpose=[Форма] ObjectPath=[списка] БЕЗ ошибки, тогда как py-порт тот же вызов отвергал.
//
// Статическая проверка: [CmdletBinding(PositionalBinding=$false)] объявлен, число Position
// не выше нормы области; в py-порте все add_argument именованные.
// Поведенческая: лишний позиционный аргумент, указывающий на канареечный файл, роняет вызов
// и файл остаётся байт-в-байт. Валидные фикстуры не нужны — связывание параметров происходит
// до тела скрипта. Только для read-only навыков: у web-stop и db-run все параметры
// Mandatory=$false, связывание пройдёт и ТЕЛО ВЫПОЛНИТСЯ — гард остановил бы Apache и запустил
// 1С. Выход 1 при нарушении. Запуск: node tests/skills/check-positional-binding.mjs [--runtime python]
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const SKILLS_DIR = join(ROOT, '.claude', 'skills');
const IS_WIN = process.platform === 'win32';
// PowerShell вне Windows не исполняется — это природа платформы, а не пробел в покрытии
// (см. debug/macmini-testing.md).
const requested = process.argv.includes('--runtime')
? [process.argv[process.argv.indexOf('--runtime') + 1] === 'python' ? 'python' : 'powershell']
: ['powershell', 'python'];
const runtimes = requested.filter(rt => rt !== 'powershell' || IS_WIN);
if (requested.includes('powershell') && !IS_WIN) {
console.log(`[powershell] пропущен: PowerShell не исполняется на ${process.platform}`);
}
if (runtimes.length === 0) {
console.log('Нечего проверять: запрошен только powershell, а он на этой ОС не исполняется.');
process.exit(1);
}
const PY = process.env.PYTHON || (IS_WIN ? 'python' : 'python3');
// Семья read-only навыков определяется по имени, а не списком: новый *-info/*-validate
// попадает под гард сам, без правки этого файла.
const isReadOnly = name => /-(info|validate)$/.test(name) || name === 'cfe-diff';
const skills = readdirSync(SKILLS_DIR)
.filter(isReadOnly)
.filter(name => existsSync(join(SKILLS_DIR, name, 'scripts', `${name}.ps1`)))
.sort();
if (skills.length === 0) {
console.error('Не найдено ни одного read-only навыка со скриптом — гард потерял цель.');
process.exit(1);
}
// Пишущие навыки — всё остальное со скриптом. Список тоже по имени, чтобы новый навык
// попадал под гард сам.
const writeSkills = readdirSync(SKILLS_DIR)
.filter(name => !isReadOnly(name))
.filter(name => existsSync(join(SKILLS_DIR, name, 'scripts', `${name}.ps1`)))
.sort();
if (writeSkills.length === 0) {
console.error('Не найдено ни одного пишущего навыка со скриптом — гард потерял цель.');
process.exit(1);
}
const violations = [];
const fail = (skill, msg) => violations.push(`${skill}: ${msg}`);
// --- 1. Статическая проверка объявлений ---
function paramBlock(text) {
const start = text.indexOf('\nparam(\n');
if (start < 0) return null;
const end = text.indexOf('\n)\n', start);
return end < 0 ? null : text.slice(start, end);
}
function checkStatic(skill, allowPositional) {
const ps1Path = join(SKILLS_DIR, skill, 'scripts', `${skill}.ps1`);
const ps1 = readFileSync(ps1Path, 'utf8');
if (!/\[CmdletBinding\(\s*PositionalBinding\s*=\s*\$false\s*\)\]\s*\nparam\(/.test(ps1)) {
fail(skill, '.ps1: нет [CmdletBinding(PositionalBinding=$false)] перед param()');
}
const block = paramBlock(ps1);
if (block === null) {
fail(skill, '.ps1: не разобран блок param()');
} else {
const positions = [...block.matchAll(/Position\s*=\s*(\d+)/g)].map(m => m[1]);
if (!allowPositional && positions.length > 0) {
fail(skill, `.ps1: у пишущего навыка не должно быть позиционных параметров (Position=${positions.join(', ')})`);
} else if (allowPositional && positions.length > 1) {
fail(skill, `.ps1: позиционных параметров больше одного (Position=${positions.join(', ')})`);
} else if (allowPositional && positions.length === 1 && positions[0] !== '0') {
fail(skill, `.ps1: единственный позиционный параметр должен быть Position=0, а не Position=${positions[0]}`);
}
}
const pyPath = join(SKILLS_DIR, skill, 'scripts', `${skill}.py`);
if (existsSync(pyPath)) {
const py = readFileSync(pyPath, 'utf8');
for (const m of py.matchAll(/add_argument\(\s*(['"])([^'"]+)\1/g)) {
if (!m[2].startsWith('-')) {
fail(skill, `.py: позиционный аргумент argparse '${m[2]}' — все параметры должны быть именованными`);
}
}
}
}
for (const skill of skills) checkStatic(skill, true);
for (const skill of writeSkills) checkStatic(skill, false);
// --- 2. Поведенческая проверка (только read-only, см. шапку): лишний позиционный аргумент не трогает файл ---
const CANARY = 'канарейка: этот файл не должен быть перезаписан отчётом навыка\n';
const work = mkdtempSync(join(tmpdir(), 'posbind-'));
try {
for (const runtime of runtimes) {
for (const skill of skills) {
const ext = runtime === 'python' ? '.py' : '.ps1';
const script = join(SKILLS_DIR, skill, 'scripts', `${skill}${ext}`);
if (!existsSync(script)) continue;
const canary = join(work, `${skill}-${runtime}.canary`);
writeFileSync(canary, CANARY, 'utf8');
// Первый аргумент — заведомо несуществующий вход, второй — лишний: связывание
// параметров происходит до тела скрипта, поэтому до чтения входа дело не доходит.
const bogus = join(work, 'нет-такого-пути');
const [cmd, argv] = runtime === 'python'
? [PY, [script, bogus, canary]]
: ['powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, bogus, canary]];
const r = spawnSync(cmd, argv, { cwd: work, encoding: 'utf8' });
// Не запустившийся интерпретатор даёт status: null — «код возврата ненулевой» прошло бы
// вакуумно, и гард молча зеленел бы, ничего не проверив.
if (r.error) {
fail(skill, `[${runtime}] не удалось запустить ${cmd}: ${r.error.message}`);
continue;
}
if (r.status === 0) {
fail(skill, `[${runtime}] лишний позиционный аргумент принят без ошибки`);
}
if (readFileSync(canary, 'utf8') !== CANARY) {
fail(skill, `[${runtime}] лишний позиционный аргумент ПЕРЕЗАПИСАЛ указанный файл`);
}
}
}
} finally {
rmSync(work, { recursive: true, force: true });
}
// --- Итог ---
if (violations.length) {
console.error(`Нарушений: ${violations.length}\n`);
for (const v of violations) console.error(` ${v}`);
process.exit(1);
}
console.log(`OK — read-only: ${skills.length}, пишущих: ${writeSkills.length}, рантаймы: ${runtimes.join(', ')}.`);
console.log('У read-only позиционным остаётся только путь ко входу (и лишний аргумент падает, файл цел);');
console.log('у пишущих позиционных параметров нет вовсе.');