mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-07 20:20:20 +03:00
feat(xdto-compile,xdto-decompile,xdto-validate): пакеты XDTO из XML-схемы
Три навыка для работы с пакетами XDTO. Формат описания — обычная XSD, своего DSL нет: рутину снимает конвертер (локальные объявления префиксов dNpM на каждой ссылке, инвертированная кратность lowerBound/upperBound, фасеты атрибутами вместо дочерних элементов, обязательный порядок элементов верхнего уровня). То, чего XSD выразить не может — nillable у атрибута, qualified у свойства, «атрибут записан явно» — едет атрибутами из пространства имён модели XDTO по правилу «то же имя, что в Package.bin». Свойства объекта метаданных живут в xs:appinfo, поэтому пара decompile → compile замыкается без потерь. Инвариант bin → xsd → bin проверен побайтово на 760 пакетах выгрузок Бухгалтерии и ERP 8.3.24 (харнесс debug/xdto/roundtrip-corpus.mjs). Сборка из рукописной XSD проверена загрузкой в базу 8.3.24 — именно она вскрыла обязательный порядок import→property→valueType→objectType, невидимый для корпусной сверки: все выгрузки уже канонические. xdto-validate ловит два класса тихих дефектов, которые платформа не диагностирует: подмену неразрешённого чужого типа на xs:anyType при импорте XML-схемы и nillable у свойства-атрибута, теряемый экспортом схемы в Конфигураторе. Тесты: 18 снэпшот-кейсов на синтетических схемах (типовые конфигурации в репозиторий не тащим), паритет PS↔PY на общих эталонах. Раннер получил caseFiles — копирование файлов кейса в workDir для навыков с файловым, а не JSON входом. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d544071b1e
commit
d05aef54b4
@@ -0,0 +1,402 @@
|
||||
# xdto-validate v1.0 — Validate a 1C XDTO package
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Alias('Path')]
|
||||
[string]$PackagePath,
|
||||
|
||||
[string]$ConfigDir,
|
||||
|
||||
[switch]$Detailed,
|
||||
|
||||
[int]$MaxErrors = 20,
|
||||
|
||||
[string]$OutFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
$XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
$MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
|
||||
# --- Reporting ---
|
||||
|
||||
$script:errors = 0
|
||||
$script:warnings = 0
|
||||
$script:okCount = 0
|
||||
$script:stopped = $false
|
||||
$script:output = New-Object System.Text.StringBuilder
|
||||
|
||||
function Out-Line([string]$s) { [void]$script:output.AppendLine($s) }
|
||||
function Report-OK([string]$msg) {
|
||||
$script:okCount++
|
||||
if ($Detailed) { Out-Line "[OK] $msg" }
|
||||
}
|
||||
function Report-Error([string]$msg) {
|
||||
$script:errors++
|
||||
Out-Line "[ERROR] $msg"
|
||||
if ($script:errors -ge $MaxErrors) { $script:stopped = $true }
|
||||
}
|
||||
function Report-Warn([string]$msg) {
|
||||
$script:warnings++
|
||||
Out-Line "[WARN] $msg"
|
||||
}
|
||||
|
||||
# --- Resolve paths ---
|
||||
|
||||
if (-not [System.IO.Path]::IsPathRooted($PackagePath)) {
|
||||
$PackagePath = Join-Path (Get-Location).Path $PackagePath
|
||||
}
|
||||
|
||||
$binPath = $null
|
||||
$mdPath = $null
|
||||
if (Test-Path $PackagePath -PathType Leaf) {
|
||||
if ([System.IO.Path]::GetFileName($PackagePath) -eq "Package.bin") {
|
||||
$binPath = $PackagePath
|
||||
$pkgDir = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetDirectoryName($PackagePath))
|
||||
if (Test-Path "$pkgDir.xml") { $mdPath = "$pkgDir.xml" }
|
||||
} elseif ($PackagePath.EndsWith(".xml")) {
|
||||
$mdPath = $PackagePath
|
||||
$stem = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($PackagePath), [System.IO.Path]::GetFileNameWithoutExtension($PackagePath))
|
||||
$c = Join-Path (Join-Path $stem "Ext") "Package.bin"
|
||||
if (Test-Path $c) { $binPath = $c }
|
||||
}
|
||||
} elseif (Test-Path $PackagePath -PathType Container) {
|
||||
$c = Join-Path (Join-Path $PackagePath "Ext") "Package.bin"
|
||||
if (Test-Path $c) {
|
||||
$binPath = $c
|
||||
$m = "$($PackagePath.TrimEnd('\','/')).xml"
|
||||
if (Test-Path $m) { $mdPath = $m }
|
||||
}
|
||||
}
|
||||
|
||||
$fileName = if ($binPath) { [System.IO.Path]::GetFileName([System.IO.Path]::GetDirectoryName([System.IO.Path]::GetDirectoryName($binPath))) } else { $PackagePath }
|
||||
|
||||
if (-not $binPath) {
|
||||
Write-Host "[ERROR] Не найден Ext/Package.bin для пути: $PackagePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Каталог конфигурации — для проверок регистрации и зависимостей
|
||||
if (-not $ConfigDir) {
|
||||
$d = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetDirectoryName([System.IO.Path]::GetDirectoryName($binPath)))
|
||||
# .../XDTOPackages/<Имя>/Ext/Package.bin -> .../XDTOPackages -> корень
|
||||
$ConfigDir = [System.IO.Path]::GetDirectoryName($d)
|
||||
}
|
||||
|
||||
$finalize = {
|
||||
$checks = $script:okCount + $script:errors + $script:warnings
|
||||
if ($script:errors -eq 0 -and $script:warnings -eq 0 -and -not $Detailed) {
|
||||
$result = "=== Validation OK: $fileName ($checks checks) ==="
|
||||
} else {
|
||||
Out-Line ""
|
||||
Out-Line "=== Result: $($script:errors) errors, $($script:warnings) warnings ($checks checks) ==="
|
||||
$result = $script:output.ToString()
|
||||
}
|
||||
Write-Host $result
|
||||
if ($OutFile) {
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding $true
|
||||
[System.IO.File]::WriteAllText($OutFile, $script:output.ToString(), $utf8Bom)
|
||||
}
|
||||
}
|
||||
|
||||
# --- 1. Well-formedness ---
|
||||
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
try {
|
||||
$doc.Load($binPath)
|
||||
} catch {
|
||||
Report-Error "Package.bin не является корректным XML: $($_.Exception.Message)"
|
||||
& $finalize
|
||||
exit 1
|
||||
}
|
||||
$pkg = $doc.DocumentElement
|
||||
if ($pkg.get_LocalName() -ne "package") {
|
||||
Report-Error "Ожидался корневой <package>, найден <$($pkg.get_LocalName())>"
|
||||
& $finalize
|
||||
exit 1
|
||||
}
|
||||
Report-OK "Package.bin: корректный XML, корень <package>"
|
||||
|
||||
$targetNs = $pkg.GetAttribute("targetNamespace")
|
||||
if (-not $targetNs) {
|
||||
Report-Error "У <package> не задан targetNamespace"
|
||||
} else {
|
||||
Report-OK "targetNamespace: $targetNs"
|
||||
}
|
||||
|
||||
# --- 2. Encoding / EOL ---
|
||||
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if (-not ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)) {
|
||||
Report-Warn "Package.bin без BOM UTF-8 — платформа пишет файл с BOM"
|
||||
} else {
|
||||
Report-OK "Кодировка: UTF-8 с BOM"
|
||||
}
|
||||
|
||||
# --- 3. Collect declarations ---
|
||||
|
||||
$imports = New-Object System.Collections.ArrayList
|
||||
$localTypes = New-Object System.Collections.Generic.HashSet[string]
|
||||
$globalProps = New-Object System.Collections.Generic.HashSet[string]
|
||||
|
||||
foreach ($n in $pkg.ChildNodes) {
|
||||
if ($n.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
switch ($n.get_LocalName()) {
|
||||
"import" { [void]$imports.Add($n.GetAttribute("namespace")) }
|
||||
"objectType" { [void]$localTypes.Add($n.GetAttribute("name")) }
|
||||
"valueType" { [void]$localTypes.Add($n.GetAttribute("name")) }
|
||||
"property" { if ($n.HasAttribute("name")) { [void]$globalProps.Add($n.GetAttribute("name")) } }
|
||||
}
|
||||
}
|
||||
|
||||
# --- 4. Duplicate type names ---
|
||||
|
||||
$seen = @{}
|
||||
foreach ($n in $pkg.ChildNodes) {
|
||||
if ($n.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
if ($n.get_LocalName() -ne "objectType" -and $n.get_LocalName() -ne "valueType") { continue }
|
||||
$nm = $n.GetAttribute("name")
|
||||
if (-not $nm) { Report-Error "<$($n.get_LocalName())> без атрибута name"; continue }
|
||||
if ($seen.ContainsKey($nm)) {
|
||||
Report-Error "Дублирующееся имя типа: $nm"
|
||||
} else {
|
||||
$seen[$nm] = $true
|
||||
}
|
||||
}
|
||||
if ($localTypes.Count -gt 0) { Report-OK "$($localTypes.Count) тип(ов), имена уникальны" }
|
||||
|
||||
# --- 5. Type references resolve ---
|
||||
|
||||
$usedNamespaces = New-Object System.Collections.Generic.HashSet[string]
|
||||
$anyTypeProps = New-Object System.Collections.ArrayList
|
||||
$refAttrs = @("type", "base", "ref", "itemType")
|
||||
|
||||
function Resolve-Ref([System.Xml.XmlElement]$el, [string]$attr, [string]$raw) {
|
||||
if (-not $raw) { return }
|
||||
# Нотация Кларка {ns}local
|
||||
if ($raw.StartsWith("{")) {
|
||||
$close = $raw.IndexOf("}")
|
||||
if ($close -lt 0) { Report-Error "Некорректная нотация Кларка в $attr=`"$raw`""; return }
|
||||
$ns = $raw.Substring(1, $close - 1)
|
||||
$local = $raw.Substring($close + 1)
|
||||
} else {
|
||||
$parts = $raw.Split(":")
|
||||
if ($parts.Count -eq 2) {
|
||||
$ns = $el.GetNamespaceOfPrefix($parts[0])
|
||||
$local = $parts[1]
|
||||
if (-not $ns) {
|
||||
Report-Error "Префикс `"$($parts[0])`" не объявлен: $attr=`"$raw`" (тип $($el.get_LocalName()))"
|
||||
return
|
||||
}
|
||||
} else {
|
||||
$ns = $null
|
||||
$local = $parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
if ($ns -eq $XS_NS -or $ns -eq $XSI_NS) {
|
||||
if ($local -eq "anyType") { [void]$anyTypeProps.Add($el) }
|
||||
return
|
||||
}
|
||||
if ($ns -eq $targetNs) {
|
||||
if ($attr -eq "ref") {
|
||||
if (-not $globalProps.Contains($local)) {
|
||||
Report-Error "ref=`"$raw`" не разрешается: в пакете нет глобального свойства `"$local`""
|
||||
}
|
||||
} elseif (-not $localTypes.Contains($local)) {
|
||||
Report-Error "$attr=`"$raw`" не разрешается: в пакете нет типа `"$local`""
|
||||
}
|
||||
return
|
||||
}
|
||||
if ($ns) {
|
||||
[void]$usedNamespaces.Add($ns)
|
||||
if (-not $imports.Contains($ns)) {
|
||||
Report-Error "$attr=`"$raw`" ссылается на `"$ns`", но <import namespace=`"$ns`"/> не объявлен"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$nodeCount = 0
|
||||
foreach ($el in $pkg.SelectNodes("//*")) {
|
||||
if ($el.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
$nodeCount++
|
||||
foreach ($a in $refAttrs) {
|
||||
if ($el.HasAttribute($a)) { Resolve-Ref $el $a $el.GetAttribute($a) }
|
||||
}
|
||||
if ($el.HasAttribute("memberTypes")) {
|
||||
foreach ($m in ($el.GetAttribute("memberTypes") -split "\s+")) {
|
||||
if ($m) { Resolve-Ref $el "memberTypes" $m }
|
||||
}
|
||||
}
|
||||
if ($script:stopped) { break }
|
||||
}
|
||||
if (-not $script:stopped) { Report-OK "$nodeCount узлов: ссылки на типы разрешаются" }
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 6. Silent degradation to xs:anyType ---
|
||||
# Платформа при импорте XML-схемы молча подменяет неразрешённый чужой тип на anyType.
|
||||
|
||||
if ($anyTypeProps.Count -gt 0 -and $imports.Count -gt 0) {
|
||||
$names = @()
|
||||
foreach ($p in $anyTypeProps) { if ($p.HasAttribute("name")) { $names += $p.GetAttribute("name") } }
|
||||
$shown = ($names | Select-Object -First 5) -join ", "
|
||||
Report-Warn "Свойств с type=`"xs:anyType`": $($anyTypeProps.Count) при объявленных импортах ($shown). Возможна тихая деградация: при импорте XML-схемы платформа заменяет неразрешённый чужой тип на anyType без ошибки"
|
||||
}
|
||||
|
||||
# --- 7. Unused imports ---
|
||||
|
||||
foreach ($imp in $imports) {
|
||||
if (-not $usedNamespaces.Contains($imp)) {
|
||||
Report-Warn "<import namespace=`"$imp`"/> объявлен, но ни один тип из этого пространства имён не используется"
|
||||
}
|
||||
}
|
||||
if ($imports.Count -gt 0 -and $script:warnings -eq 0) { Report-OK "$($imports.Count) импорт(ов) — все используются" }
|
||||
|
||||
# --- 8. nillable on attribute-form properties ---
|
||||
|
||||
$nillAttrs = @()
|
||||
foreach ($p in $pkg.SelectNodes("//*[local-name()='property']")) {
|
||||
if ($p.GetAttribute("form") -eq "Attribute" -and $p.GetAttribute("nillable") -eq "true") {
|
||||
$nillAttrs += $p.GetAttribute("name")
|
||||
}
|
||||
}
|
||||
if ($nillAttrs.Count -gt 0) {
|
||||
Report-Warn "Свойств с nillable=`"true`" и form=`"Attribute`": $($nillAttrs.Count) ($(($nillAttrs | Select-Object -First 5) -join ', ')). Спецификация XSD не допускает nillable у атрибутов — экспорт XML-схемы в Конфигураторе их потеряет"
|
||||
}
|
||||
|
||||
# --- 9. Facet consistency ---
|
||||
|
||||
$FACET_NUM = @("totalDigits", "fractionDigits")
|
||||
$facetChecked = 0
|
||||
foreach ($t in $pkg.SelectNodes("//*[local-name()='valueType' or local-name()='typeDef']")) {
|
||||
if ($t.get_LocalName() -eq "typeDef" -and $t.GetAttribute("type", $XSI_NS) -eq "ObjectType") { continue }
|
||||
$facetChecked++
|
||||
$nm = if ($t.HasAttribute("name")) { $t.GetAttribute("name") } else { "(анонимный тип)" }
|
||||
|
||||
$len = $t.GetAttribute("length")
|
||||
if ($len -and ($t.HasAttribute("minLength") -or $t.HasAttribute("maxLength"))) {
|
||||
Report-Error "$nm : length несовместим с minLength/maxLength"
|
||||
}
|
||||
$minL = $t.GetAttribute("minLength"); $maxL = $t.GetAttribute("maxLength")
|
||||
if ($minL -and $maxL -and ([int]$minL -gt [int]$maxL)) {
|
||||
Report-Error "$nm : minLength ($minL) больше maxLength ($maxL)"
|
||||
}
|
||||
$td = $t.GetAttribute("totalDigits"); $fd = $t.GetAttribute("fractionDigits")
|
||||
if ($td -and $fd -and ([int]$fd -gt [int]$td)) {
|
||||
Report-Error "$nm : fractionDigits ($fd) больше totalDigits ($td)"
|
||||
}
|
||||
$ws = $t.GetAttribute("whiteSpace")
|
||||
if ($ws -and @("preserve", "replace", "collapse") -notcontains $ws) {
|
||||
Report-Error "$nm : недопустимое whiteSpace=`"$ws`""
|
||||
}
|
||||
$var = $t.GetAttribute("variety")
|
||||
if ($var -and @("Atomic", "List", "Union") -notcontains $var) {
|
||||
Report-Error "$nm : недопустимое variety=`"$var`""
|
||||
}
|
||||
if ($var -eq "List" -and -not $t.HasAttribute("itemType")) {
|
||||
Report-Warn "$nm : variety=`"List`" без itemType"
|
||||
}
|
||||
if ($script:stopped) { break }
|
||||
}
|
||||
if ($facetChecked -gt 0 -and -not $script:stopped) { Report-OK "$facetChecked простых тип(ов): фасеты согласованы" }
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 10. property form / bounds ---
|
||||
|
||||
foreach ($p in $pkg.SelectNodes("//*[local-name()='property']")) {
|
||||
$form = $p.GetAttribute("form")
|
||||
if ($form -and @("Element", "Attribute", "Text") -notcontains $form) {
|
||||
Report-Error "Свойство `"$($p.GetAttribute('name'))`": недопустимое form=`"$form`""
|
||||
}
|
||||
$ub = $p.GetAttribute("upperBound")
|
||||
if ($ub -and $ub -ne "-1" -and ([int]$ub -lt 1)) {
|
||||
Report-Error "Свойство `"$($p.GetAttribute('name'))`": upperBound=`"$ub`" (допустимы -1 или число ≥ 1)"
|
||||
}
|
||||
$lb = $p.GetAttribute("lowerBound")
|
||||
if ($lb -and $ub -and $ub -ne "-1" -and ([int]$lb -gt [int]$ub)) {
|
||||
Report-Error "Свойство `"$($p.GetAttribute('name'))`": lowerBound ($lb) больше upperBound ($ub)"
|
||||
}
|
||||
if (-not $p.HasAttribute("name") -and -not $p.HasAttribute("ref")) {
|
||||
Report-Error "Свойство без name и без ref"
|
||||
}
|
||||
if ($script:stopped) { break }
|
||||
}
|
||||
if (-not $script:stopped) { Report-OK "Свойства: form и кратности корректны" }
|
||||
|
||||
# --- 11. Metadata object ---
|
||||
|
||||
if ($mdPath) {
|
||||
$md = New-Object System.Xml.XmlDocument
|
||||
$md.Load($mdPath)
|
||||
$nsm = New-Object System.Xml.XmlNamespaceManager($md.NameTable)
|
||||
$nsm.AddNamespace("md", $MD_NS)
|
||||
$mdName = $md.SelectSingleNode("//md:XDTOPackage/md:Properties/md:Name", $nsm)
|
||||
$mdNs = $md.SelectSingleNode("//md:XDTOPackage/md:Properties/md:Namespace", $nsm)
|
||||
if (-not $mdName) {
|
||||
Report-Error "В объекте метаданных не задано <Name>"
|
||||
} elseif ($mdName.InnerText -ne $fileName) {
|
||||
Report-Error "<Name>$($mdName.InnerText)</Name> не совпадает с именем каталога `"$fileName`""
|
||||
} else {
|
||||
Report-OK "Объект метаданных: Name = $($mdName.InnerText)"
|
||||
}
|
||||
if ($mdNs -and $mdNs.InnerText -ne $targetNs) {
|
||||
Report-Error "<Namespace>$($mdNs.InnerText)</Namespace> не совпадает с targetNamespace пакета ($targetNs)"
|
||||
} elseif ($mdNs) {
|
||||
Report-OK "Namespace объекта метаданных совпадает с targetNamespace"
|
||||
}
|
||||
} else {
|
||||
Report-Warn "Файл объекта метаданных <Имя>.xml не найден рядом с каталогом пакета"
|
||||
}
|
||||
|
||||
# --- 12. Registration in Configuration.xml + namespace uniqueness ---
|
||||
|
||||
$configXml = Join-Path $ConfigDir "Configuration.xml"
|
||||
if (Test-Path $configXml) {
|
||||
$cfg = New-Object System.Xml.XmlDocument
|
||||
$cfg.Load($configXml)
|
||||
$nsm2 = New-Object System.Xml.XmlNamespaceManager($cfg.NameTable)
|
||||
$nsm2.AddNamespace("md", $MD_NS)
|
||||
$registered = $false
|
||||
foreach ($e in $cfg.SelectNodes("//md:Configuration/md:ChildObjects/md:XDTOPackage", $nsm2)) {
|
||||
if ($e.InnerText -eq $fileName) { $registered = $true; break }
|
||||
}
|
||||
if ($registered) {
|
||||
Report-OK "Зарегистрирован в Configuration.xml"
|
||||
} else {
|
||||
Report-Error "<XDTOPackage>$fileName</XDTOPackage> отсутствует в ChildObjects файла Configuration.xml — платформа пакет не увидит"
|
||||
}
|
||||
|
||||
# Уникальность targetNamespace среди пакетов конфигурации
|
||||
$pkgRoot = Join-Path $ConfigDir "XDTOPackages"
|
||||
if (Test-Path $pkgRoot) {
|
||||
$clash = @()
|
||||
foreach ($other in (Get-ChildItem $pkgRoot -Directory -ErrorAction SilentlyContinue)) {
|
||||
if ($other.Name -eq $fileName) { continue }
|
||||
$ob = Join-Path (Join-Path $other.FullName "Ext") "Package.bin"
|
||||
if (-not (Test-Path $ob)) { continue }
|
||||
try {
|
||||
$od = New-Object System.Xml.XmlDocument
|
||||
$od.Load($ob)
|
||||
if ($od.DocumentElement.GetAttribute("targetNamespace") -eq $targetNs) { $clash += $other.Name }
|
||||
} catch {}
|
||||
}
|
||||
if ($clash.Count -gt 0) {
|
||||
Report-Error "targetNamespace `"$targetNs`" уже занят пакет(ами): $($clash -join ', '). Платформа не допускает два пакета с одним пространством имён"
|
||||
} else {
|
||||
Report-OK "targetNamespace уникален в конфигурации"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Report-Warn "Configuration.xml не найден ($ConfigDir) — проверки регистрации и уникальности namespace пропущены"
|
||||
}
|
||||
|
||||
# --- Final ---
|
||||
|
||||
& $finalize
|
||||
if ($script:errors -gt 0) { exit 1 }
|
||||
exit 0
|
||||
@@ -0,0 +1,386 @@
|
||||
# xdto-validate v1.0 — Validate a 1C XDTO package (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("-PackagePath", "-Path", required=True)
|
||||
parser.add_argument("-ConfigDir", default="")
|
||||
parser.add_argument("-Detailed", action="store_true")
|
||||
parser.add_argument("-MaxErrors", type=int, default=20)
|
||||
parser.add_argument("-OutFile", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
package_path = os.path.abspath(args.PackagePath)
|
||||
detailed = args.Detailed
|
||||
max_errors = args.MaxErrors
|
||||
out_file = args.OutFile
|
||||
|
||||
# ── reporting ────────────────────────────────────────────────
|
||||
|
||||
state = {"errors": 0, "warnings": 0, "ok": 0, "stopped": False}
|
||||
output = []
|
||||
|
||||
|
||||
def out_line(s):
|
||||
output.append(s)
|
||||
|
||||
|
||||
def report_ok(msg):
|
||||
state["ok"] += 1
|
||||
if detailed:
|
||||
out_line(f"[OK] {msg}")
|
||||
|
||||
|
||||
def report_error(msg):
|
||||
state["errors"] += 1
|
||||
out_line(f"[ERROR] {msg}")
|
||||
if state["errors"] >= max_errors:
|
||||
state["stopped"] = True
|
||||
|
||||
|
||||
def report_warn(msg):
|
||||
state["warnings"] += 1
|
||||
out_line(f"[WARN] {msg}")
|
||||
|
||||
|
||||
# ── resolve paths ────────────────────────────────────────────
|
||||
|
||||
bin_path = None
|
||||
md_path = None
|
||||
|
||||
if os.path.isfile(package_path):
|
||||
if os.path.basename(package_path) == "Package.bin":
|
||||
bin_path = package_path
|
||||
pkg_dir = os.path.dirname(os.path.dirname(package_path))
|
||||
if os.path.exists(pkg_dir + ".xml"):
|
||||
md_path = pkg_dir + ".xml"
|
||||
elif package_path.endswith(".xml"):
|
||||
md_path = package_path
|
||||
stem = os.path.join(os.path.dirname(package_path),
|
||||
os.path.splitext(os.path.basename(package_path))[0])
|
||||
c = os.path.join(stem, "Ext", "Package.bin")
|
||||
if os.path.exists(c):
|
||||
bin_path = c
|
||||
elif os.path.isdir(package_path):
|
||||
c = os.path.join(package_path, "Ext", "Package.bin")
|
||||
if os.path.exists(c):
|
||||
bin_path = c
|
||||
m = package_path.rstrip("\\/") + ".xml"
|
||||
if os.path.exists(m):
|
||||
md_path = m
|
||||
|
||||
if not bin_path:
|
||||
print(f"[ERROR] Не найден Ext/Package.bin для пути: {package_path}")
|
||||
sys.exit(1)
|
||||
|
||||
file_name = os.path.basename(os.path.dirname(os.path.dirname(bin_path)))
|
||||
|
||||
config_dir = args.ConfigDir
|
||||
if not config_dir:
|
||||
# .../XDTOPackages/<Имя>/Ext/Package.bin -> .../XDTOPackages -> корень
|
||||
config_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(bin_path))))
|
||||
|
||||
|
||||
def finalize():
|
||||
checks = state["ok"] + state["errors"] + state["warnings"]
|
||||
if state["errors"] == 0 and state["warnings"] == 0 and not detailed:
|
||||
result = f"=== Validation OK: {file_name} ({checks} checks) ==="
|
||||
else:
|
||||
out_line("")
|
||||
out_line(f"=== Result: {state['errors']} errors, {state['warnings']} warnings ({checks} checks) ===")
|
||||
result = "\n".join(output)
|
||||
print(result)
|
||||
if out_file:
|
||||
with open(out_file, "w", encoding="utf-8-sig", newline="") as f:
|
||||
f.write("\n".join(output))
|
||||
|
||||
|
||||
def local(el):
|
||||
return etree.QName(el).localname
|
||||
|
||||
|
||||
# ── 1. well-formedness ───────────────────────────────────────
|
||||
|
||||
try:
|
||||
doc = etree.parse(bin_path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
report_error(f"Package.bin не является корректным XML: {e}")
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
pkg = doc.getroot()
|
||||
if local(pkg) != "package":
|
||||
report_error(f"Ожидался корневой <package>, найден <{local(pkg)}>")
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
report_ok("Package.bin: корректный XML, корень <package>")
|
||||
|
||||
target_ns = pkg.get("targetNamespace")
|
||||
if not target_ns:
|
||||
report_error("У <package> не задан targetNamespace")
|
||||
else:
|
||||
report_ok(f"targetNamespace: {target_ns}")
|
||||
|
||||
# ── 2. encoding ──────────────────────────────────────────────
|
||||
|
||||
with open(bin_path, "rb") as f:
|
||||
head = f.read(3)
|
||||
if head != b"\xef\xbb\xbf":
|
||||
report_warn("Package.bin без BOM UTF-8 — платформа пишет файл с BOM")
|
||||
else:
|
||||
report_ok("Кодировка: UTF-8 с BOM")
|
||||
|
||||
# ── 3. declarations ──────────────────────────────────────────
|
||||
|
||||
imports = []
|
||||
local_types = set()
|
||||
global_props = set()
|
||||
|
||||
for n in pkg:
|
||||
if not isinstance(n.tag, str):
|
||||
continue
|
||||
ln = local(n)
|
||||
if ln == "import":
|
||||
imports.append(n.get("namespace"))
|
||||
elif ln in ("objectType", "valueType"):
|
||||
local_types.add(n.get("name"))
|
||||
elif ln == "property" and n.get("name"):
|
||||
global_props.add(n.get("name"))
|
||||
|
||||
# ── 4. duplicate type names ──────────────────────────────────
|
||||
|
||||
seen = set()
|
||||
for n in pkg:
|
||||
if not isinstance(n.tag, str) or local(n) not in ("objectType", "valueType"):
|
||||
continue
|
||||
nm = n.get("name")
|
||||
if not nm:
|
||||
report_error(f"<{local(n)}> без атрибута name")
|
||||
continue
|
||||
if nm in seen:
|
||||
report_error(f"Дублирующееся имя типа: {nm}")
|
||||
else:
|
||||
seen.add(nm)
|
||||
if local_types:
|
||||
report_ok(f"{len(local_types)} тип(ов), имена уникальны")
|
||||
|
||||
# ── 5. type references resolve ───────────────────────────────
|
||||
|
||||
used_namespaces = set()
|
||||
any_type_props = []
|
||||
REF_ATTRS = ("type", "base", "ref", "itemType")
|
||||
|
||||
|
||||
def resolve_ref(el, attr, raw):
|
||||
if not raw:
|
||||
return
|
||||
if raw.startswith("{"):
|
||||
close = raw.find("}")
|
||||
if close < 0:
|
||||
report_error(f'Некорректная нотация Кларка в {attr}="{raw}"')
|
||||
return
|
||||
ns = raw[1:close]
|
||||
loc = raw[close + 1:]
|
||||
else:
|
||||
parts = raw.split(":")
|
||||
if len(parts) == 2:
|
||||
ns = el.nsmap.get(parts[0])
|
||||
loc = parts[1]
|
||||
if not ns:
|
||||
report_error(f'Префикс "{parts[0]}" не объявлен: {attr}="{raw}" (тип {local(el)})')
|
||||
return
|
||||
else:
|
||||
ns = None
|
||||
loc = parts[0]
|
||||
|
||||
if ns in (XS_NS, XSI_NS):
|
||||
if loc == "anyType":
|
||||
any_type_props.append(el)
|
||||
return
|
||||
if ns == target_ns:
|
||||
if attr == "ref":
|
||||
if loc not in global_props:
|
||||
report_error(f'ref="{raw}" не разрешается: в пакете нет глобального свойства "{loc}"')
|
||||
elif loc not in local_types:
|
||||
report_error(f'{attr}="{raw}" не разрешается: в пакете нет типа "{loc}"')
|
||||
return
|
||||
if ns:
|
||||
used_namespaces.add(ns)
|
||||
if ns not in imports:
|
||||
report_error(f'{attr}="{raw}" ссылается на "{ns}", но <import namespace="{ns}"/> не объявлен')
|
||||
|
||||
|
||||
node_count = 0
|
||||
for el in pkg.iter():
|
||||
if not isinstance(el.tag, str):
|
||||
continue
|
||||
node_count += 1
|
||||
for a in REF_ATTRS:
|
||||
if el.get(a) is not None:
|
||||
resolve_ref(el, a, el.get(a))
|
||||
if el.get("memberTypes"):
|
||||
for m in el.get("memberTypes").split():
|
||||
resolve_ref(el, "memberTypes", m)
|
||||
if state["stopped"]:
|
||||
break
|
||||
if not state["stopped"]:
|
||||
report_ok(f"{node_count} узлов: ссылки на типы разрешаются")
|
||||
|
||||
if state["stopped"]:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 6. silent degradation to xs:anyType ──────────────────────
|
||||
|
||||
if any_type_props and imports:
|
||||
names = [p.get("name") for p in any_type_props if p.get("name")]
|
||||
shown = ", ".join(names[:5])
|
||||
report_warn(
|
||||
f'Свойств с type="xs:anyType": {len(any_type_props)} при объявленных импортах ({shown}). '
|
||||
"Возможна тихая деградация: при импорте XML-схемы платформа заменяет неразрешённый "
|
||||
"чужой тип на anyType без ошибки"
|
||||
)
|
||||
|
||||
# ── 7. unused imports ────────────────────────────────────────
|
||||
|
||||
for imp in imports:
|
||||
if imp not in used_namespaces:
|
||||
report_warn(f'<import namespace="{imp}"/> объявлен, но ни один тип из этого пространства имён не используется')
|
||||
if imports and state["warnings"] == 0:
|
||||
report_ok(f"{len(imports)} импорт(ов) — все используются")
|
||||
|
||||
# ── 8. nillable on attribute-form properties ─────────────────
|
||||
|
||||
nill_attrs = [p.get("name") for p in pkg.iter()
|
||||
if isinstance(p.tag, str) and local(p) == "property"
|
||||
and p.get("form") == "Attribute" and p.get("nillable") == "true"]
|
||||
if nill_attrs:
|
||||
report_warn(
|
||||
f'Свойств с nillable="true" и form="Attribute": {len(nill_attrs)} ({", ".join(nill_attrs[:5])}). '
|
||||
"Спецификация XSD не допускает nillable у атрибутов — экспорт XML-схемы в Конфигураторе их потеряет"
|
||||
)
|
||||
|
||||
# ── 9. facet consistency ─────────────────────────────────────
|
||||
|
||||
facet_checked = 0
|
||||
for t in pkg.iter():
|
||||
if not isinstance(t.tag, str) or local(t) not in ("valueType", "typeDef"):
|
||||
continue
|
||||
if local(t) == "typeDef" and t.get(f"{{{XSI_NS}}}type") == "ObjectType":
|
||||
continue
|
||||
facet_checked += 1
|
||||
nm = t.get("name") or "(анонимный тип)"
|
||||
|
||||
if t.get("length") and (t.get("minLength") or t.get("maxLength")):
|
||||
report_error(f"{nm} : length несовместим с minLength/maxLength")
|
||||
min_l, max_l = t.get("minLength"), t.get("maxLength")
|
||||
if min_l and max_l and int(min_l) > int(max_l):
|
||||
report_error(f"{nm} : minLength ({min_l}) больше maxLength ({max_l})")
|
||||
td, fd = t.get("totalDigits"), t.get("fractionDigits")
|
||||
if td and fd and int(fd) > int(td):
|
||||
report_error(f"{nm} : fractionDigits ({fd}) больше totalDigits ({td})")
|
||||
ws = t.get("whiteSpace")
|
||||
if ws and ws not in ("preserve", "replace", "collapse"):
|
||||
report_error(f'{nm} : недопустимое whiteSpace="{ws}"')
|
||||
var = t.get("variety")
|
||||
if var and var not in ("Atomic", "List", "Union"):
|
||||
report_error(f'{nm} : недопустимое variety="{var}"')
|
||||
if var == "List" and t.get("itemType") is None:
|
||||
report_warn(f'{nm} : variety="List" без itemType')
|
||||
if state["stopped"]:
|
||||
break
|
||||
if facet_checked and not state["stopped"]:
|
||||
report_ok(f"{facet_checked} простых тип(ов): фасеты согласованы")
|
||||
|
||||
if state["stopped"]:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 10. property form / bounds ───────────────────────────────
|
||||
|
||||
for p in pkg.iter():
|
||||
if not isinstance(p.tag, str) or local(p) != "property":
|
||||
continue
|
||||
form = p.get("form")
|
||||
if form and form not in ("Element", "Attribute", "Text"):
|
||||
report_error(f'Свойство "{p.get("name")}": недопустимое form="{form}"')
|
||||
ub = p.get("upperBound")
|
||||
if ub and ub != "-1" and int(ub) < 1:
|
||||
report_error(f'Свойство "{p.get("name")}": upperBound="{ub}" (допустимы -1 или число ≥ 1)')
|
||||
lb = p.get("lowerBound")
|
||||
if lb and ub and ub != "-1" and int(lb) > int(ub):
|
||||
report_error(f'Свойство "{p.get("name")}": lowerBound ({lb}) больше upperBound ({ub})')
|
||||
if p.get("name") is None and p.get("ref") is None:
|
||||
report_error("Свойство без name и без ref")
|
||||
if state["stopped"]:
|
||||
break
|
||||
if not state["stopped"]:
|
||||
report_ok("Свойства: form и кратности корректны")
|
||||
|
||||
# ── 11. metadata object ──────────────────────────────────────
|
||||
|
||||
if md_path:
|
||||
md = etree.parse(md_path)
|
||||
md_name = md.find(f".//{{{MD_NS}}}XDTOPackage/{{{MD_NS}}}Properties/{{{MD_NS}}}Name")
|
||||
md_ns = md.find(f".//{{{MD_NS}}}XDTOPackage/{{{MD_NS}}}Properties/{{{MD_NS}}}Namespace")
|
||||
if md_name is None:
|
||||
report_error("В объекте метаданных не задано <Name>")
|
||||
elif (md_name.text or "") != file_name:
|
||||
report_error(f'<Name>{md_name.text}</Name> не совпадает с именем каталога "{file_name}"')
|
||||
else:
|
||||
report_ok(f"Объект метаданных: Name = {md_name.text}")
|
||||
if md_ns is not None and (md_ns.text or "") != target_ns:
|
||||
report_error(f"<Namespace>{md_ns.text}</Namespace> не совпадает с targetNamespace пакета ({target_ns})")
|
||||
elif md_ns is not None:
|
||||
report_ok("Namespace объекта метаданных совпадает с targetNamespace")
|
||||
else:
|
||||
report_warn("Файл объекта метаданных <Имя>.xml не найден рядом с каталогом пакета")
|
||||
|
||||
# ── 12. registration + namespace uniqueness ──────────────────
|
||||
|
||||
config_xml = os.path.join(config_dir, "Configuration.xml")
|
||||
if os.path.exists(config_xml):
|
||||
cfg = etree.parse(config_xml)
|
||||
registered = any((e.text or "") == file_name
|
||||
for e in cfg.iterfind(f".//{{{MD_NS}}}Configuration/{{{MD_NS}}}ChildObjects/{{{MD_NS}}}XDTOPackage"))
|
||||
if registered:
|
||||
report_ok("Зарегистрирован в Configuration.xml")
|
||||
else:
|
||||
report_error(f"<XDTOPackage>{file_name}</XDTOPackage> отсутствует в ChildObjects файла "
|
||||
"Configuration.xml — платформа пакет не увидит")
|
||||
|
||||
pkg_root = os.path.join(config_dir, "XDTOPackages")
|
||||
if os.path.isdir(pkg_root):
|
||||
clash = []
|
||||
for other in sorted(os.listdir(pkg_root)):
|
||||
if other == file_name:
|
||||
continue
|
||||
ob = os.path.join(pkg_root, other, "Ext", "Package.bin")
|
||||
if not os.path.exists(ob):
|
||||
continue
|
||||
try:
|
||||
if etree.parse(ob).getroot().get("targetNamespace") == target_ns:
|
||||
clash.append(other)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if clash:
|
||||
report_error(f'targetNamespace "{target_ns}" уже занят пакет(ами): {", ".join(clash)}. '
|
||||
"Платформа не допускает два пакета с одним пространством имён")
|
||||
else:
|
||||
report_ok("targetNamespace уникален в конфигурации")
|
||||
else:
|
||||
report_warn(f"Configuration.xml не найден ({config_dir}) — проверки регистрации и уникальности namespace пропущены")
|
||||
|
||||
finalize()
|
||||
sys.exit(1 if state["errors"] > 0 else 0)
|
||||
Reference in New Issue
Block a user