Auto-build: copilot (python) from 7fa279c

This commit is contained in:
github-actions[bot]
2026-05-17 11:22:33 +00:00
commit bbd2f7a8c1
207 changed files with 98901 additions and 0 deletions
@@ -0,0 +1,825 @@
# form-validate v1.6 — Validate 1C managed form
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
[Alias('Path')]
[string]$FormPath,
[switch]$Detailed,
[int]$MaxErrors = 30
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Resolve path ---
# A: Directory → Ext/Form.xml
if (Test-Path $FormPath -PathType Container) {
$FormPath = Join-Path (Join-Path $FormPath "Ext") "Form.xml"
}
# B1: Missing Ext/ (e.g. Forms/Форма/Form.xml → Forms/Форма/Ext/Form.xml)
if (-not (Test-Path $FormPath)) {
$fn = [System.IO.Path]::GetFileName($FormPath)
if ($fn -eq "Form.xml") {
$c = Join-Path (Join-Path (Split-Path $FormPath) "Ext") $fn
if (Test-Path $c) { $FormPath = $c }
}
}
# B2: Descriptor (Forms/Форма.xml → Forms/Форма/Ext/Form.xml)
if (-not (Test-Path $FormPath) -and $FormPath.EndsWith(".xml")) {
$stem = [System.IO.Path]::GetFileNameWithoutExtension($FormPath)
$dir = Split-Path $FormPath
$c = Join-Path (Join-Path (Join-Path $dir $stem) "Ext") "Form.xml"
if (Test-Path $c) { $FormPath = $c }
}
# --- Load XML ---
if (-not (Test-Path $FormPath)) {
Write-Error "File not found: $FormPath"
exit 1
}
$xmlDoc = New-Object System.Xml.XmlDocument
$xmlDoc.PreserveWhitespace = $false
try {
$xmlDoc.Load((Resolve-Path $FormPath).Path)
} catch {
Write-Host "[ERROR] XML parse error: $($_.Exception.Message)"
Write-Host ""
Write-Host "---"
Write-Host "Errors: 1, Warnings: 0"
exit 1
}
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$nsMgr.AddNamespace("f", "http://v8.1c.ru/8.3/xcf/logform")
$nsMgr.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
$root = $xmlDoc.DocumentElement
# --- Detect context: config vs EPF/ERF ---
# Walk up from FormPath looking for Configuration.xml → config context
# No Configuration.xml → external data processor / report (EPF/ERF)
$script:isConfigContext = $false
$walkDir = Split-Path (Resolve-Path $FormPath) -Parent
for ($i = 0; $i -lt 15; $i++) {
if (-not $walkDir -or $walkDir -eq (Split-Path $walkDir)) { break }
if (Test-Path (Join-Path $walkDir "Configuration.xml")) {
$script:isConfigContext = $true
break
}
$walkDir = Split-Path $walkDir
}
# --- Counters ---
$errors = 0
$warnings = 0
$stopped = $false
$script:okCount = 0
function Report-OK {
param([string]$msg)
$script:okCount++
if ($Detailed) { Write-Host "[OK] $msg" }
}
function Report-Error {
param([string]$msg)
$script:errors++
Write-Host "[ERROR] $msg"
if ($script:errors -ge $MaxErrors) {
$script:stopped = $true
}
}
function Report-Warn {
param([string]$msg)
$script:warnings++
Write-Host "[WARN] $msg"
}
# --- Form name from path ---
$formName = [System.IO.Path]::GetFileNameWithoutExtension($FormPath)
$parentDir = [System.IO.Path]::GetDirectoryName($FormPath)
if ($parentDir) {
$extDir = [System.IO.Path]::GetFileName($parentDir)
if ($extDir -eq "Ext") {
$formDir = [System.IO.Path]::GetDirectoryName($parentDir)
if ($formDir) { $formName = [System.IO.Path]::GetFileName($formDir) }
}
}
if ($Detailed) {
Write-Host "=== Validation: $formName ==="
Write-Host ""
}
# Early BaseForm detection (used in Check 5 to skip base element DataPath validation)
$hasBaseForm = ($root.SelectSingleNode("f:BaseForm", $nsMgr) -ne $null)
# --- Check 1: Root element and version ---
if ($root.LocalName -ne "Form") {
Report-Error "Root element is '$($root.LocalName)', expected 'Form'"
} else {
$version = $root.GetAttribute("version")
if ($version -eq "2.17" -or $version -eq "2.20") {
Report-OK "Root element: Form version=$version"
} elseif ($version) {
Report-Warn "Form version='$version' (expected 2.17 or 2.20)"
} else {
Report-Warn "Form version attribute missing"
}
}
# --- Check 2: AutoCommandBar ---
if (-not $stopped) {
$acb = $root.SelectSingleNode("f:AutoCommandBar", $nsMgr)
if ($acb) {
$acbName = $acb.GetAttribute("name")
$acbId = $acb.GetAttribute("id")
if ($acbId -eq "-1") {
Report-OK "AutoCommandBar: name='$acbName', id=$acbId"
} else {
Report-Error "AutoCommandBar id='$acbId', expected '-1'"
}
} else {
Report-Error "AutoCommandBar element missing"
}
}
# --- Collect all elements with IDs ---
$elementIds = @{} # id -> name (element ID pool)
$allElements = @() # @{Name; Tag; Id; ParentName; Node}
function Collect-Elements {
param($node, [string]$parentName)
foreach ($child in $node.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
$name = $child.GetAttribute("name")
$id = $child.GetAttribute("id")
if ($name -and $id) {
$tag = $child.LocalName
$script:allElements += @{
Name = $name
Tag = $tag
Id = $id
ParentName = $parentName
Node = $child
}
# Track element IDs (skip AutoCommandBar which has -1)
if ($id -ne "-1") {
if ($elementIds.ContainsKey($id)) {
Report-Error "Duplicate element id=${id}: '$name' and '$($elementIds[$id])'"
} else {
$elementIds[$id] = $name
}
}
# Recurse into ChildItems
$childItems = $child.SelectSingleNode("f:ChildItems", $nsMgr)
if ($childItems) {
Collect-Elements -node $childItems -parentName $name
}
}
}
}
# Collect from ChildItems
$childItemsRoot = $root.SelectSingleNode("f:ChildItems", $nsMgr)
if ($childItemsRoot) {
Collect-Elements -node $childItemsRoot -parentName "(root)"
}
# Also collect from AutoCommandBar's ChildItems
$acb = $root.SelectSingleNode("f:AutoCommandBar", $nsMgr)
if ($acb) {
$acbChildren = $acb.SelectSingleNode("f:ChildItems", $nsMgr)
if ($acbChildren) {
Collect-Elements -node $acbChildren -parentName "ФормаКоманднаяПанель"
}
}
# --- Check 3: Unique element IDs ---
if (-not $stopped) {
$dupCount = ($allElements | Group-Object { $_.Id } | Where-Object { $_.Count -gt 1 -and $_.Name -ne "-1" }).Count
if ($dupCount -eq 0) {
Report-OK "Unique element IDs: $($elementIds.Count) elements"
}
}
# --- Collect attributes (separate ID pool) ---
$attrMap = @{} # name -> node
$attrIds = @{} # id -> name
$attrNodes = $root.SelectNodes("f:Attributes/f:Attribute", $nsMgr)
foreach ($attr in $attrNodes) {
$attrName = $attr.GetAttribute("name")
$attrId = $attr.GetAttribute("id")
if ($attrName) {
$attrMap[$attrName] = $attr
}
if ($attrId -and $attrId -ne "") {
if ($attrIds.ContainsKey($attrId)) {
Report-Error "Duplicate attribute id=${attrId}: '$attrName' and '$($attrIds[$attrId])'"
} else {
$attrIds[$attrId] = $attrName
}
}
# Column IDs are a separate sub-pool per attribute — check uniqueness within parent
$colIds = @{}
foreach ($col in $attr.SelectNodes("f:Columns/f:Column", $nsMgr)) {
$colId = $col.GetAttribute("id")
$colName = $col.GetAttribute("name")
if ($colId -and $colId -ne "") {
if ($colIds.ContainsKey($colId)) {
Report-Error "Duplicate column id=${colId} in '$attrName': '$colName' and '$($colIds[$colId])'"
} else {
$colIds[$colId] = $colName
}
}
}
}
if (-not $stopped) {
$attrDupCount = ($attrIds.GetEnumerator() | Group-Object Value | Where-Object { $_.Count -gt 1 }).Count
if ($attrDupCount -eq 0 -and $attrIds.Count -gt 0) {
Report-OK "Unique attribute IDs: $($attrIds.Count) entries"
}
}
# --- Collect commands (separate ID pool) ---
$cmdMap = @{} # name -> node
$cmdIds = @{} # id -> name
$cmdNodes = $root.SelectNodes("f:Commands/f:Command", $nsMgr)
foreach ($cmd in $cmdNodes) {
$cmdName = $cmd.GetAttribute("name")
$cmdId = $cmd.GetAttribute("id")
if ($cmdName) {
$cmdMap[$cmdName] = $cmd
}
if ($cmdId -and $cmdId -ne "") {
if ($cmdIds.ContainsKey($cmdId)) {
Report-Error "Duplicate command id=${cmdId}: '$cmdName' and '$($cmdIds[$cmdId])'"
} else {
$cmdIds[$cmdId] = $cmdName
}
}
}
if (-not $stopped) {
if ($cmdIds.Count -gt 0) {
$cmdDupCount = ($cmdIds.GetEnumerator() | Group-Object Value | Where-Object { $_.Count -gt 1 }).Count
if ($cmdDupCount -eq 0) {
Report-OK "Unique command IDs: $($cmdIds.Count) entries"
}
}
}
# --- Check 4: Companion elements ---
# Define required companions per element type
$companionRules = @{
"InputField" = @("ContextMenu", "ExtendedTooltip")
"CheckBoxField" = @("ContextMenu", "ExtendedTooltip")
"LabelDecoration" = @("ContextMenu", "ExtendedTooltip")
"LabelField" = @("ContextMenu", "ExtendedTooltip")
"PictureDecoration" = @("ContextMenu", "ExtendedTooltip")
"PictureField" = @("ContextMenu", "ExtendedTooltip")
"CalendarField" = @("ContextMenu", "ExtendedTooltip")
"UsualGroup" = @("ExtendedTooltip")
"Pages" = @("ExtendedTooltip")
"Page" = @("ExtendedTooltip")
"Button" = @("ExtendedTooltip")
"Table" = @("ContextMenu", "AutoCommandBar", "SearchStringAddition", "ViewStatusAddition", "SearchControlAddition")
}
if (-not $stopped) {
$companionErrors = 0
$companionChecked = 0
foreach ($el in $allElements) {
if ($stopped) { break }
$tag = $el.Tag
$elName = $el.Name
$node = $el.Node
if (-not $companionRules.ContainsKey($tag)) { continue }
$required = $companionRules[$tag]
$companionChecked++
foreach ($compTag in $required) {
$compNode = $node.SelectSingleNode("f:$compTag", $nsMgr)
if (-not $compNode) {
Report-Error "[$tag] '$elName': missing companion <$compTag>"
$companionErrors++
}
}
}
if ($companionErrors -eq 0 -and $companionChecked -gt 0) {
Report-OK "Companion elements: $companionChecked elements checked"
}
}
# --- Check 5: DataPath -> Attribute references ---
if (-not $stopped) {
$pathErrors = 0
$pathChecked = 0
$pathBaseSkipped = 0
foreach ($el in $allElements) {
if ($stopped) { break }
$tag = $el.Tag
$elName = $el.Name
$node = $el.Node
# Skip companion elements
if ($tag -in @("ContextMenu", "ExtendedTooltip", "AutoCommandBar", "SearchStringAddition", "ViewStatusAddition", "SearchControlAddition")) {
continue
}
# In borrowed forms, skip DataPath check for base elements (id < 1000000)
if ($hasBaseForm -and $el.Id) {
try { if ([int]$el.Id -lt 1000000) { $pathBaseSkipped++; continue } } catch {}
}
$dpNode = $node.SelectSingleNode("f:DataPath", $nsMgr)
if (-not $dpNode) { continue }
$dataPath = $dpNode.InnerText.Trim()
if (-not $dataPath) { continue }
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
# - bare numeric (e.g. "10", "1000003") — internal index
# - "N/M:<uuid>" — metadata reference by UUID
if ($dataPath -match '^\d+$' -or $dataPath -match '^\d+/\d+:[0-9a-fA-F-]+$') {
continue
}
$pathChecked++
# Extract root segment of path, strip array indices like [0]
$cleanPath = $dataPath -replace '\[\d+\]', ''
# Strip leading '~' (current row of DynamicList: ~Список.Поле)
if ($cleanPath.StartsWith('~')) { $cleanPath = $cleanPath.Substring(1) }
$segments = $cleanPath -split '\.'
$rootAttr = $segments[0]
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
if ($rootAttr -eq 'Items') {
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
Report-Warn "[$tag] '$elName': DataPath='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
continue
}
$tableName = $segments[1]
$tableEl = $null
foreach ($candidate in $allElements) {
if ($candidate.Tag -eq 'Table' -and $candidate.Name -eq $tableName) {
$tableEl = $candidate
break
}
}
if (-not $tableEl) {
Report-Error "[$tag] '$elName': DataPath='$dataPath' — table element '$tableName' not found"
$pathErrors++
continue
}
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
# Table without DataPath — can't resolve further, accept silently
continue
}
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
$rootAttr = ($tableDp -split '\.')[0]
}
if (-not $attrMap.ContainsKey($rootAttr)) {
Report-Error "[$tag] '$elName': DataPath='$dataPath' — attribute '$rootAttr' not found"
$pathErrors++
}
}
$pathMsg = ""
if ($pathChecked -gt 0) { $pathMsg = "$pathChecked paths checked" }
if ($pathBaseSkipped -gt 0) {
$skipNote = "$pathBaseSkipped base skipped"
$pathMsg = if ($pathMsg) { "$pathMsg, $skipNote" } else { $skipNote }
}
if ($pathErrors -eq 0 -and $pathMsg) {
Report-OK "DataPath references: $pathMsg"
} elseif ($pathErrors -eq 0) {
Report-OK "DataPath references: none"
}
}
# --- Check 6: Button command references ---
if (-not $stopped) {
$cmdErrors = 0
$cmdChecked = 0
foreach ($el in $allElements) {
if ($stopped) { break }
$tag = $el.Tag
$elName = $el.Name
$node = $el.Node
if ($tag -ne "Button") { continue }
$cmdNode = $node.SelectSingleNode("f:CommandName", $nsMgr)
if (-not $cmdNode) { continue }
$cmdRef = $cmdNode.InnerText.Trim()
if (-not $cmdRef) { continue }
# Form.Command.XXX -> check command XXX exists
if ($cmdRef -match '^Form\.Command\.(.+)$') {
$cmdName = $Matches[1]
$cmdChecked++
if (-not $cmdMap.ContainsKey($cmdName)) {
Report-Error "[Button] '$elName': CommandName='$cmdRef' — command '$cmdName' not found in Commands"
$cmdErrors++
}
}
# Form.StandardCommand.XXX — skip, standard commands always exist
}
if ($cmdErrors -eq 0 -and $cmdChecked -gt 0) {
Report-OK "Command references: $cmdChecked buttons checked"
} elseif ($cmdChecked -eq 0) {
Report-OK "Command references: none"
}
}
# --- Check 7: Events have handler names ---
if (-not $stopped) {
$eventErrors = 0
$eventChecked = 0
# Form-level events
$formEvents = $root.SelectSingleNode("f:Events", $nsMgr)
if ($formEvents) {
foreach ($evt in $formEvents.SelectNodes("f:Event", $nsMgr)) {
$evtName = $evt.GetAttribute("name")
$handler = $evt.InnerText.Trim()
$eventChecked++
if (-not $handler) {
Report-Error "Form event '$evtName': empty handler name"
$eventErrors++
}
}
}
# Element-level events
foreach ($el in $allElements) {
if ($stopped) { break }
$tag = $el.Tag
$elName = $el.Name
$node = $el.Node
$eventsNode = $node.SelectSingleNode("f:Events", $nsMgr)
if (-not $eventsNode) { continue }
foreach ($evt in $eventsNode.SelectNodes("f:Event", $nsMgr)) {
$evtName = $evt.GetAttribute("name")
$handler = $evt.InnerText.Trim()
$eventChecked++
if (-not $handler) {
Report-Error "[$tag] '$elName' event '$evtName': empty handler name"
$eventErrors++
}
}
}
if ($eventErrors -eq 0 -and $eventChecked -gt 0) {
Report-OK "Event handlers: $eventChecked events checked"
} elseif ($eventChecked -eq 0) {
Report-OK "Event handlers: none"
}
}
# --- Check 8: Command actions ---
if (-not $stopped) {
$actionErrors = 0
$actionChecked = 0
foreach ($cmd in $cmdNodes) {
if ($stopped) { break }
$cmdName = $cmd.GetAttribute("name")
$actionNode = $cmd.SelectSingleNode("f:Action", $nsMgr)
$actionChecked++
if (-not $actionNode -or -not $actionNode.InnerText.Trim()) {
Report-Error "Command '$cmdName': missing or empty Action"
$actionErrors++
}
}
if ($actionErrors -eq 0 -and $actionChecked -gt 0) {
Report-OK "Command actions: $actionChecked commands checked"
} elseif ($actionChecked -eq 0) {
Report-OK "Command actions: none"
}
}
# --- Check 9: MainAttribute count ---
if (-not $stopped) {
$mainCount = 0
foreach ($attr in $attrNodes) {
$mainNode = $attr.SelectSingleNode("f:MainAttribute", $nsMgr)
if ($mainNode -and $mainNode.InnerText -eq "true") {
$mainCount++
}
}
if ($mainCount -le 1) {
$mainInfo = if ($mainCount -eq 1) { "1 main attribute" } else { "no main attribute" }
Report-OK "MainAttribute: $mainInfo"
} else {
Report-Error "Multiple MainAttribute=true ($mainCount found, expected 0 or 1)"
}
}
# --- Check 10: Title must be multilingual XML (not plain text) ---
if (-not $stopped) {
$titleNode = $root.SelectSingleNode("f:Title", $nsMgr)
if ($titleNode) {
$v8items = $titleNode.SelectNodes("v8:item", $nsMgr)
if ($v8items.Count -eq 0 -and $titleNode.InnerText.Trim() -ne "") {
Report-Error "Form Title is plain text ('$($titleNode.InnerText.Trim())') — must be multilingual XML (<v8:item>). Use top-level 'title' key in form-compile DSL."
} else {
Report-OK "Title: multilingual XML"
}
}
}
# --- Check 11: Extension-specific validations ---
$baseFormNode = $root.SelectSingleNode("f:BaseForm", $nsMgr)
$isExtension = ($baseFormNode -ne $null)
if (-not $stopped -and $isExtension) {
# 11a. BaseForm version
$bfVersion = $baseFormNode.GetAttribute("version")
if ($bfVersion) {
Report-OK "BaseForm: version=$bfVersion"
} else {
Report-Warn "BaseForm: version attribute missing"
}
# 11b. callType values validation (Before, After, Override)
$validCallTypes = @("Before", "After", "Override")
$ctErrors = 0
$ctChecked = 0
# Check form-level events
$formEventsNode = $root.SelectSingleNode("f:Events", $nsMgr)
if ($formEventsNode) {
foreach ($evt in $formEventsNode.SelectNodes("f:Event", $nsMgr)) {
$ct = $evt.GetAttribute("callType")
if ($ct) {
$ctChecked++
if ($validCallTypes -notcontains $ct) {
Report-Error "Form event '$($evt.GetAttribute('name'))': invalid callType='$ct' (expected: Before, After, Override)"
$ctErrors++
}
}
}
}
# Check element-level events
foreach ($el in $allElements) {
if ($stopped) { break }
$eventsNode = $el.Node.SelectSingleNode("f:Events", $nsMgr)
if (-not $eventsNode) { continue }
foreach ($evt in $eventsNode.SelectNodes("f:Event", $nsMgr)) {
$ct = $evt.GetAttribute("callType")
if ($ct) {
$ctChecked++
if ($validCallTypes -notcontains $ct) {
Report-Error "[$($el.Tag)] '$($el.Name)' event '$($evt.GetAttribute('name'))': invalid callType='$ct'"
$ctErrors++
}
}
}
}
# Check command actions
foreach ($cmd in $cmdNodes) {
if ($stopped) { break }
$cmdName = $cmd.GetAttribute("name")
foreach ($action in $cmd.SelectNodes("f:Action", $nsMgr)) {
$ct = $action.GetAttribute("callType")
if ($ct) {
$ctChecked++
if ($validCallTypes -notcontains $ct) {
Report-Error "Command '$cmdName' Action: invalid callType='$ct'"
$ctErrors++
}
}
}
}
if (-not $stopped -and $ctErrors -eq 0 -and $ctChecked -gt 0) {
Report-OK "callType values: $ctChecked checked"
}
# 11c. Extension ID ranges — warn if extension-added attrs/commands have id < 1000000
# Collect BaseForm attribute names to distinguish added ones
$baseAttrNames = @{}
$baseCmdNames = @{}
$bfNs = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$bfNs.AddNamespace("f", "http://v8.1c.ru/8.3/xcf/logform")
foreach ($bAttr in $baseFormNode.SelectNodes("f:Attributes/f:Attribute", $bfNs)) {
$baName = $bAttr.GetAttribute("name")
if ($baName) { $baseAttrNames[$baName] = $true }
}
foreach ($bCmd in $baseFormNode.SelectNodes("f:Commands/f:Command", $bfNs)) {
$bcName = $bCmd.GetAttribute("name")
if ($bcName) { $baseCmdNames[$bcName] = $true }
}
$idWarnCount = 0
foreach ($attr in $attrNodes) {
$aName = $attr.GetAttribute("name")
$aId = $attr.GetAttribute("id")
if ($aName -and -not $baseAttrNames.ContainsKey($aName) -and $aId) {
try {
$intId = [int]$aId
if ($intId -lt 1000000) {
Report-Warn "Attribute '$aName' (id=$aId): extension-added attribute has id < 1000000"
$idWarnCount++
}
} catch {}
}
}
foreach ($cmd in $cmdNodes) {
$cName = $cmd.GetAttribute("name")
$cId = $cmd.GetAttribute("id")
if ($cName -and -not $baseCmdNames.ContainsKey($cName) -and $cId) {
try {
$intId = [int]$cId
if ($intId -lt 1000000) {
Report-Warn "Command '$cName' (id=$cId): extension-added command has id < 1000000"
$idWarnCount++
}
} catch {}
}
}
if (-not $stopped -and $idWarnCount -eq 0) {
$extAttrCount = ($attrNodes | Where-Object { -not $baseAttrNames.ContainsKey($_.GetAttribute("name")) }).Count
$extCmdCount = ($cmdNodes | Where-Object { -not $baseCmdNames.ContainsKey($_.GetAttribute("name")) }).Count
if (($extAttrCount + $extCmdCount) -gt 0) {
Report-OK "Extension ID ranges: $extAttrCount attr(s), $extCmdCount cmd(s) — all >= 1000000"
}
}
}
# Check callType without BaseForm (structural warning)
if (-not $stopped -and -not $isExtension) {
$callTypeWithoutBase = $false
$feNode = $root.SelectSingleNode("f:Events", $nsMgr)
if ($feNode) {
foreach ($evt in $feNode.SelectNodes("f:Event", $nsMgr)) {
if ($evt.GetAttribute("callType")) { $callTypeWithoutBase = $true; break }
}
}
if (-not $callTypeWithoutBase) {
foreach ($cmd in $cmdNodes) {
foreach ($action in $cmd.SelectNodes("f:Action", $nsMgr)) {
if ($action.GetAttribute("callType")) { $callTypeWithoutBase = $true; break }
}
if ($callTypeWithoutBase) { break }
}
}
if ($callTypeWithoutBase) {
Report-Warn "callType attributes found but no BaseForm — possible incorrect structure"
}
}
# --- Check 12: Type values validation ---
$knownInvalidTypes = @(
"FormDataStructure","FormDataCollection","FormDataTree","FormDataTreeItem","FormDataCollectionItem"
"FormGroup","FormField","FormButton","FormDecoration","FormTable"
)
$validClosedTypes = @(
"xs:boolean","xs:string","xs:decimal","xs:dateTime","xs:binary"
"v8:FillChecking","v8:Null","v8:StandardPeriod","v8:StandardBeginningDate","v8:Type"
"v8:TypeDescription","v8:UUID","v8:ValueListType","v8:ValueTable","v8:ValueTree"
"v8:Universal","v8:FixedArray","v8:FixedStructure"
"v8ui:Color","v8ui:Font","v8ui:FormattedString","v8ui:HorizontalAlign"
"v8ui:Picture","v8ui:SizeChangeMode","v8ui:VerticalAlign"
"dcsset:DataCompositionComparisonType","dcsset:DataCompositionFieldPlacement"
"dcsset:Filter","dcsset:SettingsComposer","dcsset:DataCompositionSettings"
"dcssch:DataCompositionSchema"
"dcscor:DataCompositionComparisonType","dcscor:DataCompositionGroupType"
"dcscor:DataCompositionPeriodAdditionType","dcscor:DataCompositionSortDirection","dcscor:Field"
"ent:AccountType","ent:AccumulationRecordType","ent:AccountingRecordType"
)
$validCfgPrefixes = @(
"AccountingRegisterRecordSet","AccumulationRegisterRecordSet"
"BusinessProcessObject","BusinessProcessRef"
"CatalogObject","CatalogRef"
"ChartOfAccountsObject","ChartOfAccountsRef"
"ChartOfCalculationTypesObject","ChartOfCalculationTypesRef"
"ChartOfCharacteristicTypesObject","ChartOfCharacteristicTypesRef"
"ConstantsSet","DataProcessorObject","DocumentObject","DocumentRef"
"DynamicList","EnumRef","ExchangePlanObject","ExchangePlanRef"
"ExternalDataProcessorObject","ExternalReportObject"
"InformationRegisterRecordManager","InformationRegisterRecordSet"
"ReportObject","TaskObject","TaskRef"
)
if (-not $stopped) {
$typeNodes = $root.SelectNodes("//v8:Type", $nsMgr)
$typeOk = $true
$typeChecked = 0
$typeInvalid = 0
foreach ($tn in $typeNodes) {
$tv = $tn.InnerText.Trim()
if (-not $tv) { continue }
$typeChecked++
if ($tv -in $knownInvalidTypes) {
Report-Error "12. Type '$tv': invalid runtime/UI type (not valid in XDTO schema)"
$typeOk = $false; $typeInvalid++
continue
}
if ($tv -in $validClosedTypes) { continue }
if ($tv -match '^cfg:(.+)$') {
$cfgVal = $Matches[1]
if ($cfgVal -eq "DynamicList") { continue }
if ($cfgVal -match '^([^.]+)\.') {
$pfx = $Matches[1]
if ($pfx -in $validCfgPrefixes) {
# ExternalDataProcessorObject/ExternalReportObject valid only for EPF/ERF, not config
if ($script:isConfigContext -and ($pfx -eq "ExternalDataProcessorObject" -or $pfx -eq "ExternalReportObject")) {
Report-Error "12. Type '$tv': External* type in configuration context (use DataProcessorObject/ReportObject instead)"
$typeOk = $false; $typeInvalid++
}
continue
}
}
Report-Warn "12. Type '$tv': unrecognized cfg prefix"
$typeOk = $false
continue
}
if ($tv -match ':') { continue }
Report-Warn "12. Type '$tv': bare type without namespace prefix"
$typeOk = $false
}
if ($typeChecked -eq 0) {
Report-OK "12. Types: no type values to check"
} elseif ($typeOk) {
Report-OK "12. Types: $typeChecked values, all valid"
}
}
# --- Summary ---
$checks = $script:okCount + $errors + $warnings
if ($errors -eq 0 -and $warnings -eq 0 -and -not $Detailed) {
Write-Host "=== Validation OK: Form.$formName ($checks checks) ==="
} else {
Write-Host ""
if ($Detailed) {
Write-Host "---"
Write-Host "Total: $($allElements.Count) elements, $($attrNodes.Count) attributes, $($cmdNodes.Count) commands"
}
if ($stopped) {
Write-Host "Stopped after $MaxErrors errors. Fix and re-run."
}
Write-Host "=== Result: $errors errors, $warnings warnings ($checks checks) ==="
}
if ($errors -gt 0) {
exit 1
} else {
exit 0
}
@@ -0,0 +1,730 @@
#!/usr/bin/env python3
# form-validate v1.6 — Validate 1C managed form
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import os
import re
import sys
from lxml import etree
F_NS = "http://v8.1c.ru/8.3/xcf/logform"
V8_NS = "http://v8.1c.ru/8.1/data/core"
NSMAP = {"f": F_NS, "v8": V8_NS}
KNOWN_INVALID_TYPES = {
'FormDataStructure', 'FormDataCollection', 'FormDataTree',
'FormDataTreeItem', 'FormDataCollectionItem',
'FormGroup', 'FormField', 'FormButton', 'FormDecoration', 'FormTable',
}
VALID_CLOSED_TYPES = {
'xs:boolean', 'xs:string', 'xs:decimal', 'xs:dateTime', 'xs:binary',
'v8:FillChecking', 'v8:Null', 'v8:StandardPeriod', 'v8:StandardBeginningDate', 'v8:Type',
'v8:TypeDescription', 'v8:UUID', 'v8:ValueListType', 'v8:ValueTable', 'v8:ValueTree',
'v8:Universal', 'v8:FixedArray', 'v8:FixedStructure',
'v8ui:Color', 'v8ui:Font', 'v8ui:FormattedString', 'v8ui:HorizontalAlign',
'v8ui:Picture', 'v8ui:SizeChangeMode', 'v8ui:VerticalAlign',
'dcsset:DataCompositionComparisonType', 'dcsset:DataCompositionFieldPlacement',
'dcsset:Filter', 'dcsset:SettingsComposer', 'dcsset:DataCompositionSettings',
'dcssch:DataCompositionSchema',
'dcscor:DataCompositionComparisonType', 'dcscor:DataCompositionGroupType',
'dcscor:DataCompositionPeriodAdditionType', 'dcscor:DataCompositionSortDirection', 'dcscor:Field',
'ent:AccountType', 'ent:AccumulationRecordType', 'ent:AccountingRecordType',
}
VALID_CFG_PREFIXES = {
'AccountingRegisterRecordSet', 'AccumulationRegisterRecordSet',
'BusinessProcessObject', 'BusinessProcessRef',
'CatalogObject', 'CatalogRef',
'ChartOfAccountsObject', 'ChartOfAccountsRef',
'ChartOfCalculationTypesObject', 'ChartOfCalculationTypesRef',
'ChartOfCharacteristicTypesObject', 'ChartOfCharacteristicTypesRef',
'ConstantsSet', 'DataProcessorObject', 'DocumentObject', 'DocumentRef',
'DynamicList', 'EnumRef', 'ExchangePlanObject', 'ExchangePlanRef',
'ExternalDataProcessorObject', 'ExternalReportObject',
'InformationRegisterRecordManager', 'InformationRegisterRecordSet',
'ReportObject', 'TaskObject', 'TaskRef',
}
def localname(el):
return etree.QName(el.tag).localname
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Validate 1C managed form", allow_abbrev=False)
parser.add_argument("-FormPath", "-Path", required=True)
parser.add_argument("-Detailed", action="store_true")
parser.add_argument("-MaxErrors", type=int, default=30)
args = parser.parse_args()
form_path = args.FormPath
detailed = args.Detailed
max_errors = args.MaxErrors
if not os.path.isabs(form_path):
form_path = os.path.join(os.getcwd(), form_path)
# A: Directory → Ext/Form.xml
if os.path.isdir(form_path):
form_path = os.path.join(form_path, 'Ext', 'Form.xml')
# B1: Missing Ext/ (e.g. Forms/Форма/Form.xml → Forms/Форма/Ext/Form.xml)
if not os.path.exists(form_path):
fn = os.path.basename(form_path)
if fn == 'Form.xml':
c = os.path.join(os.path.dirname(form_path), 'Ext', fn)
if os.path.exists(c):
form_path = c
# B2: Descriptor (Forms/Форма.xml → Forms/Форма/Ext/Form.xml)
if not os.path.exists(form_path) and form_path.endswith('.xml'):
stem = os.path.splitext(os.path.basename(form_path))[0]
parent = os.path.dirname(form_path)
c = os.path.join(parent, stem, 'Ext', 'Form.xml')
if os.path.exists(c):
form_path = c
if not os.path.isfile(form_path):
print(f"File not found: {form_path}", file=sys.stderr)
sys.exit(1)
# --- Load XML ---
try:
xml_parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(form_path, xml_parser)
except Exception as e:
print(f"[ERROR] XML parse error: {e}")
print()
print("---")
print("Errors: 1, Warnings: 0")
sys.exit(1)
root = tree.getroot()
# Detect context: config vs EPF/ERF
is_config_context = False
walk_dir = os.path.dirname(os.path.abspath(form_path))
for _ in range(15):
parent = os.path.dirname(walk_dir)
if parent == walk_dir:
break
if os.path.isfile(os.path.join(walk_dir, 'Configuration.xml')):
is_config_context = True
break
walk_dir = parent
errors = 0
warnings = 0
ok_count = 0
stopped = False
output_lines = []
def report_ok(msg):
nonlocal ok_count
ok_count += 1
if detailed:
output_lines.append(f"[OK] {msg}")
def report_error(msg):
nonlocal errors, stopped
errors += 1
output_lines.append(f"[ERROR] {msg}")
if errors >= max_errors:
stopped = True
def report_warn(msg):
nonlocal warnings
warnings += 1
output_lines.append(f"[WARN] {msg}")
# --- Form name from path ---
form_name = os.path.splitext(os.path.basename(form_path))[0]
parent_dir = os.path.dirname(form_path)
if parent_dir:
ext_dir = os.path.basename(parent_dir)
if ext_dir == "Ext":
form_dir = os.path.dirname(parent_dir)
if form_dir:
form_name = os.path.basename(form_dir)
output_lines.append(f"=== Validation: Form.{form_name} ===")
output_lines.append("")
# Early BaseForm detection
has_base_form = root.find(f"{{{F_NS}}}BaseForm") is not None
# --- Check 1: Root element and version ---
if localname(root) != "Form":
report_error(f"Root element is '{localname(root)}', expected 'Form'")
else:
version = root.get("version", "")
if version in ("2.17", "2.20"):
report_ok(f"Root element: Form version={version}")
elif version:
report_warn(f"Form version='{version}' (expected 2.17 or 2.20)")
else:
report_warn("Form version attribute missing")
# --- Check 2: AutoCommandBar ---
if not stopped:
acb = root.find(f"{{{F_NS}}}AutoCommandBar")
if acb is not None:
acb_name = acb.get("name", "")
acb_id = acb.get("id", "")
if acb_id == "-1":
report_ok(f"AutoCommandBar: name='{acb_name}', id={acb_id}")
else:
report_error(f"AutoCommandBar id='{acb_id}', expected '-1'")
else:
report_error("AutoCommandBar element missing")
# --- Collect all elements with IDs ---
element_ids = {} # id -> name
all_elements = [] # list of dicts {Name, Tag, Id, ParentName, Node}
def collect_elements(node, parent_name):
nonlocal stopped
for child in node:
if not isinstance(child.tag, str):
continue
name = child.get("name", "")
eid = child.get("id", "")
if name and eid:
tag = localname(child)
all_elements.append({
"Name": name,
"Tag": tag,
"Id": eid,
"ParentName": parent_name,
"Node": child,
})
if eid != "-1":
if eid in element_ids:
report_error(f"Duplicate element id={eid}: '{name}' and '{element_ids[eid]}'")
else:
element_ids[eid] = name
child_items = child.find(f"{{{F_NS}}}ChildItems")
if child_items is not None:
collect_elements(child_items, name)
child_items_root = root.find(f"{{{F_NS}}}ChildItems")
if child_items_root is not None:
collect_elements(child_items_root, "(root)")
acb = root.find(f"{{{F_NS}}}AutoCommandBar")
if acb is not None:
acb_children = acb.find(f"{{{F_NS}}}ChildItems")
if acb_children is not None:
collect_elements(acb_children, "\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c")
# --- Check 3: Unique element IDs ---
if not stopped:
# Duplicates already reported during collection
dup_count = 0
id_counts = {}
for el in all_elements:
eid = el["Id"]
if eid == "-1":
continue
id_counts[eid] = id_counts.get(eid, 0) + 1
dup_count = sum(1 for v in id_counts.values() if v > 1)
if dup_count == 0:
report_ok(f"Unique element IDs: {len(element_ids)} elements")
# --- Collect attributes (separate ID pool) ---
attr_map = {} # name -> node
attr_ids = {} # id -> name
attr_nodes_parent = root.find(f"{{{F_NS}}}Attributes")
attr_nodes = []
if attr_nodes_parent is not None:
attr_nodes = attr_nodes_parent.findall(f"{{{F_NS}}}Attribute")
for attr in attr_nodes:
attr_name = attr.get("name", "")
attr_id = attr.get("id", "")
if attr_name:
attr_map[attr_name] = attr
if attr_id:
if attr_id in attr_ids:
report_error(f"Duplicate attribute id={attr_id}: '{attr_name}' and '{attr_ids[attr_id]}'")
else:
attr_ids[attr_id] = attr_name
# Column IDs uniqueness within parent
col_ids = {}
columns = attr.find(f"{{{F_NS}}}Columns")
if columns is not None:
for col in columns.findall(f"{{{F_NS}}}Column"):
col_id = col.get("id", "")
col_name = col.get("name", "")
if col_id:
if col_id in col_ids:
report_error(f"Duplicate column id={col_id} in '{attr_name}': '{col_name}' and '{col_ids[col_id]}'")
else:
col_ids[col_id] = col_name
if not stopped:
if attr_ids:
report_ok(f"Unique attribute IDs: {len(attr_ids)} entries")
# --- Collect commands (separate ID pool) ---
cmd_map = {} # name -> node
cmd_ids = {} # id -> name
cmd_nodes_parent = root.find(f"{{{F_NS}}}Commands")
cmd_nodes = []
if cmd_nodes_parent is not None:
cmd_nodes = cmd_nodes_parent.findall(f"{{{F_NS}}}Command")
for cmd in cmd_nodes:
cmd_name = cmd.get("name", "")
cmd_id = cmd.get("id", "")
if cmd_name:
cmd_map[cmd_name] = cmd
if cmd_id:
if cmd_id in cmd_ids:
report_error(f"Duplicate command id={cmd_id}: '{cmd_name}' and '{cmd_ids[cmd_id]}'")
else:
cmd_ids[cmd_id] = cmd_name
if not stopped:
if cmd_ids:
report_ok(f"Unique command IDs: {len(cmd_ids)} entries")
# --- Check 4: Companion elements ---
companion_rules = {
"InputField": ["ContextMenu", "ExtendedTooltip"],
"CheckBoxField": ["ContextMenu", "ExtendedTooltip"],
"LabelDecoration": ["ContextMenu", "ExtendedTooltip"],
"LabelField": ["ContextMenu", "ExtendedTooltip"],
"PictureDecoration": ["ContextMenu", "ExtendedTooltip"],
"PictureField": ["ContextMenu", "ExtendedTooltip"],
"CalendarField": ["ContextMenu", "ExtendedTooltip"],
"UsualGroup": ["ExtendedTooltip"],
"Pages": ["ExtendedTooltip"],
"Page": ["ExtendedTooltip"],
"Button": ["ExtendedTooltip"],
"Table": ["ContextMenu", "AutoCommandBar", "SearchStringAddition", "ViewStatusAddition", "SearchControlAddition"],
}
if not stopped:
companion_errors = 0
companion_checked = 0
for el in all_elements:
if stopped:
break
tag = el["Tag"]
el_name = el["Name"]
node = el["Node"]
if tag not in companion_rules:
continue
required = companion_rules[tag]
companion_checked += 1
for comp_tag in required:
comp_node = node.find(f"{{{F_NS}}}{comp_tag}")
if comp_node is None:
report_error(f"[{tag}] '{el_name}': missing companion <{comp_tag}>")
companion_errors += 1
if companion_errors == 0 and companion_checked > 0:
report_ok(f"Companion elements: {companion_checked} elements checked")
# --- Check 5: DataPath -> Attribute references ---
if not stopped:
path_errors = 0
path_checked = 0
path_base_skipped = 0
skip_tags = {"ContextMenu", "ExtendedTooltip", "AutoCommandBar", "SearchStringAddition", "ViewStatusAddition", "SearchControlAddition"}
for el in all_elements:
if stopped:
break
tag = el["Tag"]
el_name = el["Name"]
node = el["Node"]
if tag in skip_tags:
continue
if has_base_form and el["Id"]:
try:
if int(el["Id"]) < 1000000:
path_base_skipped += 1
continue
except (ValueError, TypeError):
pass
dp_node = node.find(f"{{{F_NS}}}DataPath")
if dp_node is None:
continue
data_path = (dp_node.text or "").strip()
if not data_path:
continue
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
# - bare numeric (e.g. "10", "1000003") — internal index
# - "N/M:<uuid>" — metadata reference by UUID
if re.match(r'^\d+$', data_path) or re.match(r'^\d+/\d+:[0-9a-fA-F-]+$', data_path):
continue
path_checked += 1
clean_path = re.sub(r'\[\d+\]', '', data_path)
# Strip leading '~' (current row of DynamicList: ~\u0421\u043f\u0438\u0441\u043e\u043a.\u041f\u043e\u043b\u0435)
if clean_path.startswith('~'):
clean_path = clean_path[1:]
segments = clean_path.split(".")
root_attr = segments[0]
# Resolve Items.<TableName>.CurrentData.<Field>... \u2014 table element, not attribute
if root_attr == 'Items':
if len(segments) < 3 or segments[2] != 'CurrentData':
report_warn(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 unknown Items.* shape, expected Items.<Table>.CurrentData.*")
continue
table_name = segments[1]
table_el = None
for candidate in all_elements:
if candidate["Tag"] == 'Table' and candidate["Name"] == table_name:
table_el = candidate
break
if table_el is None:
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 table element '{table_name}' not found")
path_errors += 1
continue
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
if table_dp_node is None or not (table_dp_node.text or "").strip():
continue
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
if table_dp.startswith('~'):
table_dp = table_dp[1:]
root_attr = table_dp.split(".")[0]
if root_attr not in attr_map:
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 attribute '{root_attr}' not found")
path_errors += 1
path_msg = ""
if path_checked > 0:
path_msg = f"{path_checked} paths checked"
if path_base_skipped > 0:
skip_note = f"{path_base_skipped} base skipped"
path_msg = f"{path_msg}, {skip_note}" if path_msg else skip_note
if path_errors == 0 and path_msg:
report_ok(f"DataPath references: {path_msg}")
# --- Check 6: Button command references ---
if not stopped:
cmd_errors = 0
cmd_checked = 0
for el in all_elements:
if stopped:
break
tag = el["Tag"]
el_name = el["Name"]
node = el["Node"]
if tag != "Button":
continue
cmd_node = node.find(f"{{{F_NS}}}CommandName")
if cmd_node is None:
continue
cmd_ref = (cmd_node.text or "").strip()
if not cmd_ref:
continue
m = re.match(r'^Form\.Command\.(.+)$', cmd_ref)
if m:
cmd_name_ref = m.group(1)
cmd_checked += 1
if cmd_name_ref not in cmd_map:
report_error(f"[Button] '{el_name}': CommandName='{cmd_ref}' \u2014 command '{cmd_name_ref}' not found in Commands")
cmd_errors += 1
if cmd_errors == 0 and cmd_checked > 0:
report_ok(f"Command references: {cmd_checked} buttons checked")
# --- Check 7: Events have handler names ---
if not stopped:
event_errors = 0
event_checked = 0
# Form-level events
form_events = root.find(f"{{{F_NS}}}Events")
if form_events is not None:
for evt in form_events.findall(f"{{{F_NS}}}Event"):
evt_name = evt.get("name", "")
handler = (evt.text or "").strip()
event_checked += 1
if not handler:
report_error(f"Form event '{evt_name}': empty handler name")
event_errors += 1
# Element-level events
for el in all_elements:
if stopped:
break
tag = el["Tag"]
el_name = el["Name"]
node = el["Node"]
events_node = node.find(f"{{{F_NS}}}Events")
if events_node is None:
continue
for evt in events_node.findall(f"{{{F_NS}}}Event"):
evt_name = evt.get("name", "")
handler = (evt.text or "").strip()
event_checked += 1
if not handler:
report_error(f"[{tag}] '{el_name}' event '{evt_name}': empty handler name")
event_errors += 1
if event_errors == 0 and event_checked > 0:
report_ok(f"Event handlers: {event_checked} events checked")
# --- Check 8: Command actions ---
if not stopped:
action_errors = 0
action_checked = 0
for cmd in cmd_nodes:
if stopped:
break
cmd_name = cmd.get("name", "")
action_node = cmd.find(f"{{{F_NS}}}Action")
action_checked += 1
if action_node is None or not (action_node.text or "").strip():
report_error(f"Command '{cmd_name}': missing or empty Action")
action_errors += 1
if action_errors == 0 and action_checked > 0:
report_ok(f"Command actions: {action_checked} commands checked")
# --- Check 9: MainAttribute count ---
if not stopped:
main_count = 0
for attr in attr_nodes:
main_node = attr.find(f"{{{F_NS}}}MainAttribute")
if main_node is not None and (main_node.text or "") == "true":
main_count += 1
if main_count <= 1:
main_info = "1 main attribute" if main_count == 1 else "no main attribute"
report_ok(f"MainAttribute: {main_info}")
else:
report_error(f"Multiple MainAttribute=true ({main_count} found, expected 0 or 1)")
# --- Check 10: Title must be multilingual XML ---
if not stopped:
title_node = root.find(f"{{{F_NS}}}Title")
if title_node is not None:
v8_items = title_node.findall(f"{{{V8_NS}}}item")
if len(v8_items) == 0 and (title_node.text or "").strip():
report_error(f"Form Title is plain text ('{(title_node.text or '').strip()}') \u2014 must be multilingual XML (<v8:item>). Use top-level 'title' key in form-compile DSL.")
else:
report_ok("Title: multilingual XML")
# --- Check 11: Extension-specific validations ---
base_form_node = root.find(f"{{{F_NS}}}BaseForm")
is_extension = base_form_node is not None
if not stopped and is_extension:
# 11a. BaseForm version
bf_version = base_form_node.get("version", "")
if bf_version:
report_ok(f"BaseForm: version={bf_version}")
else:
report_warn("BaseForm: version attribute missing")
# 11b. callType values validation
valid_call_types = {"Before", "After", "Override"}
ct_errors = 0
ct_checked = 0
form_events_node = root.find(f"{{{F_NS}}}Events")
if form_events_node is not None:
for evt in form_events_node.findall(f"{{{F_NS}}}Event"):
ct = evt.get("callType", "")
if ct:
ct_checked += 1
if ct not in valid_call_types:
report_error(f"Form event '{evt.get('name', '')}': invalid callType='{ct}' (expected: Before, After, Override)")
ct_errors += 1
for el in all_elements:
if stopped:
break
events_node = el["Node"].find(f"{{{F_NS}}}Events")
if events_node is None:
continue
for evt in events_node.findall(f"{{{F_NS}}}Event"):
ct = evt.get("callType", "")
if ct:
ct_checked += 1
if ct not in valid_call_types:
report_error(f"[{el['Tag']}] '{el['Name']}' event '{evt.get('name', '')}': invalid callType='{ct}'")
ct_errors += 1
for cmd in cmd_nodes:
if stopped:
break
cmd_name = cmd.get("name", "")
for action in cmd.findall(f"{{{F_NS}}}Action"):
ct = action.get("callType", "")
if ct:
ct_checked += 1
if ct not in valid_call_types:
report_error(f"Command '{cmd_name}' Action: invalid callType='{ct}'")
ct_errors += 1
if not stopped and ct_errors == 0 and ct_checked > 0:
report_ok(f"callType values: {ct_checked} checked")
# 11c. Extension ID ranges
base_attr_names = set()
base_cmd_names = set()
bf_attrs = base_form_node.find(f"{{{F_NS}}}Attributes")
if bf_attrs is not None:
for b_attr in bf_attrs.findall(f"{{{F_NS}}}Attribute"):
ba_name = b_attr.get("name", "")
if ba_name:
base_attr_names.add(ba_name)
bf_cmds = base_form_node.find(f"{{{F_NS}}}Commands")
if bf_cmds is not None:
for b_cmd in bf_cmds.findall(f"{{{F_NS}}}Command"):
bc_name = b_cmd.get("name", "")
if bc_name:
base_cmd_names.add(bc_name)
id_warn_count = 0
for attr in attr_nodes:
a_name = attr.get("name", "")
a_id = attr.get("id", "")
if a_name and a_name not in base_attr_names and a_id:
try:
int_id = int(a_id)
if int_id < 1000000:
report_warn(f"Attribute '{a_name}' (id={a_id}): extension-added attribute has id < 1000000")
id_warn_count += 1
except (ValueError, TypeError):
pass
for cmd in cmd_nodes:
c_name = cmd.get("name", "")
c_id = cmd.get("id", "")
if c_name and c_name not in base_cmd_names and c_id:
try:
int_id = int(c_id)
if int_id < 1000000:
report_warn(f"Command '{c_name}' (id={c_id}): extension-added command has id < 1000000")
id_warn_count += 1
except (ValueError, TypeError):
pass
if not stopped and id_warn_count == 0:
ext_attr_count = sum(1 for a in attr_nodes if a.get("name", "") not in base_attr_names)
ext_cmd_count = sum(1 for c in cmd_nodes if c.get("name", "") not in base_cmd_names)
if (ext_attr_count + ext_cmd_count) > 0:
report_ok(f"Extension ID ranges: {ext_attr_count} attr(s), {ext_cmd_count} cmd(s) \u2014 all >= 1000000")
# Check callType without BaseForm
if not stopped and not is_extension:
call_type_without_base = False
fe_node = root.find(f"{{{F_NS}}}Events")
if fe_node is not None:
for evt in fe_node.findall(f"{{{F_NS}}}Event"):
if evt.get("callType"):
call_type_without_base = True
break
if not call_type_without_base:
for cmd in cmd_nodes:
for action in cmd.findall(f"{{{F_NS}}}Action"):
if action.get("callType"):
call_type_without_base = True
break
if call_type_without_base:
break
if call_type_without_base:
report_warn("callType attributes found but no BaseForm \u2014 possible incorrect structure")
# --- Check 12: Type validation ---
if not stopped:
type_nodes = root.xpath('//v8:Type', namespaces={'v8': V8_NS})
type_error_count = 0
type_warn_count = 0
type_count = len(type_nodes)
for tn in type_nodes:
if stopped:
break
tv = (tn.text or "").strip()
if not tv:
continue
if tv in KNOWN_INVALID_TYPES:
report_error(f'12. Type "{tv}": invalid runtime/UI type (not valid in XDTO schema)')
type_error_count += 1
elif tv in VALID_CLOSED_TYPES:
pass # OK
elif tv.startswith("cfg:"):
suffix = tv[4:] # after "cfg:"
prefix = suffix.split(".")[0]
if prefix in VALID_CFG_PREFIXES or suffix == "DynamicList":
# ExternalDataProcessorObject/ExternalReportObject valid only in EPF/ERF context
if is_config_context and prefix in ('ExternalDataProcessorObject', 'ExternalReportObject'):
report_error(f'12. Type "{tv}": External* type in configuration context (use DataProcessorObject/ReportObject instead)')
type_invalid += 1
else:
report_warn(f'12. Type "{tv}": unrecognized cfg prefix')
type_warn_count += 1
elif ":" in tv:
pass # unknown namespace, pass through
else:
report_warn(f'12. Type "{tv}": bare type without namespace prefix')
type_warn_count += 1
if type_error_count == 0 and type_warn_count == 0:
if type_count > 0:
report_ok(f'12. Types: {type_count} values, all valid')
else:
report_ok('12. Types: no type values to check')
# --- Finalize ---
checks = ok_count + errors + warnings
if errors == 0 and warnings == 0 and not detailed:
result = f"=== Validation OK: Form.{form_name} ({checks} checks) ==="
else:
output_lines.append("")
output_lines.append(f"=== Result: {errors} errors, {warnings} warnings ({checks} checks) ===")
result = "\n".join(output_lines)
print(result)
if errors > 0:
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()