mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-28 22:19:41 +03:00
На 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>
183 lines
11 KiB
JavaScript
183 lines
11 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() открывала дыру заново.
|
||
// Снапшот-тесты этого не видят: они сверяют вывод, а не связывание аргументов.
|
||
//
|
||
// Две области. 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('у пишущих позиционных параметров нет вовсе.');
|