diff --git a/.claude/skills/skd-edit/scripts/skd-edit.ps1 b/.claude/skills/skd-edit/scripts/skd-edit.ps1 index 6ed914ea..c83281c0 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.18 — Atomic 1C DCS editor +# skd-edit v1.19 — Atomic 1C DCS editor # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -28,6 +28,10 @@ param( $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +# Dirty flag — set to $true by every successful mutation. If still $false at save time, +# the file is left untouched (NO-OP operations like [WARN] not found don't rewrite). +$script:Dirty = $false + # --- 1. Resolve path --- if (-not $TemplatePath.EndsWith(".xml")) { @@ -1717,6 +1721,14 @@ function Get-ContainerChildIndent($container) { # --- 6. Load XML --- +# Capture raw original BEFORE DOM parse — needed at save time to: +# (a) restore exact root opening tag (DOM serializer +# collapses multi-line xmlns into a single line); +# (b) detect NO-OP via byte-equality as an extra safety net. +$script:RawOriginal = [System.IO.File]::ReadAllText($resolvedPath, [System.Text.Encoding]::UTF8) +$rootOpenMatch = [regex]::Match($script:RawOriginal, ']*>') +if ($rootOpenMatch.Success) { $script:RawRootOpening = $rootOpenMatch.Value } else { $script:RawRootOpening = $null } + $xmlDoc = New-Object System.Xml.XmlDocument $xmlDoc.PreserveWhitespace = $true $xmlDoc.Load($resolvedPath) @@ -1767,7 +1779,7 @@ switch ($Operation) { Insert-BeforeElement $dsNode $node $refNode $childIndent } - Write-Host "[OK] Field `"$($parsed.dataPath)`" added to dataset `"$dsName`"" + $script:Dirty = $true; Write-Host "[OK] Field `"$($parsed.dataPath)`" added to dataset `"$dsName`"" if (-not $NoSelection) { $settings = Resolve-VariantSettings @@ -1783,7 +1795,7 @@ switch ($Operation) { foreach ($node in $selNodes) { Insert-BeforeElement $selection $node $null $selIndent } - Write-Host "[OK] Field `"$($parsed.dataPath)`" added to selection of variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Field `"$($parsed.dataPath)`" added to selection of variant `"$varName`"" } } } @@ -1819,7 +1831,7 @@ switch ($Operation) { Insert-BeforeElement $root $node $refNode $childIndent } - Write-Host "[OK] TotalField `"$($parsed.dataPath)`" = $($parsed.expression) added" + $script:Dirty = $true; Write-Host "[OK] TotalField `"$($parsed.dataPath)`" = $($parsed.expression) added" } } @@ -1853,7 +1865,7 @@ switch ($Operation) { Insert-BeforeElement $root $node $refNode $childIndent } - Write-Host "[OK] CalculatedField `"$($parsed.dataPath)`" = $($parsed.expression) added" + $script:Dirty = $true; Write-Host "[OK] CalculatedField `"$($parsed.dataPath)`" = $($parsed.expression) added" if (-not $NoSelection) { $settings = Resolve-VariantSettings @@ -1869,7 +1881,7 @@ switch ($Operation) { foreach ($node in $selNodes) { Insert-BeforeElement $selection $node $null $selIndent } - Write-Host "[OK] Field `"$($parsed.dataPath)`" added to selection of variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Field `"$($parsed.dataPath)`" added to selection of variant `"$varName`"" } } } @@ -1907,9 +1919,9 @@ switch ($Operation) { } } - Write-Host "[OK] Parameter `"$($parsed.name)`" added" + $script:Dirty = $true; Write-Host "[OK] Parameter `"$($parsed.name)`" added" if ($parsed.autoDates) { - Write-Host "[OK] Auto-parameters `"ДатаНачала`", `"ДатаОкончания`" added" + $script:Dirty = $true; Write-Host "[OK] Auto-parameters `"ДатаНачала`", `"ДатаОкончания`" added" } } } @@ -1966,7 +1978,7 @@ switch ($Operation) { foreach ($node in $titleNodes) { Insert-BeforeElement $paramEl $node $titleRef $childIndent } - Write-Host "[OK] Parameter `"$paramName`": title set to `"$titleVal`"" + $script:Dirty = $true; Write-Host "[OK] Parameter `"$paramName`": title set to `"$titleVal`"" } # Separate availableValue=... from simple kv pairs @@ -2033,10 +2045,10 @@ switch ($Operation) { Insert-BeforeElement $paramEl $node $refNode $childIndent } $verb = if ($wasExisting) { "updated" } else { "added" } - Write-Host "[OK] Parameter `"$paramName`": value $verb to $value" + $script:Dirty = $true; Write-Host "[OK] Parameter `"$paramName`": value $verb to $value" } elseif ($existing) { $existing.InnerText = $value - Write-Host "[OK] Parameter `"$paramName`": $key updated to $value" + $script:Dirty = $true; Write-Host "[OK] Parameter `"$paramName`": $key updated to $value" } else { # Schema order: ...value, useRestriction, availableValue*, denyIncompleteValues, use $refNode = $null @@ -2052,7 +2064,7 @@ switch ($Operation) { foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $refNode $childIndent } - Write-Host "[OK] Parameter `"$paramName`": $key=$value added" + $script:Dirty = $true; Write-Host "[OK] Parameter `"$paramName`": $key=$value added" } } } @@ -2101,7 +2113,7 @@ switch ($Operation) { Insert-BeforeElement $paramEl $node $refNode $childIndent } } - Write-Host "[OK] Parameter `"$paramName`": availableValue set to $($avItems.Count) item(s)" + $script:Dirty = $true; Write-Host "[OK] Parameter `"$paramName`": availableValue set to $($avItems.Count) item(s)" } # Process @hidden / @always flags (idempotent) @@ -2138,7 +2150,7 @@ switch ($Operation) { foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $refNode $childIndent } } - Write-Host "[OK] Parameter `"$paramName`": @hidden applied" + $script:Dirty = $true; Write-Host "[OK] Parameter `"$paramName`": @hidden applied" } if ($flagAlways) { @@ -2152,7 +2164,7 @@ switch ($Operation) { $nodes = Import-Fragment $xmlDoc "$childIndentAlways" foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $null $childIndent } } - Write-Host "[OK] Parameter `"$paramName`": @always applied" + $script:Dirty = $true; Write-Host "[OK] Parameter `"$paramName`": @always applied" } } } @@ -2230,7 +2242,7 @@ switch ($Operation) { } } - Write-Host "[OK] Parameter renamed: `"$oldName`" => `"$newName`" (expressions updated: $exprUpdated, dataParameters updated: $dpUpdated)" + $script:Dirty = $true; Write-Host "[OK] Parameter renamed: `"$oldName`" => `"$newName`" (expressions updated: $exprUpdated, dataParameters updated: $dpUpdated)" } } @@ -2307,7 +2319,7 @@ switch ($Operation) { Insert-BeforeElement $root $pe $anchor $childIndent } - Write-Host "[OK] Parameters reordered ($($allParams.Count) total, $($order.Count) explicit)" + $script:Dirty = $true; Write-Host "[OK] Parameters reordered ($($allParams.Count) total, $($order.Count) explicit)" } } @@ -2327,7 +2339,7 @@ switch ($Operation) { Insert-BeforeElement $filterEl $node $null $filterIndent } - Write-Host "[OK] Filter `"$($parsed.field) $($parsed.op)`" added to variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Filter `"$($parsed.field) $($parsed.op)`" added to variant `"$varName`"" } } @@ -2347,7 +2359,7 @@ switch ($Operation) { Insert-BeforeElement $dpEl $node $null $dpIndent } - Write-Host "[OK] DataParameter `"$($parsed.parameter)`" added to variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] DataParameter `"$($parsed.parameter)`" added to variant `"$varName`"" } } @@ -2389,7 +2401,7 @@ switch ($Operation) { } $desc = if ($parsed.field -eq "Auto") { "Auto" } else { "$($parsed.field) $($parsed.direction)" } - Write-Host "[OK] Order `"$desc`" added to variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Order `"$desc`" added to variant `"$varName`"" } } @@ -2452,7 +2464,7 @@ switch ($Operation) { } $target = if ($groupName) { "group `"$groupName`"" } else { "variant `"$varName`"" } - Write-Host "[OK] Selection `"$fieldName`" added to $target" + $script:Dirty = $true; Write-Host "[OK] Selection `"$fieldName`" added to $target" } } @@ -2469,7 +2481,7 @@ switch ($Operation) { # InnerText setter handles XML escaping automatically $queryEl.InnerText = Resolve-QueryValue $Value $script:queryBaseDir - Write-Host "[OK] Query replaced in dataset `"$dsName`"" + $script:Dirty = $true; Write-Host "[OK] Query replaced in dataset `"$dsName`"" } "patch-query" { @@ -2510,7 +2522,7 @@ switch ($Operation) { $queryEl.InnerText = $queryText.Replace($oldStr, $newStr) $suffix = if ($once) { " (1 occurrence)" } else { " ($count occurrence(s))" } - Write-Host "[OK] Query patched in dataset `"$dsName`": replaced '$oldStr'$suffix" + $script:Dirty = $true; Write-Host "[OK] Query patched in dataset `"$dsName`": replaced '$oldStr'$suffix" } } @@ -2528,9 +2540,9 @@ switch ($Operation) { $existingParam = Find-ElementByChildValue $outputEl "item" "parameter" $parsed.key $corNs if ($existingParam) { Remove-NodeWithWhitespace $existingParam - Write-Host "[OK] Replaced outputParameter `"$($parsed.key)`" in variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Replaced outputParameter `"$($parsed.key)`" in variant `"$varName`"" } else { - Write-Host "[OK] OutputParameter `"$($parsed.key)`" added to variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] OutputParameter `"$($parsed.key)`" added to variant `"$varName`"" } $fragXml = Build-OutputParamFragment -parsed $parsed -indent $outputIndent @@ -2572,7 +2584,7 @@ switch ($Operation) { } } - Write-Host "[OK] Structure set in variant `"$varName`": $Value" + $script:Dirty = $true; Write-Host "[OK] Structure set in variant `"$varName`": $Value" } "modify-structure" { @@ -2667,7 +2679,7 @@ switch ($Operation) { } $desc = if ($t.groupBy.Count -eq 0) { "details" } else { $t.groupBy -join ', ' } - Write-Host "[OK] Group `"$($t.name)`" groupItems updated: $desc" + $script:Dirty = $true; Write-Host "[OK] Group `"$($t.name)`" groupItems updated: $desc" } } @@ -2697,7 +2709,7 @@ switch ($Operation) { $desc = "$($parsed.source) > $($parsed.dest) on $($parsed.sourceExpr) = $($parsed.destExpr)" if ($parsed.parameter) { $desc += " [param $($parsed.parameter)]" } - Write-Host "[OK] DataSetLink `"$desc`" added" + $script:Dirty = $true; Write-Host "[OK] DataSetLink `"$desc`" added" } } @@ -2749,7 +2761,7 @@ switch ($Operation) { Insert-BeforeElement $root $node $refNode $childIndent } - Write-Host "[OK] DataSet `"$($parsed.name)`" added (dataSource=$dsSourceName)" + $script:Dirty = $true; Write-Host "[OK] DataSet `"$($parsed.name)`" added (dataSource=$dsSourceName)" } } @@ -2795,7 +2807,7 @@ switch ($Operation) { Insert-BeforeElement $root $node $refNode $childIndent } - Write-Host "[OK] Variant `"$($parsed.name)`" [`"$($parsed.presentation)`"] added" + $script:Dirty = $true; Write-Host "[OK] Variant `"$($parsed.name)`" [`"$($parsed.presentation)`"] added" } } @@ -2818,7 +2830,7 @@ switch ($Operation) { $desc = "$($parsed.param) = $($parsed.value)" if ($parsed.filter) { $desc += " when $($parsed.filter.field) $($parsed.filter.op)" } if ($parsed.fields -and $parsed.fields.Count -gt 0) { $desc += " for $($parsed.fields -join ', ')" } - Write-Host "[OK] ConditionalAppearance `"$desc`" added to variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] ConditionalAppearance `"$desc`" added to variant `"$varName`"" } } @@ -2828,7 +2840,7 @@ switch ($Operation) { $selection = Find-FirstElement $settings @("selection") $setNs if ($selection) { Clear-ContainerChildren $selection - Write-Host "[OK] Selection cleared in variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Selection cleared in variant `"$varName`"" } else { Write-Host "[INFO] No selection section in variant `"$varName`"" } @@ -2840,7 +2852,7 @@ switch ($Operation) { $orderEl = Find-FirstElement $settings @("order") $setNs if ($orderEl) { Clear-ContainerChildren $orderEl - Write-Host "[OK] Order cleared in variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Order cleared in variant `"$varName`"" } else { Write-Host "[INFO] No order section in variant `"$varName`"" } @@ -2852,7 +2864,7 @@ switch ($Operation) { $filterEl = Find-FirstElement $settings @("filter") $setNs if ($filterEl) { Clear-ContainerChildren $filterEl - Write-Host "[OK] Filter cleared in variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Filter cleared in variant `"$varName`"" } else { Write-Host "[INFO] No filter section in variant `"$varName`"" } @@ -2864,7 +2876,7 @@ switch ($Operation) { $caEl = Find-FirstElement $settings @("conditionalAppearance") $setNs if ($caEl) { Clear-ContainerChildren $caEl - Write-Host "[OK] ConditionalAppearance cleared in variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] ConditionalAppearance cleared in variant `"$varName`"" } else { Write-Host "[INFO] No conditionalAppearance section in variant `"$varName`"" } @@ -2927,7 +2939,7 @@ switch ($Operation) { Set-OrCreateChildElement $filterItem "userSettingID" $setNs $uid $itemIndent } - Write-Host "[OK] Filter `"$($parsed.field)`" modified in variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Filter `"$($parsed.field)`" modified in variant `"$varName`"" } } @@ -3014,7 +3026,7 @@ switch ($Operation) { Set-OrCreateChildElement $dpItem "userSettingID" $setNs $uid $itemIndent } - Write-Host "[OK] DataParameter `"$($parsed.parameter)`" modified in variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] DataParameter `"$($parsed.parameter)`" modified in variant `"$varName`"" } } @@ -3065,7 +3077,7 @@ switch ($Operation) { Insert-BeforeElement $dsNode $node $nextSib $childIndent } - Write-Host "[OK] Field `"$fieldName`" modified in dataset `"$dsName`"" + $script:Dirty = $true; Write-Host "[OK] Field `"$fieldName`" modified in dataset `"$dsName`"" } } @@ -3112,7 +3124,7 @@ switch ($Operation) { # Empty spec — remove only if ($flags.Count -eq 0 -and $kv.Count -eq 0) { - Write-Host "[OK] Field `"$dataPath`" role cleared" + $script:Dirty = $true; Write-Host "[OK] Field `"$dataPath`" role cleared" continue } @@ -3146,7 +3158,7 @@ switch ($Operation) { $desc = @() if ($flags.Count -gt 0) { $desc += ($flags | ForEach-Object { "@$_" }) -join ' ' } if ($kv.Count -gt 0) { $desc += ($kv.Keys | ForEach-Object { "$_=$($kv[$_])" }) -join ' ' } - Write-Host "[OK] Field `"$dataPath`" role set: $($desc -join ' ')" + $script:Dirty = $true; Write-Host "[OK] Field `"$dataPath`" role set: $($desc -join ' ')" } } @@ -3164,7 +3176,7 @@ switch ($Operation) { } Remove-NodeWithWhitespace $fieldEl - Write-Host "[OK] Field `"$fieldName`" removed from dataset `"$dsName`"" + $script:Dirty = $true; Write-Host "[OK] Field `"$fieldName`" removed from dataset `"$dsName`"" # Also remove from selection in variant try { @@ -3175,7 +3187,7 @@ switch ($Operation) { $selItem = Find-ElementByChildValue $selection "item" "field" $fieldName $setNs if ($selItem) { Remove-NodeWithWhitespace $selItem - Write-Host "[OK] Field `"$fieldName`" removed from selection of variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Field `"$fieldName`" removed from selection of variant `"$varName`"" } } } catch { @@ -3196,7 +3208,7 @@ switch ($Operation) { } Remove-NodeWithWhitespace $totalEl - Write-Host "[OK] TotalField `"$dataPath`" removed" + $script:Dirty = $true; Write-Host "[OK] TotalField `"$dataPath`" removed" } } @@ -3212,7 +3224,7 @@ switch ($Operation) { } Remove-NodeWithWhitespace $calcEl - Write-Host "[OK] CalculatedField `"$dataPath`" removed" + $script:Dirty = $true; Write-Host "[OK] CalculatedField `"$dataPath`" removed" # Also remove from selection try { @@ -3223,7 +3235,7 @@ switch ($Operation) { $selItem = Find-ElementByChildValue $selection "item" "field" $dataPath $setNs if ($selItem) { Remove-NodeWithWhitespace $selItem - Write-Host "[OK] Field `"$dataPath`" removed from selection of variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Field `"$dataPath`" removed from selection of variant `"$varName`"" } } } catch { } @@ -3242,7 +3254,7 @@ switch ($Operation) { } Remove-NodeWithWhitespace $paramEl - Write-Host "[OK] Parameter `"$paramName`" removed" + $script:Dirty = $true; Write-Host "[OK] Parameter `"$paramName`" removed" } } @@ -3266,7 +3278,7 @@ switch ($Operation) { } Remove-NodeWithWhitespace $filterItem - Write-Host "[OK] Filter for `"$fieldName`" removed from variant `"$varName`"" + $script:Dirty = $true; Write-Host "[OK] Filter for `"$fieldName`" removed from variant `"$varName`"" } } @@ -3400,7 +3412,7 @@ switch ($Operation) { $searchStart = $cellEnd + 1 } - Write-Host "[OK] $drillName → $tplName (param + $cellCount cell(s))" + $script:Dirty = $true; Write-Host "[OK] $drillName → $tplName (param + $cellCount cell(s))" } } @@ -3415,15 +3427,40 @@ switch ($Operation) { # Write directly — skip DOM save $enc = New-Object System.Text.UTF8Encoding($true) [System.IO.File]::WriteAllText($resolvedPath, $rawText, $enc) - Write-Host "[OK] Saved $resolvedPath" + $script:Dirty = $true; Write-Host "[OK] Saved $resolvedPath" exit 0 } } # --- 9. Save --- +if (-not $script:Dirty) { + Write-Host "[INFO] No changes -- file untouched" + exit 0 +} + $content = $xmlDoc.OuterXml $content = $content -replace '(?<=<\?xml[^?]*encoding=")utf-8(?=")', 'UTF-8' + +# Format-preserve post-processing: +# (1) restore the original raw opening tag — DOM collapses +# multi-line xmlns declarations into one line. +if ($script:RawRootOpening) { + $content = [regex]::Replace($content, ']*>', { param($m) $script:RawRootOpening }) +} + +# (2) re-escape `"` to " inside / text content (1C-style). +# Scope is anchored to those tag names so xsi:type="..." attribute quotes are untouched. +$content = [regex]::Replace( + $content, + '(<(?:\w+:)?(?:query|expression)\b[^>]*>)([\s\S]*?)()', + { param($m) $m.Groups[1].Value + $m.Groups[2].Value.Replace('"', '"') + $m.Groups[3].Value } +) + +# (3) normalize self-closing tags: `.NET XmlDocument` adds a space before `/>` +# (``) but 1C-Designer writes ``. Strip the space. +$content = [regex]::Replace($content, '(?<=\S) />', '/>') + $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 68565c93..e896deb0 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.18 — Atomic 1C DCS editor (Python port) +# skd-edit v1.19 — Atomic 1C DCS editor (Python port) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import os @@ -11,6 +11,10 @@ from lxml import etree sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") +# Dirty flag — set to True by every successful mutation. If still False at save time, +# the file is left untouched (NO-OP operations like [WARN] not found don't rewrite). +dirty = False + # ── arg parsing ────────────────────────────────────────────── VALID_OPS = [ @@ -1492,6 +1496,15 @@ def get_container_child_indent(container): # ── 6. Load XML ───────────────────────────────────────────── +# Capture raw original BEFORE parse — needed at save time to restore exact root +# opening tag (lxml's tostring() collapses multi-line +# xmlns into one line) and to detect NO-OP via byte-equality as a safety net. +with open(resolved_path, "rb") as _f: + raw_original_bytes = _f.read() +raw_original_text = raw_original_bytes.lstrip(b"\xef\xbb\xbf").decode("utf-8") +_root_open_m = re.search(r"]*>", raw_original_text, re.DOTALL) +raw_root_opening = _root_open_m.group(0) if _root_open_m else None + xml_parser = etree.XMLParser(remove_blank_text=False) tree = etree.parse(resolved_path, xml_parser) xml_doc = tree.getroot() @@ -1532,7 +1545,7 @@ if operation == "add-field": for node in nodes: insert_before_element(ds_node, node, ref_node, child_indent) - print(f'[OK] Field "{parsed["dataPath"]}" added to dataset "{ds_name}"') + dirty = True; print(f'[OK] Field "{parsed["dataPath"]}" added to dataset "{ds_name}"') if not no_selection: settings = resolve_variant_settings() @@ -1547,7 +1560,7 @@ if operation == "add-field": sel_nodes = import_fragment(xml_doc, sel_xml) for node in sel_nodes: insert_before_element(selection, node, None, sel_indent) - print(f'[OK] Field "{parsed["dataPath"]}" added to selection of variant "{var_name}"') + dirty = True; print(f'[OK] Field "{parsed["dataPath"]}" added to selection of variant "{var_name}"') elif operation == "add-total": for val in values: @@ -1579,7 +1592,7 @@ elif operation == "add-total": for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) - print(f'[OK] TotalField "{parsed["dataPath"]}" = {parsed["expression"]} added') + dirty = True; print(f'[OK] TotalField "{parsed["dataPath"]}" = {parsed["expression"]} added') elif operation == "add-calculated-field": for val in values: @@ -1610,7 +1623,7 @@ elif operation == "add-calculated-field": for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) - print(f'[OK] CalculatedField "{parsed["dataPath"]}" = {parsed["expression"]} added') + dirty = True; print(f'[OK] CalculatedField "{parsed["dataPath"]}" = {parsed["expression"]} added') if not no_selection: settings = resolve_variant_settings() @@ -1625,7 +1638,7 @@ elif operation == "add-calculated-field": sel_nodes = import_fragment(xml_doc, sel_xml) for node in sel_nodes: insert_before_element(selection, node, None, sel_indent) - print(f'[OK] Field "{parsed["dataPath"]}" added to selection of variant "{var_name}"') + dirty = True; print(f'[OK] Field "{parsed["dataPath"]}" added to selection of variant "{var_name}"') elif operation == "add-parameter": for val in values: @@ -1657,9 +1670,9 @@ elif operation == "add-parameter": for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) - print(f'[OK] Parameter "{parsed["name"]}" added') + dirty = True; print(f'[OK] Parameter "{parsed["name"]}" added') if parsed.get("autoDates"): - print('[OK] Auto-parameters "\u0414\u0430\u0442\u0430\u041d\u0430\u0447\u0430\u043b\u0430", "\u0414\u0430\u0442\u0430\u041e\u043a\u043e\u043d\u0447\u0430\u043d\u0438\u044f" added') + dirty = True; print('[OK] Auto-parameters "\u0414\u0430\u0442\u0430\u041d\u0430\u0447\u0430\u043b\u0430", "\u0414\u0430\u0442\u0430\u041e\u043a\u043e\u043d\u0447\u0430\u043d\u0438\u044f" added') elif operation == "modify-parameter": for val in values: @@ -1700,7 +1713,7 @@ elif operation == "modify-parameter": title_frag = build_mltext_xml("title", title_val, child_indent) for node in import_fragment(xml_doc, title_frag): insert_before_element(param_el, node, title_ref, child_indent) - print(f'[OK] Parameter "{param_name}": title set to "{title_val}"') + dirty = True; print(f'[OK] Parameter "{param_name}": title set to "{title_val}"') # Separate availableValue=... from simple kv pairs simple_rest = rest @@ -1739,10 +1752,10 @@ elif operation == "modify-parameter": for node in nodes: insert_before_element(param_el, node, ref_node, child_indent) verb = "updated" if was_existing else "added" - print(f'[OK] Parameter "{param_name}": value {verb} to {value}') + dirty = True; print(f'[OK] Parameter "{param_name}": value {verb} to {value}') elif existing is not None: existing.text = value - print(f'[OK] Parameter "{param_name}": {key} updated to {value}') + dirty = True; print(f'[OK] Parameter "{param_name}": {key} updated to {value}') else: # Schema order: ...value, useRestriction, availableValue*, denyIncompleteValues, use ref_node = None @@ -1752,7 +1765,7 @@ elif operation == "modify-parameter": nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(param_el, node, ref_node, child_indent) - print(f'[OK] Parameter "{param_name}": {key}={value} added') + dirty = True; print(f'[OK] Parameter "{param_name}": {key}={value} added') # Process availableValue if av_part: @@ -1789,7 +1802,7 @@ elif operation == "modify-parameter": nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(param_el, node, ref_node, child_indent) - print(f'[OK] Parameter "{param_name}": availableValue set to {len(av_items)} item(s)') + dirty = True; print(f'[OK] Parameter "{param_name}": availableValue set to {len(av_items)} item(s)') if flag_hidden: ur_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "useRestriction" and etree.QName(ch.tag).namespace == SCH_NS), None) @@ -1810,7 +1823,7 @@ elif operation == "modify-parameter": for node in import_fragment(xml_doc, f"{child_indent}false"): insert_before_element(param_el, node, ref_node, child_indent) - print(f'[OK] Parameter "{param_name}": @hidden applied') + dirty = True; print(f'[OK] Parameter "{param_name}": @hidden applied') if flag_always: use_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "use" and etree.QName(ch.tag).namespace == SCH_NS), None) @@ -1820,7 +1833,7 @@ elif operation == "modify-parameter": else: for node in import_fragment(xml_doc, f"{child_indent}Always"): insert_before_element(param_el, node, None, child_indent) - print(f'[OK] Parameter "{param_name}": @always applied') + dirty = True; print(f'[OK] Parameter "{param_name}": @always applied') elif operation == "rename-parameter": root = xml_doc @@ -1883,7 +1896,7 @@ elif operation == "rename-parameter": gc.text = new_name dp_updated += 1 - print(f'[OK] Parameter renamed: "{old_name}" => "{new_name}" (expressions updated: {expr_updated}, dataParameters updated: {dp_updated})') + dirty = True; print(f'[OK] Parameter renamed: "{old_name}" => "{new_name}" (expressions updated: {expr_updated}, dataParameters updated: {dp_updated})') elif operation == "reorder-parameters": root = xml_doc @@ -1940,7 +1953,7 @@ elif operation == "reorder-parameters": for pe in new_order: insert_before_element(root, pe, anchor, child_indent) - print(f'[OK] Parameters reordered ({len(all_params)} total, {len(order)} explicit)') + dirty = True; print(f'[OK] Parameters reordered ({len(all_params)} total, {len(order)} explicit)') elif operation == "add-filter": settings = resolve_variant_settings() @@ -1953,7 +1966,7 @@ elif operation == "add-filter": nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(filter_el, node, None, filter_indent) - print(f'[OK] Filter "{parsed["field"]} {parsed["op"]}" added to variant "{var_name}"') + dirty = True; print(f'[OK] Filter "{parsed["field"]} {parsed["op"]}" added to variant "{var_name}"') elif operation == "add-dataParameter": settings = resolve_variant_settings() @@ -1966,7 +1979,7 @@ elif operation == "add-dataParameter": nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(dp_el, node, None, dp_indent) - print(f'[OK] DataParameter "{parsed["parameter"]}" added to variant "{var_name}"') + dirty = True; print(f'[OK] DataParameter "{parsed["parameter"]}" added to variant "{var_name}"') elif operation == "add-order": settings = resolve_variant_settings() @@ -1999,7 +2012,7 @@ elif operation == "add-order": insert_before_element(order_el, node, None, order_indent) desc = "Auto" if parsed["field"] == "Auto" else f"{parsed['field']} {parsed['direction']}" - print(f'[OK] Order "{desc}" added to variant "{var_name}"') + dirty = True; print(f'[OK] Order "{desc}" added to variant "{var_name}"') elif operation == "add-selection": settings = resolve_variant_settings() @@ -2052,7 +2065,7 @@ elif operation == "add-selection": for node in sel_nodes: insert_before_element(selection, node, None, sel_indent) target = f'group "{group_name}"' if group_name else f'variant "{var_name}"' - print(f'[OK] Selection "{field_name}" added to {target}') + dirty = True; print(f'[OK] Selection "{field_name}" added to {target}') elif operation == "set-query": ds_node = resolve_data_set() @@ -2062,7 +2075,7 @@ elif operation == "set-query": print(f"No element found in dataset '{ds_name}'", file=sys.stderr) sys.exit(1) query_el.text = resolve_query_value(value_arg, query_base_dir) - print(f'[OK] Query replaced in dataset "{ds_name}"') + dirty = True; print(f'[OK] Query replaced in dataset "{ds_name}"') elif operation == "patch-query": ds_node = resolve_data_set() @@ -2095,7 +2108,7 @@ elif operation == "patch-query": query_el.text = query_text.replace(old_str, new_str) suffix = " (1 occurrence)" if once else f" ({count} occurrence(s))" - print(f'[OK] Query patched in dataset "{ds_name}": replaced \'{old_str}\'{suffix}') + dirty = True; print(f'[OK] Query patched in dataset "{ds_name}": replaced \'{old_str}\'{suffix}') elif operation == "set-outputParameter": settings = resolve_variant_settings() @@ -2108,9 +2121,9 @@ elif operation == "set-outputParameter": existing_param = find_element_by_child_value(output_el, "item", "parameter", parsed["key"], COR_NS) if existing_param is not None: remove_node_with_whitespace(existing_param) - print(f'[OK] Replaced outputParameter "{parsed["key"]}" in variant "{var_name}"') + dirty = True; print(f'[OK] Replaced outputParameter "{parsed["key"]}" in variant "{var_name}"') else: - print(f'[OK] OutputParameter "{parsed["key"]}" added to variant "{var_name}"') + dirty = True; print(f'[OK] OutputParameter "{parsed["key"]}" added to variant "{var_name}"') frag_xml = build_output_param_fragment(parsed, output_indent) nodes = import_fragment(xml_doc, frag_xml) @@ -2136,7 +2149,7 @@ elif operation == "set-structure": for node in nodes: insert_before_element(settings, node, ref_node, settings_indent) - print(f'[OK] Structure set in variant "{var_name}": {value_arg}') + dirty = True; print(f'[OK] Structure set in variant "{var_name}": {value_arg}') elif operation == "modify-structure": settings = resolve_variant_settings() @@ -2215,7 +2228,7 @@ elif operation == "modify-structure": insert_before_element(gi_el, node, None, item_indent) desc = "details" if not t["groupBy"] else ", ".join(t["groupBy"]) - print(f'[OK] Group "{t["name"]}" groupItems updated: {desc}') + dirty = True; print(f'[OK] Group "{t["name"]}" groupItems updated: {desc}') elif operation == "add-dataSetLink": for val in values: @@ -2244,7 +2257,7 @@ elif operation == "add-dataSetLink": desc = f"{parsed['source']} > {parsed['dest']} on {parsed['sourceExpr']} = {parsed['destExpr']}" if parsed.get("parameter"): desc += f" [param {parsed['parameter']}]" - print(f'[OK] DataSetLink "{desc}" added') + dirty = True; print(f'[OK] DataSetLink "{desc}" added') elif operation == "add-dataSet": child_indent = get_child_indent(xml_doc) @@ -2286,7 +2299,7 @@ elif operation == "add-dataSet": for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) - print(f'[OK] DataSet "{parsed["name"]}" added (dataSource={ds_source_name})') + dirty = True; print(f'[OK] DataSet "{parsed["name"]}" added (dataSource={ds_source_name})') elif operation == "add-variant": child_indent = get_child_indent(xml_doc) @@ -2325,7 +2338,7 @@ elif operation == "add-variant": for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) - print(f'[OK] Variant "{parsed["name"]}" ["{parsed["presentation"]}"] added') + dirty = True; print(f'[OK] Variant "{parsed["name"]}" ["{parsed["presentation"]}"] added') elif operation == "add-conditionalAppearance": settings = resolve_variant_settings() @@ -2348,7 +2361,7 @@ elif operation == "add-conditionalAppearance": desc += f" when {flt['field']} {flt['op']}" if parsed.get("fields"): desc += f" for {', '.join(parsed['fields'])}" - print(f'[OK] ConditionalAppearance "{desc}" added to variant "{var_name}"') + dirty = True; print(f'[OK] ConditionalAppearance "{desc}" added to variant "{var_name}"') elif operation == "clear-selection": settings = resolve_variant_settings() @@ -2356,7 +2369,7 @@ elif operation == "clear-selection": selection = find_first_element(settings, ["selection"], SET_NS) if selection is not None: clear_container_children(selection) - print(f'[OK] Selection cleared in variant "{var_name}"') + dirty = True; print(f'[OK] Selection cleared in variant "{var_name}"') else: print(f'[INFO] No selection section in variant "{var_name}"') @@ -2366,7 +2379,7 @@ elif operation == "clear-order": order_el = find_first_element(settings, ["order"], SET_NS) if order_el is not None: clear_container_children(order_el) - print(f'[OK] Order cleared in variant "{var_name}"') + dirty = True; print(f'[OK] Order cleared in variant "{var_name}"') else: print(f'[INFO] No order section in variant "{var_name}"') @@ -2376,7 +2389,7 @@ elif operation == "clear-filter": filter_el = find_first_element(settings, ["filter"], SET_NS) if filter_el is not None: clear_container_children(filter_el) - print(f'[OK] Filter cleared in variant "{var_name}"') + dirty = True; print(f'[OK] Filter cleared in variant "{var_name}"') else: print(f'[INFO] No filter section in variant "{var_name}"') @@ -2386,7 +2399,7 @@ elif operation == "clear-conditionalAppearance": ca_el = find_first_element(settings, ["conditionalAppearance"], SET_NS) if ca_el is not None: clear_container_children(ca_el) - print(f'[OK] ConditionalAppearance cleared in variant "{var_name}"') + dirty = True; print(f'[OK] ConditionalAppearance cleared in variant "{var_name}"') else: print(f'[INFO] No conditionalAppearance section in variant "{var_name}"') @@ -2430,7 +2443,7 @@ elif operation == "modify-filter": uid = new_uuid() if parsed["userSettingID"] == "auto" else parsed["userSettingID"] set_or_create_child_element(filter_item, "userSettingID", SET_NS, uid, item_indent) - print(f'[OK] Filter "{parsed["field"]}" modified in variant "{var_name}"') + dirty = True; print(f'[OK] Filter "{parsed["field"]}" modified in variant "{var_name}"') elif operation == "modify-dataParameter": settings = resolve_variant_settings() @@ -2496,7 +2509,7 @@ elif operation == "modify-dataParameter": uid = new_uuid() if parsed["userSettingID"] == "auto" else parsed["userSettingID"] set_or_create_child_element(dp_item, "userSettingID", SET_NS, uid, item_indent) - print(f'[OK] DataParameter "{parsed["parameter"]}" modified in variant "{var_name}"') + dirty = True; print(f'[OK] DataParameter "{parsed["parameter"]}" modified in variant "{var_name}"') elif operation == "modify-field": ds_node = resolve_data_set() @@ -2540,7 +2553,7 @@ elif operation == "modify-field": for node in nodes: insert_before_element(ds_node, node, next_sib, child_indent) - print(f'[OK] Field "{field_name}" modified in dataset "{ds_name}"') + dirty = True; print(f'[OK] Field "{field_name}" modified in dataset "{ds_name}"') elif operation == "set-field-role": ds_node = resolve_data_set() @@ -2578,7 +2591,7 @@ elif operation == "set-field-role": # Empty spec — remove only if not flags and not kv: - print(f'[OK] Field "{data_path}" role cleared') + dirty = True; print(f'[OK] Field "{data_path}" role cleared') continue # Build new @@ -2603,7 +2616,7 @@ elif operation == "set-field-role": parts.append(" ".join(f"@{f}" for f in flags)) if kv: parts.append(" ".join(f"{k}={v}" for k, v in kv)) - print(f'[OK] Field "{data_path}" role set: {" ".join(parts)}') + dirty = True; print(f'[OK] Field "{data_path}" role set: {" ".join(parts)}') elif operation == "remove-field": ds_node = resolve_data_set() @@ -2615,7 +2628,7 @@ elif operation == "remove-field": print(f'[WARN] Field "{field_name}" not found in dataset "{ds_name}"') continue remove_node_with_whitespace(field_el) - print(f'[OK] Field "{field_name}" removed from dataset "{ds_name}"') + dirty = True; print(f'[OK] Field "{field_name}" removed from dataset "{ds_name}"') try: settings = resolve_variant_settings() @@ -2625,7 +2638,7 @@ elif operation == "remove-field": sel_item = find_element_by_child_value(selection, "item", "field", field_name, SET_NS) if sel_item is not None: remove_node_with_whitespace(sel_item) - print(f'[OK] Field "{field_name}" removed from selection of variant "{var_name}"') + dirty = True; print(f'[OK] Field "{field_name}" removed from selection of variant "{var_name}"') except SystemExit: pass @@ -2637,7 +2650,7 @@ elif operation == "remove-total": print(f'[WARN] TotalField "{data_path}" not found') continue remove_node_with_whitespace(total_el) - print(f'[OK] TotalField "{data_path}" removed') + dirty = True; print(f'[OK] TotalField "{data_path}" removed') elif operation == "remove-calculated-field": for val in values: @@ -2647,7 +2660,7 @@ elif operation == "remove-calculated-field": print(f'[WARN] CalculatedField "{data_path}" not found') continue remove_node_with_whitespace(calc_el) - print(f'[OK] CalculatedField "{data_path}" removed') + dirty = True; print(f'[OK] CalculatedField "{data_path}" removed') try: settings = resolve_variant_settings() @@ -2657,7 +2670,7 @@ elif operation == "remove-calculated-field": sel_item = find_element_by_child_value(selection, "item", "field", data_path, SET_NS) if sel_item is not None: remove_node_with_whitespace(sel_item) - print(f'[OK] Field "{data_path}" removed from selection of variant "{var_name}"') + dirty = True; print(f'[OK] Field "{data_path}" removed from selection of variant "{var_name}"') except SystemExit: pass @@ -2669,7 +2682,7 @@ elif operation == "remove-parameter": print(f'[WARN] Parameter "{param_name}" not found') continue remove_node_with_whitespace(param_el) - print(f'[OK] Parameter "{param_name}" removed') + dirty = True; print(f'[OK] Parameter "{param_name}" removed') elif operation == "remove-filter": settings = resolve_variant_settings() @@ -2685,7 +2698,7 @@ elif operation == "remove-filter": print(f'[WARN] Filter for "{field_name}" not found in variant "{var_name}"') continue remove_node_with_whitespace(filter_item) - print(f'[OK] Filter for "{field_name}" removed from variant "{var_name}"') + dirty = True; print(f'[OK] Filter for "{field_name}" removed from variant "{var_name}"') elif operation == "add-drilldown": # String-based manipulation — templates use dcsat namespace with inline xmlns @@ -2816,7 +2829,7 @@ elif operation == "add-drilldown": cell_count += 1 search_start = cell_end + 1 - print(f"[OK] {drill_name} \u2192 {tpl_name} (param + {cell_count} cell(s))") + dirty = True; print(f"[OK] {drill_name} \u2192 {tpl_name} (param + {cell_count} cell(s))") # Apply insertions in reverse order to preserve offsets. # For same position: reverse insertion order so first resource ends up first in file. @@ -2829,13 +2842,36 @@ elif operation == "add-drilldown": with open(resolved_path, "wb") as f: f.write(b'\xef\xbb\xbf') f.write(raw_text.encode("utf-8")) - print(f"[OK] Saved {resolved_path}") + dirty = True; print(f"[OK] Saved {resolved_path}") sys.exit(0) # ── 9. Save ───────────────────────────────────────────────── +if not dirty: + print("[INFO] No changes -- file untouched") + sys.exit(0) + xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") xml_bytes = xml_bytes.replace(b"", b'') + +# Format-preserve post-processing (mirrors PS path): +# (1) restore the original raw opening tag — lxml collapses +# multi-line xmlns into one line. +# (2) re-escape `"` to " inside / text content; scope anchored +# to those tag names so xsi:type="..." attribute quotes are untouched. +xml_text = xml_bytes.decode("utf-8") +if raw_root_opening: + xml_text = re.sub(r"]*>", lambda m: raw_root_opening, xml_text, count=1, flags=re.DOTALL) +xml_text = re.sub( + r"(<(?:\w+:)?(?:query|expression)\b[^>]*>)([\s\S]*?)()", + lambda m: m.group(1) + m.group(2).replace('"', '"') + m.group(3), + xml_text, +) +# Normalize self-closing tags: lxml writes `` 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) +xml_bytes = xml_text.encode("utf-8") + if not xml_bytes.endswith(b"\n"): xml_bytes += b"\n" with open(resolved_path, "wb") as f: diff --git a/tests/skills/cases/skd-edit/fixtures/roundtrip-base/Template.xml b/tests/skills/cases/skd-edit/fixtures/roundtrip-base/Template.xml new file mode 100644 index 00000000..44f87f2d --- /dev/null +++ b/tests/skills/cases/skd-edit/fixtures/roundtrip-base/Template.xml @@ -0,0 +1,65 @@ + + + + ИсточникДанных1 + Local + + + DS + + П + П + + + Имя + Имя + + ИсточникДанных1 + ВЫБРАТЬ + 1 КАК П, + "literal" КАК Имя + + + Период + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Период</v8:content> + </v8:item> + + + xs:dateTime + + DateTime + + + + + Основной + + + ru + Основной + + + + + + + + + + + + + + + + diff --git a/tests/skills/cases/skd-edit/idempotent-noop-modify-field.json b/tests/skills/cases/skd-edit/idempotent-noop-modify-field.json new file mode 100644 index 00000000..c37fb0d7 --- /dev/null +++ b/tests/skills/cases/skd-edit/idempotent-noop-modify-field.json @@ -0,0 +1,13 @@ +{ + "name": "NO-OP modify-field на отсутствующее поле — файл не трогаем (regression от XML round-trip бага)", + "setup": "fixture:roundtrip-base", + "params": { + "templatePath": "Template.xml", + "operation": "modify-field", + "value": "НесуществующееПоле [Заголовок]" + }, + "expect": { + "stdoutContains": "No changes -- file untouched" + }, + "idempotent": true +} 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 new file mode 100644 index 00000000..dacd1072 --- /dev/null +++ b/tests/skills/cases/skd-edit/preserve-entities-modify-parameter-title.json @@ -0,0 +1,9 @@ +{ + "name": "modify-parameter title сохраняет " и многострочный xmlns в неизменённых местах", + "setup": "fixture:roundtrip-base", + "params": { + "templatePath": "Template.xml", + "operation": "modify-parameter", + "value": "Период [Новый период]" + } +} diff --git a/tests/skills/cases/skd-edit/preserve-xmlns-multiline.json b/tests/skills/cases/skd-edit/preserve-xmlns-multiline.json new file mode 100644 index 00000000..c18e87b0 --- /dev/null +++ b/tests/skills/cases/skd-edit/preserve-xmlns-multiline.json @@ -0,0 +1,10 @@ +{ + "name": "Многострочный xmlns корня сохраняется после успешной операции", + "setup": "fixture:roundtrip-base", + "params": { + "templatePath": "Template.xml", + "operation": "set-query", + "value": "DS=ВЫБРАТЬ 2 КАК П, "new" КАК Имя" + }, + "idempotent": true +} diff --git a/tests/skills/cases/skd-edit/snapshots/add-calculated-field-restrict/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-calculated-field-restrict/Template.xml index 01696c5d..55d1f03a 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-calculated-field-restrict/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-calculated-field-restrict/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -35,7 +42,7 @@ Служебное - "" + "" true true @@ -65,10 +72,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/add-calculated-field/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-calculated-field/Template.xml index c52a4938..74991cda 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-calculated-field/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-calculated-field/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -53,10 +60,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/add-field/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-field/Template.xml index 2afabdf0..4c8dcd6d 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-field/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-field/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -41,10 +48,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/add-filter/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-filter/Template.xml index 660ce254..b3d2e316 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-filter/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-filter/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -52,10 +59,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/add-parameter-hidden-always/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-parameter-hidden-always/Template.xml index ba6fb068..20752ce7 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-parameter-hidden-always/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-parameter-hidden-always/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -43,10 +50,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/add-parameter-ref-type/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-parameter-ref-type/Template.xml index db6b77df..fa2aaec7 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-parameter-ref-type/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-parameter-ref-type/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -40,10 +47,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/add-parameter-with-availableValue/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-parameter-with-availableValue/Template.xml index 86f7a758..681b5dbc 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-parameter-with-availableValue/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-parameter-with-availableValue/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -67,10 +74,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/add-parameter/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-parameter/Template.xml index c169bb35..a8bf8807 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-parameter/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-parameter/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -73,10 +80,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/add-selection-auto-dedup/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-selection-auto-dedup/Template.xml index 77da25b7..847e89b1 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-selection-auto-dedup/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-selection-auto-dedup/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -27,7 +34,7 @@ - + Поле1 @@ -37,10 +44,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/add-total-identity/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-total-identity/Template.xml index f63ec0d8..80d925e3 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-total-identity/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-total-identity/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -38,10 +45,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/add-total/Template.xml b/tests/skills/cases/skd-edit/snapshots/add-total/Template.xml index 2a3e7840..4c5b0816 100644 --- a/tests/skills/cases/skd-edit/snapshots/add-total/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/add-total/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -42,10 +49,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/clear-conditionalAppearance/Template.xml b/tests/skills/cases/skd-edit/snapshots/clear-conditionalAppearance/Template.xml index 62b2beb1..dc21c81e 100644 --- a/tests/skills/cases/skd-edit/snapshots/clear-conditionalAppearance/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/clear-conditionalAppearance/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -36,10 +43,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/conditional-appearance-v2/Template.xml b/tests/skills/cases/skd-edit/snapshots/conditional-appearance-v2/Template.xml index 4f068625..e0a4364b 100644 --- a/tests/skills/cases/skd-edit/snapshots/conditional-appearance-v2/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/conditional-appearance-v2/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -44,7 +51,7 @@ - + ПараметрыДанных.ПорядокОкругления @@ -65,7 +72,7 @@ - + OrGroup @@ -96,10 +103,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/idempotent-noop-modify-field/Template.xml b/tests/skills/cases/skd-edit/snapshots/idempotent-noop-modify-field/Template.xml new file mode 100644 index 00000000..44f87f2d --- /dev/null +++ b/tests/skills/cases/skd-edit/snapshots/idempotent-noop-modify-field/Template.xml @@ -0,0 +1,65 @@ + + + + ИсточникДанных1 + Local + + + DS + + П + П + + + Имя + Имя + + ИсточникДанных1 + ВЫБРАТЬ + 1 КАК П, + "literal" КАК Имя + + + Период + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Период</v8:content> + </v8:item> + + + xs:dateTime + + DateTime + + + + + Основной + + + ru + Основной + + + + + + + + + + + + + + + + diff --git a/tests/skills/cases/skd-edit/snapshots/modify-dataParameter-preserves-use/Template.xml b/tests/skills/cases/skd-edit/snapshots/modify-dataParameter-preserves-use/Template.xml index 823e8e10..11b09f60 100644 --- a/tests/skills/cases/skd-edit/snapshots/modify-dataParameter-preserves-use/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/modify-dataParameter-preserves-use/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -56,10 +63,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/modify-parameter-availableValue-replace/Template.xml b/tests/skills/cases/skd-edit/snapshots/modify-parameter-availableValue-replace/Template.xml index 967609b0..2bfae504 100644 --- a/tests/skills/cases/skd-edit/snapshots/modify-parameter-availableValue-replace/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/modify-parameter-availableValue-replace/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -67,10 +74,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/modify-parameter-combined/Template.xml b/tests/skills/cases/skd-edit/snapshots/modify-parameter-combined/Template.xml index 52bd3990..1786718e 100644 --- a/tests/skills/cases/skd-edit/snapshots/modify-parameter-combined/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/modify-parameter-combined/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -74,10 +81,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/modify-parameter-hidden-idempotent/Template.xml b/tests/skills/cases/skd-edit/snapshots/modify-parameter-hidden-idempotent/Template.xml index ba6fb068..20752ce7 100644 --- a/tests/skills/cases/skd-edit/snapshots/modify-parameter-hidden-idempotent/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/modify-parameter-hidden-idempotent/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -43,10 +50,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/modify-parameter-title/Template.xml b/tests/skills/cases/skd-edit/snapshots/modify-parameter-title/Template.xml index a6e14541..13f941c3 100644 --- a/tests/skills/cases/skd-edit/snapshots/modify-parameter-title/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/modify-parameter-title/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -53,10 +60,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/modify-parameter-value/Template.xml b/tests/skills/cases/skd-edit/snapshots/modify-parameter-value/Template.xml index 08a8314e..37d5da8a 100644 --- a/tests/skills/cases/skd-edit/snapshots/modify-parameter-value/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/modify-parameter-value/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -40,10 +47,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/modify-parameter/Template.xml b/tests/skills/cases/skd-edit/snapshots/modify-parameter/Template.xml index 35c109ee..9fe93a40 100644 --- a/tests/skills/cases/skd-edit/snapshots/modify-parameter/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/modify-parameter/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -65,10 +72,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/modify-structure-preserves-selection/Template.xml b/tests/skills/cases/skd-edit/snapshots/modify-structure-preserves-selection/Template.xml index 4f314edf..3fd86bcf 100644 --- a/tests/skills/cases/skd-edit/snapshots/modify-structure-preserves-selection/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/modify-structure-preserves-selection/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -64,10 +71,10 @@ - + - + Счет diff --git a/tests/skills/cases/skd-edit/snapshots/patch-query-batch/Template.xml b/tests/skills/cases/skd-edit/snapshots/patch-query-batch/Template.xml index 93e673a5..181ae6d1 100644 --- a/tests/skills/cases/skd-edit/snapshots/patch-query-batch/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/patch-query-batch/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -44,10 +51,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/patch-query-once/Template.xml b/tests/skills/cases/skd-edit/snapshots/patch-query-once/Template.xml index bf0521a4..6071ffce 100644 --- a/tests/skills/cases/skd-edit/snapshots/patch-query-once/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/patch-query-once/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -33,10 +40,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/patch-query/Template.xml b/tests/skills/cases/skd-edit/snapshots/patch-query/Template.xml index 5d81c376..f03902e2 100644 --- a/tests/skills/cases/skd-edit/snapshots/patch-query/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/patch-query/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -26,10 +33,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/preserve-entities-modify-parameter-title/Template.xml b/tests/skills/cases/skd-edit/snapshots/preserve-entities-modify-parameter-title/Template.xml new file mode 100644 index 00000000..db2743fe --- /dev/null +++ b/tests/skills/cases/skd-edit/snapshots/preserve-entities-modify-parameter-title/Template.xml @@ -0,0 +1,65 @@ + + + + ИсточникДанных1 + Local + + + DS + + П + П + + + Имя + Имя + + ИсточникДанных1 + ВЫБРАТЬ + 1 КАК П, + "literal" КАК Имя + + + Период + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Новый период</v8:content> + </v8:item> + + + xs:dateTime + + DateTime + + + + + Основной + + + ru + Основной + + + + + + + + + + + + + + + + diff --git a/tests/skills/cases/skd-edit/snapshots/preserve-xmlns-multiline/Template.xml b/tests/skills/cases/skd-edit/snapshots/preserve-xmlns-multiline/Template.xml new file mode 100644 index 00000000..88308dc5 --- /dev/null +++ b/tests/skills/cases/skd-edit/snapshots/preserve-xmlns-multiline/Template.xml @@ -0,0 +1,63 @@ + + + + ИсточникДанных1 + Local + + + DS + + П + П + + + Имя + Имя + + ИсточникДанных1 + DS=ВЫБРАТЬ 2 КАК П, &quot;new&quot; КАК Имя + + + Период + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Период</v8:content> + </v8:item> + + + xs:dateTime + + DateTime + + + + + Основной + + + ru + Основной + + + + + + + + + + + + + + + + diff --git a/tests/skills/cases/skd-edit/snapshots/rename-parameter/Template.xml b/tests/skills/cases/skd-edit/snapshots/rename-parameter/Template.xml index fceae3d8..0e1bb01a 100644 --- a/tests/skills/cases/skd-edit/snapshots/rename-parameter/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/rename-parameter/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -108,10 +115,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/reorder-parameters/Template.xml b/tests/skills/cases/skd-edit/snapshots/reorder-parameters/Template.xml index 76e17c33..8fe4556a 100644 --- a/tests/skills/cases/skd-edit/snapshots/reorder-parameters/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/reorder-parameters/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -81,10 +88,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/set-field-role-balance/Template.xml b/tests/skills/cases/skd-edit/snapshots/set-field-role-balance/Template.xml index 0b761cc2..fb01eddf 100644 --- a/tests/skills/cases/skd-edit/snapshots/set-field-role-balance/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/set-field-role-balance/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -39,10 +46,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/set-field-role-clear/Template.xml b/tests/skills/cases/skd-edit/snapshots/set-field-role-clear/Template.xml index cee397ad..ea4353c1 100644 --- a/tests/skills/cases/skd-edit/snapshots/set-field-role-clear/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/set-field-role-clear/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -34,10 +41,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/set-query/Template.xml b/tests/skills/cases/skd-edit/snapshots/set-query/Template.xml index 04de838c..2e6c847a 100644 --- a/tests/skills/cases/skd-edit/snapshots/set-query/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/set-query/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -26,10 +33,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/set-structure-multi-field/Template.xml b/tests/skills/cases/skd-edit/snapshots/set-structure-multi-field/Template.xml index 2378f2b7..0ec76a56 100644 --- a/tests/skills/cases/skd-edit/snapshots/set-structure-multi-field/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/set-structure-multi-field/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -77,10 +84,10 @@ - + - + diff --git a/tests/skills/cases/skd-edit/snapshots/set-structure-named/Template.xml b/tests/skills/cases/skd-edit/snapshots/set-structure-named/Template.xml index 2be2dea2..77355f6d 100644 --- a/tests/skills/cases/skd-edit/snapshots/set-structure-named/Template.xml +++ b/tests/skills/cases/skd-edit/snapshots/set-structure-named/Template.xml @@ -1,5 +1,12 @@ - + ИсточникДанных1 Local @@ -77,10 +84,10 @@ - + - + Счет diff --git a/tests/skills/runner.mjs b/tests/skills/runner.mjs index 4b629d8b..7a16300e 100644 --- a/tests/skills/runner.mjs +++ b/tests/skills/runner.mjs @@ -342,6 +342,31 @@ function normalizeContent(text, config) { // ─── Snapshot comparison ──────────────────────────────────────────────────── +// Capture raw byte contents of every file in dir, keyed by relative path. +// Used by idempotency checks to verify byte-equality after a re-run. +function snapshotWorkDirBytes(dir) { + const files = listFilesRecursive(dir); + const map = new Map(); + for (const rel of files) { + map.set(rel, readFileSync(join(dir, rel))); + } + return map; +} + +// Compare two byte-snapshots. Returns null if identical, else a list of diff lines. +function diffByteSnapshots(before, after) { + const diffs = []; + for (const [rel, b1] of before) { + if (!after.has(rel)) { diffs.push(`removed: ${rel}`); continue; } + const b2 = after.get(rel); + if (b1.length !== b2.length || !b1.equals(b2)) diffs.push(`changed: ${rel} (${b1.length} -> ${b2.length} bytes)`); + } + for (const rel of after.keys()) { + if (!before.has(rel)) diffs.push(`added: ${rel}`); + } + return diffs.length === 0 ? null : diffs; +} + function listFilesRecursive(dir, base = '') { const result = []; if (!existsSync(dir)) return result; @@ -571,6 +596,23 @@ async function runCaseAsync(testCase, opts) { } } } + + // Idempotency check: re-run the same script with the same args and assert + // every file in workDir is byte-identical to the first-run output. + if (errors.length === 0 && caseData.idempotent && !workspace.readOnly) { + const before = snapshotWorkDirBytes(workDir); + try { + const execCwd = skillConfig.cwd === 'workDir' ? workDir : undefined; + await execSkillAsync(opts.runtime, scriptPath, args, execCwd); + } catch (e) { + errors.push(`Idempotency rerun failed: exitCode=${e.status}\nstderr: ${(e.stderr || '').substring(0, 300)}`); + } + if (errors.length === 0) { + const after = snapshotWorkDirBytes(workDir); + const diffs = diffByteSnapshots(before, after); + if (diffs) errors.push(`Idempotency: workspace changed on rerun:\n ${diffs.join('\n ')}`); + } + } } // Post-run validation (on real output, before cleanup) @@ -727,6 +769,22 @@ function runCase(testCase, opts) { } } } + + // Idempotency check: re-run the same script and assert byte-equality. + if (errors.length === 0 && caseData.idempotent && !workspace.readOnly) { + const before = snapshotWorkDirBytes(workDir); + try { + const execCwd = skillConfig.cwd === 'workDir' ? workDir : undefined; + execSkillRaw(opts.runtime, scriptPath, args, execCwd); + } catch (e) { + errors.push(`Idempotency rerun failed: exitCode=${e.status}\nstderr: ${(e.stderr || '').substring(0, 300)}`); + } + if (errors.length === 0) { + const after = snapshotWorkDirBytes(workDir); + const diffs = diffByteSnapshots(before, after); + if (diffs) errors.push(`Idempotency: workspace changed on rerun:\n ${diffs.join('\n ')}`); + } + } } // Post-run validation (on real output, before cleanup)