From a813c7fd462e2b89ac90566f5a87ef175e67c777 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 9 Feb 2026 12:13:51 +0300 Subject: [PATCH] Add form-validate skill for structural validation of Form.xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks: unique IDs (per pool), companion elements, DataPath→attribute refs, button→command refs, event handlers, command actions, MainAttribute. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/form-validate/SKILL.md | 78 +++ .../form-validate/scripts/form-validate.ps1 | 486 ++++++++++++++++++ README.md | 3 +- docs/form-guide.md | 16 +- 4 files changed, 576 insertions(+), 7 deletions(-) create mode 100644 .claude/skills/form-validate/SKILL.md create mode 100644 .claude/skills/form-validate/scripts/form-validate.ps1 diff --git a/.claude/skills/form-validate/SKILL.md b/.claude/skills/form-validate/SKILL.md new file mode 100644 index 00000000..7c66e0bb --- /dev/null +++ b/.claude/skills/form-validate/SKILL.md @@ -0,0 +1,78 @@ +--- +name: form-validate +description: Валидация структурной корректности управляемой формы 1С (Form.xml) +argument-hint: +allowed-tools: + - Bash + - Read + - Glob +--- + +# /form-validate — Валидатор формы + +Проверяет Form.xml управляемой формы на структурные ошибки: уникальность ID, наличие companion-элементов, корректность ссылок DataPath и команд. + +## Использование + +``` +/form-validate +``` + +## Параметры + +| Параметр | Обязательный | По умолчанию | Описание | +|-----------|:------------:|--------------|-----------------------------| +| FormPath | да | — | Путь к файлу Form.xml | +| MaxErrors | нет | 30 | Остановиться после N ошибок | + +## Команда + +```powershell +powershell.exe -NoProfile -File .claude\skills\form-validate\scripts\form-validate.ps1 -FormPath "<путь>" +``` + +## Выполняемые проверки + +| # | Проверка | Серьёзность | +|---|---|---| +| 1 | Корневой элемент `
`, version="2.17" | ERROR / WARN | +| 2 | `` присутствует, id="-1" | ERROR | +| 3 | Уникальность ID элементов (отдельный пул) | ERROR | +| 4 | Уникальность ID реквизитов (отдельный пул) | ERROR | +| 5 | Уникальность ID команд (отдельный пул) | ERROR | +| 6 | Companion-элементы (ContextMenu, ExtendedTooltip, и др.) | ERROR | +| 7 | DataPath → ссылается на существующий реквизит | ERROR | +| 8 | CommandName кнопок → ссылается на существующую команду | ERROR | +| 9 | События имеют непустые имена обработчиков | ERROR | +| 10 | Команды имеют Action (обработчик) | ERROR | +| 11 | Не более одного MainAttribute | ERROR | + +## Вывод + +``` +=== Validation: ФормаДокумента === + +[OK] Root element: Form version=2.17 +[OK] AutoCommandBar: name='ФормаКоманднаяПанель', id=-1 +[OK] Unique element IDs: 96 elements +[OK] Unique attribute IDs: 38 entries +[OK] Unique command IDs: 5 entries +[OK] Companion elements: 86 elements checked +[OK] DataPath references: 53 paths checked +[OK] Command references: 2 buttons checked +[OK] Event handlers: 41 events checked +[OK] Command actions: 5 commands checked +[OK] MainAttribute: 1 main attribute + +--- +Total: 96 elements, 38 attributes, 5 commands +All checks passed. +``` + +Код возврата: 0 = все проверки пройдены, 1 = есть ошибки. + +## Когда использовать + +- **После `/form-compile`**: проверить корректность сгенерированной формы +- **После ручного редактирования Form.xml**: убедиться что ID уникальны, companions на месте, ссылки валидны +- **При отладке**: выявить ошибки в структуре формы до сборки EPF diff --git a/.claude/skills/form-validate/scripts/form-validate.ps1 b/.claude/skills/form-validate/scripts/form-validate.ps1 new file mode 100644 index 00000000..ca13dcdd --- /dev/null +++ b/.claude/skills/form-validate/scripts/form-validate.ps1 @@ -0,0 +1,486 @@ +param( + [Parameter(Mandatory)] + [string]$FormPath, + + [int]$MaxErrors = 30 +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# --- 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 + +# --- Counters --- + +$errors = 0 +$warnings = 0 +$stopped = $false + +function Report-OK { + param([string]$msg) + 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) } + } +} + +Write-Host "=== Validation: $formName ===" +Write-Host "" + +# --- 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") { + Report-OK "Root element: Form version=$version" + } elseif ($version) { + Report-Warn "Form version='$version' (expected 2.17)" + } 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 + + 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 + } + + $dpNode = $node.SelectSingleNode("f:DataPath", $nsMgr) + if (-not $dpNode) { continue } + + $dataPath = $dpNode.InnerText.Trim() + if (-not $dataPath) { continue } + + $pathChecked++ + + # Extract root segment of path, strip array indices like [0] + $cleanPath = $dataPath -replace '\[\d+\]', '' + $segments = $cleanPath -split '\.' + $rootAttr = $segments[0] + + if (-not $attrMap.ContainsKey($rootAttr)) { + Report-Error "[$tag] '$elName': DataPath='$dataPath' — attribute '$rootAttr' not found" + $pathErrors++ + } + } + + if ($pathErrors -eq 0 -and $pathChecked -gt 0) { + Report-OK "DataPath references: $pathChecked paths checked" + } elseif ($pathChecked -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)" + } +} + +# --- Summary --- + +Write-Host "" +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." +} + +if ($errors -eq 0 -and $warnings -eq 0) { + Write-Host "All checks passed." +} else { + Write-Host "Errors: $errors, Warnings: $warnings" +} + +if ($errors -gt 0) { + exit 1 +} else { + exit 0 +} diff --git a/README.md b/README.md index 087724ca..65c70c55 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ |--------|--------|----------|------| | Внешние обработки (EPF) | 10 навыков `/epf-*` | Создание, модификация, сборка обработок из XML-исходников | [Подробнее](docs/epf-guide.md) | | Табличный документ (MXL) | 4 навыка `/mxl-*` | Анализ, создание, компиляция макетов печатных форм | [Подробнее](docs/mxl-guide.md) | -| Управляемые формы (Form) | 2 навыка `/form-*` | Анализ и генерация управляемых форм из XML-исходников | [Подробнее](docs/form-guide.md) | +| Управляемые формы (Form) | 3 навыка `/form-*` | Анализ, генерация, валидация управляемых форм | [Подробнее](docs/form-guide.md) | | Утилиты | `/img-grid` | Наложение сетки на изображение для определения пропорций колонок | — | ## Требования @@ -59,6 +59,7 @@ ├── mxl-decompile/ # Декомпиляция макета в JSON ├── form-info/ # Анализ структуры управляемой формы ├── form-compile/ # Компиляция формы из JSON +├── form-validate/ # Валидация формы └── img-grid/ # Сетка для анализа изображений docs/ ├── epf-guide.md # Гайд: внешние обработки diff --git a/docs/form-guide.md b/docs/form-guide.md index 6eff4648..f0d8087c 100644 --- a/docs/form-guide.md +++ b/docs/form-guide.md @@ -1,6 +1,6 @@ # Управляемые формы (Form) -Навыки группы `/form-*` позволяют анализировать и генерировать управляемые формы 1С:Предприятия 8.3 из XML-исходников, не читая тысячи строк XML. +Навыки группы `/form-*` позволяют анализировать, генерировать и валидировать управляемые формы 1С:Предприятия 8.3 из XML-исходников, не читая тысячи строк XML. ## Навыки @@ -8,6 +8,7 @@ |-------|-----------|----------| | `/form-info` | `` | Компактная сводка: дерево элементов, реквизиты, команды, события | | `/form-compile` | ` ` | Генерация Form.xml из компактного JSON-определения | +| `/form-validate` | `` | Валидация: уникальность ID, companions, DataPath, команды | ## Сценарии использования @@ -157,13 +158,14 @@ Claude создаст JSON-определение и вызовет `/form-compi ### Верификация результата -После компиляции используйте `/form-info` для проверки: +После компиляции используйте `/form-validate` и `/form-info` для проверки: ``` +> /form-validate src/МояОбработка/Forms/Форма/Ext/Form.xml > /form-info src/МояОбработка/Forms/Форма/Ext/Form.xml ``` -Структура в сводке должна совпадать с определением в JSON. +`/form-validate` проверит структурную корректность (ID, companions, ссылки), `/form-info` покажет результат в виде компактной сводки. ## Примеры слеш-команд @@ -171,16 +173,18 @@ Claude создаст JSON-определение и вызовет `/form-compi > /form-info upload/acc_8.3.24/Documents/РеализацияТоваровУслуг/Forms/ФормаДокумента/Ext/Form.xml > /form-info src/МояОбработка/Forms/Форма/Ext/Form.xml > /form-compile src/form.json src/МояОбработка/Forms/Форма/Ext/Form.xml +> /form-validate src/МояОбработка/Forms/Форма/Ext/Form.xml ``` ## Связь с EPF-навыками -`/form-info` и `/form-compile` работают с формами из любых источников — конфигурации и внешних обработок. При работе с обработками: +Навыки `/form-*` работают с формами из любых источников — конфигурации и внешних обработок. При работе с обработками: 1. `/epf-add-form` — создать форму (каркас) 2. `/form-compile` — сгенерировать Form.xml из JSON-определения -3. `/form-info` — проверить результат -4. `/epf-build` — собрать EPF +3. `/form-validate` — проверить корректность +4. `/form-info` — проанализировать результат +5. `/epf-build` — собрать EPF ## Спецификации