mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-21 02:29:42 +03:00
Тот же класс дефекта, что закрыт в role-validate, был возможен во всей семье *-info / *-validate / cfe-diff: лишний позиционный аргумент связывается со следующим параметром по порядку объявления. Остальные скрипты спасала случайность типов — на втором слоте оказался [int]MaxErrors/Limit или строка с [ValidateSet], которые падают на конвертации. Любая перестановка параметров в param() открывала дыру заново, а снапшот-тесты сверяют вывод, а не связывание. Во всех 21 скрипте объявлен [CmdletBinding(PositionalBinding=$false)], входной путь помечен Position=0. Инвариант: анализирующий навык не пишет в файл, который ему не назвали по имени. Документированные вызовы в репозитории все именованные, поведение навыков не меняется — 835 кейсов зелёные на обоих рантаймах без дрейфа снэпшотов. Гард check-positional-binding.mjs держит инвариант статически (объявление и единственный Position=0; в py-порте все add_argument именованные) и поведенчески (лишний позиционный аргумент роняет вызов, канареечный файл остаётся цел). Семья определяется по имени навыка, поэтому новый *-info/*-validate попадает под гард сам. На состоянии до правки гард падает на role-validate обеими проверками. Версии обоих портов подняты синхронно; из шапок убраны чужие хвосты. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
147 lines
7.7 KiB
JavaScript
147 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
||
// Инвариант: анализирующий навык (*-info, *-validate, cfe-diff) не пишет в файл, который ему
|
||
// не назвали по имени. Позиционным остаётся только путь ко входу.
|
||
//
|
||
// Без этого лишний позиционный аргумент связывается со следующим параметром по порядку
|
||
// объявления. У role-validate им был -OutFile, и вызов вида
|
||
// role-validate.ps1 "Roles/Роль" "Roles/Роль.xml"
|
||
// перезаписывал XML роли текстом валидационного отчёта. Остальных скриптов семьи спасала
|
||
// случайность типов ([int]MaxErrors/Limit, строка с [ValidateSet] — падают на конвертации),
|
||
// то есть любая перестановка параметров в param() открывала дыру заново.
|
||
// Снапшот-тесты этого не видят: они сверяют вывод, а не связывание аргументов.
|
||
//
|
||
// Две проверки. Статическая: [CmdletBinding(PositionalBinding=$false)] объявлен, позиционный
|
||
// параметр не более одного и это Position=0; в py-порте все add_argument именованные.
|
||
// Поведенческая: лишний позиционный аргумент, указывающий на канареечный файл, роняет вызов
|
||
// и файл остаётся байт-в-байт. Валидные фикстуры не нужны — связывание параметров происходит
|
||
// до тела скрипта. Выход 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 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);
|
||
}
|
||
|
||
for (const skill of skills) {
|
||
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 (positions.length > 1) {
|
||
fail(skill, `.ps1: позиционных параметров больше одного (Position=${positions.join(', ')})`);
|
||
} else if (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]}' — все параметры должны быть именованными`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 2. Поведенческая проверка: лишний позиционный аргумент не трогает файл ---
|
||
|
||
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' });
|
||
|
||
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 — ${skills.length} read-only навыков, рантаймы: ${runtimes.join(', ')}.`);
|
||
console.log('Позиционным остаётся только путь ко входу; лишний позиционный аргумент падает и файл цел.');
|