fix(skills): чтение входного JSON — кодировка из BOM, эхо только для inline-значения (#80)

Этажом ниже разбора, на чтении файла, жил тот же класс дефектов в худшей форме. Файл в cp1251
с кириллицей: PS1 `Get-Content -Encoding UTF8` менял имя на 12 символов U+FFFD, JSON после этого
разбирался УСПЕШНО, и навык создавал объект с именем из «замен» — молча. Py-порт на том же файле
падал traceback-ом. Файл в UTF-16 давал ту же пару: traceback против ложного «JSON must have
'type' field».

Новая семья read_json_file / Read-JsonInputFile: кодировка берётся из BOM (UTF-8, UTF-16 LE/BE),
без BOM — строгий UTF-8, при провале сообщение называет файл и байт. Кодовую страницу не
подбираем: угаданное имя уехало бы в метаданные так же молча.

Эхо полученного значения печатается теперь только для inline-входа. Для файла оно показывало
первые 60 символов первой строки независимо от того, что ошибка на 120-й, и спорило с позицией
от парсера.

Заодно уравнен пустой вход: PS 5.1 на пустой строке отдаёт $null, а не ошибку, и навык уходил
дальше с $null, тогда как py-порт падал.

Раннеру добавлен ключ inputEncoding (utf-16le / utf-16be / cp1251) — иначе кейс про кодировку
не выразить, writeFileSync пишет только UTF-8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-21 16:41:54 +03:00
co-authored by Claude Opus 5
parent 0770889e47
commit 229e66b907
35 changed files with 1475 additions and 263 deletions
@@ -1,4 +1,4 @@
# interface-edit v1.19 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
# interface-edit v1.20 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
@@ -20,23 +20,56 @@ if (-not $DefinitionFile -and -not $Operation) { Write-Error "Either -Definition
# --- Разбор пользовательского JSON ---
# Одна строка в stderr вместо дампа исключения ConvertFrom-Json (issue #80): агент по стектрейсу
# идёт чинить скрипт, а не свой вызов. $source — файл или параметр. $expected заполняем только
# для полиморфного входа: у файла подсказка была бы наполнителем.
# для полиморфного входа: у файла подсказка была бы наполнителем. -Inline печатает ещё и то,
# что доехало: у файла такого вопроса нет — путь назван, позицию дал парсер, файл на диске.
# Возврат через -NoEnumerate: без него одноэлементный
# JSON-массив разворачивался бы в скаляр вторым анруллингом.
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected) {
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) {
try {
# PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null,
# тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково.
if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' }
$parsed = $text | ConvertFrom-Json
} catch {
$what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" }
$got = ($text -replace '\s+', ' ').Trim()
$label = 'got'
if ($got.Length -gt 60) { $label = 'got (first 60 chars, whitespace collapsed)'; $got = $got.Substring(0, 60) }
[Console]::Error.WriteLine("[ERROR] ${what}, ${label}: ${got} ($($_.Exception.Message))")
if ($Inline) {
$got = ($text -replace '\s+', ' ').Trim()
$label = 'got'
if (-not $got) { $got = '(empty)' }
elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) }
$what = "${what}, ${label}: ${got}"
}
[Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))")
exit 1
}
Write-Output -NoEnumerate $parsed
}
# --- Чтение входного JSON-файла ---
# Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8:
# Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого
# разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем:
# угаданное имя уйдёт в метаданные так же молча.
function Read-JsonInputFile([string]$path) {
$bytes = [System.IO.File]::ReadAllBytes($path)
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
}
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
}
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
}
try {
return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
} catch {
$detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
[Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16")
exit 1
}
}
# --- Resolve path ---
if (-not [System.IO.Path]::IsPathRooted($CIPath)) {
$CIPath = Join-Path (Get-Location).Path $CIPath
@@ -374,7 +407,7 @@ function Ensure-Section([string]$sectionName) {
function Parse-ValueList([string]$val, [string]$opName) {
$val = $val.Trim()
if ($val.StartsWith("[")) {
$arr = ConvertFrom-JsonInput $val "-Value for operation '$opName'" "a JSON array of command names"
$arr = ConvertFrom-JsonInput $val "-Value for operation '$opName'" "a JSON array of command names" -Inline
$result = @(); foreach ($item in $arr) { $result += "$item" }
return ,$result
}
@@ -539,7 +572,7 @@ function Do-Show([string[]]$commands) {
}
function Do-Place([string]$jsonVal) {
$def = ConvertFrom-JsonInput $jsonVal "-Value for operation 'place'" "a JSON object {command, group}"
$def = ConvertFrom-JsonInput $jsonVal "-Value for operation 'place'" "a JSON object {command, group}" -Inline
$cmdName = Normalize-CmdName "$($def.command)"
$groupName = "$($def.group)"
if (-not $cmdName -or -not $groupName) { Write-Error "place requires {command, group}"; exit 1 }
@@ -572,7 +605,7 @@ function Do-Place([string]$jsonVal) {
}
function Do-Order([string]$jsonVal) {
$def = ConvertFrom-JsonInput $jsonVal "-Value for operation 'order'" "a JSON object {group, commands:[...]}"
$def = ConvertFrom-JsonInput $jsonVal "-Value for operation 'order'" "a JSON object {group, commands:[...]}" -Inline
$groupName = "$($def.group)"
$commands = @($def.commands | ForEach-Object { Normalize-CmdName "$_" })
if (-not $groupName -or $commands.Count -eq 0) { Write-Error "order requires {group, commands:[...]}"; exit 1 }
@@ -610,7 +643,7 @@ function Do-Order([string]$jsonVal) {
}
function Do-SubsystemOrder([string]$jsonVal) {
$parsed = ConvertFrom-JsonInput $jsonVal "-Value for operation 'subsystem-order'" "a JSON array of subsystem paths"
$parsed = ConvertFrom-JsonInput $jsonVal "-Value for operation 'subsystem-order'" "a JSON array of subsystem paths" -Inline
$subsystems = @(); foreach ($s in $parsed) { $subsystems += "$s" }
if ($subsystems.Count -eq 0) { Write-Error "subsystem-order requires array of subsystem paths"; exit 1 }
@@ -638,7 +671,7 @@ function Do-SubsystemOrder([string]$jsonVal) {
}
function Do-GroupOrder([string]$jsonVal) {
$parsed = ConvertFrom-JsonInput $jsonVal "-Value for operation 'group-order'" "a JSON array of group names"
$parsed = ConvertFrom-JsonInput $jsonVal "-Value for operation 'group-order'" "a JSON array of group names" -Inline
$groups = @(); foreach ($g in $parsed) { $groups += "$g" }
if ($groups.Count -eq 0) { Write-Error "group-order requires array of group names"; exit 1 }
@@ -671,7 +704,7 @@ if ($DefinitionFile) {
if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) {
$DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile
}
$jsonText = Get-Content -Raw -Encoding UTF8 $DefinitionFile
$jsonText = Read-JsonInputFile $DefinitionFile
$ops = ConvertFrom-JsonInput $jsonText $DefinitionFile
if ($ops -is [System.Array]) {
foreach ($op in $ops) { $operations += $op }
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# interface-edit v1.19 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
# interface-edit v1.20 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -348,11 +348,12 @@ def import_ci_fragment(xml_string):
return nodes
def parse_json_input(text, source, expected=None):
def parse_json_input(text, source, expected=None, inline=False):
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
expected заполняем только для полиморфного входа: у файла подсказка
была бы наполнителем — имя файла и текст парсера самодостаточны.
была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
@@ -360,22 +361,51 @@ def parse_json_input(text, source, expected=None):
import json as _pj
import sys as _psys
try:
if not str(text).strip():
raise ValueError("input is empty")
return _pj.loads(text)
except ValueError as exc:
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
got = " ".join(str(text).split())
label = "got"
if len(got) > 60:
label = "got (first 60 chars, whitespace collapsed)"
got = got[:60]
print("[ERROR] %s, %s: %s (%s)" % (what, label, got, exc), file=_psys.stderr)
if inline:
got = " ".join(str(text).split())
label = "got"
if not got:
got = "(empty)"
elif len(got) > 60:
label = "got (first 60 chars)"
got = got[:60]
what = "%s, %s: %s" % (what, label, got)
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
_psys.exit(1)
def read_json_file(path):
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
"""
import sys as _psys
with open(path, "rb") as _fh:
data = _fh.read()
if data[:3] == b"\xef\xbb\xbf":
return data[3:].decode("utf-8")
if data[:2] == b"\xff\xfe":
return data[2:].decode("utf-16-le")
if data[:2] == b"\xfe\xff":
return data[2:].decode("utf-16-be")
try:
return data.decode("utf-8")
except UnicodeDecodeError as exc:
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
% (path, exc), file=_psys.stderr)
_psys.exit(1)
def parse_value_list(val, op_name):
val = val.strip()
if val.startswith("["):
arr = ci_json(parse_json_input(val, "-Value for operation '%s'" % op_name, "a JSON array of command names"))
arr = ci_json(parse_json_input(val, "-Value for operation '%s'" % op_name, "a JSON array of command names", inline=True))
return [str(item) for item in arr]
return [val]
@@ -672,7 +702,7 @@ def main():
def do_place(json_val):
nonlocal add_count, modify_count
defn = ci_json(json_val if isinstance(json_val, dict) else parse_json_input(
json_val, "-Value for operation 'place'", "a JSON object {command, group}"))
json_val, "-Value for operation 'place'", "a JSON object {command, group}", inline=True))
cmd_name = normalize_cmd_name(str(defn["command"]))
group_name = str(defn["group"])
if not cmd_name or not group_name:
@@ -701,7 +731,7 @@ def main():
def do_order(json_val):
nonlocal add_count, remove_count
defn = ci_json(json_val if isinstance(json_val, dict) else parse_json_input(
json_val, "-Value for operation 'order'", "a JSON object {group, commands:[...]}"))
json_val, "-Value for operation 'order'", "a JSON object {group, commands:[...]}", inline=True))
group_name = str(defn["group"])
commands = [normalize_cmd_name(str(c)) for c in defn["commands"]]
if not group_name or not commands:
@@ -736,7 +766,7 @@ def main():
def do_subsystem_order(json_val):
nonlocal add_count, remove_count
parsed = ci_json(json_val if isinstance(json_val, list) else parse_json_input(
json_val, "-Value for operation 'subsystem-order'", "a JSON array of subsystem paths"))
json_val, "-Value for operation 'subsystem-order'", "a JSON array of subsystem paths", inline=True))
subsystems = [str(s) for s in parsed]
if not subsystems:
print("subsystem-order requires array of subsystem paths", file=sys.stderr)
@@ -762,7 +792,7 @@ def main():
def do_group_order(json_val):
nonlocal add_count, remove_count
parsed = ci_json(json_val if isinstance(json_val, list) else parse_json_input(
json_val, "-Value for operation 'group-order'", "a JSON array of group names"))
json_val, "-Value for operation 'group-order'", "a JSON array of group names", inline=True))
groups = [str(g) for g in parsed]
if not groups:
print("group-order requires array of group names", file=sys.stderr)
@@ -791,8 +821,7 @@ def main():
def_file = args.DefinitionFile
if not os.path.isabs(def_file):
def_file = os.path.join(os.getcwd(), def_file)
with open(def_file, "r", encoding="utf-8-sig") as fh:
ops = ci_json(parse_json_input(fh.read(), def_file))
ops = ci_json(parse_json_input(read_json_file(def_file), def_file))
if isinstance(ops, list):
operations = ops
else: