From 78ac3a8c36dd76dbf278de12e0b580f2de21133a Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sun, 9 Aug 2026 14:23:37 +0300 Subject: [PATCH] =?UTF-8?q?fix(xdto-compile,xdto-edit):=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B8=D1=82=D1=8C=20=D1=83=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20support-guard=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=89=D1=83=D1=8E=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Своя версия решала по правилу «файл Ext/ParentConfigurations.bin существует → запретить». Общая разбирает заголовок bin и решает по конкретному объекту; в частности у неё есть ветка `if k == 0: return` — если на поддержке нет ни одного объекта, запрещать нечего. Эксперимент на копии каталога конфигурации ERP (заголовок bin = {6,0,0,0,1,0}, K=0): meta-compile создавал объект, xdto-compile на том же каталоге отказывал. То есть XDTO-пакет нельзя было добавить туда, где справочник добавляется свободно. У acc_8.3.24 и ut_8.3.27 заголовок {6,1,1,...} (G=1, вся конфигурация read-only) — там обе реализации отказывали одинаково. Своя версия строго более ограничительна, поэтому это не дыра, а ложные отказы плюс неинформативное сообщение. После правки ERP разрешает, acc по-прежнему отказывает — и уже с диагнозом и шагами через support-edit. Долг реестра inline-реализаций обнулился: 24 семьи, 310 копий, вариантов без обоснования не осталось. Co-Authored-By: Claude Opus 5 (1M context) --- .../xdto-compile/scripts/xdto-compile.ps1 | 209 ++++++++++------ .../xdto-compile/scripts/xdto-compile.py | 226 +++++++++++++----- .../skills/xdto-edit/scripts/xdto-edit.ps1 | 126 +++++++--- .claude/skills/xdto-edit/scripts/xdto-edit.py | 209 ++++++++++++---- tests/skills/check-inline-drift.mjs | 19 +- 5 files changed, 552 insertions(+), 237 deletions(-) diff --git a/.claude/skills/xdto-compile/scripts/xdto-compile.ps1 b/.claude/skills/xdto-compile/scripts/xdto-compile.ps1 index 4d1b257e..d4146423 100644 --- a/.claude/skills/xdto-compile/scripts/xdto-compile.ps1 +++ b/.claude/skills/xdto-compile/scripts/xdto-compile.ps1 @@ -1,4 +1,4 @@ -# xdto-compile v1.9 — Build a 1C XDTO package from an XML Schema (XSD) (+detect_format_version: ветка автономной EPF/ERF) +# xdto-compile v1.10 — Build a 1C XDTO package from an XML Schema (XSD) (+support-guard: общая реализация вместо урезанной) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory=$true, ParameterSetName='File')] @@ -41,9 +41,134 @@ $V8_NS = "http://v8.1c.ru/8.1/data/core" # read-only configs unless allowed. Trigger = bin present; reaction from # .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never # throws — guard errors degrade to allow. -# Версия формата выгрузки — из Configuration.xml проекта (климб вверх от каталога исходников). -# Её задаёт платформа выгрузки: 8.3.20-8.3.24 → 2.17, 8.3.25 → 2.18, 8.3.26 → 2.19, 8.3.27 → 2.20. -# Раньше здесь стоял хардкод 2.17, и на проекте 2.20 пакет расходился с выгрузкой платформы. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} +function Test-ExternalObjectRoot([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $false } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName } + } catch {} + return $false +} +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Get-EditMode([string]$cfgDir) { + try { + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } + } + } + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } +} +function Assert-EditAllowed([string]$targetPath, [string]$require) { + try { + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + # Autonomous external object (EPF/ERF): never part of a config on support (issue #39). + if (Test-ExternalObjectRoot $rp) { return } + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (Test-ExternalObjectRoot "$d.xml") { return } + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } + } + if ($elemUuid -and $cfgDir) { break } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $code = ""; $reason = "" + if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + $head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления." + $cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются." + $offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json." + if ($code -eq "capability-off") { + $state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя." + $fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора." + } elseif ($code -eq "not-removed") { + $state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора." + $fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно." + } else { + $state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)." + $fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят." + } + [Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote") + exit 1 + } catch { return } +} + +# --- Detect format version --- + function Detect-FormatVersion([string]$dir) { $d = $dir while ($d) { @@ -70,80 +195,6 @@ function Detect-FormatVersion([string]$dir) { return "2.17" } -function Get-RootUuid([string]$xmlPath) { - if (-not (Test-Path $xmlPath)) { return $null } - try { - [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 - $el = $mx.DocumentElement.FirstChild - while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } - if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } - } catch {} - return $null -} -function Test-ExternalObjectRoot([string]$xmlPath) { - if (-not (Test-Path $xmlPath)) { return $false } - try { - [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 - $el = $mx.DocumentElement.FirstChild - while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } - if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.get_LocalName() } - } catch {} - return $false -} -function Find-V8Project([string]$startDir) { - $d = $startDir - for ($i = 0; $i -lt 20 -and $d; $i++) { - $pj = Join-Path $d ".v8-project.json" - if (Test-Path $pj) { return $pj } - $parent = [System.IO.Path]::GetDirectoryName($d) - if ($parent -eq $d) { break } - $d = $parent - } - return $null -} -function Get-EditMode([string]$cfgDir) { - $mode = "deny" - try { - $pj = Find-V8Project $cfgDir - if ($pj) { - $cfg = Get-Content -Path $pj -Raw -Encoding UTF8 | ConvertFrom-Json - if ($cfg.PSObject.Properties.Name -contains 'editingAllowedCheck' -and $cfg.editingAllowedCheck) { - $mode = [string]$cfg.editingAllowedCheck - } - } - } catch {} - return $mode -} -function Assert-EditAllowed([string]$targetPath) { - try { - $mode = $null - $d = $targetPath - for ($i = 0; $i -lt 20 -and $d; $i++) { - $cfgXml = Join-Path $d "Configuration.xml" - $supportBin = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" - # Автономный объект (внешняя обработка/отчёт) — граница климба - foreach ($x in @(Get-ChildItem -Path $d -Filter "*.xml" -File -ErrorAction SilentlyContinue)) { - if (Test-ExternalObjectRoot $x.FullName) { return } - } - if (Test-Path $cfgXml) { - if (Test-Path $supportBin) { - $mode = Get-EditMode $d - if ($mode -eq "off") { return } - $msg = "Конфигурация находится на поддержке (Ext/ParentConfigurations.bin). Правка может быть запрещена." - if ($mode -eq "warn") { Write-Warning $msg; return } - throw "$msg Снимите с поддержки (/support-edit) или задайте editingAllowedCheck в .v8-project.json." - } - return - } - $parent = [System.IO.Path]::GetDirectoryName($d) - if ($parent -eq $d) { break } - $d = $parent - } - } catch [System.Management.Automation.RuntimeException] { - throw - } catch {} -} - # --- Load the schema --- if ($PSCmdlet.ParameterSetName -eq 'Inline') { @@ -838,7 +889,7 @@ if (-not $Name) { $Name = ($Name -replace '[^\wЀ-ӿ]', '_') if ($Name -match '^\d') { $Name = "_$Name" } -Assert-EditAllowed $OutputDir +Assert-EditAllowed $OutputDir "editable" $script:formatVersion = Detect-FormatVersion $OutputDir diff --git a/.claude/skills/xdto-compile/scripts/xdto-compile.py b/.claude/skills/xdto-compile/scripts/xdto-compile.py index 2b24d745..8cdcea09 100644 --- a/.claude/skills/xdto-compile/scripts/xdto-compile.py +++ b/.claude/skills/xdto-compile/scripts/xdto-compile.py @@ -1,4 +1,4 @@ -# xdto-compile v1.9 — Build a 1C XDTO package from an XML Schema (XSD) (Python port) (+detect_format_version: ветка автономной EPF/ERF) +# xdto-compile v1.10 — Build a 1C XDTO package from an XML Schema (XSD) (Python port) (+support-guard: общая реализация вместо урезанной) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -56,17 +56,45 @@ def _parse_xml(source, from_string=False): p = etree.XMLParser(recover=True) return (etree.fromstring(source, p) if from_string else etree.parse(source, p)) -# ── support guard (Ext/ParentConfigurations.bin) ───────────── -# См. docs/1c-support-state-spec.md. Блокирует правку объектов поставщика -# «на замке». Триггер — наличие bin; реакция из .v8-project.json -# editingAllowedCheck (deny|warn|off, по умолчанию deny). +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ + +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None -def find_v8_project(start_dir): - d = os.path.abspath(start_dir) +def _sg_is_external_root(xml_path): + if not os.path.isfile(xml_path): + return False + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str): + return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport") + except Exception: + return False + return False + +def _sg_find_v8project(start_dir): + d = start_dir for _ in range(20): + if not d: + break pj = os.path.join(d, ".v8-project.json") - if os.path.exists(pj): + if os.path.isfile(pj): return pj parent = os.path.dirname(d) if parent == d: @@ -75,36 +103,136 @@ def find_v8_project(start_dir): return None -def get_edit_mode(cfg_dir): +def _sg_get_edit_mode(cfg_dir): try: - pj = find_v8_project(cfg_dir) - if pj: - with open(pj, encoding="utf-8-sig") as f: - cfg = json.load(f) - return str(cfg.get("editingAllowedCheck") or "deny") - except Exception: # noqa: BLE001 - pass - return "deny" + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" -def is_external_object_root(xml_path): +def assert_edit_allowed(target_path, require): try: - root = _parse_xml(xml_path).getroot() - for el in root: - if isinstance(el.tag, str): - return etree.QName(el).localname in ("ExternalDataProcessor", "ExternalReport") - except Exception: # noqa: BLE001 - pass - return False + rp = os.path.abspath(target_path) + # Autonomous external object (EPF/ERF): never part of a config on support (issue #39). + if _sg_is_external_root(rp): + return + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if _sg_is_external_root(d + ".xml"): + return + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + code = "" + reason = "" + if g == 1: + blocked = True + code = "capability-off" + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + code = "not-removed" + reason = "объект не снят с поддержки — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + code = "locked" + reason = "объект на замке — редактирование сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления." + cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются." + off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json." + if code == "capability-off": + state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя." + fix = ( + "Либо снять защиту явно (навык support-edit, два шага):\n" + f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n' + f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n' + " Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора." + ) + elif code == "not-removed": + state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора." + fix = ( + "Либо сначала снять объект с поддержки, затем удалять:\n" + f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.' + ) + else: + state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)." + fix = ( + "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n" + f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n' + f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.' + ) + sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n") + sys.exit(1) + except SystemExit: + raise + except Exception: + return def detect_format_version(d): - """Версия формата выгрузки — из Configuration.xml проекта (климб вверх от каталога исходников). - - Её задаёт платформа выгрузки: 8.3.20-8.3.24 -> 2.17, 8.3.25 -> 2.18, 8.3.26 -> 2.19, - 8.3.27 -> 2.20. Раньше здесь стоял хардкод 2.17, и на проекте 2.20 пакет расходился с выгрузкой. - Тело — точная копия из остальных навыков (разрешение пути делает вызывающая сторона). - """ while d: # Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт # корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17. @@ -129,45 +257,13 @@ def detect_format_version(d): d = parent return "2.17" + def format_rank(ver): """"2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17").""" m = re.match(r'^(\d+)\.(\d+)$', ver or '') return int(m.group(1)) * 100 + int(m.group(2)) if m else 0 - -def assert_edit_allowed(target_path): - d = os.path.abspath(target_path) - for _ in range(20): - # Автономный объект (внешняя обработка/отчёт) — граница климба - try: - for f in os.listdir(d): - if f.endswith(".xml") and is_external_object_root(os.path.join(d, f)): - return - except OSError: - pass - cfg_xml = os.path.join(d, "Configuration.xml") - support_bin = os.path.join(d, "Ext", "ParentConfigurations.bin") - if os.path.exists(cfg_xml): - if os.path.exists(support_bin): - mode = get_edit_mode(d) - if mode == "off": - return - msg = ("Конфигурация находится на поддержке (Ext/ParentConfigurations.bin). " - "Правка может быть запрещена.") - if mode == "warn": - print(f"WARNING: {msg}", file=sys.stderr) - return - print(f"{msg} Снимите с поддержки (/support-edit) или задайте " - "editingAllowedCheck в .v8-project.json.", file=sys.stderr) - sys.exit(1) - return - parent = os.path.dirname(d) - if parent == d: - break - d = parent - - # ── load the schema ────────────────────────────────────────── if args.Xsd: @@ -873,7 +969,7 @@ name = re.sub(r"[^\wЀ-ӿ]", "_", name, flags=re.UNICODE) if re.match(r"^\d", name): name = "_" + name -assert_edit_allowed(args.OutputDir) +assert_edit_allowed(args.OutputDir, "editable") format_version = detect_format_version(os.path.abspath(args.OutputDir)) diff --git a/.claude/skills/xdto-edit/scripts/xdto-edit.ps1 b/.claude/skills/xdto-edit/scripts/xdto-edit.ps1 index d4570e49..048dbd1d 100644 --- a/.claude/skills/xdto-edit/scripts/xdto-edit.ps1 +++ b/.claude/skills/xdto-edit/scripts/xdto-edit.ps1 @@ -1,4 +1,4 @@ -# xdto-edit v1.4 — Point edits of a 1C XDTO package +# xdto-edit v1.5 — Point edits of a 1C XDTO package (+support-guard: общая реализация вместо урезанной) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory=$true)] @@ -27,13 +27,23 @@ $V8_NS = "http://v8.1c.ru/8.1/data/core" # read-only configs unless allowed. Trigger = bin present; reaction from # .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never # throws — guard errors degrade to allow. +function Get-RootUuid([string]$xmlPath) { + if (-not (Test-Path $xmlPath)) { return $null } + try { + [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 + $el = $mx.DocumentElement.FirstChild + while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } + if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } + } catch {} + return $null +} function Test-ExternalObjectRoot([string]$xmlPath) { if (-not (Test-Path $xmlPath)) { return $false } try { [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 $el = $mx.DocumentElement.FirstChild while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } - if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.get_LocalName() } + if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName } } catch {} return $false } @@ -49,44 +59,98 @@ function Find-V8Project([string]$startDir) { return $null } function Get-EditMode([string]$cfgDir) { - $mode = "deny" try { - $pj = Find-V8Project $cfgDir - if ($pj) { - $cfg = Get-Content -Path $pj -Raw -Encoding UTF8 | ConvertFrom-Json - if ($cfg.PSObject.Properties.Name -contains 'editingAllowedCheck' -and $cfg.editingAllowedCheck) { - $mode = [string]$cfg.editingAllowedCheck + $pj = Find-V8Project (Get-Location).Path + if (-not $pj) { $pj = Find-V8Project $cfgDir } + if (-not $pj) { return 'deny' } + $proj = Get-Content -Raw $pj | ConvertFrom-Json + $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') + if ($proj.databases) { + foreach ($db in $proj.databases) { + if ($db.configSrc) { + $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') + if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { + if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } + } + } } } - } catch {} - return $mode + if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } + return 'deny' + } catch { return 'deny' } } -function Assert-EditAllowed([string]$targetPath) { +function Assert-EditAllowed([string]$targetPath, [string]$require) { try { - $d = $targetPath - for ($i = 0; $i -lt 20 -and $d; $i++) { - foreach ($x in @(Get-ChildItem -Path $d -Filter "*.xml" -File -ErrorAction SilentlyContinue)) { - if (Test-ExternalObjectRoot $x.FullName) { return } - } - $cfgXml = Join-Path $d "Configuration.xml" - $supportBin = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" - if (Test-Path $cfgXml) { - if (Test-Path $supportBin) { - $mode = Get-EditMode $d - if ($mode -eq "off") { return } - $msg = "Конфигурация находится на поддержке (Ext/ParentConfigurations.bin). Правка может быть запрещена." - if ($mode -eq "warn") { Write-Warning $msg; return } - throw "$msg Снимите с поддержки (/support-edit) или задайте editingAllowedCheck в .v8-project.json." - } - return + $rp = $targetPath + try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} + # Autonomous external object (EPF/ERF): never part of a config on support (issue #39). + if (Test-ExternalObjectRoot $rp) { return } + $elemUuid = Get-RootUuid $rp + $cfgDir = $null; $binPath = $null + $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } + for ($i = 0; $i -lt 12 -and $d; $i++) { + if (Test-ExternalObjectRoot "$d.xml") { return } + if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } + if (-not $cfgDir) { + $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" + if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } } + if ($elemUuid -and $cfgDir) { break } $parent = [System.IO.Path]::GetDirectoryName($d) if ($parent -eq $d) { break } $d = $parent } - } catch [System.Management.Automation.RuntimeException] { - throw - } catch {} + # New object (no element file): fall back to config root uuid. + if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } + if (-not $binPath -or -not (Test-Path $binPath)) { return } + $bytes = [System.IO.File]::ReadAllBytes($binPath) + if ($bytes.Length -le 32) { return } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } + $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) + $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') + if (-not $hm.Success) { return } + $G = [int]$hm.Groups[1].Value + $K = [int]$hm.Groups[2].Value + if ($K -eq 0) { return } + $best = $null + if ($elemUuid) { + $u = [regex]::Escape($elemUuid.ToLower()) + foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { + $f1 = [int]$m.Groups[1].Value + if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } + } + } + $blocked = $false; $code = ""; $reason = "" + if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } + elseif ($require -eq 'removed') { + if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" } + } + else { + if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" } + } + if (-not $blocked) { return } + $mode = Get-EditMode $cfgDir + if ($mode -eq 'off') { return } + # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the + # latter throws and would be swallowed by this function's own catch. + if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } + $head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления." + $cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются." + $offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json." + if ($code -eq "capability-off") { + $state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя." + $fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора." + } elseif ($code -eq "not-removed") { + $state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора." + $fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно." + } else { + $state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)." + $fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят." + } + [Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote") + exit 1 + } catch { return } } # --- Resolve package ------------------------------------------------------------ @@ -126,7 +190,7 @@ if ($Value -and $Value.StartsWith("@")) { $Value = [System.IO.File]::ReadAllText($valueFile).Trim() } -Assert-EditAllowed $pkgDir +Assert-EditAllowed $pkgDir "editable" $encBom = New-Object System.Text.UTF8Encoding($true) diff --git a/.claude/skills/xdto-edit/scripts/xdto-edit.py b/.claude/skills/xdto-edit/scripts/xdto-edit.py index 8bc01bc5..4e0da549 100644 --- a/.claude/skills/xdto-edit/scripts/xdto-edit.py +++ b/.claude/skills/xdto-edit/scripts/xdto-edit.py @@ -1,4 +1,4 @@ -# xdto-edit v1.4 — Point edits of a 1C XDTO package (Python port) +# xdto-edit v1.5 — Point edits of a 1C XDTO package (Python port) (+support-guard: общая реализация вместо урезанной) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -56,14 +56,45 @@ def _parse_xml(source, from_string=False): return (etree.fromstring(source, p) if from_string else etree.parse(source, p)) -# ── support guard ──────────────────────────────────────────── -# См. docs/1c-support-state-spec.md. +# ============================================================ +# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md +# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin +# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off, +# default deny). Never throws (except sys.exit on deny) — errors degrade to allow. +# ============================================================ -def find_v8_project(start_dir): - d = os.path.abspath(start_dir) +def _sg_root_uuid(xml_path): + if not os.path.isfile(xml_path): + return None + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str) and child.get("uuid"): + return child.get("uuid") + except Exception: + return None + return None + + +def _sg_is_external_root(xml_path): + if not os.path.isfile(xml_path): + return False + try: + mx = etree.parse(xml_path).getroot() + for child in mx: + if isinstance(child.tag, str): + return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport") + except Exception: + return False + return False + +def _sg_find_v8project(start_dir): + d = start_dir for _ in range(20): + if not d: + break pj = os.path.join(d, ".v8-project.json") - if os.path.exists(pj): + if os.path.isfile(pj): return pj parent = os.path.dirname(d) if parent == d: @@ -72,53 +103,133 @@ def find_v8_project(start_dir): return None -def get_edit_mode(cfg_dir): +def _sg_get_edit_mode(cfg_dir): try: - pj = find_v8_project(cfg_dir) - if pj: - with open(pj, encoding="utf-8-sig") as f: - return str(json.load(f).get("editingAllowedCheck") or "deny") - except Exception: # noqa: BLE001 - pass - return "deny" + pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir) + if not pj: + return "deny" + proj = json.loads(open(pj, encoding="utf-8-sig").read()) + cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/") + for db in proj.get("databases", []): + src = db.get("configSrc") + if src: + src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/") + if cfg_full == src_full or cfg_full.startswith(src_full + os.sep): + if db.get("editingAllowedCheck"): + return db["editingAllowedCheck"] + if proj.get("editingAllowedCheck"): + return proj["editingAllowedCheck"] + return "deny" + except Exception: + return "deny" -def is_external_object_root(xml_path): +def assert_edit_allowed(target_path, require): try: - for el in _parse_xml(xml_path).getroot(): - if isinstance(el.tag, str): - return local(el) in ("ExternalDataProcessor", "ExternalReport") - except Exception: # noqa: BLE001 - pass - return False - - -def assert_edit_allowed(target_path): - d = os.path.abspath(target_path) - for _ in range(20): - try: - for f in os.listdir(d): - if f.endswith(".xml") and is_external_object_root(os.path.join(d, f)): - return - except OSError: - pass - if os.path.exists(os.path.join(d, "Configuration.xml")): - if os.path.exists(os.path.join(d, "Ext", "ParentConfigurations.bin")): - mode = get_edit_mode(d) - if mode == "off": - return - msg = ("Конфигурация находится на поддержке (Ext/ParentConfigurations.bin). " - "Правка может быть запрещена.") - if mode == "warn": - print("WARNING: " + msg, file=sys.stderr) - return - die(msg + " Снимите с поддержки (/support-edit) или задайте " - "editingAllowedCheck в .v8-project.json.") + rp = os.path.abspath(target_path) + # Autonomous external object (EPF/ERF): never part of a config on support (issue #39). + if _sg_is_external_root(rp): return - parent = os.path.dirname(d) - if parent == d: - break - d = parent + elem_uuid = _sg_root_uuid(rp) + cfg_dir = None + bin_path = None + d = rp if os.path.isdir(rp) else os.path.dirname(rp) + for _ in range(12): + if not d: + break + if _sg_is_external_root(d + ".xml"): + return + if not elem_uuid: + elem_uuid = _sg_root_uuid(d + ".xml") + if not cfg_dir: + cand = os.path.join(d, "Ext", "ParentConfigurations.bin") + if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")): + cfg_dir = d + bin_path = cand + if elem_uuid and cfg_dir: + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + if not elem_uuid and cfg_dir: + elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml")) + if not bin_path or not os.path.exists(bin_path): + return + data = open(bin_path, "rb").read() + if len(data) <= 32: + return + if data[:3] == b"\xef\xbb\xbf": + data = data[3:] + text = data.decode("utf-8", "replace") + h = re.match(r"\{6,(\d+),(\d+),", text) + if not h: + return + g = int(h.group(1)) + k = int(h.group(2)) + if k == 0: + return + best = None + if elem_uuid: + for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text): + f1 = int(m.group(1)) + if best is None or f1 < best: + best = f1 + blocked = False + code = "" + reason = "" + if g == 1: + blocked = True + code = "capability-off" + reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" + elif require == "removed": + if best is not None and best != 2: + blocked = True + code = "not-removed" + reason = "объект не снят с поддержки — удаление сломает обновления" + else: + if best is not None and best == 0: + blocked = True + code = "locked" + reason = "объект на замке — редактирование сломает обновления" + if not blocked: + return + mode = _sg_get_edit_mode(cfg_dir) + if mode == "off": + return + if mode == "warn": + sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n") + return + head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления." + cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются." + off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json." + if code == "capability-off": + state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя." + fix = ( + "Либо снять защиту явно (навык support-edit, два шага):\n" + f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n' + f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n' + " Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора." + ) + elif code == "not-removed": + state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора." + fix = ( + "Либо сначала снять объект с поддержки, затем удалять:\n" + f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.' + ) + else: + state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)." + fix = ( + "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n" + f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n' + f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.' + ) + sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n") + sys.exit(1) + except SystemExit: + raise + except Exception: + return # ── resolve package ────────────────────────────────────────── @@ -157,7 +268,7 @@ if args.Value.startswith("@"): with open(value_file, encoding="utf-8-sig") as f: args.Value = f.read().strip() -assert_edit_allowed(pkg_dir) +assert_edit_allowed(pkg_dir, "editable") SKILLS = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) DECOMPILE = os.path.join(SKILLS, "xdto-decompile", "scripts", "xdto-decompile.py") diff --git a/tests/skills/check-inline-drift.mjs b/tests/skills/check-inline-drift.mjs index 9406b09f..9a38f02a 100644 --- a/tests/skills/check-inline-drift.mjs +++ b/tests/skills/check-inline-drift.mjs @@ -38,10 +38,7 @@ const FAMILIES = [ { id: 'full', authority: 'cf-edit', consumers: ['form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile', 'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit', - 'subsystem-compile', 'subsystem-edit', 'template-add'] }, - { id: 'xdto-compile-own', authority: 'xdto-compile', consumers: [] }, - { id: 'xdto-edit-own', authority: 'xdto-edit', consumers: [] }, - // Итог: xdto-* несут свою урезанную реализацию guard-а вместо общей — см. FINDINGS.md. + 'subsystem-compile', 'subsystem-edit', 'template-add', 'xdto-compile', 'xdto-edit'] }, ], }, { @@ -50,9 +47,7 @@ const FAMILIES = [ { id: 'full', authority: 'cf-edit', consumers: ['form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile', 'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit', - 'subsystem-compile', 'subsystem-edit', 'template-add'] }, - // В PY xdto-* держат ту же логику встроенной в assert_edit_allowed, отдельной функции нет. - { id: 'xdto-own', authority: 'xdto-compile', consumers: [], consumersPs1: ['xdto-edit'], port: 'ps1' }, + 'subsystem-compile', 'subsystem-edit', 'template-add', 'xdto-compile', 'xdto-edit'] }, ], }, { @@ -61,10 +56,9 @@ const FAMILIES = [ { id: 'full', authority: 'cf-edit', consumers: ['form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile', 'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit', - 'subsystem-compile', 'subsystem-edit', 'template-add'], + 'subsystem-compile', 'subsystem-edit', 'template-add', 'xdto-compile', 'xdto-edit'], // *-info навыки несут хелпер только в PS1-порте — см. debug/inline-utils/FINDINGS.md. consumersPs1: ['form-info', 'meta-info', 'mxl-info', 'role-info', 'skd-info'] }, - { id: 'xdto-own', authority: 'xdto-compile', consumers: [], consumersPs1: ['xdto-edit'], port: 'ps1' }, ], }, { @@ -73,8 +67,7 @@ const FAMILIES = [ { id: 'full', authority: 'cf-edit', consumers: ['form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile', 'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit', - 'subsystem-compile', 'subsystem-edit', 'template-add'], - consumersPs1: ['xdto-compile', 'xdto-edit'] }, + 'subsystem-compile', 'subsystem-edit', 'template-add', 'xdto-compile', 'xdto-edit'] }, ], }, { @@ -83,8 +76,8 @@ const FAMILIES = [ { id: 'full', authority: 'cf-edit', consumers: ['form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile', 'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit', - 'subsystem-compile', 'subsystem-edit', 'template-add'], - consumersPs1: ['support-edit', 'xdto-compile'] }, + 'subsystem-compile', 'subsystem-edit', 'template-add', 'xdto-compile', 'xdto-edit'], + consumersPs1: ['support-edit'] }, ], },