Files
cc-1c-skills/tests/skills/check-positional-binding.mjs
T
0442aa015a fix(tests,web-test): не отдавать rmSync/cpSync пути с не-ASCII символами
На Windows fs.rmSync и fs.cpSync молча не делают ничего, когда не-ASCII символы
есть в самом аргументе пути (nodejs/node#61067). Для аудитории проекта это боевой
сценарий: кириллическое имя пользователя даёт кириллический %TEMP%, где раннер
создаёт воркспейсы, плюс кириллические имена объектов 1С в путях внутри кейсов.

Замерено на восьми сборках. Затронуты 22.18.0–22.23.2 (последняя LTS Jod) и
24.12.0–24.14.0; исправны 22.15.1, 24.15.0+, 25.x, 26.x. Фикс приехал в fs-слой
Node 24.15 и в ветку 22.x не бэкпортирован, поэтому «обновить Node» вопрос не
закрывает. Зависимость не монотонна по версиям (24.14 чинит rmSync, но не
cpSync) — гард по номеру версии невозможен, решает только сам путь.

Симптомы: rmSync молча ничего не удаляет; cpSync с не-ASCII приёмником молча
ничего не копирует; cpSync с не-ASCII источником валит процесс нативно
(0xC0000409) мимо try/catch. Не затронуты mkdirSync, readdirSync, lstatSync,
copyFileSync (включая перезапись), unlinkSync, rmdirSync — на них стоит обход.

Что ломалось: под кириллическим %TEMP% фикстуры не доезжали до воркспейсов
(meta-info — 15 ложных падений из 26, один кейс ложно-зелёный на пустом
воркспейсе); cfe-validate/module-state-flag-without-file был красным даже при
ASCII %TEMP% (кириллица в самом deletePath); --update-snapshots молча не сносил
старый эталон, то есть портил коммитимые артефакты.

Реализация — единый fsutil в двух побайтно одинаковых копиях (tests/common/ и
внутри автономного навыка web-test), раскатанный на все 42 точки вызова в 11
файлах: tests/skills/*, tests/web-test/*, hooks/test/run.mjs, движок web-test.
Предикат судит по resolve(p), а не по строке аргумента: относительный
ASCII-аргумент при не-ASCII cwd платформа роняет так же молча. Ретраи доживают
до ручного обхода, перезапись как у cpSync с force: true, настоящие ошибки не
глотаются, выживший после удаления путь — громкая ошибка.

Гард tests/skills/check-nonascii-fs.mjs в check-all.mjs проверяет хелпер, а не
платформу (поэтому зелёный и на исправной Node), сверяет хеши обеих копий и
печатает справкой состояние текущей сборки.

Co-Authored-By: androman.pro <5669019+andromanpro@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:27:02 +03:00

183 lines
11 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 } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
// fs.rmSync/fs.cpSync напрямую не зовём: на Windows они молча ничего не делают,
// когда в пути есть не-ASCII символы. Подробности и таблица сборок — в самом модуле.
import { removePathSync } from '../common/fsutil.mjs';
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 {
removePathSync(work);
}
// --- Итог ---
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('у пишущих позиционных параметров нет вовсе.');