From 57e99d144e0601d18260966b362625eddd4803e1 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sun, 19 Jul 2026 16:48:01 +0300 Subject: [PATCH] =?UTF-8?q?fix(skills):=20round-trip=20=D1=81=D0=BE=D1=85?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20EOL/BOM/encoding?= =?UTF-8?q?=20=D0=B2=20py=20edit-=D0=BF=D0=BE=D1=80=D1=82=D0=B0=D1=85=20(#?= =?UTF-8?q?44/#46/#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Py-порты (lxml) при точечном редактировании существующего 1С-XML переписывали весь файл: CRLF→LF, encoding="UTF-8"→"utf-8", добавляли финальный перенос, плодили литерал (сериализация \r из tail'ов). Результат — широкий шумовой diff и скрытый лишний текст-узел при exit 0 и зелёной валидации. PS1-порты (XmlDocument) багу не подвержены — эталон. Фикс во всех 13 round-trip re-serialize py-навыках: перед записью детектится стиль существующего файла (BOM / EOL / регистр encoding / финальный перенос) и восстанавливается при сохранении; переносы канонизируются к LF (убирает ), затем приводятся к EOL источника. Новый файл (путь не существует) → прежнее поведение, снапшоты не двигаются. Навыки: cf-edit, meta-edit, meta-remove, interface-edit, subsystem-edit, skd-edit, form-edit, form-add, help-add, template-add, template-remove, form-remove, cfe-borrow. Версии py+ps1 подняты синхронно. Harness (tests/skills/runner.mjs): снята маска в normalizeXmlContent (порты её больше не порождают → гвардия ловит регресс); добавлен expect.preserves — raw-байтовая проверка BOM/EOL/encoding/финального переноса/отсутствия в обход нормализации. Регрессионные round-trip кейсы на CRLF+BOM+UTF-8 фикстурах для cf-edit/meta-edit/subsystem-edit. Верификация: py 556/556, ps1 556/556; платформа 1С 8.3.24 (verify-snapshots) cf-edit 12/12, meta-edit/subsystem-edit round-trip загружаются; негатив-тест подтверждает, что harness ловит дефект на старом коде. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/cf-edit/scripts/cf-edit.ps1 | 2 +- .claude/skills/cf-edit/scripts/cf-edit.py | 48 +++- .../skills/cfe-borrow/scripts/cfe-borrow.ps1 | 2 +- .../skills/cfe-borrow/scripts/cfe-borrow.py | 48 +++- .claude/skills/form-add/scripts/form-add.ps1 | 2 +- .claude/skills/form-add/scripts/form-add.py | 50 +++- .../skills/form-edit/scripts/form-edit.ps1 | 2 +- .claude/skills/form-edit/scripts/form-edit.py | 38 ++- .../form-remove/scripts/remove-form.ps1 | 2 +- .../skills/form-remove/scripts/remove-form.py | 50 +++- .claude/skills/help-add/scripts/add-help.ps1 | 2 +- .claude/skills/help-add/scripts/add-help.py | 50 +++- .../interface-edit/scripts/interface-edit.ps1 | 2 +- .../interface-edit/scripts/interface-edit.py | 48 +++- .../skills/meta-edit/scripts/meta-edit.ps1 | 2 +- .claude/skills/meta-edit/scripts/meta-edit.py | 49 +++- .../meta-remove/scripts/meta-remove.ps1 | 2 +- .../skills/meta-remove/scripts/meta-remove.py | 48 +++- .claude/skills/skd-edit/scripts/skd-edit.ps1 | 2 +- .claude/skills/skd-edit/scripts/skd-edit.py | 34 ++- .../subsystem-edit/scripts/subsystem-edit.ps1 | 2 +- .../subsystem-edit/scripts/subsystem-edit.py | 48 +++- .../template-add/scripts/add-template.ps1 | 2 +- .../template-add/scripts/add-template.py | 50 +++- .../scripts/remove-template.ps1 | 2 +- .../scripts/remove-template.py | 50 +++- .../fixtures/crlf-config/Configuration.xml | 251 +++++++++++++++++ .../Ext/ClientApplicationInterface.xml | 18 ++ .../crlf-config/Languages/Русский.xml | 16 ++ .../cf-edit/roundtrip-crlf-preserve.json | 17 ++ .../crlf-catalog/Catalogs/КрлфСпр.xml | 136 ++++++++++ .../Catalogs/КрлфСпр/Ext/ObjectModule.bsl | 1 + .../fixtures/crlf-catalog/Configuration.xml | 252 +++++++++++++++++ .../Ext/ClientApplicationInterface.xml | 18 ++ .../crlf-catalog/Languages/Русский.xml | 16 ++ .../meta-edit/roundtrip-crlf-preserve.json | 16 ++ .../crlf-subsystem/CommonModules/Второй.xml | 23 ++ .../CommonModules/Второй/Ext/Module.bsl | 1 + .../crlf-subsystem/CommonModules/М.xml | 23 ++ .../CommonModules/М/Ext/Module.bsl | 1 + .../fixtures/crlf-subsystem/Configuration.xml | 254 ++++++++++++++++++ .../Ext/ClientApplicationInterface.xml | 18 ++ .../crlf-subsystem/Languages/Русский.xml | 16 ++ .../crlf-subsystem/Subsystems/КрлфПС.xml | 24 ++ .../roundtrip-crlf-preserve.json | 18 ++ tests/skills/runner.mjs | 45 +++- 46 files changed, 1698 insertions(+), 103 deletions(-) create mode 100644 tests/skills/cases/cf-edit/fixtures/crlf-config/Configuration.xml create mode 100644 tests/skills/cases/cf-edit/fixtures/crlf-config/Ext/ClientApplicationInterface.xml create mode 100644 tests/skills/cases/cf-edit/fixtures/crlf-config/Languages/Русский.xml create mode 100644 tests/skills/cases/cf-edit/roundtrip-crlf-preserve.json create mode 100644 tests/skills/cases/meta-edit/fixtures/crlf-catalog/Catalogs/КрлфСпр.xml create mode 100644 tests/skills/cases/meta-edit/fixtures/crlf-catalog/Catalogs/КрлфСпр/Ext/ObjectModule.bsl create mode 100644 tests/skills/cases/meta-edit/fixtures/crlf-catalog/Configuration.xml create mode 100644 tests/skills/cases/meta-edit/fixtures/crlf-catalog/Ext/ClientApplicationInterface.xml create mode 100644 tests/skills/cases/meta-edit/fixtures/crlf-catalog/Languages/Русский.xml create mode 100644 tests/skills/cases/meta-edit/roundtrip-crlf-preserve.json create mode 100644 tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/Второй.xml create mode 100644 tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/Второй/Ext/Module.bsl create mode 100644 tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/М.xml create mode 100644 tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/М/Ext/Module.bsl create mode 100644 tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Configuration.xml create mode 100644 tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Ext/ClientApplicationInterface.xml create mode 100644 tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Languages/Русский.xml create mode 100644 tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Subsystems/КрлфПС.xml create mode 100644 tests/skills/cases/subsystem-edit/roundtrip-crlf-preserve.json diff --git a/.claude/skills/cf-edit/scripts/cf-edit.ps1 b/.claude/skills/cf-edit/scripts/cf-edit.ps1 index 71f356e3..e62e947c 100644 --- a/.claude/skills/cf-edit/scripts/cf-edit.ps1 +++ b/.claude/skills/cf-edit/scripts/cf-edit.ps1 @@ -1,4 +1,4 @@ -# cf-edit v1.9 — Edit 1C configuration root (Configuration.xml) +# cf-edit v1.10 — Edit 1C configuration root (Configuration.xml) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)][Alias('Path')][string]$ConfigPath, diff --git a/.claude/skills/cf-edit/scripts/cf-edit.py b/.claude/skills/cf-edit/scripts/cf-edit.py index 4fbdf2fe..ba0452d8 100644 --- a/.claude/skills/cf-edit/scripts/cf-edit.py +++ b/.claude/skills/cf-edit/scripts/cf-edit.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# cf-edit v1.9 — Edit 1C configuration root (Configuration.xml) +# cf-edit v1.10 — Edit 1C configuration root (Configuration.xml) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -324,13 +324,49 @@ def parse_batch_value(val): return items -def save_xml_bom(tree, path): - xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") - xml_bytes = xml_bytes.replace(b"", b'') - if not xml_bytes.endswith(b"\n"): +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + +def save_xml_bom(tree, path): + style = _detect_xml_style(path) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/.claude/skills/cfe-borrow/scripts/cfe-borrow.ps1 b/.claude/skills/cfe-borrow/scripts/cfe-borrow.ps1 index 6002842c..88916f8a 100644 --- a/.claude/skills/cfe-borrow/scripts/cfe-borrow.ps1 +++ b/.claude/skills/cfe-borrow/scripts/cfe-borrow.ps1 @@ -1,4 +1,4 @@ -# cfe-borrow v1.8 — Borrow objects from configuration into extension (CFE) +# cfe-borrow v1.9 — Borrow objects from configuration into extension (CFE) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)][string]$ExtensionPath, diff --git a/.claude/skills/cfe-borrow/scripts/cfe-borrow.py b/.claude/skills/cfe-borrow/scripts/cfe-borrow.py index 0d3f4620..66511670 100644 --- a/.claude/skills/cfe-borrow/scripts/cfe-borrow.py +++ b/.claude/skills/cfe-borrow/scripts/cfe-borrow.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# cfe-borrow v1.8 — Borrow objects from configuration into extension (CFE) +# cfe-borrow v1.9 — Borrow objects from configuration into extension (CFE) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -349,13 +349,49 @@ def expand_self_closing(container, parent_indent): container.text = "\r\n" + 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"", b'') - if not xml_bytes.endswith(b"\n"): +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + +def save_xml_bom(tree, path): + style = _detect_xml_style(path) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/.claude/skills/form-add/scripts/form-add.ps1 b/.claude/skills/form-add/scripts/form-add.ps1 index e4d15755..f95b94c8 100644 --- a/.claude/skills/form-add/scripts/form-add.ps1 +++ b/.claude/skills/form-add/scripts/form-add.ps1 @@ -1,4 +1,4 @@ -# form-add v1.9 — Add managed form to 1C config object +# form-add v1.10 — Add managed form to 1C config object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] diff --git a/.claude/skills/form-add/scripts/form-add.py b/.claude/skills/form-add/scripts/form-add.py index b77a6379..80cabd8f 100644 --- a/.claude/skills/form-add/scripts/form-add.py +++ b/.claude/skills/form-add/scripts/form-add.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# form-add v1.9 — Add managed form to 1C config object +# form-add v1.10 — Add managed form to 1C config object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -210,14 +210,50 @@ def detect_format_version(d): return "2.17" -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"", b'') - if not xml_bytes.endswith(b"\n"): +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + +def save_xml_with_bom(tree, path): + """Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline.""" + style = _detect_xml_style(path) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/.claude/skills/form-edit/scripts/form-edit.ps1 b/.claude/skills/form-edit/scripts/form-edit.ps1 index 7c35378d..4bbff2e9 100644 --- a/.claude/skills/form-edit/scripts/form-edit.ps1 +++ b/.claude/skills/form-edit/scripts/form-edit.ps1 @@ -1,4 +1,4 @@ -# form-edit v1.4 — Edit 1C managed form elements +# form-edit v1.5 — Edit 1C managed form elements # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] diff --git a/.claude/skills/form-edit/scripts/form-edit.py b/.claude/skills/form-edit/scripts/form-edit.py index 4307e27d..943cdf6b 100644 --- a/.claude/skills/form-edit/scripts/form-edit.py +++ b/.claude/skills/form-edit/scripts/form-edit.py @@ -1,4 +1,4 @@ -# form-edit v1.4 — Edit 1C managed form elements (Python port) +# form-edit v1.5 — Edit 1C managed form elements (Python port) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -1475,14 +1475,40 @@ if elem_events_list: # ── 13. Save ──────────────────────────────────────────────── +# Round-trip: определить стиль исходного файла (на диске он ещё не перезаписан). +try: + _fe_raw = open(resolved_form_path, "rb").read() +except OSError: + _fe_raw = None +if _fe_raw is not None: + _fe_bom = _fe_raw.startswith(b"\xef\xbb\xbf") + _fe_body = _fe_raw[3:] if _fe_bom else _fe_raw + _fe_crlf = b"\r\n" in _fe_body + _fe_enc_m = re.search(rb'encoding="([^"]+)"', _fe_body[:200]) + _fe_enc = _fe_enc_m.group(1).decode("ascii") if _fe_enc_m else "utf-8" + _fe_final_nl = _fe_body.endswith(b"\n") +else: + _fe_bom, _fe_crlf, _fe_enc, _fe_final_nl = True, False, "utf-8", True + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") -# Fix XML declaration quotes -xml_bytes = xml_bytes.replace(b"", b'') -if not xml_bytes.endswith(b"\n"): +# Восстановить регистр encoding как в оригинале. +xml_bytes = xml_bytes.replace( + b"", + b'') +# Канонизировать переносы к LF (убирает от \r в tail'ах). +xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) +# Финальный перенос — как в оригинале. +xml_bytes = xml_bytes.rstrip(b"\n") +if _fe_final_nl: xml_bytes += b"\n" -# Write with BOM +# EOL — как в оригинале. +if _fe_crlf: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") +# Write preserving BOM as in original. with open(resolved_form_path, "wb") as f: - f.write(b'\xef\xbb\xbf') + if _fe_bom: + f.write(b'\xef\xbb\xbf') f.write(xml_bytes) # ── 14. Summary ───────────────────────────────────────────── diff --git a/.claude/skills/form-remove/scripts/remove-form.ps1 b/.claude/skills/form-remove/scripts/remove-form.ps1 index 7956d730..9f7b3925 100644 --- a/.claude/skills/form-remove/scripts/remove-form.ps1 +++ b/.claude/skills/form-remove/scripts/remove-form.ps1 @@ -1,4 +1,4 @@ -# form-remove v1.3 — Remove form from 1C object +# form-remove v1.4 — Remove form from 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] diff --git a/.claude/skills/form-remove/scripts/remove-form.py b/.claude/skills/form-remove/scripts/remove-form.py index e64a8d04..c5f3c89a 100644 --- a/.claude/skills/form-remove/scripts/remove-form.py +++ b/.claude/skills/form-remove/scripts/remove-form.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# remove-form v1.3 — Remove form from 1C object +# remove-form v1.4 — Remove form from 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -13,14 +13,50 @@ from lxml import etree 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"", b'') - if not xml_bytes.endswith(b"\n"): +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + +def save_xml_with_bom(tree, path): + """Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline.""" + style = _detect_xml_style(path) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/.claude/skills/help-add/scripts/add-help.ps1 b/.claude/skills/help-add/scripts/add-help.ps1 index f1f502f6..a803c30f 100644 --- a/.claude/skills/help-add/scripts/add-help.ps1 +++ b/.claude/skills/help-add/scripts/add-help.ps1 @@ -1,4 +1,4 @@ -# help-add v1.8 — Add built-in help to 1C object +# help-add v1.9 — Add built-in help to 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] diff --git a/.claude/skills/help-add/scripts/add-help.py b/.claude/skills/help-add/scripts/add-help.py index 38056ef3..e4489af3 100644 --- a/.claude/skills/help-add/scripts/add-help.py +++ b/.claude/skills/help-add/scripts/add-help.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# add-help v1.8 — Add built-in help to 1C object +# add-help v1.9 — Add built-in help to 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -205,14 +205,50 @@ def detect_format_version(d): return "2.17" -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"", b'') - if not xml_bytes.endswith(b"\n"): +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + +def save_xml_with_bom(tree, path): + """Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline.""" + style = _detect_xml_style(path) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/.claude/skills/interface-edit/scripts/interface-edit.ps1 b/.claude/skills/interface-edit/scripts/interface-edit.ps1 index 759df166..b542a738 100644 --- a/.claude/skills/interface-edit/scripts/interface-edit.ps1 +++ b/.claude/skills/interface-edit/scripts/interface-edit.ps1 @@ -1,4 +1,4 @@ -# interface-edit v1.7 — Edit 1C CommandInterface.xml +# interface-edit v1.8 — Edit 1C CommandInterface.xml # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)][Alias('Path')][string]$CIPath, diff --git a/.claude/skills/interface-edit/scripts/interface-edit.py b/.claude/skills/interface-edit/scripts/interface-edit.py index 3967aae0..8a5ab8a6 100644 --- a/.claude/skills/interface-edit/scripts/interface-edit.py +++ b/.claude/skills/interface-edit/scripts/interface-edit.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# interface-edit v1.7 — Edit 1C CommandInterface.xml +# interface-edit v1.8 — Edit 1C CommandInterface.xml # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -287,13 +287,49 @@ def parse_value_list(val): return [val] -def save_xml_bom(tree, path): - xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") - xml_bytes = xml_bytes.replace(b"", b'') - if not xml_bytes.endswith(b"\n"): +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + +def save_xml_bom(tree, path): + style = _detect_xml_style(path) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/.claude/skills/meta-edit/scripts/meta-edit.ps1 b/.claude/skills/meta-edit/scripts/meta-edit.ps1 index 82aff8c3..ed6e6c61 100644 --- a/.claude/skills/meta-edit/scripts/meta-edit.ps1 +++ b/.claude/skills/meta-edit/scripts/meta-edit.ps1 @@ -1,4 +1,4 @@ -# meta-edit v1.21 — Edit existing 1C metadata object XML (+modify-property Type: структурный дескриптор, guard от порчи) +# meta-edit v1.22 — Edit existing 1C metadata object XML # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [string]$DefinitionFile, diff --git a/.claude/skills/meta-edit/scripts/meta-edit.py b/.claude/skills/meta-edit/scripts/meta-edit.py index 1e507afb..2161d469 100644 --- a/.claude/skills/meta-edit/scripts/meta-edit.py +++ b/.claude/skills/meta-edit/scripts/meta-edit.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# meta-edit v1.21 — Edit existing 1C metadata object XML (+modify-property Type: структурный дескриптор, guard от порчи) +# meta-edit v1.22 — Edit existing 1C metadata object XML # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -2896,11 +2896,46 @@ def set_complex_property(property_name, values): # ============================================================ +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: + xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + def save_xml(tree, path): - """Save XML tree with BOM and proper encoding declaration.""" + """Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline.""" + style = _detect_xml_style(path) xml_bytes = etree.tostring(tree, xml_declaration=True, 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( @@ -2908,10 +2943,10 @@ 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" + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/.claude/skills/meta-remove/scripts/meta-remove.ps1 b/.claude/skills/meta-remove/scripts/meta-remove.ps1 index b51228d5..abd2a53d 100644 --- a/.claude/skills/meta-remove/scripts/meta-remove.ps1 +++ b/.claude/skills/meta-remove/scripts/meta-remove.ps1 @@ -1,4 +1,4 @@ -# meta-remove v1.4 — Remove metadata object from 1C configuration dump +# meta-remove v1.5 — Remove metadata object from 1C configuration dump # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] diff --git a/.claude/skills/meta-remove/scripts/meta-remove.py b/.claude/skills/meta-remove/scripts/meta-remove.py index 63cd5bdc..80c9b5ee 100644 --- a/.claude/skills/meta-remove/scripts/meta-remove.py +++ b/.claude/skills/meta-remove/scripts/meta-remove.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# meta-remove v1.4 — Remove metadata object from 1C configuration dump +# meta-remove v1.5 — Remove metadata object from 1C configuration dump # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -275,13 +275,49 @@ def localname(el): return etree.QName(el.tag).localname -def save_xml_bom(tree, path): - xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") - xml_bytes = xml_bytes.replace(b"", b'') - if not xml_bytes.endswith(b"\n"): +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + +def save_xml_bom(tree, path): + style = _detect_xml_style(path) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/.claude/skills/skd-edit/scripts/skd-edit.ps1 b/.claude/skills/skd-edit/scripts/skd-edit.ps1 index da131019..16200b8e 100644 --- a/.claude/skills/skd-edit/scripts/skd-edit.ps1 +++ b/.claude/skills/skd-edit/scripts/skd-edit.ps1 @@ -1,4 +1,4 @@ -# skd-edit v1.29 — Atomic 1C DCS editor +# skd-edit v1.30 — Atomic 1C DCS editor # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: парный .py собирает выражения автодат вне f-string ради совместимости с python 3.9 (PEP 701). param( diff --git a/.claude/skills/skd-edit/scripts/skd-edit.py b/.claude/skills/skd-edit/scripts/skd-edit.py index 2491034a..a230210d 100644 --- a/.claude/skills/skd-edit/scripts/skd-edit.py +++ b/.claude/skills/skd-edit/scripts/skd-edit.py @@ -1,4 +1,4 @@ -# skd-edit v1.29 — Atomic 1C DCS editor (Python port) +# skd-edit v1.30 — Atomic 1C DCS editor (Python port) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -1984,6 +1984,12 @@ raw_root_opening = _root_open_m.group(0) if _root_open_m else None # Detect line ending convention so save can normalize back to whatever the source used. line_ending = "\r\n" if "\r\n" in raw_original_text else "\n" +# Round-trip: сохранить BOM / регистр encoding / финальный перенос как в оригинале. +_skd_had_bom = raw_original_bytes.startswith(b"\xef\xbb\xbf") +_skd_body = raw_original_bytes[3:] if _skd_had_bom else raw_original_bytes +_skd_enc_m = re.search(rb'encoding="([^"]+)"', _skd_body[:200]) +_skd_enc = _skd_enc_m.group(1).decode("ascii") if _skd_enc_m else "utf-8" +_skd_final_nl = _skd_body.endswith(b"\n") xml_parser = etree.XMLParser(remove_blank_text=False) tree = etree.parse(resolved_path, xml_parser) @@ -3423,7 +3429,10 @@ if not dirty: sys.exit(0) xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") -xml_bytes = xml_bytes.replace(b"", b'') +# Round-trip: восстановить регистр encoding как в оригинале. +xml_bytes = xml_bytes.replace( + b"", + b'') # Format-preserve post-processing (mirrors PS path): # (1) restore the original raw opening tag — lxml collapses @@ -3435,17 +3444,24 @@ if raw_root_opening: # defensive — strip any space before `/>` so PS and PY ports stay byte-equivalent. xml_text = re.sub(r"(?<=\S) />", "/>", xml_text) -# Normalize line endings to match source. +# Канонизировать переносы к LF (убирает возможный ), затем к стилю источника. +xml_text = xml_text.replace(" \n", "\n").replace(" ", "").replace("\r\n", "\n").replace("\r", "\n") if line_ending == "\r\n": - xml_text = re.sub(r"(?", b'') - if not xml_bytes.endswith(b"\n"): +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + +def save_xml_bom(tree, path): + style = _detect_xml_style(path) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/.claude/skills/template-add/scripts/add-template.ps1 b/.claude/skills/template-add/scripts/add-template.ps1 index 6addff85..a69acff7 100644 --- a/.claude/skills/template-add/scripts/add-template.ps1 +++ b/.claude/skills/template-add/scripts/add-template.ps1 @@ -1,4 +1,4 @@ -# template-add v1.8 — Add template to 1C object +# template-add v1.9 — Add template to 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] diff --git a/.claude/skills/template-add/scripts/add-template.py b/.claude/skills/template-add/scripts/add-template.py index dcaf132e..0b07e163 100644 --- a/.claude/skills/template-add/scripts/add-template.py +++ b/.claude/skills/template-add/scripts/add-template.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# add-template v1.8 — Add template to 1C object +# add-template v1.9 — Add template to 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -198,14 +198,50 @@ 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"", b'') - if not xml_bytes.endswith(b"\n"): +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + +def save_xml_with_bom(tree, path): + """Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline.""" + style = _detect_xml_style(path) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/.claude/skills/template-remove/scripts/remove-template.ps1 b/.claude/skills/template-remove/scripts/remove-template.ps1 index f750c377..e02a9d90 100644 --- a/.claude/skills/template-remove/scripts/remove-template.ps1 +++ b/.claude/skills/template-remove/scripts/remove-template.ps1 @@ -1,4 +1,4 @@ -# template-remove v1.2 — Remove template from 1C object +# template-remove v1.3 — Remove template from 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] diff --git a/.claude/skills/template-remove/scripts/remove-template.py b/.claude/skills/template-remove/scripts/remove-template.py index 01cfc695..ac1da917 100644 --- a/.claude/skills/template-remove/scripts/remove-template.py +++ b/.claude/skills/template-remove/scripts/remove-template.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# remove-template v1.1 — Remove template from 1C object +# remove-template v1.3 — Remove template from 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -13,14 +13,50 @@ from lxml import etree 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"", b'') - if not xml_bytes.endswith(b"\n"): +def _detect_xml_style(path): + """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding / + финальный перенос. None → файл новый (сохранить текущее поведение).""" + try: + raw = open(path, "rb").read() + except OSError: + return None + bom = raw.startswith(b"\xef\xbb\xbf") + body = raw[3:] if bom else raw + crlf = b"\r\n" in body + m = re.search(rb'encoding="([^"]+)"', body[:200]) + enc = m.group(1).decode("ascii") if m else "utf-8" + final_nl = body.endswith(b"\n") + return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl} + + +def _finalize_xml_bytes(xml_bytes, style): + """Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" + enc_decl = style["enc"] if style else "utf-8" + xml_bytes = xml_bytes.replace( + b"", + b'') + # Канонизировать переносы к LF (убирает от \r в tail'ах) + xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"") + .replace(b"\r\n", b"\n").replace(b"\r", b"\n")) + # Финальный перенос — как в оригинале (новый файл → есть) + want_final_nl = style["final_nl"] if style else True + xml_bytes = xml_bytes.rstrip(b"\n") + if want_final_nl: xml_bytes += b"\n" + # EOL — как в оригинале (новый файл → LF, текущее поведение) + if style and style["crlf"]: + xml_bytes = xml_bytes.replace(b"\n", b"\r\n") + return xml_bytes + + +def save_xml_with_bom(tree, path): + """Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline.""" + style = _detect_xml_style(path) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + xml_bytes = _finalize_xml_bytes(xml_bytes, style) with open(path, "wb") as f: - f.write(b"\xef\xbb\xbf") + if style is None or style["bom"]: + f.write(b"\xef\xbb\xbf") f.write(xml_bytes) diff --git a/tests/skills/cases/cf-edit/fixtures/crlf-config/Configuration.xml b/tests/skills/cases/cf-edit/fixtures/crlf-config/Configuration.xml new file mode 100644 index 00000000..a45d0db9 --- /dev/null +++ b/tests/skills/cases/cf-edit/fixtures/crlf-config/Configuration.xml @@ -0,0 +1,251 @@ + + + + + + 9cd510cd-abfc-11d4-9434-004095e12fc7 + d41d3bac-c6c1-4a8d-afcd-81fc29f496d0 + + + 9fcd25a0-4822-11d4-9414-008048da11f9 + c8e78ba2-51c5-45bb-a0ef-8c3c7ff76053 + + + e3687481-0a87-462c-a166-9f34594f9bba + 342f337b-45a8-422b-965d-11330d52b334 + + + 9de14907-ec23-4a07-96f0-85521cb6b53b + a3448b91-cbda-4e31-a0f2-e32bc1884e7c + + + 51f2d5d8-ea4d-4064-8892-82951750031e + 41d207a0-776a-40af-bcd1-925d3acffb82 + + + e68182ea-4237-4383-967f-90c1e3370bc7 + d73b5446-4f92-46db-a779-91d4e78c664a + + + fb282519-d103-4dd3-bc12-cb271d631dfc + c5472dbb-d2be-4f93-a745-564e39298ef2 + + + + КрлфКонф + + + ru + КрлфКонф + + + + + Version8_3_27 + ManagedApplication + + PlatformApplication + + Russian + + + + + false + false + false + + + + + + + + + + + + + + + + + + + + + + Biometrics + true + + + Location + false + + + BackgroundLocation + false + + + BluetoothPrinters + false + + + WiFiPrinters + false + + + Contacts + false + + + Calendars + false + + + PushNotifications + false + + + LocalNotifications + false + + + InAppPurchases + false + + + PersonalComputerFileExchange + false + + + Ads + false + + + NumberDialing + false + + + CallProcessing + false + + + CallLog + false + + + AutoSendSMS + false + + + ReceiveSMS + false + + + SMSLog + false + + + Camera + false + + + Microphone + false + + + MusicLibrary + false + + + PictureAndVideoLibraries + false + + + AudioPlaybackAndVibration + false + + + BackgroundAudioPlaybackAndVibration + false + + + InstallPackages + false + + + OSBackup + true + + + ApplicationUsageStatistics + false + + + BarcodeScanning + false + + + BackgroundAudioRecording + false + + + AllFilesAccess + false + + + Videoconferences + false + + + NFC + false + + + DocumentScanning + false + + + SpeechToText + false + + + Geofences + false + + + IncomingShareRequests + false + + + AllIncomingShareRequestsTypesProcessing + false + + + + + + Normal + + + Language.Русский + + + + + + Managed + NotAutoFree + DontUse + DontUse + TaxiEnableVersion8_2 + DontUse + Version8_3_27 + + + + Русский + + + \ No newline at end of file diff --git a/tests/skills/cases/cf-edit/fixtures/crlf-config/Ext/ClientApplicationInterface.xml b/tests/skills/cases/cf-edit/fixtures/crlf-config/Ext/ClientApplicationInterface.xml new file mode 100644 index 00000000..b6691f68 --- /dev/null +++ b/tests/skills/cases/cf-edit/fixtures/crlf-config/Ext/ClientApplicationInterface.xml @@ -0,0 +1,18 @@ + + + + + cbab57f2-a0f3-4f0a-89ea-4cb19570ab75 + + + + + b553047f-c9aa-4157-978d-448ecad24248 + + + + + + + + \ No newline at end of file diff --git a/tests/skills/cases/cf-edit/fixtures/crlf-config/Languages/Русский.xml b/tests/skills/cases/cf-edit/fixtures/crlf-config/Languages/Русский.xml new file mode 100644 index 00000000..1ad6b469 --- /dev/null +++ b/tests/skills/cases/cf-edit/fixtures/crlf-config/Languages/Русский.xml @@ -0,0 +1,16 @@ + + + + + Русский + + + ru + Русский + + + + ru + + + \ No newline at end of file diff --git a/tests/skills/cases/cf-edit/roundtrip-crlf-preserve.json b/tests/skills/cases/cf-edit/roundtrip-crlf-preserve.json new file mode 100644 index 00000000..40d353f9 --- /dev/null +++ b/tests/skills/cases/cf-edit/roundtrip-crlf-preserve.json @@ -0,0 +1,17 @@ +{ + "name": "round-trip: modify-property на CRLF+BOM+UTF-8 без финального переноса сохраняет стиль (issue #47)", + "setup": "fixture:crlf-config", + "input": [ + { "operation": "modify-property", "value": "Version=1.0.0.2" } + ], + "expect": { + "preserves": { + "file": "Configuration.xml", + "bom": true, + "eol": "crlf", + "encoding": "UTF-8", + "finalNewline": false, + "noCR13": true + } + } +} diff --git a/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Catalogs/КрлфСпр.xml b/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Catalogs/КрлфСпр.xml new file mode 100644 index 00000000..e9ff46fb --- /dev/null +++ b/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Catalogs/КрлфСпр.xml @@ -0,0 +1,136 @@ + + + + + + 385dd09c-ff5b-40a9-943e-38f475c99b91 + 86b48a10-bde9-4ecc-856c-e25ce31a5b4b + + + 8993478d-375a-453b-b8f9-bba39e975083 + 5b0b0523-ecbc-499a-9b22-fea054be03e7 + + + d55fc5b7-1aa2-4ce1-ad1d-d2b770e38655 + 9bf0c8c8-4881-49e8-a62f-f482ebd42d66 + + + ee9cced9-3790-4bd1-8f30-da4c747eb2df + a4530e53-72f7-4d6c-861d-bee708f2cd9a + + + 2ec45d37-0f33-4baf-8fa7-28003c959f8c + f6bd4075-e78b-4ca9-88f4-13b4efcf3225 + + + + КрлфСпр + + + ru + КРЛФ спр + + + + false + HierarchyFoldersAndItems + false + 2 + true + true + + ToItems + 9 + 25 + String + Variable + WholeCatalog + false + true + AsDescription + + Auto + InDialog + false + BothWays + + Catalog.КрлфСпр.StandardAttribute.Description + Catalog.КрлфСпр.StandardAttribute.Code + + Begin + DontUse + Directly + + + + + + + + + + + false + + + Managed + Use + + + + + + Use + Auto + DontUse + false + false + + + + + База + + + ru + База + + + + + xs:string + + 10 + Variable + + + false + + + + false + + false + false + + + false + + DontCheck + Items + + + Auto + Auto + + + Auto + ForItem + DontIndex + Use + Use + + + + + diff --git a/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Catalogs/КрлфСпр/Ext/ObjectModule.bsl b/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Catalogs/КрлфСпр/Ext/ObjectModule.bsl new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Catalogs/КрлфСпр/Ext/ObjectModule.bsl @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Configuration.xml b/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Configuration.xml new file mode 100644 index 00000000..e088855a --- /dev/null +++ b/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Configuration.xml @@ -0,0 +1,252 @@ + + + + + + 9cd510cd-abfc-11d4-9434-004095e12fc7 + d89cc1d9-05a5-4cf0-9e80-5346137b1348 + + + 9fcd25a0-4822-11d4-9414-008048da11f9 + 9118e89e-788f-4d02-b85d-655cfe7dc3ae + + + e3687481-0a87-462c-a166-9f34594f9bba + 651a9a19-7e3e-48cc-b8db-b3dee0cd8c96 + + + 9de14907-ec23-4a07-96f0-85521cb6b53b + 55ce5471-7a5f-4096-9f77-28ddaf35af44 + + + 51f2d5d8-ea4d-4064-8892-82951750031e + b31abffa-ac94-494b-b8f6-80fcea916b76 + + + e68182ea-4237-4383-967f-90c1e3370bc7 + 2029b0cf-47d7-4670-9678-da1cb439fac2 + + + fb282519-d103-4dd3-bc12-cb271d631dfc + 015ce36a-7802-4869-83a6-4bf96066a5b2 + + + + КонфM + + + ru + КонфM + + + + + Version8_3_27 + ManagedApplication + + PlatformApplication + + Russian + + + + + false + false + false + + + + + + + + + + + + + + + + + + + + + + Biometrics + true + + + Location + false + + + BackgroundLocation + false + + + BluetoothPrinters + false + + + WiFiPrinters + false + + + Contacts + false + + + Calendars + false + + + PushNotifications + false + + + LocalNotifications + false + + + InAppPurchases + false + + + PersonalComputerFileExchange + false + + + Ads + false + + + NumberDialing + false + + + CallProcessing + false + + + CallLog + false + + + AutoSendSMS + false + + + ReceiveSMS + false + + + SMSLog + false + + + Camera + false + + + Microphone + false + + + MusicLibrary + false + + + PictureAndVideoLibraries + false + + + AudioPlaybackAndVibration + false + + + BackgroundAudioPlaybackAndVibration + false + + + InstallPackages + false + + + OSBackup + true + + + ApplicationUsageStatistics + false + + + BarcodeScanning + false + + + BackgroundAudioRecording + false + + + AllFilesAccess + false + + + Videoconferences + false + + + NFC + false + + + DocumentScanning + false + + + SpeechToText + false + + + Geofences + false + + + IncomingShareRequests + false + + + AllIncomingShareRequestsTypesProcessing + false + + + + + + Normal + + + Language.Русский + + + + + + Managed + NotAutoFree + DontUse + DontUse + TaxiEnableVersion8_2 + DontUse + Version8_3_27 + + + + Русский + КрлфСпр + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Ext/ClientApplicationInterface.xml b/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Ext/ClientApplicationInterface.xml new file mode 100644 index 00000000..ff3ba1ab --- /dev/null +++ b/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Ext/ClientApplicationInterface.xml @@ -0,0 +1,18 @@ + + + + + cbab57f2-a0f3-4f0a-89ea-4cb19570ab75 + + + + + b553047f-c9aa-4157-978d-448ecad24248 + + + + + + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Languages/Русский.xml b/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Languages/Русский.xml new file mode 100644 index 00000000..f8b91e59 --- /dev/null +++ b/tests/skills/cases/meta-edit/fixtures/crlf-catalog/Languages/Русский.xml @@ -0,0 +1,16 @@ + + + + + Русский + + + ru + Русский + + + + ru + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-edit/roundtrip-crlf-preserve.json b/tests/skills/cases/meta-edit/roundtrip-crlf-preserve.json new file mode 100644 index 00000000..407906b4 --- /dev/null +++ b/tests/skills/cases/meta-edit/roundtrip-crlf-preserve.json @@ -0,0 +1,16 @@ +{ + "name": "round-trip: add-attribute на CRLF+BOM+UTF-8 файле сохраняет стиль, без (issue #44)", + "setup": "fixture:crlf-catalog", + "params": { "objectPath": "Catalogs/КрлфСпр.xml" }, + "input": { "add": { "attributes": ["Новый: Boolean"] } }, + "expect": { + "preserves": { + "file": "Catalogs/КрлфСпр.xml", + "bom": true, + "eol": "crlf", + "encoding": "UTF-8", + "finalNewline": true, + "noCR13": true + } + } +} diff --git a/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/Второй.xml b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/Второй.xml new file mode 100644 index 00000000..d46abf54 --- /dev/null +++ b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/Второй.xml @@ -0,0 +1,23 @@ + + + + + Второй + + + ru + Второй + + + + false + false + false + false + false + false + false + DontUse + + + diff --git a/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/Второй/Ext/Module.bsl b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/Второй/Ext/Module.bsl new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/Второй/Ext/Module.bsl @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/М.xml b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/М.xml new file mode 100644 index 00000000..5cf29b9d --- /dev/null +++ b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/М.xml @@ -0,0 +1,23 @@ + + + + + М + + + ru + М + + + + false + false + false + false + false + false + false + DontUse + + + diff --git a/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/М/Ext/Module.bsl b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/М/Ext/Module.bsl new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/CommonModules/М/Ext/Module.bsl @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Configuration.xml b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Configuration.xml new file mode 100644 index 00000000..19aa5597 --- /dev/null +++ b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Configuration.xml @@ -0,0 +1,254 @@ + + + + + + 9cd510cd-abfc-11d4-9434-004095e12fc7 + 5a5b12bf-6195-4ec6-9345-74ac5d4a51e9 + + + 9fcd25a0-4822-11d4-9414-008048da11f9 + d63f4369-8cd6-48a9-9eb2-d59ee96c1cd7 + + + e3687481-0a87-462c-a166-9f34594f9bba + e056dc64-9149-4384-9d91-1bc0006f3b0a + + + 9de14907-ec23-4a07-96f0-85521cb6b53b + 400759d9-9144-4b60-a73b-6d4d54f8e608 + + + 51f2d5d8-ea4d-4064-8892-82951750031e + 5443c8e3-afcc-4853-8b92-7a211d728c9f + + + e68182ea-4237-4383-967f-90c1e3370bc7 + 8bdc6930-957c-456e-847f-d2126faa50ea + + + fb282519-d103-4dd3-bc12-cb271d631dfc + 3c5dc348-1768-4268-99d7-bae149d144c8 + + + + КонфS + + + ru + КонфS + + + + + Version8_3_27 + ManagedApplication + + PlatformApplication + + Russian + + + + + false + false + false + + + + + + + + + + + + + + + + + + + + + + Biometrics + true + + + Location + false + + + BackgroundLocation + false + + + BluetoothPrinters + false + + + WiFiPrinters + false + + + Contacts + false + + + Calendars + false + + + PushNotifications + false + + + LocalNotifications + false + + + InAppPurchases + false + + + PersonalComputerFileExchange + false + + + Ads + false + + + NumberDialing + false + + + CallProcessing + false + + + CallLog + false + + + AutoSendSMS + false + + + ReceiveSMS + false + + + SMSLog + false + + + Camera + false + + + Microphone + false + + + MusicLibrary + false + + + PictureAndVideoLibraries + false + + + AudioPlaybackAndVibration + false + + + BackgroundAudioPlaybackAndVibration + false + + + InstallPackages + false + + + OSBackup + true + + + ApplicationUsageStatistics + false + + + BarcodeScanning + false + + + BackgroundAudioRecording + false + + + AllFilesAccess + false + + + Videoconferences + false + + + NFC + false + + + DocumentScanning + false + + + SpeechToText + false + + + Geofences + false + + + IncomingShareRequests + false + + + AllIncomingShareRequestsTypesProcessing + false + + + + + + Normal + + + Language.Русский + + + + + + Managed + NotAutoFree + DontUse + DontUse + TaxiEnableVersion8_2 + DontUse + Version8_3_27 + + + + Русский + М + Второй + КрлфПС + + + \ No newline at end of file diff --git a/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Ext/ClientApplicationInterface.xml b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Ext/ClientApplicationInterface.xml new file mode 100644 index 00000000..4b374972 --- /dev/null +++ b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Ext/ClientApplicationInterface.xml @@ -0,0 +1,18 @@ + + + + + cbab57f2-a0f3-4f0a-89ea-4cb19570ab75 + + + + + b553047f-c9aa-4157-978d-448ecad24248 + + + + + + + + \ No newline at end of file diff --git a/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Languages/Русский.xml b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Languages/Русский.xml new file mode 100644 index 00000000..f642cb52 --- /dev/null +++ b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Languages/Русский.xml @@ -0,0 +1,16 @@ + + + + + Русский + + + ru + Русский + + + + ru + + + \ No newline at end of file diff --git a/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Subsystems/КрлфПС.xml b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Subsystems/КрлфПС.xml new file mode 100644 index 00000000..ee093fba --- /dev/null +++ b/tests/skills/cases/subsystem-edit/fixtures/crlf-subsystem/Subsystems/КрлфПС.xml @@ -0,0 +1,24 @@ + + + + + КрлфПС + + + ru + КРЛФ ПС + + + + true + true + false + + + + CommonModule.М + + + + + diff --git a/tests/skills/cases/subsystem-edit/roundtrip-crlf-preserve.json b/tests/skills/cases/subsystem-edit/roundtrip-crlf-preserve.json new file mode 100644 index 00000000..a6fe450c --- /dev/null +++ b/tests/skills/cases/subsystem-edit/roundtrip-crlf-preserve.json @@ -0,0 +1,18 @@ +{ + "name": "round-trip: add-content на CRLF+BOM+UTF-8 файле сохраняет стиль, без (issue #46)", + "setup": "fixture:crlf-subsystem", + "params": { "subsystemPath": "Subsystems/КрлфПС.xml" }, + "input": [ + { "operation": "add-content", "value": "CommonModule.Второй" } + ], + "expect": { + "preserves": { + "file": "Subsystems/КрлфПС.xml", + "bom": true, + "eol": "crlf", + "encoding": "UTF-8", + "finalNewline": true, + "noCR13": true + } + } +} diff --git a/tests/skills/runner.mjs b/tests/skills/runner.mjs index a713bbde..28d95a23 100644 --- a/tests/skills/runner.mjs +++ b/tests/skills/runner.mjs @@ -299,9 +299,7 @@ function normalizeXmlContent(text, opts = {}) { /<\?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). + // 2. Strip xmlns declarations (Python etree strips unused ones). // Skipped for Configuration.xml: those declarations are load-bearing (they back // xsi:type values like app:ApplicationUsePurpose in UsePurposes) and dropping them // is exactly the corruption of issue #38 — keeping them lets the test guard against it. @@ -347,6 +345,37 @@ function normalizeContent(text, config, relFile) { return s; } +// ─── Byte-style preservation check (round-trip #44/#46/#47) ───────────────── +// Проверяет СЫРЫЕ байты файла (в обход normalizeContent): BOM / EOL / регистр +// encoding / финальный перенос / отсутствие . spec: { file, bom, eol:"crlf"|"lf", +// encoding, finalNewline, noCR13 }. Возвращает массив ошибок. +function checkPreserves(workDir, spec) { + const errs = []; + const target = join(workDir, spec.file); + if (!existsSync(target)) { errs.push(`preserves: file not found: ${spec.file}`); return errs; } + const buf = readFileSync(target); + const hasBom = buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF; + const body = hasBom ? buf.subarray(3) : buf; + const text = body.toString('utf8'); + if (spec.bom !== undefined && hasBom !== spec.bom) + errs.push(`preserves: BOM expected ${spec.bom}, got ${hasBom}`); + if (spec.eol) { + const hasCR = body.includes(0x0d); + const wantCR = spec.eol === 'crlf'; + if (hasCR !== wantCR) errs.push(`preserves: EOL expected ${spec.eol} (CR=${wantCR}), got CR=${hasCR}`); + } + if (spec.encoding) { + const m = /encoding="([^"]+)"/.exec(text); + if (!m || m[1] !== spec.encoding) errs.push(`preserves: encoding expected "${spec.encoding}", got "${m ? m[1] : '?'}"`); + } + if (spec.finalNewline !== undefined) { + const endsNL = body.length > 0 && body[body.length - 1] === 0x0a; + if (endsNL !== spec.finalNewline) errs.push(`preserves: finalNewline expected ${spec.finalNewline}, got ${endsNL}`); + } + if (spec.noCR13 && text.includes(' ')) errs.push(`preserves: unexpected literal in output`); + return errs; +} + // ─── Snapshot comparison ──────────────────────────────────────────────────── // Capture raw byte contents of every file in dir, keyed by relative path. @@ -608,6 +637,11 @@ async function runCaseAsync(testCase, opts) { if (stdout.includes(needle)) errors.push(`stdout unexpectedly contains "${needle}"`); } } + if (caseData.expect?.preserves) { + const specs = Array.isArray(caseData.expect.preserves) + ? caseData.expect.preserves : [caseData.expect.preserves]; + for (const spec of specs) errors.push(...checkPreserves(workDir, spec)); + } if (errors.length === 0 && !caseData.expectError && !workspace.readOnly) { const snapshotConfig = { ...skillConfig.snapshot, runtime: opts.runtime }; if (opts.updateSnapshots) { @@ -785,6 +819,11 @@ function runCase(testCase, opts) { if (stdout.includes(needle)) errors.push(`stdout unexpectedly contains "${needle}"`); } } + if (caseData.expect?.preserves) { + const specs = Array.isArray(caseData.expect.preserves) + ? caseData.expect.preserves : [caseData.expect.preserves]; + for (const spec of specs) errors.push(...checkPreserves(workDir, spec)); + } // Snapshot comparison (skip for external/read-only workspaces) if (errors.length === 0 && !caseData.expectError && !workspace.readOnly) {