From 9475aa61714f3353bced32423a391122b564f920 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 19 Aug 2026 14:26:50 +0300 Subject: [PATCH] =?UTF-8?q?fix(skd-edit):=20=D0=BD=D0=B5=20=D1=81=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B0=D1=82=D1=8C=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B5=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20xmlns=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B5=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При пересборке поля навык сохранял через OuterXml/tostring и срезал из фрагмента ВСЕ объявления namespace. Для префиксов, объявленных в корне схемы, это верно — DOM переобъявляет их на фрагменте избыточно. Но объявление, которого в корне нет, значимо: xmlns:d5p1 живёт только локально на и связывает префикс, которым квалифицировано значение узла (d5p1:CatalogRef.X). Срез оставлял висячий префикс — XML остаётся well-formed (префикс в тексте парсер не проверяет), а 1С отвергает тип. Радиус — modify-field. Слепой срез заменён на Strip-InheritedXmlns / strip_inherited_xmlns: объявление выбрасывается, только если корень объявляет тот же префикс с тем же URI. Карта корня снимается с уже имевшегося RawRootOpening; разделитель \s+, поскольку корневой тег бывает разложен по строкам. Префикс и URI сравниваются ординально — в PS -eq и @{} регистронезависимы. Подставлено во все пять точек среза каждого порта; для корневых префиксов поведение не меняется. Кейс modify-field-ref-type: modify-field по ref-полю не был покрыт. Проверено, что кейс краснеет до правки — и по снэпшоту, и по skd-validate. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/skd-edit/scripts/skd-edit.ps1 | 42 +++++++++++-- .claude/skills/skd-edit/scripts/skd-edit.py | 36 +++++++++-- .../cases/skd-edit/modify-field-ref-type.json | 22 +++++++ .../modify-field-ref-type/Template.xml | 59 +++++++++++++++++++ 4 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 tests/skills/cases/skd-edit/modify-field-ref-type.json create mode 100644 tests/skills/cases/skd-edit/snapshots/modify-field-ref-type/Template.xml diff --git a/.claude/skills/skd-edit/scripts/skd-edit.ps1 b/.claude/skills/skd-edit/scripts/skd-edit.ps1 index 2f3d2852..dafcd97d 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.37 — Atomic 1C DCS editor (+esc_xml/esc_xml_text: разное экранирование атрибута и текста) +# skd-edit v1.38 — Atomic 1C DCS editor # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: парный .py собирает выражения автодат вне f-string ради совместимости с python 3.9 (PEP 701). param( @@ -335,6 +335,36 @@ function Parse-FieldShorthand { return $result } +# DOM отдаёт фрагмент через OuterXml как самостоятельный документ и переобъявляет на нём каждый +# префикс, который был в области видимости. Такие объявления избыточны: родительский контекст в точке +# вставки их уже даёт. Но объявление, которого в корне НЕТ, значимо — например xmlns:d5p1 на : +# только оно связывает префикс, которым квалифицировано значение узла (d5p1:CatalogRef.X). Срезать его +# нельзя, иначе отказ будет тихим: XML останется well-formed (префикс в тексте парсер не проверяет), а +# 1С отвергнет тип. Поэтому выбрасываем лишь унаследованное — префикс, объявленный в корне с тем же URI. +function Strip-InheritedXmlns([string]$raw) { + if ([string]::IsNullOrEmpty($raw)) { return $raw } + # Корень не распознан — сохраняем прежнее поведение, чтобы не менять статус-кво. + if (-not $script:RawRootOpening) { + return [regex]::Replace($raw, ' xmlns(?::\w+)?="[^"]*"', '') + } + # Dictionary, а не @{}: хэш в PS сравнивает ключи регистронезависимо, а префикс XML регистрозависим. + $rootNs = New-Object 'System.Collections.Generic.Dictionary[string,string]' + # В корне атрибуты бывают разложены по строкам — разделитель \s+, а не пробел. + foreach ($m in [regex]::Matches($script:RawRootOpening, '\s+xmlns(?::(\w+))?="([^"]*)"')) { + $rootNs[$m.Groups[1].Value] = $m.Groups[2].Value + } + return [regex]::Replace($raw, ' xmlns(?::(\w+))?="([^"]*)"', { + param($m) + $prefix = $m.Groups[1].Value + $uri = $m.Groups[2].Value + # URI регистрозависим: -eq в PS этого не даёт, сравниваем ординально. + if ($rootNs.ContainsKey($prefix) -and [string]::Equals($rootNs[$prefix], $uri, [System.StringComparison]::Ordinal)) { + return '' + } + return $m.Value + }) +} + function Read-FieldProperties($fieldEl) { $props = @{ dataPath = ""; field = ""; title = ""; type = "" @@ -353,7 +383,7 @@ function Read-FieldProperties($fieldEl) { # siblings when shorthand overrides only the ru content. Strip xmlns # redeclarations that OuterXml adds for sub-elements. $raw = $ch.OuterXml - $raw = [regex]::Replace($raw, ' xmlns(?::\w+)?="[^"]*"', '') + $raw = Strip-InheritedXmlns $raw $props._rawTitle = $raw # Also extract ru content as plain string (backward compat — used by # external consumers reading $existing.title). @@ -376,7 +406,7 @@ function Read-FieldProperties($fieldEl) { # .NET OuterXml re-declares xmlns on every element where the prefix is in # scope (because the fragment is treated as standalone). Strip these since # the parent context at insertion point already provides them. - $raw = [regex]::Replace($raw, ' xmlns(?::\w+)?="[^"]*"', '') + $raw = Strip-InheritedXmlns $raw $props["_rawValueType"] = $raw $typeEl = $null foreach ($gc in $ch.ChildNodes) { @@ -412,7 +442,7 @@ function Read-FieldProperties($fieldEl) { # Defense in depth: preserve OuterXml of unknown children so rebuild # doesn't silently drop them (custom , , etc.). $raw = $ch.OuterXml - $raw = [regex]::Replace($raw, ' xmlns(?::\w+)?="[^"]*"', '') + $raw = Strip-InheritedXmlns $raw $props._unknownChildren += $raw } } @@ -2455,7 +2485,7 @@ switch ($Operation) { $titleFrag = $null if ($existingTitle) { $rawTitle = $existingTitle.OuterXml - $rawTitle = [regex]::Replace($rawTitle, ' xmlns(?::\w+)?="[^"]*"', '') + $rawTitle = Strip-InheritedXmlns $rawTitle # Count occurrences — if >1, treat as multi-lang. $itemCount = ([regex]::Matches($rawTitle, '')).Count if ($itemCount -gt 1) { @@ -3730,7 +3760,7 @@ switch ($Operation) { # what the user explicitly set. if ($kv.Contains($gc.LocalName)) { continue } $raw = $gc.OuterXml - $raw = [regex]::Replace($raw, ' xmlns(?::\w+)?="[^"]*"', '') + $raw = Strip-InheritedXmlns $raw $preservedRoleChildren += $raw } Remove-NodeWithWhitespace $oldRole diff --git a/.claude/skills/skd-edit/scripts/skd-edit.py b/.claude/skills/skd-edit/scripts/skd-edit.py index ec1afa1c..c1b71ca2 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.37 — Atomic 1C DCS editor (Python port) (+esc_xml/esc_xml_text: разное экранирование атрибута и текста) +# skd-edit v1.38 — Atomic 1C DCS editor (Python port) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -435,6 +435,30 @@ def parse_field_shorthand(s): return result +# lxml отдаёт фрагмент через tostring как самостоятельный документ и переобъявляет на нём каждый +# префикс, который был в области видимости. Такие объявления избыточны: родительский контекст в точке +# вставки их уже даёт. Но объявление, которого в корне НЕТ, значимо — например xmlns:d5p1 на : +# только оно связывает префикс, которым квалифицировано значение узла (d5p1:CatalogRef.X). Срезать его +# нельзя, иначе отказ будет тихим: XML останется well-formed (префикс в тексте парсер не проверяет), а +# 1С отвергнет тип. Поэтому выбрасываем лишь унаследованное — префикс, объявленный в корне с тем же URI. +def strip_inherited_xmlns(raw): + if not raw: + return raw + # Корень не распознан — сохраняем прежнее поведение, чтобы не менять статус-кво. + if not raw_root_opening: + return re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) + root_ns = {} + # В корне атрибуты бывают разложены по строкам — разделитель \s+, а не пробел. + for m in re.finditer(r'\s+xmlns(?::(\w+))?="([^"]*)"', raw_root_opening): + root_ns[m.group(1) or ""] = m.group(2) + + def _drop_inherited(m): + prefix = m.group(1) or "" + return "" if root_ns.get(prefix) == m.group(2) else m.group(0) + + return re.sub(r' xmlns(?::(\w+))?="([^"]*)"', _drop_inherited, raw) + + def read_field_properties(field_el): props = {"dataPath": "", "field": "", "title": "", "type": "", "roles": [], "restrict": [], "_rawTypeText": "", "_rawTitle": None, "_unknownChildren": []} @@ -450,7 +474,7 @@ def read_field_properties(field_el): elif ln == "title": # Preserve full multi-lang title OuterXml; also extract ru content for compat. raw = etree.tostring(ch, encoding="unicode", with_tail=False) - raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) + raw = strip_inherited_xmlns(raw) props["_rawTitle"] = raw for item in ch: if isinstance(item.tag, str) and local_name(item) == "item": @@ -469,7 +493,7 @@ def read_field_properties(field_el): # expressible via shorthand. Strip xmlns declarations that lxml re-emits when # serializing a sub-element (parent context already provides them). raw = etree.tostring(ch, encoding="unicode", with_tail=False) - raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) + raw = strip_inherited_xmlns(raw) props["_rawValueType"] = raw for gc in ch: if isinstance(gc.tag, str) and local_name(gc) == "Type": @@ -494,7 +518,7 @@ def read_field_properties(field_el): # Defense in depth: preserve OuterXml of unknown children so rebuild # doesn't silently drop them (custom , , etc.). raw = etree.tostring(ch, encoding="unicode", with_tail=False) - raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) + raw = strip_inherited_xmlns(raw) props["_unknownChildren"].append(raw) return props @@ -2238,7 +2262,7 @@ elif operation == "modify-parameter": title_frag = None if existing_title is not None: raw_title = etree.tostring(existing_title, encoding="unicode", with_tail=False) - raw_title = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw_title) + raw_title = strip_inherited_xmlns(raw_title) if raw_title.count("") > 1: title_frag = child_indent + patch_mltext_ru(raw_title, title_val, child_indent) remove_node_with_whitespace(existing_title) @@ -3201,7 +3225,7 @@ elif operation == "set-field-role": if ln in kv_keys: continue raw = etree.tostring(gc, encoding="unicode", with_tail=False) - raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) + raw = strip_inherited_xmlns(raw) preserved_role_children.append(raw) remove_node_with_whitespace(old_role) diff --git a/tests/skills/cases/skd-edit/modify-field-ref-type.json b/tests/skills/cases/skd-edit/modify-field-ref-type.json new file mode 100644 index 00000000..2d2f4a41 --- /dev/null +++ b/tests/skills/cases/skd-edit/modify-field-ref-type.json @@ -0,0 +1,22 @@ +{ + "name": "modify-field: локальное объявление xmlns у ссылочного типа сохраняется", + "preRun": [ + { + "script": "skd-compile/scripts/skd-compile", + "input": { + "dataSets": [{ + "name": "Основной", + "query": "ВЫБРАТЬ Т.Сотрудник, Т.Сумма ИЗ Регистр КАК Т", + "fields": ["Сотрудник: CatalogRef.Сотрудники", "Сумма: decimal(15,2)"] + }] + }, + "args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" } + } + ], + "params": { + "templatePath": "Template.xml", + "operation": "modify-field", + "value": "Сотрудник [Работник]" + }, + "idempotent": true +} diff --git a/tests/skills/cases/skd-edit/snapshots/modify-field-ref-type/Template.xml b/tests/skills/cases/skd-edit/snapshots/modify-field-ref-type/Template.xml new file mode 100644 index 00000000..0bbe257c --- /dev/null +++ b/tests/skills/cases/skd-edit/snapshots/modify-field-ref-type/Template.xml @@ -0,0 +1,59 @@ + + + + ИсточникДанных1 + Local + + + Основной + + Сотрудник + Сотрудник + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Работник</v8:content> + </v8:item> + + + d5p1:CatalogRef.Сотрудники + + + + Сумма + Сумма + + xs:decimal + + 15 + 2 + Any + + + + ИсточникДанных1 + ВЫБРАТЬ Т.Сотрудник, Т.Сумма ИЗ Регистр КАК Т + + + Основной + + + ru + Основной + + + + + + + + + + + + + + + + + \ No newline at end of file