feat(skd-decompile): слои 2-3 — dataSources, dataSets, поля

- Распознавание типов (string/decimal/boolean/date/dateTime/time/CatalogRef.X
  и пр.) с qualifiers (decimal-точность/знак, string Length/AllowedLength,
  date fractions) → shorthand или composite-массив.
- Роли (@dimension/@account/@balance/@period) с детектом сложных roleAttributes
  как sentinel.
- Restrictions (#noField/#noFilter/#noGroup/#noOrder) из useRestriction.
- Multilingual title с авто-сворачиванием {ru:"..."} в строку.
- appearance с поддержкой LocalStringType значений (например, Формат).
- presentationExpression.
- Свёртка дефолтного dataSource (ИсточникДанных1/Local) в умолчание.
- Автодетект object vs shorthand формы поля.

Bit-perfect round-trip на синтетике из 11 разнотипных полей.
Реальный ERP-отчёт АнализВерсийОбъектов декомпилируется с 0 warnings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-05-21 13:54:17 +03:00
parent 643211f2fb
commit 765e1d8885
@@ -1,4 +1,4 @@
# skd-decompile v0.1 — Decompile 1C DCS Template.xml to JSON DSL (draft)
# skd-decompile v0.2 — Decompile 1C DCS Template.xml to JSON DSL (draft)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -34,22 +34,27 @@ if ($root.LocalName -ne 'DataCompositionSchema') {
# --- 1. Namespace manager ---
$ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$ns.AddNamespace("dcs", "http://v8.1c.ru/8.1/data-composition-system/schema")
$ns.AddNamespace("dcscom", "http://v8.1c.ru/8.1/data-composition-system/common")
$ns.AddNamespace("dcscor", "http://v8.1c.ru/8.1/data-composition-system/core")
$ns.AddNamespace("dcsset", "http://v8.1c.ru/8.1/data-composition-system/settings")
$ns.AddNamespace("dcsat", "http://v8.1c.ru/8.1/data-composition-system/areatemplate")
$ns.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
$ns.AddNamespace("v8ui", "http://v8.1c.ru/8.1/data/ui")
$ns.AddNamespace("xs", "http://www.w3.org/2001/XMLSchema")
$ns.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
$NS_SCHEMA = "http://v8.1c.ru/8.1/data-composition-system/schema"
$NS_COM = "http://v8.1c.ru/8.1/data-composition-system/common"
$NS_COR = "http://v8.1c.ru/8.1/data-composition-system/core"
$NS_SET = "http://v8.1c.ru/8.1/data-composition-system/settings"
$NS_AT = "http://v8.1c.ru/8.1/data-composition-system/areatemplate"
$NS_V8 = "http://v8.1c.ru/8.1/data/core"
$NS_V8UI = "http://v8.1c.ru/8.1/data/ui"
$NS_XS = "http://www.w3.org/2001/XMLSchema"
$NS_XSI = "http://www.w3.org/2001/XMLSchema-instance"
$NS_CFG = "http://v8.1c.ru/8.1/data/enterprise/current-config"
# Root may use default namespace = schema; XPath needs explicit prefix
$rootNS = $root.NamespaceURI
if (-not $rootNS) { $rootNS = "http://v8.1c.ru/8.1/data-composition-system/schema" }
# Re-bind dcs to actual root namespace if different
$ns.AddNamespace("r", $rootNS)
$ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$ns.AddNamespace("r", $NS_SCHEMA)
$ns.AddNamespace("dcscom", $NS_COM)
$ns.AddNamespace("dcscor", $NS_COR)
$ns.AddNamespace("dcsset", $NS_SET)
$ns.AddNamespace("dcsat", $NS_AT)
$ns.AddNamespace("v8", $NS_V8)
$ns.AddNamespace("v8ui", $NS_V8UI)
$ns.AddNamespace("xs", $NS_XS)
$ns.AddNamespace("xsi", $NS_XSI)
# --- 2. Warnings accumulator ---
@@ -70,7 +75,7 @@ function New-Sentinel {
return [ordered]@{ '__unsupported__' = [ordered]@{ id = $id; kind = $kind; loc = $loc } }
}
# --- 3. Extract: dataSets (query only, no fields yet) ---
# --- 3. Helpers ---
function Get-Text {
param($node, [string]$xpath)
@@ -79,45 +84,290 @@ function Get-Text {
if ($n) { return $n.InnerText } else { return $null }
}
# Extract LocalStringType (multilingual title) → string (if only ru) or hashtable
function Get-MLText {
param($node)
if (-not $node) { return $null }
$items = $node.SelectNodes("v8:item", $ns)
if ($items.Count -eq 0) { return $null }
$dict = [ordered]@{}
foreach ($it in $items) {
$lang = Get-Text $it "v8:lang"
$content = Get-Text $it "v8:content"
if ($lang) { $dict[$lang] = if ($content) { $content } else { "" } }
}
if ($dict.Count -eq 1 -and $dict.Contains('ru')) { return $dict['ru'] }
return $dict
}
# Strip namespace prefix from xsi:type value (e.g. "dcsset:Foo" → "Foo")
function Get-LocalXsiType {
param($node)
if (-not $node) { return $null }
$t = $node.GetAttribute("type", $NS_XSI)
if ($t -match ':(.+)$') { return $matches[1] }
return $t
}
# Convert one <v8:Type> element + sibling qualifiers → shorthand type string
function Get-OneTypeShorthand {
param($typeNode, $qualNumber, $qualString, $qualDate)
$raw = $typeNode.InnerText.Trim()
# Strip namespace prefix; check if it's d5p1: (config refs)
$local = $raw
if ($raw -match '^([^:]+):(.+)$') {
$prefix = $matches[1]
$local = $matches[2]
# Resolve prefix → namespace URI
$uri = $typeNode.GetNamespaceOfPrefix($prefix)
if ($uri -eq $NS_CFG) {
return $local # CatalogRef.X, DocumentRef.X, etc.
}
if ($uri -eq $NS_XS) {
switch ($local) {
'string' {
if ($qualString) {
$len = [int](Get-Text $qualString "v8:Length")
$allowed = Get-Text $qualString "v8:AllowedLength"
if ($len -eq 0) { return 'string' }
if ($allowed -eq 'Fixed') { return "string($len,fix)" }
return "string($len)"
}
return 'string'
}
'boolean' { return 'boolean' }
'decimal' {
if ($qualNumber) {
$d = [int](Get-Text $qualNumber "v8:Digits")
$f = [int](Get-Text $qualNumber "v8:FractionDigits")
$sign = Get-Text $qualNumber "v8:AllowedSign"
$signSuf = ''
if ($sign -eq 'Nonnegative') { $signSuf = ',nonneg' }
# defaults: 10,2,Any → "decimal"
if ($d -eq 10 -and $f -eq 2 -and -not $signSuf) { return 'decimal' }
if ($f -eq 0) { return "decimal($d$signSuf)" }
return "decimal($d,$f$($signSuf -replace '^,',''))".Replace('decimal(', 'decimal(').Replace(',,',',')
}
return 'decimal'
}
'dateTime' {
$frac = if ($qualDate) { Get-Text $qualDate "v8:DateFractions" } else { 'DateTime' }
switch ($frac) {
'Date' { return 'date' }
'Time' { return 'time' }
default { return 'dateTime' }
}
}
default { return $local }
}
}
if ($uri -eq $NS_V8) {
# v8:StandardPeriod, etc.
return $local
}
}
return $local
}
# valueType → string shorthand OR array of shorthands (composite)
function Get-ValueTypeShorthand {
param($valueTypeNode)
if (-not $valueTypeNode) { return $null }
$types = $valueTypeNode.SelectNodes("v8:Type", $ns)
if ($types.Count -eq 0) { return $null }
$qualN = $valueTypeNode.SelectSingleNode("v8:NumberQualifiers", $ns)
$qualS = $valueTypeNode.SelectSingleNode("v8:StringQualifiers", $ns)
$qualD = $valueTypeNode.SelectSingleNode("v8:DateQualifiers", $ns)
$shorts = @()
foreach ($t in $types) { $shorts += (Get-OneTypeShorthand -typeNode $t -qualNumber $qualN -qualString $qualS -qualDate $qualD) }
if ($shorts.Count -eq 1) { return $shorts[0] }
return ,$shorts
}
# <role> → array of @tokens; if non-simple — null + sentinel via $script:roleSentinel
function Get-RoleTokens {
param($roleNode, [string]$loc)
if (-not $roleNode) { return $null }
$tokens = @()
$hasComplex = $false
foreach ($child in $roleNode.ChildNodes) {
if ($child.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
if ($child.NamespaceURI -ne $NS_COM) { $hasComplex = $true; continue }
switch ($child.LocalName) {
'dimension' { if ($child.InnerText -eq 'true') { $tokens += '@dimension' } }
'account' { if ($child.InnerText -eq 'true') { $tokens += '@account' } }
'balance' { if ($child.InnerText -eq 'true') { $tokens += '@balance' } }
'periodNumber' {
# Expect periodNumber=1 + periodType=Main → @period
$pType = Get-Text $roleNode "dcscom:periodType"
if ($child.InnerText -eq '1' -and $pType -eq 'Main') { $tokens += '@period' } else { $hasComplex = $true }
}
'periodType' { } # handled with periodNumber above
default {
$hasComplex = $true
}
}
}
if ($hasComplex) {
# emit sentinel separately so caller can attach it to field obj
$null = New-Sentinel -kind 'ComplexRole' -loc $loc -detail 'Роль с дополнительными атрибутами не сворачивается в @-флаг'
}
return $tokens
}
# <useRestriction> → array of #tokens
function Get-RestrictionTokens {
param($urNode)
if (-not $urNode) { return @() }
$tokens = @()
$map = @{ 'field' = '#noField'; 'condition' = '#noFilter'; 'group' = '#noGroup'; 'order' = '#noOrder' }
foreach ($key in 'field','condition','group','order') {
$v = Get-Text $urNode "r:$key"
if ($v -eq 'true') { $tokens += $map[$key] }
}
return $tokens
}
# <appearance> → hashtable {param: value}
function Get-AppearanceDict {
param($appNode)
if (-not $appNode) { return $null }
$dict = [ordered]@{}
$items = $appNode.SelectNodes("dcscor:item", $ns)
foreach ($it in $items) {
$p = Get-Text $it "dcscor:parameter"
$valNode = $it.SelectSingleNode("dcscor:value", $ns)
if (-not $p -or -not $valNode) { continue }
# Value can be xs:string, v8ui:HorizontalAlign, v8:LocalStringType, etc.
$valType = Get-LocalXsiType $valNode
if ($valType -eq 'LocalStringType') {
$dict[$p] = Get-MLText $valNode
} else {
$dict[$p] = $valNode.InnerText
}
}
return $dict
}
# Build a field JSON entry (shorthand if possible, object form otherwise)
function Build-Field {
param($fieldNode, [string]$loc)
$dataPath = Get-Text $fieldNode "r:dataPath"
$fieldName = Get-Text $fieldNode "r:field"
$titleNode = $fieldNode.SelectSingleNode("r:title", $ns)
$title = Get-MLText $titleNode
$valueTypeNode = $fieldNode.SelectSingleNode("r:valueType", $ns)
$typeShort = Get-ValueTypeShorthand $valueTypeNode
$roleTokens = Get-RoleTokens $fieldNode.SelectSingleNode("r:role", $ns) "$loc/role"
$restrictTokens = Get-RestrictionTokens $fieldNode.SelectSingleNode("r:useRestriction", $ns)
$appNode = $fieldNode.SelectSingleNode("r:appearance", $ns)
$appearance = Get-AppearanceDict $appNode
$presExpr = Get-Text $fieldNode "r:presentationExpression"
$needsObject = $title -or $appearance -or $presExpr -or ($typeShort -is [array])
if (-not $needsObject) {
# shorthand: "Name: type @role #restrict"
$parts = @($fieldName)
if ($typeShort) { $parts[0] = "$fieldName`: $typeShort" }
if ($roleTokens) { $parts[0] += ' ' + ($roleTokens -join ' ') }
if ($restrictTokens) { $parts[0] += ' ' + ($restrictTokens -join ' ') }
# dataPath ≠ field — fall back to object form
if ($dataPath -and $dataPath -ne $fieldName) {
# unusual case; use object form
} else {
return $parts[0]
}
}
$obj = [ordered]@{ field = $fieldName }
if ($dataPath -and $dataPath -ne $fieldName) { $obj['dataPath'] = $dataPath }
if ($title) { $obj['title'] = $title }
if ($typeShort) { $obj['type'] = $typeShort }
if ($roleTokens) {
if ($roleTokens.Count -eq 1) { $obj['role'] = $roleTokens[0] -replace '^@','' }
else { $obj['role'] = ($roleTokens | ForEach-Object { $_ -replace '^@','' }) }
}
if ($restrictTokens) { $obj['restrict'] = ($restrictTokens | ForEach-Object { $_ -replace '^#','' }) }
if ($presExpr) { $obj['presentationExpression'] = $presExpr }
if ($appearance) { $obj['appearance'] = $appearance }
return $obj
}
# --- 4. dataSources ---
$dataSources = @()
$dsourceNodes = $root.SelectNodes("r:dataSource", $ns)
foreach ($dsn in $dsourceNodes) {
$nm = Get-Text $dsn "r:name"
$tp = Get-Text $dsn "r:dataSourceType"
$dataSources += [ordered]@{ name = $nm; type = $tp }
}
# Default: single ИсточникДанных1/Local → omit from output
$emitDataSources = $true
if ($dataSources.Count -eq 1 -and $dataSources[0].name -eq 'ИсточникДанных1' -and $dataSources[0].type -eq 'Local') {
$emitDataSources = $false
}
# --- 5. dataSets ---
$dataSets = @()
$dsNodes = $root.SelectNodes("r:dataSet", $ns)
foreach ($dsNode in $dsNodes) {
$xsiType = $dsNode.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
$xsiType = Get-LocalXsiType $dsNode
$name = Get-Text $dsNode "r:name"
$ds = [ordered]@{ name = $name }
switch -Regex ($xsiType) {
'DataSetQuery$' {
$query = Get-Text $dsNode "r:query"
# Decode XML entities — XmlDocument already decoded &amp; → & in InnerText
$ds['query'] = $query
# fields — layer 3
switch ($xsiType) {
'DataSetQuery' {
$ds['query'] = Get-Text $dsNode "r:query"
}
'DataSetObject$' {
'DataSetObject' {
$ds['objectName'] = Get-Text $dsNode "r:objectName"
}
'DataSetUnion$' {
$ds['__unsupported__'] = (New-Sentinel -kind 'DataSetUnion' -loc "dataSet[name=$name]" -detail 'Реализуется в слое 15')['__unsupported__']
'DataSetUnion' {
$ds['__unsupported__'] = (New-Sentinel -kind 'DataSetUnion' -loc "dataSet[$name]" -detail 'Реализуется в слое 15')['__unsupported__']
}
default {
$ds['__unsupported__'] = (New-Sentinel -kind "DataSetType:$xsiType" -loc "dataSet[name=$name]" -detail "Неизвестный тип набора данных")['__unsupported__']
$ds['__unsupported__'] = (New-Sentinel -kind "DataSetType:$xsiType" -loc "dataSet[$name]" -detail "Неизвестный тип набора данных")['__unsupported__']
}
}
# Fields
$fieldNodes = $dsNode.SelectNodes("r:field", $ns)
if ($fieldNodes.Count -gt 0) {
$fields = @()
$fi = 0
foreach ($fn in $fieldNodes) {
$fxsi = Get-LocalXsiType $fn
if ($fxsi -ne 'DataSetFieldField') {
$fields += (New-Sentinel -kind "FieldType:$fxsi" -loc "dataSet[$name]/field[$fi]" -detail 'Тип поля не DataSetFieldField')
} else {
$fields += (Build-Field -fieldNode $fn -loc "dataSet[$name]/field[$fi]")
}
$fi++
}
$ds['fields'] = $fields
}
# dataSource attachment — omit if matches default
$dsSrc = Get-Text $dsNode "r:dataSource"
if ($emitDataSources -and $dsSrc) { $ds['dataSource'] = $dsSrc }
$dataSets += $ds
}
# --- 4. Build top-level JSON object ---
# --- 6. Build top-level JSON object ---
$out = [ordered]@{
dataSets = $dataSets
}
$out = [ordered]@{}
if ($emitDataSources) { $out['dataSources'] = $dataSources }
$out['dataSets'] = $dataSets
# --- 5. Serialize ---
# --- 7. Serialize ---
$json = $out | ConvertTo-Json -Depth 32
# Unescape \uXXXX → UTF-8 literals (PS 5.1 ConvertTo-Json escapes non-ASCII)
# Unescape \uXXXX → UTF-8 literals
$json = [regex]::Replace($json, '\\u([0-9a-fA-F]{4})', {
param($m)
[char][int]("0x" + $m.Groups[1].Value)
@@ -130,7 +380,6 @@ if ($OutputPath) {
$enc = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($OutputPath, $json, $enc)
# Write warnings.md alongside, if any
if ($script:warnings.Count -gt 0) {
$wPath = [System.IO.Path]::ChangeExtension($OutputPath, $null).TrimEnd('.') + '.warnings.md'
$sb = New-Object System.Text.StringBuilder
@@ -142,12 +391,10 @@ if ($OutputPath) {
[void]$sb.AppendLine("- **$($w.id)** ($($w.kind)) at `$($w.loc)`: $($w.detail)")
}
[System.IO.File]::WriteAllText($wPath, $sb.ToString(), $enc)
Write-Host "Warnings written: $wPath ($($script:warnings.Count) issue(s))" -ForegroundColor Yellow
Write-Host "Warnings: $wPath ($($script:warnings.Count) issue(s))" -ForegroundColor Yellow
}
# Summary to stderr
$stats = "dataSets=$($dataSets.Count), warnings=$($script:warnings.Count)"
[Console]::Error.WriteLine("Decompiled: $stats")
[Console]::Error.WriteLine("Decompiled: dataSets=$($dataSets.Count), warnings=$($script:warnings.Count)")
} else {
Write-Output $json
if ($script:warnings.Count -gt 0) {