From 23d2cb42de2b830ad13436df374ba529fc69a07b Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 19 May 2026 13:38:31 +0300 Subject: [PATCH] fix(skd-edit): preserve , detect line endings, drop CRLF leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeted follow-ups к round-trip фиксу: * modify-field больше не теряет при перестройке поля — Read-FieldProperties сохраняет полный OuterXml элемента (StringQualifiers, NumberQualifiers, DateQualifiers и т.п.), Build-FieldFragment отдаёт его обратно. Лишние xmlns-декларации, добавляемые сериализатором при выгрузке поддерева, стрипаются регексом. * Line-ending convention теперь определяется при load (CRLF vs LF) и единообразно применяется в финале save. Раньше CreateWhitespace и Build-*Fragment везде использовали CRLF, что приводило к смешанным переносам в LF-исходниках (и наоборот) и к non-idempotent выходу modify-parameter title (run 1 → \n\t\t\r\n... → run 2 → \r\n\t\t<title>\r\n...). * PS Insert-BeforeElement переведён на LF; все -join "`r`n" → "`n"; py "\r\n".join → "\n". Конечная нормализация переносов делается в save в соответствии со script:LineEnding. * preserve-entities-modify-parameter-title.json теперь idempotent: true (после фикса CRLF leak'а двойной прогон byte-identical). На реальной схеме diff после modify-field составил 30 строк: целевая вставка title плюс полезная одноразовая коррекция ранее повреждённых " в text-content <dcsat:expression>. modify-field идемпотентен. skd-edit v1.19 -> v1.20. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .claude/skills/skd-edit/scripts/skd-edit.ps1 | 103 +++++++++++------- .claude/skills/skd-edit/scripts/skd-edit.py | 92 ++++++++++------ ...serve-entities-modify-parameter-title.json | 3 +- 3 files changed, 125 insertions(+), 73 deletions(-) diff --git a/.claude/skills/skd-edit/scripts/skd-edit.ps1 b/.claude/skills/skd-edit/scripts/skd-edit.ps1 index c83281c0..784264a4 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.19 — Atomic 1C DCS editor +# skd-edit v1.20 — Atomic 1C DCS editor # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -203,7 +203,15 @@ function Read-FieldProperties($fieldEl) { } } "valueType" { - # Read type info — store the raw element for now, we'll use type from parsed if overridden + # Preserve the entire <valueType> OuterXml so rebuild can re-emit qualifiers + # (StringQualifiers, NumberQualifiers, DateQualifiers, etc.) that would + # otherwise be lost. Also extract Type string for type-override shorthand. + $raw = $ch.OuterXml + # .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+)?="[^"]*"', '') + $props["_rawValueType"] = $raw $typeEl = $null foreach ($gc in $ch.ChildNodes) { if ($gc.NodeType -eq 'Element' -and $gc.LocalName -eq 'Type') { @@ -727,7 +735,7 @@ function Build-ValueTypeXml { if ($typeStr -eq "boolean") { $lines += "$indent<v8:Type>xs:boolean</v8:Type>" - return $lines -join "`r`n" + return $lines -join "`n" } if ($typeStr -match '^string(\((\d+)\))?$') { @@ -737,7 +745,7 @@ function Build-ValueTypeXml { $lines += "$indent`t<v8:Length>$len</v8:Length>" $lines += "$indent`t<v8:AllowedLength>Variable</v8:AllowedLength>" $lines += "$indent</v8:StringQualifiers>" - return $lines -join "`r`n" + return $lines -join "`n" } if ($typeStr -match '^decimal\((\d+),(\d+)(,nonneg)?\)$') { @@ -750,7 +758,7 @@ function Build-ValueTypeXml { $lines += "$indent`t<v8:FractionDigits>$fraction</v8:FractionDigits>" $lines += "$indent`t<v8:AllowedSign>$sign</v8:AllowedSign>" $lines += "$indent</v8:NumberQualifiers>" - return $lines -join "`r`n" + return $lines -join "`n" } if ($typeStr -match '^(date|dateTime)$') { @@ -762,26 +770,26 @@ function Build-ValueTypeXml { $lines += "$indent<v8:DateQualifiers>" $lines += "$indent`t<v8:DateFractions>$fractions</v8:DateFractions>" $lines += "$indent</v8:DateQualifiers>" - return $lines -join "`r`n" + return $lines -join "`n" } if ($typeStr -eq "StandardPeriod") { $lines += "$indent<v8:Type>v8:StandardPeriod</v8:Type>" - return $lines -join "`r`n" + return $lines -join "`n" } if ($typeStr -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef)\.') { $lines += "$indent<v8:Type xmlns:d5p1=`"http://v8.1c.ru/8.1/data/enterprise/current-config`">d5p1:$(Esc-Xml $typeStr)</v8:Type>" - return $lines -join "`r`n" + return $lines -join "`n" } if ($typeStr.Contains('.')) { $lines += "$indent<v8:Type xmlns:d5p1=`"http://v8.1c.ru/8.1/data/enterprise/current-config`">d5p1:$(Esc-Xml $typeStr)</v8:Type>" - return $lines -join "`r`n" + return $lines -join "`n" } $lines += "$indent<v8:Type>$(Esc-Xml $typeStr)</v8:Type>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-MLTextXml { @@ -793,7 +801,7 @@ function Build-MLTextXml { $lines += "$indent`t`t<v8:content>$(Esc-Xml $text)</v8:content>" $lines += "$indent`t</v8:item>" $lines += "$indent</$tag>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-RoleXml { @@ -812,7 +820,7 @@ function Build-RoleXml { } } $lines += "$indent</role>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-RestrictionXml { @@ -834,7 +842,7 @@ function Build-RestrictionXml { } } $lines += "$indent</useRestriction>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-FieldFragment { @@ -857,14 +865,18 @@ function Build-FieldFragment { $roleXml = Build-RoleXml -roles $parsed.roles -indent "$i`t" if ($roleXml) { $lines += $roleXml } - if ($parsed.type) { + if ($parsed.rawValueType) { + # Preserve original <valueType> verbatim — keeps qualifiers (StringQualifiers, + # NumberQualifiers, DateQualifiers, …) that aren't expressible via shorthand. + $lines += "$i`t" + $parsed.rawValueType + } elseif ($parsed.type) { $lines += "$i`t<valueType>" $lines += (Build-ValueTypeXml -typeStr $parsed.type -indent "$i`t`t") $lines += "$i`t</valueType>" } $lines += "$i</field>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-TotalFragment { @@ -876,7 +888,7 @@ function Build-TotalFragment { $lines += "$i`t<dataPath>$(Esc-Xml $parsed.dataPath)</dataPath>" $lines += "$i`t<expression>$(Esc-Xml $parsed.expression)</expression>" $lines += "$i</totalField>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-CalcFieldFragment { @@ -903,7 +915,7 @@ function Build-CalcFieldFragment { } $lines += "$i</calculatedField>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-ParamValueXml { @@ -1010,7 +1022,7 @@ function Build-ParamFragment { } $lines += "$i</parameter>" - $fragments += ($lines -join "`r`n") + $fragments += ($lines -join "`n") if ($parsed.autoDates) { $paramName = $parsed.name @@ -1027,7 +1039,7 @@ function Build-ParamFragment { $bLines += "$i`t<useRestriction>true</useRestriction>" $bLines += "$i`t<expression>$(Esc-Xml "&$paramName.ДатаНачала")</expression>" $bLines += "$i</parameter>" - $fragments += ($bLines -join "`r`n") + $fragments += ($bLines -join "`n") $eLines = @() $eLines += "$i<parameter>" @@ -1040,7 +1052,7 @@ function Build-ParamFragment { $eLines += "$i`t<useRestriction>true</useRestriction>" $eLines += "$i`t<expression>$(Esc-Xml "&$paramName.ДатаОкончания")</expression>" $eLines += "$i</parameter>" - $fragments += ($eLines -join "`r`n") + $fragments += ($eLines -join "`n") } return ,$fragments @@ -1075,7 +1087,7 @@ function Build-FilterItemFragment { } $lines += "$i</dcsset:item>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-SelectionItemFragment { @@ -1116,7 +1128,7 @@ function Build-SelectionItemFragment { $lines += "$i`t<dcsset:field>$(Esc-Xml $fieldName)</dcsset:field>" $lines += "$i</dcsset:item>" } - return $lines -join "`r`n" + return $lines -join "`n" } function Build-DataParamFragment { @@ -1158,7 +1170,7 @@ function Build-DataParamFragment { } $lines += "$i</dcscor:item>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-OrderItemFragment { @@ -1174,7 +1186,7 @@ function Build-OrderItemFragment { $lines += "$i`t<dcsset:orderType>$($parsed.direction)</dcsset:orderType>" $lines += "$i</dcsset:item>" } - return $lines -join "`r`n" + return $lines -join "`n" } function Build-DataSetLinkFragment { @@ -1191,7 +1203,7 @@ function Build-DataSetLinkFragment { $lines += "$i`t<parameter>$(Esc-Xml $parsed.parameter)</parameter>" } $lines += "$i</dataSetLink>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-DataSetQueryFragment { @@ -1204,7 +1216,7 @@ function Build-DataSetQueryFragment { $lines += "$i`t<dataSource>$(Esc-Xml $parsed.dataSource)</dataSource>" $lines += "$i`t<query>$(Esc-Xml $parsed.query)</query>" $lines += "$i</dataSet>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-VariantFragment { @@ -1230,7 +1242,7 @@ function Build-VariantFragment { $lines += "$i`t`t</dcsset:item>" $lines += "$i`t</dcsset:settings>" $lines += "$i</settingsVariant>" - return $lines -join "`r`n" + return $lines -join "`n" } function Emit-FilterComparison { @@ -1312,7 +1324,7 @@ function Build-ConditionalAppearanceItemFragment { $lines += "$i`t</dcsset:appearance>" $lines += "$i</dcsset:item>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-StructureItemFragment { @@ -1364,7 +1376,7 @@ function Build-StructureItemFragment { } $lines += "$i</dcsset:item>" - return $lines -join "`r`n" + return $lines -join "`n" } function Build-OutputParamFragment { @@ -1392,7 +1404,7 @@ function Build-OutputParamFragment { } $lines += "$i</dcscor:item>" - return $lines -join "`r`n" + return $lines -join "`n" } # --- 5. XML helpers --- @@ -1437,7 +1449,9 @@ function Get-ChildIndent($container) { } function Insert-BeforeElement($container, $newNode, $refNode, $childIndent) { - $ws = $xmlDoc.CreateWhitespace("`r`n$childIndent") + # LF line endings — 1С DCS files use LF consistently; CRLF causes idempotency + # leaks when modify-* removes one whitespace and inserts a different-style one. + $ws = $xmlDoc.CreateWhitespace("`n$childIndent") if ($refNode) { $container.InsertBefore($ws, $refNode) | Out-Null $container.InsertBefore($newNode, $ws) | Out-Null @@ -1450,7 +1464,7 @@ function Insert-BeforeElement($container, $newNode, $refNode, $childIndent) { $container.AppendChild($ws) | Out-Null $container.AppendChild($newNode) | Out-Null $parentIndent = if ($childIndent.Length -gt 1) { $childIndent.Substring(0, $childIndent.Length - 1) } else { "" } - $closeWs = $xmlDoc.CreateWhitespace("`r`n$parentIndent") + $closeWs = $xmlDoc.CreateWhitespace("`n$parentIndent") $container.AppendChild($closeWs) | Out-Null } } @@ -1729,6 +1743,10 @@ $script:RawOriginal = [System.IO.File]::ReadAllText($resolvedPath, [System.Text. $rootOpenMatch = [regex]::Match($script:RawOriginal, '<DataCompositionSchema\b[^>]*>') if ($rootOpenMatch.Success) { $script:RawRootOpening = $rootOpenMatch.Value } else { $script:RawRootOpening = $null } +# Detect line ending convention so save can normalize back to whatever the source used. +# 1С Designer writes CRLF on Windows; LF-edited files should stay LF. +$script:LineEnding = if ($script:RawOriginal.Contains("`r`n")) { "`r`n" } else { "`n" } + $xmlDoc = New-Object System.Xml.XmlDocument $xmlDoc.PreserveWhitespace = $true $xmlDoc.Load($resolvedPath) @@ -2021,7 +2039,7 @@ switch ($Operation) { } } $valueLines = Build-ParamValueXml -type $declaredType -value $value -indent $childIndent - $fragXml = $valueLines -join "`r`n" + $fragXml = $valueLines -join "`n" $wasExisting = ($null -ne $existing) if ($existing) { @@ -2107,7 +2125,7 @@ switch ($Operation) { } foreach ($av in $avItems) { $avLines = Build-AvailableValueFragment -item $av -declaredType $declaredType -indent $childIndent - $fragXml = $avLines -join "`r`n" + $fragXml = $avLines -join "`n" $nodes = Import-Fragment $xmlDoc $fragXml foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $refNode $childIndent @@ -2671,7 +2689,7 @@ switch ($Operation) { $lines += "$itemIndent`t<dcsset:periodAdditionBegin xsi:type=`"xs:dateTime`">0001-01-01T00:00:00</dcsset:periodAdditionBegin>" $lines += "$itemIndent`t<dcsset:periodAdditionEnd xsi:type=`"xs:dateTime`">0001-01-01T00:00:00</dcsset:periodAdditionEnd>" $lines += "$itemIndent</dcsset:item>" - $fragXml = $lines -join "`r`n" + $fragXml = $lines -join "`n" $nodes = Import-Fragment $xmlDoc $fragXml foreach ($node in $nodes) { Insert-BeforeElement $giEl $node $null $itemIndent @@ -2992,7 +3010,7 @@ switch ($Operation) { } else { $valLines += "$itemIndent<dcscor:value xsi:type=`"xs:string`">$(Esc-Xml "$($parsed.value)")</dcscor:value>" } - $valXml = $valLines -join "`r`n" + $valXml = $valLines -join "`n" $valNodes = Import-Fragment $xmlDoc $valXml foreach ($node in $valNodes) { Insert-BeforeElement $dpItem $node $null $itemIndent @@ -3056,6 +3074,9 @@ switch ($Operation) { type = if ($parsed.type) { $parsed.type } else { $existing.type } roles = if ($parsed.roles -and $parsed.roles.Count -gt 0) { $parsed.roles } else { $existing.roles } restrict = if ($parsed.restrict -and $parsed.restrict.Count -gt 0) { $parsed.restrict } else { $existing.restrict } + # Preserve raw <valueType> only when user did NOT override type via shorthand — + # otherwise the override path rebuilds valueType from $parsed.type. + rawValueType = if ($parsed.type) { $null } else { $existing._rawValueType } } # Remember position (NextSibling after whitespace) @@ -3143,7 +3164,7 @@ switch ($Operation) { $lines += "$fieldIndent`t<dcscom:$k>$(Esc-Xml $kv[$k])</dcscom:$k>" } $lines += "$fieldIndent</role>" - $fragXml = $lines -join "`r`n" + $fragXml = $lines -join "`n" # Insert before <valueType>, else before <inputParameters>, else at end $refNode = $null @@ -3461,6 +3482,14 @@ $content = [regex]::Replace( # (`<foo bar="x" />`) but 1C-Designer writes `<foo bar="x"/>`. Strip the space. $content = [regex]::Replace($content, '(?<=\S) />', '/>') +# (4) normalize line endings to match source — operations may mix LF (from new +# fragments) with whatever the source used (CRLF on Windows, LF on Linux/git). +if ($script:LineEnding -eq "`r`n") { + $content = $content -replace '(?<!\r)\n', "`r`n" +} else { + $content = $content -replace "`r`n", "`n" +} + $enc = New-Object System.Text.UTF8Encoding($true) [System.IO.File]::WriteAllText($resolvedPath, $content, $enc) diff --git a/.claude/skills/skd-edit/scripts/skd-edit.py b/.claude/skills/skd-edit/scripts/skd-edit.py index e896deb0..a1e0d2e5 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.19 — Atomic 1C DCS editor (Python port) +# skd-edit v1.20 — Atomic 1C DCS editor (Python port) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import os @@ -231,6 +231,13 @@ def read_field_properties(field_el): if isinstance(gc.tag, str) and local_name(gc) == "content": props["title"] = (gc.text or "").strip() elif ln == "valueType": + # Preserve full <valueType> serialization so rebuild can re-emit qualifiers + # (StringQualifiers, NumberQualifiers, DateQualifiers, …) that aren't + # 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") + raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) + props["_rawValueType"] = raw for gc in ch: if isinstance(gc.tag, str) and local_name(gc) == "Type": props["_rawTypeText"] = (gc.text or "").strip() @@ -683,7 +690,7 @@ def build_value_type_xml(type_str, indent): if type_str == "boolean": lines.append(f"{indent}<v8:Type>xs:boolean</v8:Type>") - return "\r\n".join(lines) + return "\n".join(lines) m = re.match(r'^string(\((\d+)\))?$', type_str) if m: @@ -693,7 +700,7 @@ def build_value_type_xml(type_str, indent): lines.append(f"{indent}\t<v8:Length>{length}</v8:Length>") lines.append(f"{indent}\t<v8:AllowedLength>Variable</v8:AllowedLength>") lines.append(f"{indent}</v8:StringQualifiers>") - return "\r\n".join(lines) + return "\n".join(lines) m = re.match(r'^decimal\((\d+),(\d+)(,nonneg)?\)$', type_str) if m: @@ -705,7 +712,7 @@ def build_value_type_xml(type_str, indent): lines.append(f"{indent}\t<v8:FractionDigits>{fraction}</v8:FractionDigits>") lines.append(f"{indent}\t<v8:AllowedSign>{sign}</v8:AllowedSign>") lines.append(f"{indent}</v8:NumberQualifiers>") - return "\r\n".join(lines) + return "\n".join(lines) m = re.match(r'^(date|dateTime)$', type_str) if m: @@ -714,22 +721,22 @@ def build_value_type_xml(type_str, indent): lines.append(f"{indent}<v8:DateQualifiers>") lines.append(f"{indent}\t<v8:DateFractions>{fractions}</v8:DateFractions>") lines.append(f"{indent}</v8:DateQualifiers>") - return "\r\n".join(lines) + return "\n".join(lines) if type_str == "StandardPeriod": lines.append(f"{indent}<v8:Type>v8:StandardPeriod</v8:Type>") - return "\r\n".join(lines) + return "\n".join(lines) if re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef)\.', type_str): lines.append(f'{indent}<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:{esc_xml(type_str)}</v8:Type>') - return "\r\n".join(lines) + return "\n".join(lines) if "." in type_str: lines.append(f'{indent}<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:{esc_xml(type_str)}</v8:Type>') - return "\r\n".join(lines) + return "\n".join(lines) lines.append(f"{indent}<v8:Type>{esc_xml(type_str)}</v8:Type>") - return "\r\n".join(lines) + return "\n".join(lines) def build_mltext_xml(tag, text, indent): @@ -741,7 +748,7 @@ def build_mltext_xml(tag, text, indent): f"{indent}\t</v8:item>", f"{indent}</{tag}>", ] - return "\r\n".join(lines) + return "\n".join(lines) def build_role_xml(roles, indent): @@ -755,7 +762,7 @@ def build_role_xml(roles, indent): else: lines.append(f"{indent}\t<dcscom:{role}>true</dcscom:{role}>") lines.append(f"{indent}</role>") - return "\r\n".join(lines) + return "\n".join(lines) def build_restriction_xml(restrict, indent): @@ -768,7 +775,7 @@ def build_restriction_xml(restrict, indent): if xml_name: lines.append(f"{indent}\t<{xml_name}>true</{xml_name}>") lines.append(f"{indent}</useRestriction>") - return "\r\n".join(lines) + return "\n".join(lines) def build_field_fragment(parsed, indent): @@ -787,13 +794,17 @@ def build_field_fragment(parsed, indent): if role_xml: lines.append(role_xml) - if parsed.get("type"): + if parsed.get("rawValueType"): + # Preserve original <valueType> verbatim — keeps qualifiers (StringQualifiers, + # NumberQualifiers, DateQualifiers, …) that aren't expressible via shorthand. + lines.append(f"{i}\t" + parsed["rawValueType"]) + elif parsed.get("type"): lines.append(f"{i}\t<valueType>") lines.append(build_value_type_xml(parsed["type"], f"{i}\t\t")) lines.append(f"{i}\t</valueType>") lines.append(f"{i}</field>") - return "\r\n".join(lines) + return "\n".join(lines) def build_total_fragment(parsed, indent): @@ -804,7 +815,7 @@ def build_total_fragment(parsed, indent): f"{i}\t<expression>{esc_xml(parsed['expression'])}</expression>", f"{i}</totalField>", ] - return "\r\n".join(lines) + return "\n".join(lines) def build_calc_field_fragment(parsed, indent): @@ -823,7 +834,7 @@ def build_calc_field_fragment(parsed, indent): lines.append(build_value_type_xml(parsed["type"], f"{i}\t\t")) lines.append(f"{i}\t</valueType>") lines.append(f"{i}</calculatedField>") - return "\r\n".join(lines) + return "\n".join(lines) def build_param_value_xml(type_str, value, indent, tag_name="value", tag_ns=""): @@ -897,7 +908,7 @@ def build_param_fragment(parsed, indent): lines.append(f"{i}\t<use>Always</use>") lines.append(f"{i}</parameter>") - fragments.append("\r\n".join(lines)) + fragments.append("\n".join(lines)) if parsed.get("autoDates"): param_name = parsed["name"] @@ -914,7 +925,7 @@ def build_param_fragment(parsed, indent): f"{i}\t<expression>{esc_xml('&' + param_name + '.\u0414\u0430\u0442\u0430\u041d\u0430\u0447\u0430\u043b\u0430')}</expression>", f"{i}</parameter>", ] - fragments.append("\r\n".join(b_lines)) + fragments.append("\n".join(b_lines)) e_lines = [ f"{i}<parameter>", @@ -928,7 +939,7 @@ def build_param_fragment(parsed, indent): f"{i}\t<expression>{esc_xml('&' + param_name + '.\u0414\u0430\u0442\u0430\u041e\u043a\u043e\u043d\u0447\u0430\u043d\u0438\u044f')}</expression>", f"{i}</parameter>", ] - fragments.append("\r\n".join(e_lines)) + fragments.append("\n".join(e_lines)) return fragments @@ -955,7 +966,7 @@ def build_filter_item_fragment(parsed, indent): lines.append(f"{i}\t<dcsset:userSettingID>{esc_xml(uid)}</dcsset:userSettingID>") lines.append(f"{i}</dcsset:item>") - return "\r\n".join(lines) + return "\n".join(lines) def build_selection_item_fragment(field_name, indent): @@ -986,13 +997,13 @@ def build_selection_item_fragment(field_name, indent): lines.append(f"{i}\t</dcsset:item>") lines.append(f"{i}\t<dcsset:placement>Auto</dcsset:placement>") lines.append(f"{i}</dcsset:item>") - return "\r\n".join(lines) + return "\n".join(lines) lines = [ f'{i}<dcsset:item xsi:type="dcsset:SelectedItemField">', f"{i}\t<dcsset:field>{esc_xml(field_name)}</dcsset:field>", f"{i}</dcsset:item>", ] - return "\r\n".join(lines) + return "\n".join(lines) def build_data_param_fragment(parsed, indent): @@ -1027,7 +1038,7 @@ def build_data_param_fragment(parsed, indent): lines.append(f"{i}\t<dcsset:userSettingID>{esc_xml(uid)}</dcsset:userSettingID>") lines.append(f"{i}</dcscor:item>") - return "\r\n".join(lines) + return "\n".join(lines) def build_order_item_fragment(parsed, indent): @@ -1040,7 +1051,7 @@ def build_order_item_fragment(parsed, indent): f"{i}\t<dcsset:orderType>{parsed['direction']}</dcsset:orderType>", f"{i}</dcsset:item>", ] - return "\r\n".join(lines) + return "\n".join(lines) def build_data_set_link_fragment(parsed, indent): @@ -1055,7 +1066,7 @@ def build_data_set_link_fragment(parsed, indent): if parsed.get("parameter"): lines.append(f"{i}\t<parameter>{esc_xml(parsed['parameter'])}</parameter>") lines.append(f"{i}</dataSetLink>") - return "\r\n".join(lines) + return "\n".join(lines) def build_data_set_query_fragment(parsed, indent): @@ -1067,7 +1078,7 @@ def build_data_set_query_fragment(parsed, indent): f"{i}\t<query>{esc_xml(parsed['query'])}</query>", f"{i}</dataSet>", ] - return "\r\n".join(lines) + return "\n".join(lines) def build_variant_fragment(parsed, indent): @@ -1092,7 +1103,7 @@ def build_variant_fragment(parsed, indent): f"{i}\t</dcsset:settings>", f"{i}</settingsVariant>", ] - return "\r\n".join(lines) + return "\n".join(lines) def _emit_filter_comparison(lines, f, indent): @@ -1159,7 +1170,7 @@ def build_conditional_appearance_item_fragment(parsed, indent): lines.append(f"{i}\t</dcsset:appearance>") lines.append(f"{i}</dcsset:item>") - return "\r\n".join(lines) + return "\n".join(lines) def build_structure_item_fragment(item, indent): @@ -1196,7 +1207,7 @@ def build_structure_item_fragment(item, indent): lines.append(build_structure_item_fragment(child, f"{i}\t")) lines.append(f"{i}</dcsset:item>") - return "\r\n".join(lines) + return "\n".join(lines) def build_output_param_fragment(parsed, indent): @@ -1219,7 +1230,7 @@ def build_output_param_fragment(parsed, indent): lines.append(f'{i}\t<dcscor:value xsi:type="{ptype}">{esc_xml(val)}</dcscor:value>') lines.append(f"{i}</dcscor:item>") - return "\r\n".join(lines) + return "\n".join(lines) # ── 5. XML helpers ────────────────────────────────────────── @@ -1505,6 +1516,9 @@ raw_original_text = raw_original_bytes.lstrip(b"\xef\xbb\xbf").decode("utf-8") _root_open_m = re.search(r"<DataCompositionSchema\b[^>]*>", raw_original_text, re.DOTALL) 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" + xml_parser = etree.XMLParser(remove_blank_text=False) tree = etree.parse(resolved_path, xml_parser) xml_doc = tree.getroot() @@ -1739,7 +1753,7 @@ elif operation == "modify-parameter": declared_type = re.sub(r'^d\d+p\d+:', '', (tnode.text or "").strip()) break value_lines = build_param_value_xml(declared_type, value, child_indent) - frag_xml = "\r\n".join(value_lines) + frag_xml = "\n".join(value_lines) was_existing = existing is not None if existing is not None: # Find next-element sibling as ref before removing @@ -1798,7 +1812,7 @@ elif operation == "modify-parameter": break for av in av_items: av_lines = build_available_value_fragment(av, declared_type, child_indent) - frag_xml = "\r\n".join(av_lines) + frag_xml = "\n".join(av_lines) nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(param_el, node, ref_node, child_indent) @@ -2223,7 +2237,7 @@ elif operation == "modify-structure": f'{item_indent}\t<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>', f'{item_indent}</dcsset:item>', ] - frag_xml = "\r\n".join(lines) + frag_xml = "\n".join(lines) for node in import_fragment(xml_doc, frag_xml): insert_before_element(gi_el, node, None, item_indent) @@ -2486,7 +2500,7 @@ elif operation == "modify-dataParameter": else: val_lines.append(f'{item_indent}<dcscor:value xsi:type="xs:string">{esc_xml(str(pv))}</dcscor:value>') - val_xml = "\r\n".join(val_lines) + val_xml = "\n".join(val_lines) val_nodes = import_fragment(xml_doc, val_xml) for node in val_nodes: insert_before_element(dp_item, node, None, item_indent) @@ -2532,6 +2546,8 @@ elif operation == "modify-field": "type": parsed["type"] if parsed.get("type") else existing["type"], "roles": parsed["roles"] if parsed.get("roles") else existing["roles"], "restrict": parsed["restrict"] if parsed.get("restrict") else existing["restrict"], + # Preserve raw <valueType> only when user did NOT override type via shorthand. + "rawValueType": None if parsed.get("type") else existing.get("_rawValueType"), } # Find next element sibling for position @@ -2605,7 +2621,7 @@ elif operation == "set-field-role": for k, v in kv: lines.append(f"{field_indent}\t<dcscom:{k}>{esc_xml(v)}</dcscom:{k}>") lines.append(f"{field_indent}</role>") - frag_xml = "\r\n".join(lines) + frag_xml = "\n".join(lines) ref_node = next((ch for ch in field_el if isinstance(ch.tag, str) and local_name(ch) in ("valueType", "inputParameters") and etree.QName(ch.tag).namespace == SCH_NS), None) for node in import_fragment(xml_doc, frag_xml): @@ -2870,6 +2886,12 @@ xml_text = re.sub( # Normalize self-closing tags: lxml writes `<foo bar="x"/>` already (no space), but be # 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. +if line_ending == "\r\n": + xml_text = re.sub(r"(?<!\r)\n", "\r\n", xml_text) +else: + xml_text = xml_text.replace("\r\n", "\n") xml_bytes = xml_text.encode("utf-8") if not xml_bytes.endswith(b"\n"): diff --git a/tests/skills/cases/skd-edit/preserve-entities-modify-parameter-title.json b/tests/skills/cases/skd-edit/preserve-entities-modify-parameter-title.json index dacd1072..44d1ebd5 100644 --- a/tests/skills/cases/skd-edit/preserve-entities-modify-parameter-title.json +++ b/tests/skills/cases/skd-edit/preserve-entities-modify-parameter-title.json @@ -5,5 +5,6 @@ "templatePath": "Template.xml", "operation": "modify-parameter", "value": "Период [Новый период]" - } + }, + "idempotent": true }