test(skills): свести verify-snapshots с runner по разбору кейса

Верификатор читает тот же DSL, что и функциональный раннер, но своей
реализацией, и отставал на пять ключей: preRun[].cwd, inputFrom, cwd на уровне
кейса, раскрытие {workDir} в args_extra и маппинг from: outputPath. Кейсы,
опирающиеся на них, до платформы не доезжали вовсе — падали на подготовке
фикстуры, причём шаг preRun с относительным путём писал её в корень репозитория.

Незнакомый from теперь роняет кейс с внятным сообщением: молчаливый default
маскировал расхождение под дефект навыка (флаг уходил без значения).

Платформенная верификация: mxl-compile 28/36 -> 36/36, mxl-decompile 0/7 -> 7/7,
mxl-info 2/7 -> 7/7, mxl-validate 0/4 -> 4/4, skd-decompile 0/17 -> 17/17.

Оба ключа, которых не было в документации DSL, дописаны в README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-12 14:40:18 +03:00
co-authored by Claude Opus 5
parent 241a56a29f
commit 37108b15c4
2 changed files with 33 additions and 4 deletions
+11
View File
@@ -260,6 +260,7 @@ ibcmd-проход автоматически `○ skipped`, если рядом
| `outputPath` | нет | Относительный путь для навыков с `-OutputPath` |
| `args_extra` | нет | Массив дополнительных CLI-аргументов |
| `preRun` | нет | Массив шагов подготовки (см. ниже) |
| `inputFrom` | нет | Имя файла в `workDir` — вход берётся из него, а не из `input`. Нужно, когда вход производит `preRun` (напр. декомпилятор): `input` пишется ПОСЛЕ preRun и затёр бы его |
| `expect` | нет | Дополнительные проверки (см. ниже) |
| `expectError` | нет | `true` или строка — ожидается ошибка |
| `noSnapshot` | нет | Непустая строка с причиной — кейс объявляет, что эталон не нужен (см. «Эталоны») |
@@ -309,6 +310,16 @@ ibcmd-проход автоматически `○ skipped`, если рядом
| `{ "script": "<навык>/scripts/<файл>", "input": {...}, "args": { "-Flag": "{inputFile}" } }` | Прогон другого навыка для подготовки фикстуры. Плейсхолдеры: `{inputFile}`, `{workDir}` |
| `{ "writeFile": { "path": "<путь>", "content": "<строка или объект>" } }` | Записать произвольный файл в `workDir` (объект сериализуется в JSON) |
Дополнительно у шага-скрипта: `"cwd": "{workDir}"` — запустить шаг из рабочего каталога. Нужно,
когда относительный путь в `args` (напр. `-OutputPath: "Template.xml"`) должен лечь в фикстуру, а
не в текущий каталог процесса. Записи различаются: у шага — `{workDir}` в скобках, у кейса —
`"cwd": "workDir"` без них.
Оба раннера (`runner.mjs` и `verify-snapshots.mjs`) читают этот DSL **каждый своей реализацией**.
Добавляя ключ, добавляй его в оба и в эту таблицу: недокументированный ключ, который понимает
только один раннер, даёт тихую дыру — кейс зелёный в функциональном прогоне и не доезжает до
платформы в верификации.
## Эталоны (snapshots)
Эталон — директория `snapshots/<имя-кейса>/` внутри папки навыка. Содержит ожидаемый выход навыка после нормализации.
+22 -4
View File
@@ -563,11 +563,17 @@ function buildSkillArgs(skillConfig, caseData, workDir, inputFile, runtime) {
args.push(mapping.flag);
switch (mapping.from) {
case 'inputFile':
args.push(inputFile || '');
// inputFrom: вход берётся из файла в workDir, а не из case.input. Нужно, когда вход
// производит preRun (например, декомпилятор) — case.input пишется ПОСЛЕ preRun и
// затёр бы его. Как в runner.mjs.
args.push(caseData.inputFrom ? join(workDir, caseData.inputFrom) : (inputFile || ''));
break;
case 'workDir':
args.push(workDir);
break;
case 'outputPath':
args.push(join(workDir, caseData.outputPath || ''));
break;
case 'workPath': {
const field = mapping.field || 'objectPath';
const val = caseData.params?.[field] ?? caseData[field];
@@ -592,10 +598,19 @@ function buildSkillArgs(skillConfig, caseData, workDir, inputFile, runtime) {
args.push(String(caseData.params?.[field] ?? caseData[field] ?? ''));
} else if (mapping.from === 'literal') {
args.push(mapping.value || '');
} else {
// Незнакомый from раньше молча не давал значения — флаг уходил без аргумента, и
// это выглядело как дефект навыка. DSL читают два раннера, поэтому расхождение
// должно быть громким.
throw new Error(`_skill.json: неизвестный "from": "${mapping.from}" у флага ${mapping.flag}`
+ ' — verify-snapshots.mjs не знает этого маппинга (см. buildSkillArgs)');
}
}
}
if (caseData.args_extra) args.push(...caseData.args_extra);
// Плейсхолдер {workDir} раскрывается и в args_extra — как в runner.mjs.
if (caseData.args_extra) {
args.push(...caseData.args_extra.map(a => typeof a === 'string' ? a.replace('{workDir}', workDir) : a));
}
return { scriptPath, args };
}
@@ -631,7 +646,10 @@ function runPreSteps(preRun, workDir, runtime, log) {
}
const stepName = step.script.split('/').pop();
try {
execSkill(runtime, step.script, preArgs);
// cwd: "{workDir}" — шаг запускается из рабочего каталога, чтобы относительные
// пути в его args (напр. -OutputPath Template.xml) легли в фикстуру, а не в репозиторий.
const preCwd = step.cwd === '{workDir}' ? workDir : REPO_ROOT;
execSkill(runtime, step.script, preArgs, 60_000, preCwd);
log(`preRun: ${stepName}`, true);
} catch (e) {
log(`preRun: ${stepName}`, false, e.stderr || e.message);
@@ -875,7 +893,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
try {
const { args } = buildSkillArgs(skillConfig, caseData, workDir, inputFile, opts.runtime);
const mainCwd = skillConfig.cwd === 'workDir' ? workDir : REPO_ROOT;
const mainCwd = (caseData.cwd || skillConfig.cwd) === 'workDir' ? workDir : REPO_ROOT;
const output = execSkill(opts.runtime, skillConfig.script, args, 60_000, mainCwd);
const lastLine = output.trim().split('\n').pop();
if (caseData.expectError) {