diff --git a/.claude/skills/cf-edit/scripts/cf-edit.py b/.claude/skills/cf-edit/scripts/cf-edit.py
index 3af0d6ec..39313f06 100644
--- a/.claude/skills/cf-edit/scripts/cf-edit.py
+++ b/.claude/skills/cf-edit/scripts/cf-edit.py
@@ -131,7 +131,9 @@ def parse_batch_value(val):
def save_xml_bom(tree, path):
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/cfe-borrow/scripts/cfe-borrow.py b/.claude/skills/cfe-borrow/scripts/cfe-borrow.py
index cec322c9..0140da1c 100644
--- a/.claude/skills/cfe-borrow/scripts/cfe-borrow.py
+++ b/.claude/skills/cfe-borrow/scripts/cfe-borrow.py
@@ -305,7 +305,9 @@ def expand_self_closing(container, parent_indent):
def save_xml_bom(tree, path):
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/epf-add-form/scripts/add-form.py b/.claude/skills/epf-add-form/scripts/add-form.py
index 2bd85909..5ea16241 100644
--- a/.claude/skills/epf-add-form/scripts/add-form.py
+++ b/.claude/skills/epf-add-form/scripts/add-form.py
@@ -15,7 +15,9 @@ NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
def save_xml_with_bom(tree, path):
"""Save XML tree to file with UTF-8 BOM."""
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/form-add/scripts/form-add.py b/.claude/skills/form-add/scripts/form-add.py
index 41efdf4c..d4aa7960 100644
--- a/.claude/skills/form-add/scripts/form-add.py
+++ b/.claude/skills/form-add/scripts/form-add.py
@@ -18,7 +18,9 @@ NSMAP = {
def save_xml_with_bom(tree, path):
"""Save XML tree to file with UTF-8 BOM."""
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/form-edit/scripts/form-edit.py b/.claude/skills/form-edit/scripts/form-edit.py
index 4aced255..94860ac7 100644
--- a/.claude/skills/form-edit/scripts/form-edit.py
+++ b/.claude/skills/form-edit/scripts/form-edit.py
@@ -1242,8 +1242,10 @@ if elem_events_list:
# ── 13. Save ────────────────────────────────────────────────
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
-# Fix encoding declaration case
-xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+# Fix XML declaration quotes
+xml_bytes = xml_bytes.replace(b"", b'')
+if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
# Write with BOM
with open(resolved_form_path, "wb") as f:
f.write(b'\xef\xbb\xbf')
diff --git a/.claude/skills/form-remove/scripts/remove-form.py b/.claude/skills/form-remove/scripts/remove-form.py
index b9354344..0bbcdbba 100644
--- a/.claude/skills/form-remove/scripts/remove-form.py
+++ b/.claude/skills/form-remove/scripts/remove-form.py
@@ -16,7 +16,9 @@ NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
def save_xml_with_bom(tree, path):
"""Save XML tree to file with UTF-8 BOM."""
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/help-add/scripts/add-help.py b/.claude/skills/help-add/scripts/add-help.py
index c441cd79..03567724 100644
--- a/.claude/skills/help-add/scripts/add-help.py
+++ b/.claude/skills/help-add/scripts/add-help.py
@@ -14,7 +14,9 @@ NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
def save_xml_with_bom(tree, path):
"""Save XML tree to file with UTF-8 BOM."""
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/interface-edit/scripts/interface-edit.py b/.claude/skills/interface-edit/scripts/interface-edit.py
index 927f213c..fbd2050f 100644
--- a/.claude/skills/interface-edit/scripts/interface-edit.py
+++ b/.claude/skills/interface-edit/scripts/interface-edit.py
@@ -95,7 +95,9 @@ def parse_value_list(val):
def save_xml_bom(tree, path):
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/meta-compile/scripts/meta-compile.py b/.claude/skills/meta-compile/scripts/meta-compile.py
index 4abbf5ca..df777898 100644
--- a/.claude/skills/meta-compile/scripts/meta-compile.py
+++ b/.claude/skills/meta-compile/scripts/meta-compile.py
@@ -2340,7 +2340,7 @@ if obj_type == 'WebService':
X(f'\t{obj_type}>')
X('')
-metadata_xml = '\n'.join(lines)
+metadata_xml = '\n'.join(lines) + '\n'
# ---------------------------------------------------------------------------
# 16. Write files
@@ -2502,9 +2502,13 @@ if os.path.isfile(config_xml_path):
# Write back preserving BOM
tree.write(config_xml_path, encoding='utf-8', xml_declaration=True)
- # Re-read to add BOM
+ # Re-read to add BOM, fix declaration quotes, ensure trailing newline
with open(config_xml_path, 'r', encoding='utf-8') as f:
raw = f.read()
+ if raw.startswith(""):
+ raw = raw.replace("", '', 1)
+ if not raw.endswith('\n'):
+ raw += '\n'
write_utf8_bom(config_xml_path, raw)
reg_result = 'added'
else:
diff --git a/.claude/skills/meta-edit/scripts/meta-edit.py b/.claude/skills/meta-edit/scripts/meta-edit.py
index 0894323f..c5a3f363 100644
--- a/.claude/skills/meta-edit/scripts/meta-edit.py
+++ b/.claude/skills/meta-edit/scripts/meta-edit.py
@@ -1997,8 +1997,8 @@ def set_complex_property(property_name, values):
def save_xml(tree, path):
"""Save XML tree with BOM and proper encoding declaration."""
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- # Fix encoding quotes: encoding='UTF-8' -> encoding="UTF-8"
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ # Fix XML declaration quotes
+ xml_bytes = xml_bytes.replace(b"", b'')
# Fix d5p1 namespace declarations stripped by lxml (it treats them as unused
# because d5p1: appears only in text content, not in element/attribute names)
xml_bytes = re.sub(
@@ -2006,6 +2006,8 @@ def save_xml(tree, path):
b'\\1 xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config"\\2',
xml_bytes
)
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/meta-remove/scripts/meta-remove.py b/.claude/skills/meta-remove/scripts/meta-remove.py
index 7d917468..dc9eca1d 100644
--- a/.claude/skills/meta-remove/scripts/meta-remove.py
+++ b/.claude/skills/meta-remove/scripts/meta-remove.py
@@ -99,7 +99,9 @@ def localname(el):
def save_xml_bom(tree, path):
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/skd-edit/scripts/skd-edit.py b/.claude/skills/skd-edit/scripts/skd-edit.py
index d82586c2..e1b6e1c0 100644
--- a/.claude/skills/skd-edit/scripts/skd-edit.py
+++ b/.claude/skills/skd-edit/scripts/skd-edit.py
@@ -1919,7 +1919,9 @@ elif operation == "remove-filter":
# ── 9. Save ─────────────────────────────────────────────────
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
-xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+xml_bytes = xml_bytes.replace(b"", b'')
+if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(resolved_path, "wb") as f:
f.write(b'\xef\xbb\xbf')
f.write(xml_bytes)
diff --git a/.claude/skills/subsystem-edit/scripts/subsystem-edit.py b/.claude/skills/subsystem-edit/scripts/subsystem-edit.py
index bcc4f079..36158da3 100644
--- a/.claude/skills/subsystem-edit/scripts/subsystem-edit.py
+++ b/.claude/skills/subsystem-edit/scripts/subsystem-edit.py
@@ -122,7 +122,9 @@ def parse_value_list(val):
def save_xml_bom(tree, path):
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/template-add/scripts/add-template.py b/.claude/skills/template-add/scripts/add-template.py
index 462d1924..096981cf 100644
--- a/.claude/skills/template-add/scripts/add-template.py
+++ b/.claude/skills/template-add/scripts/add-template.py
@@ -23,7 +23,9 @@ TYPE_MAP = {
def save_xml_with_bom(tree, path):
"""Save XML tree to file with UTF-8 BOM."""
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/.claude/skills/template-remove/scripts/remove-template.py b/.claude/skills/template-remove/scripts/remove-template.py
index bd241b0c..5a94a046 100644
--- a/.claude/skills/template-remove/scripts/remove-template.py
+++ b/.claude/skills/template-remove/scripts/remove-template.py
@@ -16,7 +16,9 @@ NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
def save_xml_with_bom(tree, path):
"""Save XML tree to file with UTF-8 BOM."""
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
- xml_bytes = xml_bytes.replace(b"encoding='UTF-8'", b'encoding="UTF-8"')
+ xml_bytes = xml_bytes.replace(b"", b'')
+ if not xml_bytes.endswith(b"\n"):
+ xml_bytes += b"\n"
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
diff --git a/tests/skills/runner.mjs b/tests/skills/runner.mjs
index 9faab96a..a1726818 100644
--- a/tests/skills/runner.mjs
+++ b/tests/skills/runner.mjs
@@ -255,11 +255,37 @@ function buildArgs(skillConfig, caseData, workDir, inputFilePath, runtime) {
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
+function normalizeXmlContent(text) {
+ let s = text;
+ // 1. XML declaration: normalize quotes and encoding case
+ s = s.replace(
+ /<\?xml\s+version=['"]1\.0['"]\s+encoding=['"]([^'"]+)['"]\s*\?>/gi,
+ (_, enc) => ``
+ );
+ // 2. Remove
(CR encoded as XML entity by Python etree)
+ s = s.replace(/
/g, '');
+ // 3. Strip xmlns declarations (Python etree strips unused ones)
+ s = s.replace(/\s+xmlns(?::[\w]+)?="[^"]*"/g, '');
+ // 4. Normalize self-closing tags: remove space before />
+ s = s.replace(/\s*\/>/g, '/>');
+ // 5. Collapse whitespace between tags: "> \n\t <" → "><"
+ s = s.replace(/>\s+<');
+ // 6. Normalize empty elements: →
+ s = s.replace(/<([\w:.]+)([^>]*)><\/\1>/g, '<$1$2/>');
+ // 7. Strip trailing whitespace
+ s = s.trimEnd();
+ return s;
+}
+
function normalizeContent(text, config) {
// Strip BOM
let s = text.replace(/^\uFEFF/, '');
// Normalize line endings
s = s.replace(/\r\n/g, '\n');
+ // Normalize XML differences (Python etree serialization quirks)
+ if (config?.runtime === 'python') {
+ s = normalizeXmlContent(s);
+ }
// Normalize UUIDs
if (config?.normalizeUuids) {
@@ -453,7 +479,7 @@ async function runCaseAsync(testCase, opts) {
}
}
if (errors.length === 0 && !caseData.expectError && !workspace.readOnly) {
- const snapshotConfig = skillConfig.snapshot || {};
+ const snapshotConfig = { ...skillConfig.snapshot, runtime: opts.runtime };
if (opts.updateSnapshots) {
updateSnapshot(workDir, snapshotDir, snapshotConfig);
} else {
@@ -599,7 +625,7 @@ function runCase(testCase, opts) {
// Snapshot comparison (skip for external/read-only workspaces)
if (errors.length === 0 && !caseData.expectError && !workspace.readOnly) {
- const snapshotConfig = skillConfig.snapshot || {};
+ const snapshotConfig = { ...skillConfig.snapshot, runtime: opts.runtime };
if (opts.updateSnapshots) {
updateSnapshot(workDir, snapshotDir, snapshotConfig);
} else {