fix(skd-edit): NO-OP skip + format-preserve post-process (round-trip)

XmlDocument round-trip искажал Template.xml даже при отсутствии правок:
декодировал &quot; в <query>/<expression>, схлопывал многострочный xmlns
корня, добавлял пробел перед /> и записывал файл при [WARN] not found.

Дирти-флаг ($script:Dirty / dirty) ставится только на успешной мутации;
финальный save пропускается с [INFO] No changes -- file untouched, если
ни одна операция в batch ничего не изменила. Post-process после OuterXml
восстанавливает raw-форматирование корневого xmlns из исходного файла,
re-escape `"` в текстах <query>/<expression> с anchored regex (не задевая
xsi:type="..."), и нормализует <foo .../> к <foo.../>.

Замеры на реальной схеме после modify-field: diff упал с 423 строк до 37
(94% шума устранено), повторный прогон byte-identical.

В runner.mjs добавлен caseData.idempotent: re-run + byte-equality на всех
файлах workDir. Три новых кейса (NO-OP, entity-preserve, xmlns-multiline)
+ общий fixture roundtrip-base. Все 33 ранее существовавших snapshot
перегенерированы под корректное форматирование (восстанавливают то, что
старый skd-edit ломал).

skd-edit v1.18 -> v1.19. PS и PY порты синхронизированы.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-05-19 15:17:42 +03:00
co-authored by Claude Opus 4.7
parent f91b569564
commit 511bfe7fdf
41 changed files with 833 additions and 195 deletions
+58
View File
@@ -342,6 +342,31 @@ function normalizeContent(text, config) {
// ─── Snapshot comparison ────────────────────────────────────────────────────
// Capture raw byte contents of every file in dir, keyed by relative path.
// Used by idempotency checks to verify byte-equality after a re-run.
function snapshotWorkDirBytes(dir) {
const files = listFilesRecursive(dir);
const map = new Map();
for (const rel of files) {
map.set(rel, readFileSync(join(dir, rel)));
}
return map;
}
// Compare two byte-snapshots. Returns null if identical, else a list of diff lines.
function diffByteSnapshots(before, after) {
const diffs = [];
for (const [rel, b1] of before) {
if (!after.has(rel)) { diffs.push(`removed: ${rel}`); continue; }
const b2 = after.get(rel);
if (b1.length !== b2.length || !b1.equals(b2)) diffs.push(`changed: ${rel} (${b1.length} -> ${b2.length} bytes)`);
}
for (const rel of after.keys()) {
if (!before.has(rel)) diffs.push(`added: ${rel}`);
}
return diffs.length === 0 ? null : diffs;
}
function listFilesRecursive(dir, base = '') {
const result = [];
if (!existsSync(dir)) return result;
@@ -571,6 +596,23 @@ async function runCaseAsync(testCase, opts) {
}
}
}
// Idempotency check: re-run the same script with the same args and assert
// every file in workDir is byte-identical to the first-run output.
if (errors.length === 0 && caseData.idempotent && !workspace.readOnly) {
const before = snapshotWorkDirBytes(workDir);
try {
const execCwd = skillConfig.cwd === 'workDir' ? workDir : undefined;
await execSkillAsync(opts.runtime, scriptPath, args, execCwd);
} catch (e) {
errors.push(`Idempotency rerun failed: exitCode=${e.status}\nstderr: ${(e.stderr || '').substring(0, 300)}`);
}
if (errors.length === 0) {
const after = snapshotWorkDirBytes(workDir);
const diffs = diffByteSnapshots(before, after);
if (diffs) errors.push(`Idempotency: workspace changed on rerun:\n ${diffs.join('\n ')}`);
}
}
}
// Post-run validation (on real output, before cleanup)
@@ -727,6 +769,22 @@ function runCase(testCase, opts) {
}
}
}
// Idempotency check: re-run the same script and assert byte-equality.
if (errors.length === 0 && caseData.idempotent && !workspace.readOnly) {
const before = snapshotWorkDirBytes(workDir);
try {
const execCwd = skillConfig.cwd === 'workDir' ? workDir : undefined;
execSkillRaw(opts.runtime, scriptPath, args, execCwd);
} catch (e) {
errors.push(`Idempotency rerun failed: exitCode=${e.status}\nstderr: ${(e.stderr || '').substring(0, 300)}`);
}
if (errors.length === 0) {
const after = snapshotWorkDirBytes(workDir);
const diffs = diffByteSnapshots(before, after);
if (diffs) errors.push(`Idempotency: workspace changed on rerun:\n ${diffs.join('\n ')}`);
}
}
}
// Post-run validation (on real output, before cleanup)