From ccbbecbcf2896e12ed11f99d6294ed36c10ea89b Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 10 Aug 2026 13:35:50 +0300 Subject: [PATCH] =?UTF-8?q?fix(mxl-decompile):=20=D0=B0=D0=B1=D1=81=D0=BE?= =?UTF-8?q?=D0=BB=D1=8E=D1=82=D0=BD=D1=8B=D0=B9=20OutputPath=20+=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B8=D0=B9=20=D1=81=D0=B5=D1=80=D0=B8=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=82=D0=BE=D1=80=20=D0=B4=D0=B5=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=B8=D0=BB=D1=8F=D1=82=D0=BE=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Безусловная склейка OutputPath с текущим каталогом давала "C:\cwd\C:\out.json" и роняла запись сообщением про формат пути — навык не умел писать по абсолютному пути вообще. Py-порт этот случай обрабатывал, ps1 нет. Приведено к общей идиоме репозитория; кейс на абсолютный путь падает без правки и проходит с ней. Кейс заодно вскрыл, что порты пишут РАЗНЫЙ JSON: ps1 отдавал стиль ConvertTo-Json из PS 5.1 (выравнивание ключей, \uXXXX вместо кириллицы), py — json.dumps(indent=2). Один и тот же макет давал 4708 байт против 1744. Не всплывало потому, что ни один кейс не снимал сам JSON — все проверяли только stdout. mxl-decompile был единственным декомпилятором мимо общего сериализатора: у form/meta/skd-decompile для этого свой ConvertTo-CompactJson. Перенесён вариант skd-decompile как самый полный, py дополнительно переведён на newline='' — как у соседей. Теперь порты совпадают байт в байт, а decompile→compile даёт исходный XML байт в байт. Семья заведена в check-inline-drift.mjs (4 функции, 3 варианта): раньше шесть копий жили вообще без гарда. В form-decompile переименована локальная переменная в string-literal — копии различались только ею. Co-Authored-By: Claude Opus 5 (1M context) --- .../form-decompile/scripts/form-decompile.ps1 | 2 +- .../form-decompile/scripts/form-decompile.py | 26 ++-- .../mxl-decompile/scripts/mxl-decompile.ps1 | 142 ++++++++++++++++-- .../mxl-decompile/scripts/mxl-decompile.py | 123 ++++++++++++++- .../mxl-decompile/output-absolute-path.json | 17 +++ .../output-absolute-path/Template.xml | 66 ++++++++ .../snapshots/output-absolute-path/back.json | 1 + tests/skills/check-inline-drift.mjs | 37 +++++ 8 files changed, 387 insertions(+), 27 deletions(-) create mode 100644 tests/skills/cases/mxl-decompile/output-absolute-path.json create mode 100644 tests/skills/cases/mxl-decompile/snapshots/output-absolute-path/Template.xml create mode 100644 tests/skills/cases/mxl-decompile/snapshots/output-absolute-path/back.json diff --git a/.claude/skills/form-decompile/scripts/form-decompile.ps1 b/.claude/skills/form-decompile/scripts/form-decompile.ps1 index 1e1ff248..799646fb 100644 --- a/.claude/skills/form-decompile/scripts/form-decompile.ps1 +++ b/.claude/skills/form-decompile/scripts/form-decompile.ps1 @@ -1,4 +1,4 @@ -# form-decompile v0.148 — Decompile 1C managed Form.xml to JSON DSL (draft) +# form-decompile v0.149 — Decompile 1C managed Form.xml to JSON DSL (draft) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью. param( diff --git a/.claude/skills/form-decompile/scripts/form-decompile.py b/.claude/skills/form-decompile/scripts/form-decompile.py index e3cbb9ff..be51c82f 100644 --- a/.claude/skills/form-decompile/scripts/form-decompile.py +++ b/.claude/skills/form-decompile/scripts/form-decompile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# form-decompile v0.148 — Decompile 1C managed Form.xml to JSON DSL (draft) +# form-decompile v0.149 — Decompile 1C managed Form.xml to JSON DSL (draft) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью. # @@ -103,29 +103,29 @@ def _attr(node, name, ns_uri=None): def convert_string_to_json_literal(s): if s is None: return 'null' - sb = ['"'] + out = ['"'] for ch in s: code = ord(ch) if code == 0x22: - sb.append('\\"') + out.append('\\"') elif code == 0x5C: - sb.append('\\\\') + out.append('\\\\') elif code == 0x08: - sb.append('\\b') + out.append('\\b') elif code == 0x09: - sb.append('\\t') + out.append('\\t') elif code == 0x0A: - sb.append('\\n') + out.append('\\n') elif code == 0x0C: - sb.append('\\f') + out.append('\\f') elif code == 0x0D: - sb.append('\\r') + out.append('\\r') elif code < 0x20: - sb.append('\\u%04x' % code) + out.append('\\u%04x' % code) else: - sb.append(ch) - sb.append('"') - return ''.join(sb) + out.append(ch) + out.append('"') + return ''.join(out) def _num_to_str(obj): diff --git a/.claude/skills/mxl-decompile/scripts/mxl-decompile.ps1 b/.claude/skills/mxl-decompile/scripts/mxl-decompile.ps1 index 60e9e6a7..063fde64 100644 --- a/.claude/skills/mxl-decompile/scripts/mxl-decompile.ps1 +++ b/.claude/skills/mxl-decompile/scripts/mxl-decompile.ps1 @@ -1,4 +1,4 @@ -# mxl-decompile v1.1 — Decompile 1C spreadsheet to JSON +# mxl-decompile v1.2 — Decompile 1C spreadsheet to JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -618,22 +618,146 @@ $result["fonts"] = $fontsOut $result["styles"] = $styleDefs $result["areas"] = [array]$dslAreas -# --- 16. Convert to JSON and fix Unicode --- +# --- 16. Convert to JSON --- -$json = $result | ConvertTo-Json -Depth 10 +# Custom JSON serializer — компактный, 2-пробельный indent, массивы примитивов inline. +# В отличие от ConvertTo-Json (PS5.1): +# - не выравнивает ключи объекта по самому длинному +# - не разворачивает массивы примитивов на отдельные строки +# - кириллица в UTF-8 (без \uXXXX-escapes) +function Convert-StringToJsonLiteral { + param([string]$s) + if ($null -eq $s) { return 'null' } + $sb = New-Object System.Text.StringBuilder + [void]$sb.Append('"') + foreach ($ch in $s.ToCharArray()) { + $code = [int]$ch + if ($code -eq 0x22) { [void]$sb.Append('\"') } + elseif ($code -eq 0x5C) { [void]$sb.Append('\\') } + elseif ($code -eq 0x08) { [void]$sb.Append('\b') } + elseif ($code -eq 0x09) { [void]$sb.Append('\t') } + elseif ($code -eq 0x0A) { [void]$sb.Append('\n') } + elseif ($code -eq 0x0C) { [void]$sb.Append('\f') } + elseif ($code -eq 0x0D) { [void]$sb.Append('\r') } + elseif ($code -lt 0x20) { [void]$sb.AppendFormat('\u{0:x4}', $code) } + else { [void]$sb.Append($ch) } + } + [void]$sb.Append('"') + return $sb.ToString() +} -# PS 5.1 escapes non-ASCII as \uXXXX — unescape back to UTF-8 -$json = [regex]::Replace($json, '\\u([0-9A-Fa-f]{4})', { - param($m) - [char][int]("0x" + $m.Groups[1].Value) -}) +# Попробовать сериализовать значение полностью inline (одна строка). +# Возвращает строку либо $null, если содержимое не помещается. +function Try-InlineJson { + param($obj) + if ($null -eq $obj) { return 'null' } + if ($obj -is [bool]) { if ($obj) { return 'true' } else { return 'false' } } + if ($obj -is [string]) { return (Convert-StringToJsonLiteral $obj) } + if ($obj -is [int] -or $obj -is [long]) { return "$obj" } + if ($obj -is [double] -or $obj -is [single] -or $obj -is [decimal]) { + return ([System.Convert]::ToString($obj, [System.Globalization.CultureInfo]::InvariantCulture)) + } + if ($obj -is [System.Collections.IDictionary]) { + if ($obj.Count -eq 0) { return '{}' } + $parts = @() + foreach ($k in $obj.Keys) { + $v = Try-InlineJson $obj[$k] + if ($null -eq $v) { return $null } + $parts += "$(Convert-StringToJsonLiteral "$k"): $v" + } + return '{ ' + ($parts -join ', ') + ' }' + } + if ($obj -is [System.Management.Automation.PSCustomObject]) { + $props = @($obj.PSObject.Properties) + if ($props.Count -eq 0) { return '{}' } + $parts = @() + foreach ($p in $props) { + $v = Try-InlineJson $p.Value + if ($null -eq $v) { return $null } + $parts += "$(Convert-StringToJsonLiteral "$($p.Name)"): $v" + } + return '{ ' + ($parts -join ', ') + ' }' + } + if ($obj -is [array] -or $obj -is [System.Collections.IList]) { + $items = @($obj) + if ($items.Count -eq 0) { return '[]' } + $parts = @() + foreach ($it in $items) { + $v = Try-InlineJson $it + if ($null -eq $v) { return $null } + $parts += $v + } + return '[' + ($parts -join ', ') + ']' + } + return $null +} + +function ConvertTo-CompactJson { + param($obj, [int]$depth = 0, [string]$indentUnit = ' ', [int]$lineLimit = 400) + $indent = $indentUnit * $depth + $childIndent = $indentUnit * ($depth + 1) + + if ($null -eq $obj) { return 'null' } + if ($obj -is [bool]) { if ($obj) { return 'true' } else { return 'false' } } + if ($obj -is [string]) { return (Convert-StringToJsonLiteral $obj) } + if ($obj -is [int] -or $obj -is [long]) { return "$obj" } + if ($obj -is [double] -or $obj -is [single] -or $obj -is [decimal]) { + return ([System.Convert]::ToString($obj, [System.Globalization.CultureInfo]::InvariantCulture)) + } + + # Try inline для объектов и массивов с объектами — если помещается в lineLimit с учётом текущего indent. + $isContainer = ($obj -is [System.Collections.IDictionary]) -or ($obj -is [System.Management.Automation.PSCustomObject]) -or ($obj -is [array]) -or ($obj -is [System.Collections.IList]) + if ($isContainer) { + $inlineAttempt = Try-InlineJson $obj + if ($null -ne $inlineAttempt -and ($indent.Length + $inlineAttempt.Length) -le $lineLimit) { + return $inlineAttempt + } + } + + # Hashtable / OrderedDictionary — объект multi-line + if ($obj -is [System.Collections.IDictionary]) { + $keys = @($obj.Keys) + if ($keys.Count -eq 0) { return '{}' } + $parts = @() + foreach ($k in $keys) { + $val = ConvertTo-CompactJson -obj $obj[$k] -depth ($depth + 1) -indentUnit $indentUnit -lineLimit $lineLimit + $parts += "$childIndent$(Convert-StringToJsonLiteral "$k"): $val" + } + return "{`n" + ($parts -join ",`n") + "`n$indent}" + } + if ($obj -is [System.Management.Automation.PSCustomObject]) { + $props = @($obj.PSObject.Properties) + if ($props.Count -eq 0) { return '{}' } + $parts = @() + foreach ($p in $props) { + $val = ConvertTo-CompactJson -obj $p.Value -depth ($depth + 1) -indentUnit $indentUnit -lineLimit $lineLimit + $parts += "$childIndent$(Convert-StringToJsonLiteral "$($p.Name)"): $val" + } + return "{`n" + ($parts -join ",`n") + "`n$indent}" + } + # Array / IList multi-line + if ($obj -is [array] -or $obj -is [System.Collections.IList]) { + $items = @($obj) + if ($items.Count -eq 0) { return '[]' } + $parts = @($items | ForEach-Object { "$childIndent$(ConvertTo-CompactJson -obj $_ -depth ($depth + 1) -indentUnit $indentUnit -lineLimit $lineLimit)" }) + return "[`n" + ($parts -join ",`n") + "`n$indent]" + } + # Fallback + return (Convert-StringToJsonLiteral "$obj") +} + +$json = ConvertTo-CompactJson $result # --- 17. Output --- if ($OutputPath) { + # Путь принимаем и относительным, и абсолютным: безусловная склейка с текущим каталогом + # давала "C:\cwd\C:\out.json" и роняла WriteAllText сообщением про формат пути. + $outAbs = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } + else { Join-Path (Get-Location).Path $OutputPath } $enc = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText( - (Join-Path (Get-Location) $OutputPath), + $outAbs, $json, $enc ) diff --git a/.claude/skills/mxl-decompile/scripts/mxl-decompile.py b/.claude/skills/mxl-decompile/scripts/mxl-decompile.py index f28e8720..06a94457 100644 --- a/.claude/skills/mxl-decompile/scripts/mxl-decompile.py +++ b/.claude/skills/mxl-decompile/scripts/mxl-decompile.py @@ -1,9 +1,8 @@ #!/usr/bin/env python3 -# mxl-decompile v1.1 — Decompile 1C spreadsheet to JSON +# mxl-decompile v1.2 — Decompile 1C spreadsheet to JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse -import json import os import sys from collections import OrderedDict @@ -63,6 +62,122 @@ def int_of(node, default=0): return default +# Custom JSON serializer — компактный, 2-пробельный indent, массивы примитивов inline. +# В отличие от ConvertTo-Json (PS5.1): +# - не выравнивает ключи объекта по самому длинному +# - не разворачивает массивы примитивов на отдельные строки +# - кириллица в UTF-8 (без \uXXXX-escapes) +def convert_string_to_json_literal(s): + if s is None: + return 'null' + out = ['"'] + for ch in s: + code = ord(ch) + if code == 0x22: + out.append('\\"') + elif code == 0x5C: + out.append('\\\\') + elif code == 0x08: + out.append('\\b') + elif code == 0x09: + out.append('\\t') + elif code == 0x0A: + out.append('\\n') + elif code == 0x0C: + out.append('\\f') + elif code == 0x0D: + out.append('\\r') + elif code < 0x20: + out.append('\\u%04x' % code) + else: + out.append(ch) + out.append('"') + return ''.join(out) + + +def _fmt_number(v): + if isinstance(v, bool): + return 'true' if v else 'false' + if isinstance(v, int): + return str(v) + if isinstance(v, float): + # Invariant culture: '.' decimal sep + if v == int(v): + # Preserve float-ness: PS [double] 5.0 → "5" + # Match PS ToString invariant: 5.0 → "5" + return str(int(v)) + return repr(v) + return str(v) + + +def try_inline_json(obj): + if obj is None: + return 'null' + if isinstance(obj, bool): + return 'true' if obj else 'false' + if isinstance(obj, str): + return convert_string_to_json_literal(obj) + if isinstance(obj, (int, float)): + return _fmt_number(obj) + if isinstance(obj, dict): + if len(obj) == 0: + return '{}' + parts = [] + for k, v in obj.items(): + vs = try_inline_json(v) + if vs is None: + return None + parts.append(convert_string_to_json_literal(str(k)) + ': ' + vs) + return '{ ' + ', '.join(parts) + ' }' + if isinstance(obj, (list, tuple)): + if len(obj) == 0: + return '[]' + parts = [] + for it in obj: + vs = try_inline_json(it) + if vs is None: + return None + parts.append(vs) + return '[' + ', '.join(parts) + ']' + return None + + +def convert_to_compact_json(obj, depth=0, indent_unit=' ', line_limit=400): + indent = indent_unit * depth + child_indent = indent_unit * (depth + 1) + + if obj is None: + return 'null' + if isinstance(obj, bool): + return 'true' if obj else 'false' + if isinstance(obj, str): + return convert_string_to_json_literal(obj) + if isinstance(obj, (int, float)): + return _fmt_number(obj) + + # Try inline для объектов и массивов с объектами — если помещается в lineLimit с учётом текущего indent. + is_container = isinstance(obj, (dict, list, tuple)) + if is_container: + inline_attempt = try_inline_json(obj) + if inline_attempt is not None and (len(indent) + len(inline_attempt)) <= line_limit: + return inline_attempt + + if isinstance(obj, dict): + if len(obj) == 0: + return '{}' + parts = [] + for k, v in obj.items(): + val = convert_to_compact_json(v, depth + 1, indent_unit, line_limit) + parts.append(child_indent + convert_string_to_json_literal(str(k)) + ': ' + val) + return "{\n" + ",\n".join(parts) + "\n" + indent + "}" + if isinstance(obj, (list, tuple)): + if len(obj) == 0: + return '[]' + parts = [child_indent + convert_to_compact_json(it, depth + 1, indent_unit, line_limit) for it in obj] + return "[\n" + ",\n".join(parts) + "\n" + indent + "]" + return convert_string_to_json_literal(str(obj)) + + # --- Main --- def main(): @@ -707,13 +822,13 @@ def main(): # --- 16. Convert to JSON --- - json_str = json.dumps(result, ensure_ascii=False, indent=2) + json_str = convert_to_compact_json(result) # --- 17. Output --- if output_path: abs_path = os.path.join(os.getcwd(), output_path) if not os.path.isabs(output_path) else output_path - with open(abs_path, "w", encoding="utf-8") as fh: + with open(abs_path, "w", encoding="utf-8", newline="") as fh: fh.write(json_str) print(f"[OK] Decompiled: {output_path}") else: diff --git a/tests/skills/cases/mxl-decompile/output-absolute-path.json b/tests/skills/cases/mxl-decompile/output-absolute-path.json new file mode 100644 index 00000000..5b5c30e8 --- /dev/null +++ b/tests/skills/cases/mxl-decompile/output-absolute-path.json @@ -0,0 +1,17 @@ +{ + "name": "OutputPath принимается и абсолютным", + "preRun": [ + { + "script": "mxl-compile/scripts/mxl-compile", + "input": { "columns": 2, "areas": [{ "name": "Test", "rows": [{ "cells": [{ "col": 1, "text": "A" }, { "col": 2, "text": "B" }] }] }] }, + "args": { "-JsonPath": "{inputFile}", "-OutputPath": "Template.xml" }, + "cwd": "{workDir}" + } + ], + "params": { "templatePath": "Template.xml" }, + "args_extra": ["-OutputPath", "{workDir}/back.json"], + "expect": { + "files": ["back.json"], + "stdoutContains": "[OK] Decompiled:" + } +} diff --git a/tests/skills/cases/mxl-decompile/snapshots/output-absolute-path/Template.xml b/tests/skills/cases/mxl-decompile/snapshots/output-absolute-path/Template.xml new file mode 100644 index 00000000..e2c0a9af --- /dev/null +++ b/tests/skills/cases/mxl-decompile/snapshots/output-absolute-path/Template.xml @@ -0,0 +1,66 @@ + + + + ru + ru + + ru + Русский + Русский + + + + 2 + + + 0 + + + 0 + + 2 + + + ru + A + + + + + + 1 + + 2 + + + ru + B + + + + + + + true + 1 + 1 + 1 + + Test + + Rows + 0 + 0 + -1 + -1 + + + + + 10 + + + 0 + Text + + \ No newline at end of file diff --git a/tests/skills/cases/mxl-decompile/snapshots/output-absolute-path/back.json b/tests/skills/cases/mxl-decompile/snapshots/output-absolute-path/back.json new file mode 100644 index 00000000..e11b0818 --- /dev/null +++ b/tests/skills/cases/mxl-decompile/snapshots/output-absolute-path/back.json @@ -0,0 +1 @@ +{ "columns": 2, "defaultWidth": 10, "fonts": { "default": { "face": "Arial", "size": 10 } }, "styles": {}, "areas": [{ "name": "Test", "rows": [{ "cells": [{ "col": 1, "style": "default", "text": "A" }, { "col": 2, "style": "default", "text": "B" }] }] }] } \ No newline at end of file diff --git a/tests/skills/check-inline-drift.mjs b/tests/skills/check-inline-drift.mjs index 2c190b22..f4a2b4bb 100644 --- a/tests/skills/check-inline-drift.mjs +++ b/tests/skills/check-inline-drift.mjs @@ -319,6 +319,43 @@ const FAMILIES = [ ], }, + // ─── Компактный JSON декомпиляторов ───────────────────────────────────── + // Штатные сериализаторы не годятся: ConvertTo-Json (PS5.1) выравнивает ключи по самому + // длинному и эскейпит кириллицу в \uXXXX, json.dumps даёт иную раскладку inline/multiline. + // Поэтому у декомпиляторов свой сериализатор — и он обязан совпадать в портах байт в байт, + // иначе один и тот же макет даёт разный DSL на разных рантаймах. + { + name: 'decompile json: string literal', + py: 'convert_string_to_json_literal', ps1: 'Convert-StringToJsonLiteral', + variants: [ + { id: 'base', authority: 'skd-decompile', consumers: ['form-decompile', 'mxl-decompile'] }], + }, + { + name: 'decompile json: try inline', + py: 'try_inline_json', ps1: 'Try-InlineJson', + variants: [ + { id: 'base', authority: 'skd-decompile', consumers: ['mxl-decompile'] }, + { id: 'no-pscustomobject', authority: 'form-decompile', consumers: [], + why: 'form-decompile строит дерево на ordered-хэштейблах и ветку PSCustomObject не проходит' }], + }, + { + // Только в PY: в ps1 числа печатает [System.Convert]::ToString с InvariantCulture прямо + // в теле сериализатора, отдельной функции там нет — ps1: null. + name: 'decompile json: number', py: '_fmt_number', ps1: null, + variants: [ + { id: 'base', authority: 'skd-decompile', consumers: ['mxl-decompile'] }], + }, + { + name: 'decompile json: compact', + py: 'convert_to_compact_json', ps1: 'ConvertTo-CompactJson', + variants: [ + { id: 'base', authority: 'skd-decompile', consumers: ['mxl-decompile'] }, + { id: 'line-limit-120', authority: 'form-decompile', consumers: [], + why: 'у форм строки DSL длиннее, порог inline снижен со 400 до 120' }, + { id: 'legacy', authority: 'meta-decompile', consumers: [], + why: 'ранний вариант со своим Quote-Json и без inline-попытки; сведение меняет вывод meta-decompile' }], + }, + ]; // ─── Семьи, разъехавшиеся целиком ───────────────────────────────────────────