From a6a708d14d110647a2a27d0bd98cb680f8f940bb Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 14 Feb 2026 21:52:17 +0300 Subject: [PATCH] Add subsystem and interface skills (6 new skills) - subsystem-info: analyze subsystem structure (overview, content, ci, tree modes) - subsystem-compile: create subsystem from JSON definition - subsystem-edit: edit Content, ChildObjects, properties of existing subsystem - subsystem-validate: validate subsystem XML structure (13 checks) - interface-edit: edit CommandInterface.xml (hide/show, place, order) - interface-validate: validate CommandInterface.xml structure (13 checks) Co-Authored-By: Claude Opus 4.6 --- .claude/skills/interface-edit/SKILL.md | 18 + .claude/skills/interface-edit/reference.md | 48 ++ .../interface-edit/scripts/interface-edit.ps1 | 490 ++++++++++++++++++ .claude/skills/interface-validate/SKILL.md | 82 +++ .../scripts/interface-validate.ps1 | 382 ++++++++++++++ .claude/skills/subsystem-compile/SKILL.md | 65 +++ .../scripts/subsystem-compile.ps1 | 338 ++++++++++++ .claude/skills/subsystem-edit/SKILL.md | 57 ++ .../subsystem-edit/scripts/subsystem-edit.ps1 | 403 ++++++++++++++ .claude/skills/subsystem-info/SKILL.md | 61 +++ .../subsystem-info/scripts/subsystem-info.ps1 | 483 +++++++++++++++++ .claude/skills/subsystem-validate/SKILL.md | 41 ++ .../scripts/subsystem-validate.ps1 | 314 +++++++++++ README.md | 12 +- 14 files changed, 2793 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/interface-edit/SKILL.md create mode 100644 .claude/skills/interface-edit/reference.md create mode 100644 .claude/skills/interface-edit/scripts/interface-edit.ps1 create mode 100644 .claude/skills/interface-validate/SKILL.md create mode 100644 .claude/skills/interface-validate/scripts/interface-validate.ps1 create mode 100644 .claude/skills/subsystem-compile/SKILL.md create mode 100644 .claude/skills/subsystem-compile/scripts/subsystem-compile.ps1 create mode 100644 .claude/skills/subsystem-edit/SKILL.md create mode 100644 .claude/skills/subsystem-edit/scripts/subsystem-edit.ps1 create mode 100644 .claude/skills/subsystem-info/SKILL.md create mode 100644 .claude/skills/subsystem-info/scripts/subsystem-info.ps1 create mode 100644 .claude/skills/subsystem-validate/SKILL.md create mode 100644 .claude/skills/subsystem-validate/scripts/subsystem-validate.ps1 diff --git a/.claude/skills/interface-edit/SKILL.md b/.claude/skills/interface-edit/SKILL.md new file mode 100644 index 00000000..e1a1f276 --- /dev/null +++ b/.claude/skills/interface-edit/SKILL.md @@ -0,0 +1,18 @@ +--- +name: interface-edit +description: Настройка командного интерфейса подсистемы 1С — скрытие/показ команд, размещение в группах, порядок +argument-hint: +allowed-tools: + - Bash + - Read + - Write + - Glob +--- + +# /interface-edit — редактирование CommandInterface.xml + +Операции: hide, show, place, order, subsystem-order, group-order. Подробнее: `.claude/skills/interface-edit/reference.md` + +```powershell +powershell.exe -NoProfile -File '.claude\skills\interface-edit\scripts\interface-edit.ps1' -CIPath '' -Operation hide -Value '' +``` diff --git a/.claude/skills/interface-edit/reference.md b/.claude/skills/interface-edit/reference.md new file mode 100644 index 00000000..1eb97a8b --- /dev/null +++ b/.claude/skills/interface-edit/reference.md @@ -0,0 +1,48 @@ +# /interface-edit — редактирование CommandInterface.xml + +Точечное редактирование файла командного интерфейса подсистемы 1С. + +## Параметры + +| Параметр | Описание | +|----------|----------| +| CIPath | Путь к CommandInterface.xml | +| DefinitionFile | JSON-файл с массивом операций | +| Operation | Одна операция: hide, show, place, order, subsystem-order, group-order | +| Value | Значение для операции | +| CreateIfMissing | Создать файл если не существует | +| NoValidate | Пропустить авто-валидацию | + +## Операции + +| Операция | Значение | Описание | +|----------|----------|----------| +| hide | Cmd.Name или массив | Скрыть команду (CommandsVisibility, false) | +| show | Cmd.Name или массив | Показать команду (visibility, true) | +| place | {"command":"...","group":"CommandGroup.X"} | Разместить команду в группе | +| order | {"group":"...","commands":[...]} | Задать порядок команд в группе | +| subsystem-order | ["Subsystem.X.Subsystem.A",...] | Порядок дочерних подсистем | +| group-order | ["NavigationPanelOrdinary",...] | Порядок групп | + +## Примеры + +```powershell +# Скрыть команду +... -CIPath Subsystems/Продажи/Ext/CommandInterface.xml -Operation hide -Value "Catalog.Товары.StandardCommand.OpenList" + +# Показать команду +... -Operation show -Value "Report.Продажи.Command.Отчёт" + +# Разместить в группе +... -Operation place -Value '{"command":"Report.X.Command.Y","group":"CommandGroup.Отчеты"}' + +# Задать порядок подсистем +... -Operation subsystem-order -Value '["Subsystem.X.Subsystem.A","Subsystem.X.Subsystem.B"]' + +# Создать новый CI +... -CIPath -Operation subsystem-order -Value '[...]' -CreateIfMissing +``` + +## Авто-валидация + +После каждой операции автоматически запускается `/interface-validate`. Подавить: `-NoValidate`. diff --git a/.claude/skills/interface-edit/scripts/interface-edit.ps1 b/.claude/skills/interface-edit/scripts/interface-edit.ps1 new file mode 100644 index 00000000..5fc64f37 --- /dev/null +++ b/.claude/skills/interface-edit/scripts/interface-edit.ps1 @@ -0,0 +1,490 @@ +# interface-edit v1.0 — Edit 1C CommandInterface.xml +# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +param( + [Parameter(Mandatory)][string]$CIPath, + [string]$DefinitionFile, + [ValidateSet("hide","show","place","order","subsystem-order","group-order")] + [string]$Operation, + [string]$Value, + [switch]$CreateIfMissing, + [switch]$NoValidate +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# --- Mode validation --- +if ($DefinitionFile -and $Operation) { Write-Error "Cannot use both -DefinitionFile and -Operation"; exit 1 } +if (-not $DefinitionFile -and -not $Operation) { Write-Error "Either -DefinitionFile or -Operation is required"; exit 1 } + +# --- Resolve path --- +if (-not [System.IO.Path]::IsPathRooted($CIPath)) { + $CIPath = Join-Path (Get-Location).Path $CIPath +} +$resolvedPath = $CIPath + +# --- Namespaces --- +$script:ciNs = "http://v8.1c.ru/8.3/xcf/extrnprops" +$script:xrNs = "http://v8.1c.ru/8.3/xcf/readable" +$script:xsiNs = "http://www.w3.org/2001/XMLSchema-instance" +$script:xsNs = "http://www.w3.org/2001/XMLSchema" + +# --- Create if missing --- +if (-not (Test-Path $CIPath)) { + if ($CreateIfMissing) { + $parentDir = [System.IO.Path]::GetDirectoryName($CIPath) + if (-not (Test-Path $parentDir)) { + New-Item -ItemType Directory -Path $parentDir -Force | Out-Null + } + $emptyCI = @" + + + +"@ + $utf8Bom = New-Object System.Text.UTF8Encoding($true) + [System.IO.File]::WriteAllText($CIPath, $emptyCI, $utf8Bom) + Write-Host "[INFO] Created new CommandInterface.xml: $CIPath" + } else { + Write-Error "File not found: $CIPath (use -CreateIfMissing to create)" + exit 1 + } +} +$resolvedPath = (Resolve-Path $CIPath).Path + +# --- Load XML --- +$script:xmlDoc = New-Object System.Xml.XmlDocument +$script:xmlDoc.PreserveWhitespace = $true +$script:xmlDoc.Load($resolvedPath) + +$script:addCount = 0 +$script:removeCount = 0 +$script:modifyCount = 0 + +function Info([string]$msg) { Write-Host "[INFO] $msg" } +function Warn([string]$msg) { Write-Host "[WARN] $msg" } + +# --- Detect structure --- +$root = $script:xmlDoc.DocumentElement +if ($root.LocalName -ne "CommandInterface") { + Write-Error "Expected root element, got <$($root.LocalName)>" + exit 1 +} + +# Section canonical order +$script:sectionOrder = @("CommandsVisibility","CommandsPlacement","CommandsOrder","SubsystemsOrder","GroupsOrder") + +# --- XML manipulation helpers --- +function Get-ChildIndent($container) { + foreach ($child in $container.ChildNodes) { + if ($child.NodeType -eq 'Whitespace' -or $child.NodeType -eq 'SignificantWhitespace') { + if ($child.Value -match '^\r?\n(\t+)$') { return $Matches[1] } + if ($child.Value -match '^\r?\n(\t+)') { return $Matches[1] } + } + } + $depth = 0; $current = $container + while ($current -and $current -ne $script:xmlDoc.DocumentElement) { $depth++; $current = $current.ParentNode } + return "`t" * ($depth + 1) +} + +function Insert-BeforeElement($container, $newNode, $refNode, $childIndent) { + $ws = $script:xmlDoc.CreateWhitespace("`r`n$childIndent") + if ($refNode) { + $container.InsertBefore($ws, $refNode) | Out-Null + $container.InsertBefore($newNode, $ws) | Out-Null + } else { + $trailing = $container.LastChild + if ($trailing -and ($trailing.NodeType -eq 'Whitespace' -or $trailing.NodeType -eq 'SignificantWhitespace')) { + $container.InsertBefore($ws, $trailing) | Out-Null + $container.InsertBefore($newNode, $trailing) | Out-Null + } else { + $container.AppendChild($ws) | Out-Null + $container.AppendChild($newNode) | Out-Null + $parentIndent = if ($childIndent.Length -gt 1) { $childIndent.Substring(0, $childIndent.Length - 1) } else { "" } + $closeWs = $script:xmlDoc.CreateWhitespace("`r`n$parentIndent") + $container.AppendChild($closeWs) | Out-Null + } + } +} + +function Remove-NodeWithWhitespace($node) { + $parent = $node.ParentNode + $prev = $node.PreviousSibling + $next = $node.NextSibling + if ($prev -and ($prev.NodeType -eq 'Whitespace' -or $prev.NodeType -eq 'SignificantWhitespace')) { + $parent.RemoveChild($prev) | Out-Null + } elseif ($next -and ($next.NodeType -eq 'Whitespace' -or $next.NodeType -eq 'SignificantWhitespace')) { + $parent.RemoveChild($next) | Out-Null + } + $parent.RemoveChild($node) | Out-Null +} + +function Import-CIFragment([string]$xmlString) { + $wrapper = "<_W xmlns=`"$($script:ciNs)`" xmlns:xr=`"$($script:xrNs)`" xmlns:xsi=`"$($script:xsiNs)`" xmlns:xs=`"$($script:xsNs)`">$xmlString" + $frag = New-Object System.Xml.XmlDocument + $frag.PreserveWhitespace = $true + $frag.LoadXml($wrapper) + $nodes = @() + foreach ($child in $frag.DocumentElement.ChildNodes) { + if ($child.NodeType -eq 'Element') { + $nodes += $script:xmlDoc.ImportNode($child, $true) + } + } + return ,$nodes +} + +# --- Ensure section exists, creating it in correct order if needed --- +function Ensure-Section([string]$sectionName) { + # Find existing + foreach ($child in $root.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq $sectionName) { + return $child + } + } + + # Create new section + $newSection = $script:xmlDoc.CreateElement($sectionName, $script:ciNs) + + # Find the correct insertion point: before the first section that comes AFTER us in canonical order + $myIdx = [array]::IndexOf($script:sectionOrder, $sectionName) + $refNode = $null + foreach ($child in $root.ChildNodes) { + if ($child.NodeType -ne 'Element') { continue } + $childIdx = [array]::IndexOf($script:sectionOrder, $child.LocalName) + if ($childIdx -gt $myIdx) { + # Find the whitespace before this element to insert before it + $prev = $child.PreviousSibling + if ($prev -and ($prev.NodeType -eq 'Whitespace' -or $prev.NodeType -eq 'SignificantWhitespace')) { + $refNode = $prev + } else { + $refNode = $child + } + break + } + } + + $rootIndent = Get-ChildIndent $root + # Add closing whitespace inside the new section + $closeWs = $script:xmlDoc.CreateWhitespace("`r`n$rootIndent") + $newSection.AppendChild($closeWs) | Out-Null + + if ($refNode) { + $ws = $script:xmlDoc.CreateWhitespace("`r`n$rootIndent") + $root.InsertBefore($ws, $refNode) | Out-Null + $root.InsertBefore($newSection, $ws) | Out-Null + } else { + Insert-BeforeElement $root $newSection $null $rootIndent + } + return $newSection +} + +# --- Parse value: string or JSON array --- +function Parse-ValueList([string]$val) { + $val = $val.Trim() + if ($val.StartsWith("[")) { + $arr = $val | ConvertFrom-Json + $result = @(); foreach ($item in $arr) { $result += "$item" } + return ,$result + } + return @($val) +} + +# --- Find Command element by name in a section --- +function Find-CommandByName($section, [string]$cmdName) { + foreach ($child in $section.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Command") { + if ($child.GetAttribute("name") -eq $cmdName) { return $child } + } + } + return $null +} + +# --- Operations --- + +function Do-Hide([string[]]$commands) { + $section = Ensure-Section "CommandsVisibility" + $sectionIndent = Get-ChildIndent $section + + foreach ($cmd in $commands) { + $existing = Find-CommandByName $section $cmd + if ($existing) { + # Check if already false + $commonEl = $null + foreach ($vis in $existing.ChildNodes) { + if ($vis.NodeType -eq 'Element' -and $vis.LocalName -eq "Visibility") { + foreach ($c in $vis.ChildNodes) { + if ($c.NodeType -eq 'Element' -and $c.LocalName -eq "Common") { $commonEl = $c; break } + } + } + } + if ($commonEl -and $commonEl.InnerText.Trim() -eq "false") { + Warn "Already hidden: $cmd" + continue + } + # Change true -> false + if ($commonEl) { + $commonEl.InnerText = "false" + $script:modifyCount++ + Info "Changed to hidden: $cmd" + continue + } + } + # Add new entry + $fragXml = "false" + $nodes = Import-CIFragment $fragXml + if ($nodes.Count -gt 0) { + Insert-BeforeElement $section $nodes[0] $null $sectionIndent + $script:addCount++ + Info "Hidden: $cmd" + } + } +} + +function Do-Show([string[]]$commands) { + $section = $null + foreach ($child in $root.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "CommandsVisibility") { + $section = $child; break + } + } + + foreach ($cmd in $commands) { + if (-not $section) { + # No CommandsVisibility section — showing means adding with true + $section = Ensure-Section "CommandsVisibility" + } + $existing = Find-CommandByName $section $cmd + if ($existing) { + $commonEl = $null + foreach ($vis in $existing.ChildNodes) { + if ($vis.NodeType -eq 'Element' -and $vis.LocalName -eq "Visibility") { + foreach ($c in $vis.ChildNodes) { + if ($c.NodeType -eq 'Element' -and $c.LocalName -eq "Common") { $commonEl = $c; break } + } + } + } + if ($commonEl -and $commonEl.InnerText.Trim() -eq "true") { + Warn "Already shown: $cmd" + continue + } + if ($commonEl -and $commonEl.InnerText.Trim() -eq "false") { + # Change false -> true + $commonEl.InnerText = "true" + $script:modifyCount++ + Info "Changed to shown: $cmd" + continue + } + } + # Add new entry with true + $sectionIndent = Get-ChildIndent $section + $fragXml = "true" + $nodes = Import-CIFragment $fragXml + if ($nodes.Count -gt 0) { + Insert-BeforeElement $section $nodes[0] $null $sectionIndent + $script:addCount++ + Info "Shown: $cmd" + } + } +} + +function Do-Place([string]$jsonVal) { + $def = $jsonVal | ConvertFrom-Json + $cmdName = "$($def.command)" + $groupName = "$($def.group)" + if (-not $cmdName -or -not $groupName) { Write-Error "place requires {command, group}"; exit 1 } + + $section = Ensure-Section "CommandsPlacement" + $sectionIndent = Get-ChildIndent $section + + # Check existing + $existing = Find-CommandByName $section $cmdName + if ($existing) { + # Update group + foreach ($child in $existing.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "CommandGroup") { + $child.InnerText = $groupName + $script:modifyCount++ + Info "Updated placement: $cmdName -> $groupName" + return + } + } + } + + # Add new + $fragXml = "$groupNameAuto" + $nodes = Import-CIFragment $fragXml + if ($nodes.Count -gt 0) { + Insert-BeforeElement $section $nodes[0] $null $sectionIndent + $script:addCount++ + Info "Placed: $cmdName -> $groupName" + } +} + +function Do-Order([string]$jsonVal) { + $def = $jsonVal | ConvertFrom-Json + $groupName = "$($def.group)" + $commands = @($def.commands | ForEach-Object { "$_" }) + if (-not $groupName -or $commands.Count -eq 0) { Write-Error "order requires {group, commands:[...]}"; exit 1 } + + $section = Ensure-Section "CommandsOrder" + $sectionIndent = Get-ChildIndent $section + + # Remove existing entries for this group + $toRemove = @() + foreach ($child in $section.ChildNodes) { + if ($child.NodeType -ne 'Element') { continue } + if ($child.LocalName -ne "Command") { continue } + foreach ($gc in $child.ChildNodes) { + if ($gc.NodeType -eq 'Element' -and $gc.LocalName -eq "CommandGroup" -and $gc.InnerText.Trim() -eq $groupName) { + $toRemove += $child + break + } + } + } + foreach ($node in $toRemove) { + Remove-NodeWithWhitespace $node + $script:removeCount++ + } + + # Add new entries in order + foreach ($cmdName in $commands) { + $fragXml = "$groupName" + $nodes = Import-CIFragment $fragXml + if ($nodes.Count -gt 0) { + Insert-BeforeElement $section $nodes[0] $null $sectionIndent + $script:addCount++ + } + } + Info "Set order for $groupName : $($commands.Count) commands" +} + +function Do-SubsystemOrder([string]$jsonVal) { + $parsed = $jsonVal | ConvertFrom-Json + $subsystems = @(); foreach ($s in $parsed) { $subsystems += "$s" } + if ($subsystems.Count -eq 0) { Write-Error "subsystem-order requires array of subsystem paths"; exit 1 } + + $section = Ensure-Section "SubsystemsOrder" + $sectionIndent = Get-ChildIndent $section + + # Clear existing + $toRemove = @() + foreach ($child in @($section.ChildNodes)) { + if ($child.NodeType -eq 'Element') { $toRemove += $child } + } + foreach ($node in $toRemove) { + Remove-NodeWithWhitespace $node + $script:removeCount++ + } + + # Add new entries + foreach ($sub in $subsystems) { + $newEl = $script:xmlDoc.CreateElement("Subsystem", $script:ciNs) + $newEl.InnerText = $sub + Insert-BeforeElement $section $newEl $null $sectionIndent + $script:addCount++ + } + Info "Set subsystem order: $($subsystems.Count) entries" +} + +function Do-GroupOrder([string]$jsonVal) { + $parsed = $jsonVal | ConvertFrom-Json + $groups = @(); foreach ($g in $parsed) { $groups += "$g" } + if ($groups.Count -eq 0) { Write-Error "group-order requires array of group names"; exit 1 } + + $section = Ensure-Section "GroupsOrder" + $sectionIndent = Get-ChildIndent $section + + # Clear existing + $toRemove = @() + foreach ($child in @($section.ChildNodes)) { + if ($child.NodeType -eq 'Element') { $toRemove += $child } + } + foreach ($node in $toRemove) { + Remove-NodeWithWhitespace $node + $script:removeCount++ + } + + # Add new entries + foreach ($grp in $groups) { + $newEl = $script:xmlDoc.CreateElement("Group", $script:ciNs) + $newEl.InnerText = $grp + Insert-BeforeElement $section $newEl $null $sectionIndent + $script:addCount++ + } + Info "Set group order: $($groups.Count) entries" +} + +# --- Execute operations --- +$operations = @() +if ($DefinitionFile) { + if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) { + $DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile + } + $jsonText = Get-Content -Raw -Encoding UTF8 $DefinitionFile + $ops = $jsonText | ConvertFrom-Json + if ($ops -is [System.Array]) { + foreach ($op in $ops) { $operations += $op } + } else { + $operations += $ops + } +} else { + $operations += @{ operation = $Operation; value = $Value } +} + +foreach ($op in $operations) { + $opName = if ($op.operation) { "$($op.operation)" } else { "$Operation" } + $opValue = if ($op.value) { "$($op.value)" } else { "$Value" } + + switch ($opName) { + "hide" { Do-Hide (Parse-ValueList $opValue) } + "show" { Do-Show (Parse-ValueList $opValue) } + "place" { Do-Place $opValue } + "order" { Do-Order $opValue } + "subsystem-order" { Do-SubsystemOrder $opValue } + "group-order" { Do-GroupOrder $opValue } + default { Write-Error "Unknown operation: $opName"; exit 1 } + } +} + +# --- Save --- +$settings = New-Object System.Xml.XmlWriterSettings +$settings.Encoding = New-Object System.Text.UTF8Encoding($true) +$settings.Indent = $false +$settings.NewLineHandling = [System.Xml.NewLineHandling]::None + +$memStream = New-Object System.IO.MemoryStream +$writer = [System.Xml.XmlWriter]::Create($memStream, $settings) +$script:xmlDoc.Save($writer) +$writer.Flush(); $writer.Close() + +$bytes = $memStream.ToArray() +$memStream.Close() +$text = [System.Text.Encoding]::UTF8.GetString($bytes) +if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) } +$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"') + +$utf8Bom = New-Object System.Text.UTF8Encoding($true) +[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom) +Info "Saved: $resolvedPath" + +# --- Auto-validate --- +if (-not $NoValidate) { + $validateScript = Join-Path (Join-Path $PSScriptRoot "..\..\interface-validate") "scripts\interface-validate.ps1" + $validateScript = [System.IO.Path]::GetFullPath($validateScript) + if (Test-Path $validateScript) { + Write-Host "" + Write-Host "--- Running interface-validate ---" + & powershell.exe -NoProfile -File $validateScript -CIPath $resolvedPath + } +} + +# --- Summary --- +Write-Host "" +Write-Host "=== interface-edit summary ===" +Write-Host " Added: $($script:addCount)" +Write-Host " Removed: $($script:removeCount)" +Write-Host " Modified: $($script:modifyCount)" +exit 0 diff --git a/.claude/skills/interface-validate/SKILL.md b/.claude/skills/interface-validate/SKILL.md new file mode 100644 index 00000000..8a438b13 --- /dev/null +++ b/.claude/skills/interface-validate/SKILL.md @@ -0,0 +1,82 @@ +--- +name: interface-validate +description: Валидация структурной корректности командного интерфейса 1С (CommandInterface.xml) — формат, секции, ссылки на команды, дубликаты +argument-hint: [-MaxErrors 30] +allowed-tools: + - Bash + - Read + - Glob +--- + +# /interface-validate — валидация CommandInterface.xml + +Проверяет XML командного интерфейса из выгрузки конфигурации на структурные ошибки: корневой элемент, допустимые секции, порядок, формат ссылок на команды, дубликаты. + +## Параметры + +| Параметр | Обязательный | По умолчанию | Описание | +|-----------|:------------:|--------------|------------------------------------| +| CIPath | да | — | Путь к CommandInterface.xml | +| MaxErrors | нет | 30 | Остановиться после N ошибок | +| OutFile | нет | — | Записать результат в файл (UTF-8 BOM) | + +## Команда + +```powershell +powershell.exe -NoProfile -File '.claude\skills\interface-validate\scripts\interface-validate.ps1' -CIPath '' +``` + +## Проверки (13) + +| # | Проверка | Серьёзность | +|----|--------------------------------------------------------------|-------------| +| 1 | XML well-formedness + root element (CommandInterface, version, namespace) | ERROR | +| 2 | Допустимые дочерние элементы (только 5 секций) | ERROR | +| 3 | Порядок секций корректен | ERROR | +| 4 | Нет дублирующихся секций | ERROR | +| 5 | CommandsVisibility — Command.name + Visibility/xr:Common | ERROR | +| 6 | CommandsVisibility — нет дубликатов по name | WARN | +| 7 | CommandsPlacement — Command.name + CommandGroup + Placement | ERROR | +| 8 | CommandsOrder — Command.name + CommandGroup | ERROR | +| 9 | SubsystemsOrder — Subsystem непустой, формат Subsystem.X | ERROR | +| 10 | SubsystemsOrder — нет дубликатов | WARN | +| 11 | GroupsOrder — Group непустой | ERROR | +| 12 | GroupsOrder — нет дубликатов | WARN | +| 13 | Формат ссылок на команды | WARN | + +## Вывод + +``` +=== Validation: CommandInterface (Продажи) === + +[OK] 1. Root structure: CommandInterface, version 2.17, namespace valid +[OK] 2. Child elements: 5 valid sections +[OK] 3. Section order: correct +[OK] 4. No duplicate sections +[OK] 5. CommandsVisibility: 55 entries, all valid +[OK] 6. CommandsVisibility: no duplicates +[OK] 7. CommandsPlacement: 3 entries, all valid +[OK] 8. CommandsOrder: 12 entries, all valid +[OK] 9. SubsystemsOrder: 9 entries, all valid format +[OK] 10. SubsystemsOrder: no duplicates +[OK] 11. GroupsOrder: 7 entries, all valid +[OK] 12. GroupsOrder: no duplicates +[OK] 13. Command reference format: all valid +--- +Errors: 0, Warnings: 0 +``` + +Код возврата: 0 = все проверки пройдены, 1 = есть ошибки. + +## Примеры + +```powershell +# CommandInterface подсистемы +... -CIPath upload/acc_8.3.24/Subsystems/Продажи/Ext/CommandInterface.xml + +# Корневой CommandInterface конфигурации +... -CIPath upload/acc_8.3.24/Ext/CommandInterface.xml + +# С лимитом ошибок +... -CIPath -MaxErrors 10 +``` diff --git a/.claude/skills/interface-validate/scripts/interface-validate.ps1 b/.claude/skills/interface-validate/scripts/interface-validate.ps1 new file mode 100644 index 00000000..5340761f --- /dev/null +++ b/.claude/skills/interface-validate/scripts/interface-validate.ps1 @@ -0,0 +1,382 @@ +# interface-validate v1.0 — Validate 1C CommandInterface.xml structure +# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +param( + [Parameter(Mandatory)][string]$CIPath, + [int]$MaxErrors = 30, + [string]$OutFile +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# --- Resolve path --- +if (-not [System.IO.Path]::IsPathRooted($CIPath)) { + $CIPath = Join-Path (Get-Location).Path $CIPath +} +if (-not (Test-Path $CIPath)) { + Write-Host "[ERROR] File not found: $CIPath" + exit 1 +} +$resolvedPath = (Resolve-Path $CIPath).Path + +# --- Derive context name from path --- +$contextName = "" +$parentParts = $resolvedPath -split '[/\\]' +for ($i = 0; $i -lt $parentParts.Count; $i++) { + if ($parentParts[$i] -eq "Subsystems" -and ($i + 1) -lt $parentParts.Count) { + $contextName = $parentParts[$i + 1] + } +} +if (-not $contextName) { $contextName = "Root" } + +# --- Output infrastructure --- +$script:errors = 0 +$script:warnings = 0 +$script:stopped = $false +$script:output = New-Object System.Text.StringBuilder 8192 +$script:allCommandNames = @() + +function Out-Line([string]$msg) { $script:output.AppendLine($msg) | Out-Null } +function Report-OK([string]$msg) { 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" +} + +Out-Line "=== Validation: CommandInterface ($contextName) ===" +Out-Line "" + +# --- Valid section names and order --- +$validSections = @("CommandsVisibility","CommandsPlacement","CommandsOrder","SubsystemsOrder","GroupsOrder") + +# Command reference patterns +$stdCmdPattern = '^[A-Za-z]+\.[^\s\.]+\.StandardCommand\.\w+$' +$customCmdPattern = '^[A-Za-z]+\.[^\s\.]+\.Command\.\w+$' +$commonCmdPattern = '^CommonCommand\.\w+$' +$uuidCmdPattern = '^0:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + +# --- 1. XML well-formedness + root structure --- +$xmlDoc = $null +try { + [xml]$xmlDoc = Get-Content -Path $resolvedPath -Encoding UTF8 +} catch { + Report-Error "1. XML parse error: $($_.Exception.Message)" + $script:stopped = $true +} + +if (-not $script:stopped) { + $root = $xmlDoc.DocumentElement + + if ($root.LocalName -ne "CommandInterface") { + Report-Error "1. Root element: expected , got <$($root.LocalName)>" + $script:stopped = $true + } else { + $nsUri = $root.NamespaceURI + $version = $root.GetAttribute("version") + $expectedNs = "http://v8.1c.ru/8.3/xcf/extrnprops" + if ($nsUri -ne $expectedNs) { + Report-Error "1. Root namespace: expected $expectedNs, got $nsUri" + } elseif (-not $version) { + Report-Warn "1. Root structure: CommandInterface, namespace valid, but no version attribute" + } else { + Report-OK "1. Root structure: CommandInterface, version $version, namespace valid" + } + } +} + +# --- Setup namespace manager --- +$ns = $null +if (-not $script:stopped) { + $ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable) + $ns.AddNamespace("ci", "http://v8.1c.ru/8.3/xcf/extrnprops") + $ns.AddNamespace("xr", "http://v8.1c.ru/8.3/xcf/readable") + $ns.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance") + $ns.AddNamespace("xs", "http://www.w3.org/2001/XMLSchema") +} + +# --- 2. Valid child elements --- +if (-not $script:stopped) { + $root = $xmlDoc.DocumentElement + $foundSections = @() + $invalidElements = @() + foreach ($child in $root.ChildNodes) { + if ($child.NodeType -ne 'Element') { continue } + if ($child.LocalName -in $validSections) { + $foundSections += $child.LocalName + } else { + $invalidElements += $child.LocalName + } + } + if ($invalidElements.Count -gt 0) { + Report-Error "2. Invalid child elements: $($invalidElements -join ', ')" + } else { + Report-OK "2. Child elements: $($foundSections.Count) valid sections" + } +} + +# --- 3. Section order --- +if (-not $script:stopped) { + $orderOk = $true + $lastIdx = -1 + foreach ($sec in $foundSections) { + $idx = [array]::IndexOf($validSections, $sec) + if ($idx -lt $lastIdx) { + Report-Error "3. Section order: '$sec' appears after a later section (expected: CommandsVisibility -> CommandsPlacement -> CommandsOrder -> SubsystemsOrder -> GroupsOrder)" + $orderOk = $false + break + } + $lastIdx = $idx + } + if ($orderOk) { Report-OK "3. Section order: correct" } +} + +# --- 4. No duplicate sections --- +if (-not $script:stopped) { + $dupes = $foundSections | Group-Object | Where-Object { $_.Count -gt 1 } + if ($dupes) { + $dupeNames = ($dupes | ForEach-Object { $_.Name }) -join ", " + Report-Error "4. Duplicate sections: $dupeNames" + } else { + Report-OK "4. No duplicate sections" + } +} + +# --- 5. CommandsVisibility --- +if (-not $script:stopped) { + $visSection = $root.SelectSingleNode("ci:CommandsVisibility", $ns) + if ($visSection) { + $visOk = $true + $visNames = @() + $visCount = 0 + foreach ($cmd in $visSection.ChildNodes) { + if ($cmd.NodeType -ne 'Element') { continue } + $visCount++ + $cmdName = $cmd.GetAttribute("name") + if (-not $cmdName) { + Report-Error "5. CommandsVisibility: Command element without 'name' attribute" + $visOk = $false; continue + } + $visNames += $cmdName + $script:allCommandNames += $cmdName + $visibility = $cmd.SelectSingleNode("ci:Visibility", $ns) + if (-not $visibility) { + Report-Error "5. CommandsVisibility[$cmdName]: missing " + $visOk = $false; continue + } + $common = $visibility.SelectSingleNode("xr:Common", $ns) + if (-not $common) { + Report-Error "5. CommandsVisibility[$cmdName]: missing " + $visOk = $false; continue + } + $val = $common.InnerText.Trim() + if ($val -ne "true" -and $val -ne "false") { + Report-Error "5. CommandsVisibility[$cmdName]: xr:Common='$val' (expected true/false)" + $visOk = $false + } + } + if ($visOk) { Report-OK "5. CommandsVisibility: $visCount entries, all valid" } + } else { + Report-OK "5. CommandsVisibility: not present" + $visNames = @() + } +} + +# --- 6. CommandsVisibility duplicates --- +if (-not $script:stopped) { + if ($visNames.Count -gt 0) { + $dupes = $visNames | Group-Object | Where-Object { $_.Count -gt 1 } + if ($dupes) { + $dupeNames = ($dupes | ForEach-Object { $_.Name }) -join ", " + Report-Warn "6. CommandsVisibility: duplicates: $dupeNames" + } else { + Report-OK "6. CommandsVisibility: no duplicates" + } + } else { + Report-OK "6. CommandsVisibility: no duplicates (empty)" + } +} + +# --- 7. CommandsPlacement --- +if (-not $script:stopped) { + $plcSection = $root.SelectSingleNode("ci:CommandsPlacement", $ns) + if ($plcSection) { + $plcOk = $true + $plcCount = 0 + foreach ($cmd in $plcSection.ChildNodes) { + if ($cmd.NodeType -ne 'Element') { continue } + $plcCount++ + $cmdName = $cmd.GetAttribute("name") + if (-not $cmdName) { + Report-Error "7. CommandsPlacement: Command without 'name' attribute" + $plcOk = $false; continue + } + $script:allCommandNames += $cmdName + $grpEl = $cmd.SelectSingleNode("ci:CommandGroup", $ns) + if (-not $grpEl -or -not $grpEl.InnerText.Trim()) { + Report-Error "7. CommandsPlacement[$cmdName]: missing or empty " + $plcOk = $false; continue + } + $placementEl = $cmd.SelectSingleNode("ci:Placement", $ns) + if (-not $placementEl) { + Report-Error "7. CommandsPlacement[$cmdName]: missing " + $plcOk = $false + } elseif ($placementEl.InnerText.Trim() -ne "Auto") { + Report-Warn "7. CommandsPlacement[$cmdName]: Placement='$($placementEl.InnerText.Trim())' (expected Auto)" + } + } + if ($plcOk) { Report-OK "7. CommandsPlacement: $plcCount entries, all valid" } + } else { + Report-OK "7. CommandsPlacement: not present" + } +} + +# --- 8. CommandsOrder --- +if (-not $script:stopped) { + $ordSection = $root.SelectSingleNode("ci:CommandsOrder", $ns) + if ($ordSection) { + $ordOk = $true + $ordCount = 0 + foreach ($cmd in $ordSection.ChildNodes) { + if ($cmd.NodeType -ne 'Element') { continue } + $ordCount++ + $cmdName = $cmd.GetAttribute("name") + if (-not $cmdName) { + Report-Error "8. CommandsOrder: Command without 'name' attribute" + $ordOk = $false; continue + } + $script:allCommandNames += $cmdName + $grpEl = $cmd.SelectSingleNode("ci:CommandGroup", $ns) + if (-not $grpEl -or -not $grpEl.InnerText.Trim()) { + Report-Error "8. CommandsOrder[$cmdName]: missing or empty " + $ordOk = $false + } + } + if ($ordOk) { Report-OK "8. CommandsOrder: $ordCount entries, all valid" } + } else { + Report-OK "8. CommandsOrder: not present" + } +} + +# --- 9. SubsystemsOrder format --- +if (-not $script:stopped) { + $subSection = $root.SelectSingleNode("ci:SubsystemsOrder", $ns) + $subNames = @() + if ($subSection) { + $subOk = $true + $subCount = 0 + foreach ($sub in $subSection.ChildNodes) { + if ($sub.NodeType -ne 'Element') { continue } + $subCount++ + $text = $sub.InnerText.Trim() + $subNames += $text + if (-not $text) { + Report-Error "9. SubsystemsOrder: empty element" + $subOk = $false + } elseif ($text -notmatch '^Subsystem\.') { + Report-Error "9. SubsystemsOrder: '$text' - expected format Subsystem.X..." + $subOk = $false + } + } + if ($subOk) { Report-OK "9. SubsystemsOrder: $subCount entries, all valid format" } + } else { + Report-OK "9. SubsystemsOrder: not present" + } +} + +# --- 10. SubsystemsOrder duplicates --- +if (-not $script:stopped) { + if ($subNames.Count -gt 0) { + $dupes = $subNames | Group-Object | Where-Object { $_.Count -gt 1 } + if ($dupes) { + $dupeNames = ($dupes | ForEach-Object { $_.Name }) -join ", " + Report-Warn "10. SubsystemsOrder: duplicates: $dupeNames" + } else { + Report-OK "10. SubsystemsOrder: no duplicates" + } + } else { + Report-OK "10. SubsystemsOrder: no duplicates (empty)" + } +} + +# --- 11. GroupsOrder entries --- +if (-not $script:stopped) { + $grpSection = $root.SelectSingleNode("ci:GroupsOrder", $ns) + $grpNames = @() + if ($grpSection) { + $grpOk = $true + $grpCount = 0 + foreach ($grp in $grpSection.ChildNodes) { + if ($grp.NodeType -ne 'Element') { continue } + $grpCount++ + $text = $grp.InnerText.Trim() + $grpNames += $text + if (-not $text) { + Report-Error "11. GroupsOrder: empty element" + $grpOk = $false + } + } + if ($grpOk) { Report-OK "11. GroupsOrder: $grpCount entries, all valid" } + } else { + Report-OK "11. GroupsOrder: not present" + } +} + +# --- 12. GroupsOrder duplicates --- +if (-not $script:stopped) { + if ($grpNames.Count -gt 0) { + $dupes = $grpNames | Group-Object | Where-Object { $_.Count -gt 1 } + if ($dupes) { + $dupeNames = ($dupes | ForEach-Object { $_.Name }) -join ", " + Report-Warn "12. GroupsOrder: duplicates: $dupeNames" + } else { + Report-OK "12. GroupsOrder: no duplicates" + } + } else { + Report-OK "12. GroupsOrder: no duplicates (empty)" + } +} + +# --- 13. Command reference format --- +if (-not $script:stopped) { + if ($script:allCommandNames.Count -gt 0) { + $badRefs = @() + foreach ($ref in $script:allCommandNames) { + if ($ref -match $stdCmdPattern) { continue } + if ($ref -match $customCmdPattern) { continue } + if ($ref -match $commonCmdPattern) { continue } + if ($ref -match $uuidCmdPattern) { continue } + $badRefs += $ref + } + if ($badRefs.Count -eq 0) { + Report-OK "13. Command reference format: all $($script:allCommandNames.Count) valid" + } else { + $shown = $badRefs[0..([Math]::Min(4, $badRefs.Count - 1))] + Report-Warn "13. Command reference format: $($badRefs.Count) unrecognized: $($shown -join ', ')$(if($badRefs.Count -gt 5){' ...'})" + } + } else { + Report-OK "13. Command reference format: n/a (no commands)" + } +} + +# --- Finalize --- +Out-Line "---" +Out-Line "Errors: $($script:errors), Warnings: $($script:warnings)" + +$result = $script:output.ToString() +Write-Host $result + +if ($OutFile) { + if (-not [System.IO.Path]::IsPathRooted($OutFile)) { + $OutFile = Join-Path (Get-Location).Path $OutFile + } + $utf8Bom = New-Object System.Text.UTF8Encoding($true) + [System.IO.File]::WriteAllText($OutFile, $result, $utf8Bom) + Write-Host "Written to: $OutFile" +} + +if ($script:errors -gt 0) { exit 1 } else { exit 0 } diff --git a/.claude/skills/subsystem-compile/SKILL.md b/.claude/skills/subsystem-compile/SKILL.md new file mode 100644 index 00000000..5f20f426 --- /dev/null +++ b/.claude/skills/subsystem-compile/SKILL.md @@ -0,0 +1,65 @@ +--- +name: subsystem-compile +description: Создать подсистему 1С — XML-исходники из JSON-определения. Используй когда пользователь просит добавить подсистему (раздел) в конфигурацию +argument-hint: [-DefinitionFile | -Value ] -OutputDir [-Parent ] +allowed-tools: + - Bash + - Read + - Write + - Glob +--- + +# /subsystem-compile — генерация подсистемы из JSON + +Принимает JSON-определение подсистемы → генерирует XML + файловую структуру + регистрирует в родителе (Configuration.xml или родительская подсистема). + +## Параметры и команда + +| Параметр | Описание | +|----------|----------| +| `DefinitionFile` | Путь к JSON-файлу определения | +| `Value` | Инлайн JSON-строка (альтернатива DefinitionFile) | +| `OutputDir` | Корень выгрузки (где `Subsystems/`, `Configuration.xml`) | +| `Parent` | Путь к XML родительской подсистемы (для вложенных) | +| `NoValidate` | Пропустить авто-валидацию | + +```powershell +powershell.exe -NoProfile -File '.claude\skills\subsystem-compile\scripts\subsystem-compile.ps1' -Value '' -OutputDir '' +``` + +## JSON-определение + +```json +{ + "name": "МояПодсистема", + "synonym": "Моя подсистема", + "comment": "", + "includeInCommandInterface": true, + "useOneCommand": false, + "explanation": "Описание раздела", + "picture": "CommonPicture.МояКартинка", + "content": ["Catalog.Товары", "Document.Заказ"], + "children": ["ДочерняяА", "ДочерняяБ"] +} +``` + +Минимально: только `name`. Остальное — дефолты. + +## Примеры + +```powershell +# Минимальная подсистема +... -Value '{"name":"Тест"}' -OutputDir config/ + +# С составом и картинкой +... -Value '{"name":"Продажи","content":["Catalog.Товары","Report.Продажи"],"picture":"CommonPicture.Продажи"}' -OutputDir config/ + +# Вложенная подсистема +... -Value '{"name":"Дочерняя"}' -OutputDir config/ -Parent config/Subsystems/Продажи.xml +``` + +## Что генерируется + +- `{OutputDir}/Subsystems/{Name}.xml` — определение подсистемы +- `{OutputDir}/Subsystems/{Name}/` — каталог (если есть children) +- `Configuration.xml` или родительская подсистема — регистрация в `` diff --git a/.claude/skills/subsystem-compile/scripts/subsystem-compile.ps1 b/.claude/skills/subsystem-compile/scripts/subsystem-compile.ps1 new file mode 100644 index 00000000..4c6774d3 --- /dev/null +++ b/.claude/skills/subsystem-compile/scripts/subsystem-compile.ps1 @@ -0,0 +1,338 @@ +# subsystem-compile v1.0 — Create 1C subsystem from JSON definition +# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +param( + [string]$DefinitionFile, + [string]$Value, + [Parameter(Mandatory)][string]$OutputDir, + [string]$Parent, + [switch]$NoValidate +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# --- 1. Load JSON --- +if ($DefinitionFile -and $Value) { + Write-Error "Cannot use both -DefinitionFile and -Value" + exit 1 +} +if (-not $DefinitionFile -and -not $Value) { + Write-Error "Either -DefinitionFile or -Value is required" + exit 1 +} + +if ($DefinitionFile) { + if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) { + $DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile + } + if (-not (Test-Path $DefinitionFile)) { + Write-Error "Definition file not found: $DefinitionFile" + exit 1 + } + $json = Get-Content -Raw -Encoding UTF8 $DefinitionFile +} else { + $json = $Value +} + +$def = $json | ConvertFrom-Json + +if (-not $def.name) { + Write-Error "JSON must have 'name' field" + exit 1 +} + +$objName = "$($def.name)" + +# Resolve OutputDir +if (-not [System.IO.Path]::IsPathRooted($OutputDir)) { + $OutputDir = Join-Path (Get-Location).Path $OutputDir +} + +# --- 2. XML helpers --- +$script:xml = New-Object System.Text.StringBuilder 8192 + +function X([string]$text) { + $script:xml.AppendLine($text) | Out-Null +} + +function Esc-Xml([string]$s) { + return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"') +} + +function Split-CamelCase([string]$name) { + if (-not $name) { return $name } + $result = [regex]::Replace($name, '([a-z\u0430-\u044F\u0451])([A-Z\u0410-\u042F\u0401])', '$1 $2') + if ($result.Length -gt 1) { + $result = $result.Substring(0,1) + $result.Substring(1).ToLower() + } + return $result +} + +function Emit-MLText([string]$indent, [string]$tag, [string]$text) { + if (-not $text) { + X "$indent<$tag/>" + return + } + X "$indent<$tag>" + X "$indent`t" + X "$indent`t`tru" + X "$indent`t`t$(Esc-Xml $text)" + X "$indent`t" + X "$indent" +} + +function New-Guid-String { + return [System.Guid]::NewGuid().ToString() +} + +# --- 3. Resolve defaults --- +$synonym = if ($def.synonym) { "$($def.synonym)" } else { Split-CamelCase $objName } +$comment = if ($def.comment) { "$($def.comment)" } else { "" } +$includeHelpInContents = "true" +$includeInCI = if ($null -ne $def.includeInCommandInterface) { "$($def.includeInCommandInterface)".ToLower() } else { "true" } +$useOneCommand = if ($null -ne $def.useOneCommand) { "$($def.useOneCommand)".ToLower() } else { "false" } +$explanation = if ($def.explanation) { "$($def.explanation)" } else { "" } +$picture = if ($def.picture) { "$($def.picture)" } else { "" } + +$contentItems = @() +if ($def.content) { + foreach ($c in $def.content) { $contentItems += "$c" } +} + +$children = @() +if ($def.children) { + foreach ($ch in $def.children) { $children += "$ch" } +} + +# --- 4. Build XML --- +$uuid = New-Guid-String +$indent = "`t`t`t" + +X '' +X '' +X "`t" +X "`t`t" + +# Name +X "`t`t`t$(Esc-Xml $objName)" + +# Synonym +Emit-MLText "`t`t`t" "Synonym" $synonym + +# Comment +if ($comment) { + X "`t`t`t$(Esc-Xml $comment)" +} else { + X "`t`t`t" +} + +# Boolean properties +X "`t`t`t$includeHelpInContents" +X "`t`t`t$includeInCI" +X "`t`t`t$useOneCommand" + +# Explanation +Emit-MLText "`t`t`t" "Explanation" $explanation + +# Picture +if ($picture) { + X "`t`t`t" + X "`t`t`t`t$picture" + X "`t`t`t`tfalse" + X "`t`t`t" +} else { + X "`t`t`t" +} + +# Content +if ($contentItems.Count -gt 0) { + X "`t`t`t" + foreach ($item in $contentItems) { + X "`t`t`t`t$(Esc-Xml $item)" + } + X "`t`t`t" +} else { + X "`t`t`t" +} + +X "`t`t" + +# ChildObjects +if ($children.Count -gt 0) { + X "`t`t" + foreach ($ch in $children) { + X "`t`t`t$(Esc-Xml $ch)" + } + X "`t`t" +} else { + X "`t`t" +} + +X "`t" +X '' + +# --- 5. Write files --- + +# Determine target directory +if ($Parent) { + # Nested subsystem + if (-not [System.IO.Path]::IsPathRooted($Parent)) { + $Parent = Join-Path (Get-Location).Path $Parent + } + if (-not (Test-Path $Parent)) { + Write-Error "Parent subsystem not found: $Parent" + exit 1 + } + $parentDir = [System.IO.Path]::GetDirectoryName($Parent) + $parentBaseName = [System.IO.Path]::GetFileNameWithoutExtension($Parent) + $subsDir = Join-Path (Join-Path $parentDir $parentBaseName) "Subsystems" +} else { + # Top-level subsystem + $subsDir = Join-Path $OutputDir "Subsystems" +} + +if (-not (Test-Path $subsDir)) { + New-Item -ItemType Directory -Path $subsDir -Force | Out-Null +} + +$targetXml = Join-Path $subsDir "$objName.xml" + +# Write XML +$xmlContent = $script:xml.ToString() +$utf8Bom = New-Object System.Text.UTF8Encoding($true) +[System.IO.File]::WriteAllText($targetXml, $xmlContent, $utf8Bom) +Write-Host "[OK] Created: $targetXml" + +# Create subdirectory if children exist +if ($children.Count -gt 0) { + $childSubsDir = Join-Path (Join-Path $subsDir $objName) "Subsystems" + if (-not (Test-Path $childSubsDir)) { + New-Item -ItemType Directory -Path $childSubsDir -Force | Out-Null + Write-Host "[OK] Created directory: $childSubsDir" + } +} + +# --- 6. Register in parent --- +$parentXmlPath = $null +if ($Parent) { + $parentXmlPath = $Parent +} else { + $configXml = Join-Path $OutputDir "Configuration.xml" + if (Test-Path $configXml) { + $parentXmlPath = $configXml + } +} + +if ($parentXmlPath -and (Test-Path $parentXmlPath)) { + $doc = New-Object System.Xml.XmlDocument + $doc.PreserveWhitespace = $true + $doc.Load($parentXmlPath) + + $ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable) + $ns.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses") + + # Find ChildObjects + $childObjects = $null + if ($Parent) { + $childObjects = $doc.SelectSingleNode("//md:Subsystem/md:ChildObjects", $ns) + } else { + $childObjects = $doc.SelectSingleNode("//md:Configuration/md:ChildObjects", $ns) + } + + if ($childObjects) { + # Check for self-closing tag + $isSelfClosing = (-not $childObjects.HasChildNodes) -or ($childObjects.IsEmpty) + + # Check if already registered + $alreadyExists = $false + foreach ($child in $childObjects.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Subsystem" -and $child.InnerText -eq $objName) { + $alreadyExists = $true + break + } + } + + if (-not $alreadyExists) { + $newEl = $doc.CreateElement("Subsystem", "http://v8.1c.ru/8.3/MDClasses") + $newEl.InnerText = $objName + + if ($isSelfClosing) { + # Expand self-closing tag + $parentIndent = "" + $prev = $childObjects.PreviousSibling + if ($prev -and ($prev.NodeType -eq 'Whitespace' -or $prev.NodeType -eq 'SignificantWhitespace')) { + if ($prev.Value -match '(\t+)$') { $parentIndent = $Matches[1] } + } + $childIndent = "$parentIndent`t" + $ws1 = $doc.CreateWhitespace("`r`n$childIndent") + $ws2 = $doc.CreateWhitespace("`r`n$parentIndent") + $childObjects.AppendChild($ws1) | Out-Null + $childObjects.AppendChild($newEl) | Out-Null + $childObjects.AppendChild($ws2) | Out-Null + } else { + # Insert before trailing whitespace + $childIndent = "`t`t`t" + foreach ($child in $childObjects.ChildNodes) { + if ($child.NodeType -eq 'Whitespace' -or $child.NodeType -eq 'SignificantWhitespace') { + if ($child.Value -match '^\r?\n(\t+)') { $childIndent = $Matches[1]; break } + } + } + $trailing = $childObjects.LastChild + $ws = $doc.CreateWhitespace("`r`n$childIndent") + if ($trailing -and ($trailing.NodeType -eq 'Whitespace' -or $trailing.NodeType -eq 'SignificantWhitespace')) { + $childObjects.InsertBefore($ws, $trailing) | Out-Null + $childObjects.InsertBefore($newEl, $trailing) | Out-Null + } else { + $childObjects.AppendChild($ws) | Out-Null + $childObjects.AppendChild($newEl) | Out-Null + } + } + + # Save parent XML + $settings = New-Object System.Xml.XmlWriterSettings + $settings.Encoding = New-Object System.Text.UTF8Encoding($true) + $settings.Indent = $false + $settings.NewLineHandling = [System.Xml.NewLineHandling]::None + + $memStream = New-Object System.IO.MemoryStream + $writer = [System.Xml.XmlWriter]::Create($memStream, $settings) + $doc.Save($writer) + $writer.Flush(); $writer.Close() + + $bytes = $memStream.ToArray() + $memStream.Close() + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) } + $text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"') + [System.IO.File]::WriteAllText($parentXmlPath, $text, $utf8Bom) + + Write-Host "[OK] Registered in: $parentXmlPath" + } else { + Write-Host "[SKIP] Already registered in: $parentXmlPath" + } + } else { + Write-Host "[WARN] ChildObjects not found in: $parentXmlPath" + } +} else { + Write-Host "[INFO] No parent XML to register in" +} + +# --- 7. Auto-validate --- +if (-not $NoValidate) { + $validateScript = Join-Path (Join-Path $PSScriptRoot "..\..\subsystem-validate") "scripts\subsystem-validate.ps1" + $validateScript = [System.IO.Path]::GetFullPath($validateScript) + if (Test-Path $validateScript) { + Write-Host "" + Write-Host "--- Running subsystem-validate ---" + & powershell.exe -NoProfile -File $validateScript -SubsystemPath $targetXml + } +} + +Write-Host "" +Write-Host "=== subsystem-compile summary ===" +Write-Host " Name: $objName" +Write-Host " UUID: $uuid" +Write-Host " Content: $($contentItems.Count) objects" +Write-Host " Children: $($children.Count)" +Write-Host " File: $targetXml" +exit 0 diff --git a/.claude/skills/subsystem-edit/SKILL.md b/.claude/skills/subsystem-edit/SKILL.md new file mode 100644 index 00000000..05b2d208 --- /dev/null +++ b/.claude/skills/subsystem-edit/SKILL.md @@ -0,0 +1,57 @@ +--- +name: subsystem-edit +description: Точечное редактирование подсистемы 1С — добавление/удаление объектов в Content, дочерних подсистем, изменение свойств +argument-hint: -SubsystemPath -Operation -Value +allowed-tools: + - Bash + - Read + - Write + - Glob +--- + +# /subsystem-edit — редактирование подсистемы 1С + +Точечное редактирование XML подсистемы: состав, дочерние подсистемы, свойства. + +## Параметры и команда + +| Параметр | Описание | +|----------|----------| +| `SubsystemPath` | Путь к XML-файлу подсистемы | +| `DefinitionFile` | JSON-файл с массивом операций | +| `Operation` | Одна операция (альтернатива DefinitionFile) | +| `Value` | Значение для операции | +| `NoValidate` | Пропустить авто-валидацию | + +```powershell +powershell.exe -NoProfile -File '.claude\skills\subsystem-edit\scripts\subsystem-edit.ps1' -SubsystemPath '' -Operation add-content -Value 'Catalog.Товары' +``` + +## Операции + +| Операция | Значение | Описание | +|----------|----------|----------| +| `add-content` | `"Catalog.X"` или `["Catalog.X","Document.Y"]` | Добавить объекты в Content | +| `remove-content` | `"Catalog.X"` или `["Catalog.X"]` | Удалить объекты из Content | +| `add-child` | `"ИмяПодсистемы"` | Добавить дочернюю подсистему в ChildObjects | +| `remove-child` | `"ИмяПодсистемы"` | Удалить дочернюю подсистему | +| `set-property` | `{"name":"prop","value":"val"}` | Изменить свойство (Synonym, IncludeInCommandInterface, UseOneCommand, etc.) | + +## Примеры + +```powershell +# Добавить объект в состав +... -SubsystemPath Subsystems/Продажи.xml -Operation add-content -Value "Document.Заказ" + +# Добавить несколько объектов +... -SubsystemPath Subsystems/Продажи.xml -Operation add-content -Value '["Catalog.Товары","Report.Продажи"]' + +# Удалить объект из состава +... -SubsystemPath Subsystems/Продажи.xml -Operation remove-content -Value "Report.Старый" + +# Добавить дочернюю подсистему +... -SubsystemPath Subsystems/Продажи.xml -Operation add-child -Value "НоваяДочерняя" + +# Изменить свойство +... -SubsystemPath Subsystems/Продажи.xml -Operation set-property -Value '{"name":"IncludeInCommandInterface","value":"false"}' +``` diff --git a/.claude/skills/subsystem-edit/scripts/subsystem-edit.ps1 b/.claude/skills/subsystem-edit/scripts/subsystem-edit.ps1 new file mode 100644 index 00000000..4769f47d --- /dev/null +++ b/.claude/skills/subsystem-edit/scripts/subsystem-edit.ps1 @@ -0,0 +1,403 @@ +# subsystem-edit v1.0 — Edit existing 1C subsystem XML +# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +param( + [Parameter(Mandatory)][string]$SubsystemPath, + [string]$DefinitionFile, + [ValidateSet("add-content","remove-content","add-child","remove-child","set-property")] + [string]$Operation, + [string]$Value, + [switch]$NoValidate +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# --- Mode validation --- +if ($DefinitionFile -and $Operation) { Write-Error "Cannot use both -DefinitionFile and -Operation"; exit 1 } +if (-not $DefinitionFile -and -not $Operation) { Write-Error "Either -DefinitionFile or -Operation is required"; exit 1 } + +# --- Resolve path --- +if (-not [System.IO.Path]::IsPathRooted($SubsystemPath)) { + $SubsystemPath = Join-Path (Get-Location).Path $SubsystemPath +} +if (Test-Path $SubsystemPath -PathType Container) { + $dirName = Split-Path $SubsystemPath -Leaf + $candidate = Join-Path $SubsystemPath "$dirName.xml" + if (Test-Path $candidate) { $SubsystemPath = $candidate } + else { Write-Error "No $dirName.xml found in directory"; exit 1 } +} +if (-not (Test-Path $SubsystemPath)) { Write-Error "File not found: $SubsystemPath"; exit 1 } +$resolvedPath = (Resolve-Path $SubsystemPath).Path + +# --- Load XML with PreserveWhitespace --- +$script:xmlDoc = New-Object System.Xml.XmlDocument +$script:xmlDoc.PreserveWhitespace = $true +$script:xmlDoc.Load($resolvedPath) + +$script:addCount = 0 +$script:removeCount = 0 +$script:modifyCount = 0 + +function Info([string]$msg) { Write-Host "[INFO] $msg" } +function Warn([string]$msg) { Write-Host "[WARN] $msg" } + +# --- Detect structure --- +$root = $script:xmlDoc.DocumentElement +$script:mdNs = "http://v8.1c.ru/8.3/MDClasses" +$script:xrNs = "http://v8.1c.ru/8.3/xcf/readable" +$script:xsiNs = "http://www.w3.org/2001/XMLSchema-instance" +$script:v8Ns = "http://v8.1c.ru/8.1/data/core" + +$script:sub = $null +foreach ($child in $root.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Subsystem") { + $script:sub = $child; break + } +} +if (-not $script:sub) { Write-Error "No element found"; exit 1 } + +$script:propsEl = $null +$script:childObjsEl = $null +foreach ($child in $script:sub.ChildNodes) { + if ($child.NodeType -ne 'Element') { continue } + if ($child.LocalName -eq "Properties") { $script:propsEl = $child } + if ($child.LocalName -eq "ChildObjects") { $script:childObjsEl = $child } +} + +$script:objName = "" +foreach ($child in $script:propsEl.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Name") { + $script:objName = $child.InnerText.Trim(); break + } +} +Info "Subsystem: $($script:objName)" + +# --- XML manipulation helpers (from meta-edit pattern) --- +function Import-Fragment([string]$xmlString) { + $wrapper = "<_W xmlns=`"$($script:mdNs)`" xmlns:xsi=`"$($script:xsiNs)`" xmlns:v8=`"$($script:v8Ns)`" xmlns:xr=`"$($script:xrNs)`" xmlns:xs=`"http://www.w3.org/2001/XMLSchema`">$xmlString" + $frag = New-Object System.Xml.XmlDocument + $frag.PreserveWhitespace = $true + $frag.LoadXml($wrapper) + $nodes = @() + foreach ($child in $frag.DocumentElement.ChildNodes) { + if ($child.NodeType -eq 'Element') { + $nodes += $script:xmlDoc.ImportNode($child, $true) + } + } + return ,$nodes +} + +function Get-ChildIndent($container) { + foreach ($child in $container.ChildNodes) { + if ($child.NodeType -eq 'Whitespace' -or $child.NodeType -eq 'SignificantWhitespace') { + if ($child.Value -match '^\r?\n(\t+)$') { return $Matches[1] } + if ($child.Value -match '^\r?\n(\t+)') { return $Matches[1] } + } + } + $depth = 0; $current = $container + while ($current -and $current -ne $script:xmlDoc.DocumentElement) { $depth++; $current = $current.ParentNode } + return "`t" * ($depth + 1) +} + +function Insert-BeforeElement($container, $newNode, $refNode, $childIndent) { + $ws = $script:xmlDoc.CreateWhitespace("`r`n$childIndent") + if ($refNode) { + $container.InsertBefore($ws, $refNode) | Out-Null + $container.InsertBefore($newNode, $ws) | Out-Null + } else { + $trailing = $container.LastChild + if ($trailing -and ($trailing.NodeType -eq 'Whitespace' -or $trailing.NodeType -eq 'SignificantWhitespace')) { + $container.InsertBefore($ws, $trailing) | Out-Null + $container.InsertBefore($newNode, $trailing) | Out-Null + } else { + $container.AppendChild($ws) | Out-Null + $container.AppendChild($newNode) | Out-Null + $parentIndent = if ($childIndent.Length -gt 1) { $childIndent.Substring(0, $childIndent.Length - 1) } else { "" } + $closeWs = $script:xmlDoc.CreateWhitespace("`r`n$parentIndent") + $container.AppendChild($closeWs) | Out-Null + } + } +} + +function Remove-NodeWithWhitespace($node) { + $parent = $node.ParentNode + $prev = $node.PreviousSibling + $next = $node.NextSibling + if ($prev -and ($prev.NodeType -eq 'Whitespace' -or $prev.NodeType -eq 'SignificantWhitespace')) { + $parent.RemoveChild($prev) | Out-Null + } elseif ($next -and ($next.NodeType -eq 'Whitespace' -or $next.NodeType -eq 'SignificantWhitespace')) { + $parent.RemoveChild($next) | Out-Null + } + $parent.RemoveChild($node) | Out-Null +} + +function Expand-SelfClosingElement($container, $parentIndent) { + # If the element is self-closing (empty), add whitespace for children + if (-not $container.HasChildNodes -or $container.IsEmpty) { + $childIndent = "$parentIndent`t" + # The element is self-closing; we need to add something to make it non-empty + # Adding a whitespace node will force opening+closing tags + $closeWs = $script:xmlDoc.CreateWhitespace("`r`n$parentIndent") + $container.AppendChild($closeWs) | Out-Null + } +} + +# --- Parse value: string or JSON array --- +function Parse-ValueList([string]$val) { + $val = $val.Trim() + if ($val.StartsWith("[")) { + $arr = $val | ConvertFrom-Json + $result = @(); foreach ($item in $arr) { $result += "$item" } + return ,$result + } + return @($val) +} + +# --- Operations --- +function Do-AddContent([string[]]$items) { + $contentEl = $null + foreach ($child in $script:propsEl.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Content") { + $contentEl = $child; break + } + } + if (-not $contentEl) { Write-Error "No element found"; exit 1 } + + # Get existing items for dedup + $existing = @() + foreach ($child in $contentEl.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Item") { + $existing += $child.InnerText.Trim() + } + } + + # Determine indentation + $propsIndent = Get-ChildIndent $script:propsEl + $contentIndent = "$propsIndent`t" + + # Expand self-closing if needed + if (-not $contentEl.HasChildNodes -or $contentEl.IsEmpty) { + Expand-SelfClosingElement $contentEl $propsIndent + $contentIndent = "$propsIndent`t" + } else { + $contentIndent = Get-ChildIndent $contentEl + } + + foreach ($item in $items) { + if ($item -in $existing) { + Warn "Content already contains: $item" + continue + } + $fragXml = "$item" + $nodes = Import-Fragment $fragXml + if ($nodes.Count -gt 0) { + Insert-BeforeElement $contentEl $nodes[0] $null $contentIndent + $script:addCount++ + Info "Added content: $item" + } + } +} + +function Do-RemoveContent([string[]]$items) { + $contentEl = $null + foreach ($child in $script:propsEl.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Content") { + $contentEl = $child; break + } + } + if (-not $contentEl) { Write-Error "No element found"; exit 1 } + + foreach ($item in $items) { + $found = $false + foreach ($child in @($contentEl.ChildNodes)) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Item" -and $child.InnerText.Trim() -eq $item) { + Remove-NodeWithWhitespace $child + $script:removeCount++ + Info "Removed content: $item" + $found = $true + break + } + } + if (-not $found) { Warn "Content item not found: $item" } + } +} + +function Do-AddChild([string]$childName) { + if (-not $script:childObjsEl) { Write-Error "No element found"; exit 1 } + + # Dedup check + foreach ($child in $script:childObjsEl.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Subsystem" -and $child.InnerText.Trim() -eq $childName) { + Warn "ChildObjects already contains: $childName" + return + } + } + + $subIndent = Get-ChildIndent $script:sub + if (-not $script:childObjsEl.HasChildNodes -or $script:childObjsEl.IsEmpty) { + Expand-SelfClosingElement $script:childObjsEl $subIndent + } + $childIndent = Get-ChildIndent $script:childObjsEl + + $newEl = $script:xmlDoc.CreateElement("Subsystem", $script:mdNs) + $newEl.InnerText = $childName + Insert-BeforeElement $script:childObjsEl $newEl $null $childIndent + $script:addCount++ + Info "Added child subsystem: $childName" +} + +function Do-RemoveChild([string]$childName) { + if (-not $script:childObjsEl) { Write-Error "No element found"; exit 1 } + + $found = $false + foreach ($child in @($script:childObjsEl.ChildNodes)) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Subsystem" -and $child.InnerText.Trim() -eq $childName) { + Remove-NodeWithWhitespace $child + $script:removeCount++ + Info "Removed child subsystem: $childName" + $found = $true + break + } + } + if (-not $found) { Warn "Child subsystem not found: $childName" } +} + +function Do-SetProperty([string]$jsonVal) { + $propDef = $jsonVal | ConvertFrom-Json + $propName = "$($propDef.name)" + $propValue = "$($propDef.value)" + + $propEl = $null + foreach ($child in $script:propsEl.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq $propName) { + $propEl = $child; break + } + } + if (-not $propEl) { + Write-Error "Property '$propName' not found in Properties" + exit 1 + } + + $boolProps = @("IncludeInCommandInterface","UseOneCommand","IncludeHelpInContents") + if ($propName -in $boolProps) { + $propEl.InnerText = $propValue.ToLower() + $script:modifyCount++ + Info "Set $propName = $propValue" + return + } + + $mlProps = @("Synonym","Explanation") + if ($propName -in $mlProps) { + if (-not $propValue) { + # Clear - make self-closing + $propEl.InnerXml = "" + $script:modifyCount++ + Info "Cleared $propName" + } else { + $indent = Get-ChildIndent $script:propsEl + $mlXml = "`r`n$indent`t`r`n$indent`t`tru`r`n$indent`t`t$([System.Security.SecurityElement]::Escape($propValue))`r`n$indent`t`r`n$indent" + $propEl.InnerXml = $mlXml + $script:modifyCount++ + Info "Set $propName = `"$propValue`"" + } + return + } + + if ($propName -eq "Comment") { + if (-not $propValue) { $propEl.InnerXml = "" } + else { $propEl.InnerText = $propValue } + $script:modifyCount++ + Info "Set Comment = `"$propValue`"" + return + } + + if ($propName -eq "Picture") { + if (-not $propValue) { + $propEl.InnerXml = "" + } else { + $indent = Get-ChildIndent $script:propsEl + $picXml = "`r`n$indent`t$propValue`r`n$indent`tfalse`r`n$indent" + $propEl.InnerXml = $picXml + } + $script:modifyCount++ + Info "Set Picture = `"$propValue`"" + return + } + + # Generic text property + $propEl.InnerText = $propValue + $script:modifyCount++ + Info "Set $propName = `"$propValue`"" +} + +# --- Execute operations --- +$operations = @() +if ($DefinitionFile) { + if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) { + $DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile + } + $jsonText = Get-Content -Raw -Encoding UTF8 $DefinitionFile + $ops = $jsonText | ConvertFrom-Json + if ($ops -is [System.Array]) { + foreach ($op in $ops) { $operations += $op } + } else { + $operations += $ops + } +} else { + $operations += @{ operation = $Operation; value = $Value } +} + +foreach ($op in $operations) { + $opName = if ($op.operation) { "$($op.operation)" } else { "$Operation" } + $opValue = if ($op.value) { "$($op.value)" } else { "$Value" } + + switch ($opName) { + "add-content" { Do-AddContent (Parse-ValueList $opValue) } + "remove-content" { Do-RemoveContent (Parse-ValueList $opValue) } + "add-child" { Do-AddChild $opValue } + "remove-child" { Do-RemoveChild $opValue } + "set-property" { Do-SetProperty $opValue } + default { Write-Error "Unknown operation: $opName"; exit 1 } + } +} + +# --- Save --- +$settings = New-Object System.Xml.XmlWriterSettings +$settings.Encoding = New-Object System.Text.UTF8Encoding($true) +$settings.Indent = $false +$settings.NewLineHandling = [System.Xml.NewLineHandling]::None + +$memStream = New-Object System.IO.MemoryStream +$writer = [System.Xml.XmlWriter]::Create($memStream, $settings) +$script:xmlDoc.Save($writer) +$writer.Flush(); $writer.Close() + +$bytes = $memStream.ToArray() +$memStream.Close() +$text = [System.Text.Encoding]::UTF8.GetString($bytes) +if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) } +$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"') + +$utf8Bom = New-Object System.Text.UTF8Encoding($true) +[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom) +Info "Saved: $resolvedPath" + +# --- Auto-validate --- +if (-not $NoValidate) { + $validateScript = Join-Path (Join-Path $PSScriptRoot "..\..\subsystem-validate") "scripts\subsystem-validate.ps1" + $validateScript = [System.IO.Path]::GetFullPath($validateScript) + if (Test-Path $validateScript) { + Write-Host "" + Write-Host "--- Running subsystem-validate ---" + & powershell.exe -NoProfile -File $validateScript -SubsystemPath $resolvedPath + } +} + +# --- Summary --- +Write-Host "" +Write-Host "=== subsystem-edit summary ===" +Write-Host " Subsystem: $($script:objName)" +Write-Host " Added: $($script:addCount)" +Write-Host " Removed: $($script:removeCount)" +Write-Host " Modified: $($script:modifyCount)" +exit 0 diff --git a/.claude/skills/subsystem-info/SKILL.md b/.claude/skills/subsystem-info/SKILL.md new file mode 100644 index 00000000..801d5f97 --- /dev/null +++ b/.claude/skills/subsystem-info/SKILL.md @@ -0,0 +1,61 @@ +--- +name: subsystem-info +description: Анализ структуры подсистемы 1С из XML-выгрузки — состав, дочерние подсистемы, командный интерфейс, дерево иерархии. Используй для изучения структуры подсистем и навигации по конфигурации +argument-hint: [-Mode overview|content|ci|tree] [-Name <элемент>] +allowed-tools: + - Bash + - Read + - Glob +--- + +# /subsystem-info — Структура подсистемы 1С + +Читает XML подсистемы из выгрузки конфигурации 1С и выводит компактное описание структуры. + +## Параметры и команда + +| Параметр | Описание | +|----------|----------| +| `SubsystemPath` | Путь к XML-файлу подсистемы, каталогу подсистемы или каталогу `Subsystems/` (для tree) | +| `Mode` | Режим: `overview` (default), `content`, `ci`, `tree` | +| `Name` | Drill-down: тип объекта в content, секция в ci, имя подсистемы в tree | +| `Limit` / `Offset` | Пагинация (по умолчанию 150 строк) | +| `OutFile` | Записать результат в файл (UTF-8 BOM) | + +```powershell +powershell.exe -NoProfile -File .claude\skills\subsystem-info\scripts\subsystem-info.ps1 -SubsystemPath "<путь>" +``` + +## Четыре режима + +| Режим | Что показывает | +|---|---| +| `overview` *(default)* | Компактная сводка: свойства, состав (сгруппирован по типам), дочерние подсистемы, наличие CI | +| `content` | Список Content с группировкой по типу объекта. `-Name Catalog` — только каталоги | +| `ci` | Разбор CommandInterface.xml: видимость, размещение, порядок команд/подсистем/групп | +| `tree` | Рекурсивное дерево иерархии подсистем с маркерами [CI], [OneCmd], [Скрыт] | + +## Примеры + +```powershell +# Обзор подсистемы +... -SubsystemPath Subsystems/Продажи.xml + +# Состав подсистемы +... -SubsystemPath Subsystems/Администрирование.xml -Mode content + +# Только документы в составе +... -SubsystemPath Subsystems/Продажи.xml -Mode content -Name Document + +# Командный интерфейс подсистемы +... -SubsystemPath Subsystems/Продажи.xml -Mode ci + +# Дерево подсистем от корня +... -SubsystemPath Subsystems -Mode tree + +# Дерево от конкретной подсистемы +... -SubsystemPath Subsystems/Администрирование.xml -Mode tree + +# Дерево только для одной подсистемы +... -SubsystemPath Subsystems -Mode tree -Name Администрирование +``` diff --git a/.claude/skills/subsystem-info/scripts/subsystem-info.ps1 b/.claude/skills/subsystem-info/scripts/subsystem-info.ps1 new file mode 100644 index 00000000..77520980 --- /dev/null +++ b/.claude/skills/subsystem-info/scripts/subsystem-info.ps1 @@ -0,0 +1,483 @@ +# subsystem-info v1.0 — Compact summary of 1C subsystem structure +# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +param( + [Parameter(Mandatory=$true)][string]$SubsystemPath, + [ValidateSet("overview","content","ci","tree")] + [string]$Mode = "overview", + [string]$Name, + [int]$Limit = 150, + [int]$Offset = 0, + [string]$OutFile +) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# --- Output helper --- +$script:lines = @() +function Out([string]$text) { $script:lines += $text } + +# --- Resolve path --- +if (-not [System.IO.Path]::IsPathRooted($SubsystemPath)) { + $SubsystemPath = Join-Path (Get-Location).Path $SubsystemPath +} + +# --- Helper: get LocalString text --- +function Get-MLText($node) { + if (-not $node -or -not $node.HasChildNodes) { return "" } + foreach ($item in $node.ChildNodes) { + if ($item.NodeType -ne 'Element') { continue } + $lang = ""; $content = "" + foreach ($c in $item.ChildNodes) { + if ($c.NodeType -ne 'Element') { continue } + if ($c.LocalName -eq "lang") { $lang = $c.InnerText } + if ($c.LocalName -eq "content") { $content = $c.InnerText } + } + if ($lang -eq "ru" -and $content) { return $content } + } + # fallback: first item + foreach ($item in $node.ChildNodes) { + if ($item.NodeType -ne 'Element') { continue } + foreach ($c in $item.ChildNodes) { + if ($c.NodeType -ne 'Element') { continue } + if ($c.LocalName -eq "content" -and $c.InnerText) { return $c.InnerText } + } + } + return "" +} + +# --- Helper: load subsystem XML --- +function Load-SubsystemXml([string]$xmlPath) { + [xml]$doc = Get-Content -Path $xmlPath -Encoding UTF8 + $ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable) + $ns.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses") + $ns.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core") + $ns.AddNamespace("xr", "http://v8.1c.ru/8.3/xcf/readable") + $ns.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance") + $sub = $doc.SelectSingleNode("/md:MetaDataObject/md:Subsystem", $ns) + if (-not $sub) { + Write-Host "[ERROR] Not a valid subsystem XML: $xmlPath" + exit 1 + } + return @{ Doc=$doc; Ns=$ns; Sub=$sub } +} + +# --- Helper: get content items --- +function Get-ContentItems($props, $ns) { + $items = @() + $contentNode = $props.SelectSingleNode("md:Content", $ns) + if (-not $contentNode -or -not $contentNode.HasChildNodes) { return $items } + foreach ($item in $contentNode.SelectNodes("xr:Item", $ns)) { + $items += $item.InnerText + } + return $items +} + +# --- Helper: get child subsystem names --- +function Get-ChildNames($sub, $ns) { + $names = @() + $co = $sub.SelectSingleNode("md:ChildObjects", $ns) + if (-not $co -or -not $co.HasChildNodes) { return $names } + foreach ($child in $co.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Subsystem") { + $names += $child.InnerText + } + } + return $names +} + +# --- Helper: group content by type --- +function Group-ContentByType($items) { + $groups = [ordered]@{} + foreach ($item in $items) { + if ($item -match '^([^.]+)\.(.+)$') { + $type = $Matches[1] + $name = $Matches[2] + } elseif ($item -match '^[0-9a-fA-F]{8}-') { + $type = "[UUID]" + $name = $item + } else { + $type = "[Other]" + $name = $item + } + if (-not $groups.Contains($type)) { $groups[$type] = @() } + $groups[$type] += $name + } + return $groups +} + +# --- Helper: find subsystem dir from XML path --- +function Get-SubsystemDir([string]$xmlPath) { + $dir = [System.IO.Path]::GetDirectoryName($xmlPath) + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($xmlPath) + return Join-Path $dir $baseName +} + +# ============================================================ +# Mode: tree +# ============================================================ +if ($Mode -eq "tree") { + $isDir = Test-Path $SubsystemPath -PathType Container + $rootDir = $null + $rootXml = $null + + if ($isDir) { + # Subsystems/ directory — show all top-level subsystems + $rootDir = $SubsystemPath + } else { + # Specific subsystem XML — show tree from this subsystem + if (-not (Test-Path $SubsystemPath)) { + Write-Host "[ERROR] File not found: $SubsystemPath" + exit 1 + } + $rootXml = $SubsystemPath + } + + # Box-drawing chars (PS 5.1 compatible) + $script:T_BRANCH = [char]0x251C + [char]0x2500 + [char]0x2500 + " " # ├── + $script:T_LAST = [char]0x2514 + [char]0x2500 + [char]0x2500 + " " # └── + $script:T_PIPE = [char]0x2502 + " " # │ + $script:T_ARROW = [char]0x2192 # → + + function Get-TreeLine([string]$xmlPath) { + $parsed = Load-SubsystemXml $xmlPath + $sub = $parsed.Sub; $ns = $parsed.Ns + $props = $sub.SelectSingleNode("md:Properties", $ns) + $name = $props.SelectSingleNode("md:Name", $ns).InnerText + + $markers = @() + $subDir = Get-SubsystemDir $xmlPath + $ciPath = Join-Path (Join-Path $subDir "Ext") "CommandInterface.xml" + if (Test-Path $ciPath) { $markers += "CI" } + $useOne = $props.SelectSingleNode("md:UseOneCommand", $ns) + if ($useOne -and $useOne.InnerText -eq "true") { $markers += "OneCmd" } + $inclCI = $props.SelectSingleNode("md:IncludeInCommandInterface", $ns) + if ($inclCI -and $inclCI.InnerText -eq "false") { $markers += "Скрыт" } + $markerStr = if ($markers.Count -gt 0) { " [$($markers -join ', ')]" } else { "" } + + $contentItems = @(Get-ContentItems $props $ns) + $childNames = @(Get-ChildNames $sub $ns) + $childStr = if ($childNames.Count -gt 0) { ", $($childNames.Count) дочерних" } else { "" } + + return @{ + Label = "$name$markerStr ($($contentItems.Count) объектов$childStr)" + SubDir = $subDir + ChildNames = $childNames + } + } + + function Build-TreeEntry([string]$xmlPath, [string]$prefix, [bool]$isLast, [bool]$isRoot) { + $info = Get-TreeLine $xmlPath + + $connector = if ($isRoot) { "" } elseif ($isLast) { $script:T_LAST } else { $script:T_BRANCH } + Out "$prefix$connector$($info.Label)" + + if ($info.ChildNames.Count -gt 0) { + $childPrefix = if ($isRoot) { "" } elseif ($isLast) { "$prefix " } else { "$prefix$($script:T_PIPE)" } + $subsDir = Join-Path $info.SubDir "Subsystems" + for ($i = 0; $i -lt $info.ChildNames.Count; $i++) { + $childXml = Join-Path $subsDir "$($info.ChildNames[$i]).xml" + $childIsLast = ($i -eq $info.ChildNames.Count - 1) + if (Test-Path $childXml) { + Build-TreeEntry $childXml $childPrefix $childIsLast $false + } else { + $conn2 = if ($childIsLast) { $script:T_LAST } else { $script:T_BRANCH } + Out "$childPrefix$conn2$($info.ChildNames[$i]) [NOT FOUND]" + } + } + } + } + + if ($rootDir) { + $label = Split-Path $rootDir -Leaf + Out "Дерево подсистем от: $label/" + Out "" + $xmlFiles = @(Get-ChildItem $rootDir -Filter "*.xml" -File | Sort-Object Name) + if ($Name) { + $xmlFiles = @($xmlFiles | Where-Object { $_.BaseName -eq $Name }) + if ($xmlFiles.Count -eq 0) { + Write-Host "[ERROR] Subsystem '$Name' not found in $rootDir" + exit 1 + } + } + for ($i = 0; $i -lt $xmlFiles.Count; $i++) { + Build-TreeEntry $xmlFiles[$i].FullName "" ($i -eq $xmlFiles.Count - 1) $true + } + } else { + Build-TreeEntry $rootXml "" $true $true + } + +} elseif ($Mode -eq "ci") { +# ============================================================ +# Mode: ci — CommandInterface.xml +# ============================================================ + if (Test-Path $SubsystemPath -PathType Container) { + Write-Host "[ERROR] ci mode requires a subsystem .xml file, not a directory" + exit 1 + } + if (-not (Test-Path $SubsystemPath)) { + Write-Host "[ERROR] File not found: $SubsystemPath" + exit 1 + } + + $parsed = Load-SubsystemXml $SubsystemPath + $sub = $parsed.Sub; $ns = $parsed.Ns + $props = $sub.SelectSingleNode("md:Properties", $ns) + $subName = $props.SelectSingleNode("md:Name", $ns).InnerText + + $subDir = Get-SubsystemDir $SubsystemPath + $ciPath = Join-Path (Join-Path $subDir "Ext") "CommandInterface.xml" + + if (-not (Test-Path $ciPath)) { + Out "Командный интерфейс: $subName" + Out "" + Out "Файл CommandInterface.xml не найден." + Out "Путь: $ciPath" + } else { + [xml]$ciDoc = Get-Content -Path $ciPath -Encoding UTF8 + $ciNs = New-Object System.Xml.XmlNamespaceManager($ciDoc.NameTable) + $ciNs.AddNamespace("ci", "http://v8.1c.ru/8.3/xcf/extrnprops") + $ciNs.AddNamespace("xr", "http://v8.1c.ru/8.3/xcf/readable") + + $ciRoot = $ciDoc.DocumentElement + + Out "Командный интерфейс: $subName" + Out "" + + # --- CommandsVisibility --- + $visSection = $ciRoot.SelectSingleNode("ci:CommandsVisibility", $ciNs) + if ($visSection) { + $hidden = @(); $shown = @() + foreach ($cmd in $visSection.SelectNodes("ci:Command", $ciNs)) { + $cmdName = $cmd.GetAttribute("name") + $vis = $cmd.SelectSingleNode("ci:Visibility/xr:Common", $ciNs) + if ($vis -and $vis.InnerText -eq "false") { $hidden += $cmdName } + else { $shown += $cmdName } + } + $total = $hidden.Count + $shown.Count + if (-not $Name -or $Name -eq "visibility") { + Out "Видимость ($total):" + if ($hidden.Count -gt 0) { + Out " СКРЫТО ($($hidden.Count)):" + foreach ($h in $hidden) { Out " $h" } + } + if ($shown.Count -gt 0) { + Out " ПОКАЗАНО ($($shown.Count)):" + foreach ($s in $shown) { Out " $s" } + } + Out "" + } + } + + # --- CommandsPlacement --- + $placeSection = $ciRoot.SelectSingleNode("ci:CommandsPlacement", $ciNs) + if ($placeSection) { + $placements = @() + foreach ($cmd in $placeSection.SelectNodes("ci:Command", $ciNs)) { + $cmdName = $cmd.GetAttribute("name") + $grp = $cmd.SelectSingleNode("ci:CommandGroup", $ciNs) + $pl = $cmd.SelectSingleNode("ci:Placement", $ciNs) + $grpText = if ($grp) { $grp.InnerText } else { "?" } + $plText = if ($pl) { $pl.InnerText } else { "?" } + $placements += @{ Name=$cmdName; Group=$grpText; Placement=$plText } + } + if ((-not $Name -or $Name -eq "placement") -and $placements.Count -gt 0) { + Out "Размещение ($($placements.Count)):" + $arrow = [char]0x2192 + foreach ($p in $placements) { + Out " $($p.Name) $arrow $($p.Group) ($($p.Placement))" + } + Out "" + } + } + + # --- CommandsOrder --- + $orderSection = $ciRoot.SelectSingleNode("ci:CommandsOrder", $ciNs) + if ($orderSection) { + $orderGroups = [ordered]@{} + foreach ($cmd in $orderSection.SelectNodes("ci:Command", $ciNs)) { + $cmdName = $cmd.GetAttribute("name") + $grp = $cmd.SelectSingleNode("ci:CommandGroup", $ciNs) + $grpText = if ($grp) { $grp.InnerText } else { "?" } + if (-not $orderGroups.Contains($grpText)) { $orderGroups[$grpText] = @() } + $orderGroups[$grpText] += $cmdName + } + $totalOrder = 0 + foreach ($k in $orderGroups.Keys) { $totalOrder += $orderGroups[$k].Count } + if ((-not $Name -or $Name -eq "order") -and $totalOrder -gt 0) { + Out "Порядок команд ($totalOrder):" + foreach ($grpName in $orderGroups.Keys) { + Out " [$grpName]:" + foreach ($c in $orderGroups[$grpName]) { Out " $c" } + } + Out "" + } + } + + # --- SubsystemsOrder --- + $subOrderSection = $ciRoot.SelectSingleNode("ci:SubsystemsOrder", $ciNs) + if ($subOrderSection) { + $subOrder = @() + foreach ($s in $subOrderSection.SelectNodes("ci:Subsystem", $ciNs)) { + $subOrder += $s.InnerText + } + if ((-not $Name -or $Name -eq "subsystems") -and $subOrder.Count -gt 0) { + Out "Порядок подсистем ($($subOrder.Count)):" + for ($i = 0; $i -lt $subOrder.Count; $i++) { + Out " $($i+1). $($subOrder[$i])" + } + Out "" + } + } + + # --- GroupsOrder --- + $grpOrderSection = $ciRoot.SelectSingleNode("ci:GroupsOrder", $ciNs) + if ($grpOrderSection) { + $grpOrder = @() + foreach ($g in $grpOrderSection.SelectNodes("ci:Group", $ciNs)) { + $grpOrder += $g.InnerText + } + if ((-not $Name -or $Name -eq "groups") -and $grpOrder.Count -gt 0) { + Out "Порядок групп ($($grpOrder.Count)):" + foreach ($g in $grpOrder) { Out " $g" } + } + } + } + +} else { +# ============================================================ +# Mode: overview / content — requires a subsystem XML file +# ============================================================ + if (Test-Path $SubsystemPath -PathType Container) { + $dirName = Split-Path $SubsystemPath -Leaf + $candidate = Join-Path $SubsystemPath "$dirName.xml" + if (Test-Path $candidate) { + $SubsystemPath = $candidate + } else { + Write-Host "[ERROR] No $dirName.xml found in directory. Use -Mode tree for directory listing." + exit 1 + } + } + + if (-not (Test-Path $SubsystemPath)) { + Write-Host "[ERROR] File not found: $SubsystemPath" + exit 1 + } + + $parsed = Load-SubsystemXml $SubsystemPath + $sub = $parsed.Sub; $ns = $parsed.Ns + $props = $sub.SelectSingleNode("md:Properties", $ns) + + $subName = $props.SelectSingleNode("md:Name", $ns).InnerText + $synonym = Get-MLText $props.SelectSingleNode("md:Synonym", $ns) + $comment = $props.SelectSingleNode("md:Comment", $ns) + $commentText = if ($comment -and $comment.InnerText) { $comment.InnerText } else { "" } + $inclHelp = $props.SelectSingleNode("md:IncludeHelpInContents", $ns).InnerText + $inclCI = $props.SelectSingleNode("md:IncludeInCommandInterface", $ns).InnerText + $useOneCmd = $props.SelectSingleNode("md:UseOneCommand", $ns).InnerText + $explanation = Get-MLText $props.SelectSingleNode("md:Explanation", $ns) + + # Picture + $picNode = $props.SelectSingleNode("md:Picture", $ns) + $picText = "" + if ($picNode -and $picNode.HasChildNodes) { + $picRef = $picNode.SelectSingleNode("xr:Ref", $ns) + if ($picRef -and $picRef.InnerText) { $picText = $picRef.InnerText } + } + + # Content + $contentItems = @(Get-ContentItems $props $ns) + $groups = Group-ContentByType $contentItems + + # Children + $childNames = @(Get-ChildNames $sub $ns) + + # CI presence + $subDir = Get-SubsystemDir $SubsystemPath + $ciPath = Join-Path (Join-Path $subDir "Ext") "CommandInterface.xml" + $hasCI = Test-Path $ciPath + + if ($Mode -eq "overview") { + Out "Подсистема: $subName" + if ($synonym -and $synonym -ne $subName) { Out "Синоним: $synonym" } + if ($commentText) { Out "Комментарий: $commentText" } + Out "ВключатьВКомандныйИнтерфейс: $inclCI" + Out "ИспользоватьОднуКоманду: $useOneCmd" + if ($explanation) { Out "Пояснение: $explanation" } + if ($picText) { Out "Картинка: $picText" } + + # Content summary + if ($contentItems.Count -gt 0) { + $parts = @() + foreach ($type in $groups.Keys) { + $parts += "$type`: $($groups[$type].Count)" + } + Out "Состав: $($contentItems.Count) объектов ($($parts -join ', '))" + } else { + Out "Состав: пусто" + } + + # Children + if ($childNames.Count -gt 0) { + Out "Дочерние подсистемы ($($childNames.Count)): $($childNames -join ', ')" + } + + # CI + if ($hasCI) { + Out "Командный интерфейс: есть" + } + + } elseif ($Mode -eq "content") { + Out "Состав подсистемы $subName ($($contentItems.Count) объектов):" + Out "" + + if ($Name) { + # Filter by type + if ($groups.Contains($Name)) { + $filtered = $groups[$Name] + Out "$Name ($($filtered.Count)):" + foreach ($n in $filtered) { Out " $n" } + } else { + Out "[INFO] Тип '$Name' не найден в составе." + Out "Доступные типы: $($groups.Keys -join ', ')" + } + } else { + foreach ($type in $groups.Keys) { + Out "$type ($($groups[$type].Count)):" + foreach ($n in $groups[$type]) { Out " $n" } + Out "" + } + } + } +} + +# --- Pagination and output --- +$totalLines = $script:lines.Count +$outLines = $script:lines + +if ($Offset -gt 0) { + if ($Offset -ge $totalLines) { + Write-Host "[INFO] Offset $Offset exceeds total lines ($totalLines). Nothing to show." + exit 0 + } + $outLines = $outLines[$Offset..($totalLines - 1)] +} + +if ($Limit -gt 0 -and $outLines.Count -gt $Limit) { + $shown = $outLines[0..($Limit - 1)] + $remaining = $totalLines - $Offset - $Limit + $shown += "" + $shown += "[ОБРЕЗАНО] Показано $Limit из $totalLines строк. Используйте -Offset $($Offset + $Limit) для продолжения." + $outLines = $shown +} + +if ($OutFile) { + if (-not [System.IO.Path]::IsPathRooted($OutFile)) { + $OutFile = Join-Path (Get-Location).Path $OutFile + } + $utf8 = New-Object System.Text.UTF8Encoding($true) + [System.IO.File]::WriteAllLines($OutFile, $outLines, $utf8) + Write-Host "Output written to $OutFile" +} else { + foreach ($l in $outLines) { Write-Host $l } +} diff --git a/.claude/skills/subsystem-validate/SKILL.md b/.claude/skills/subsystem-validate/SKILL.md new file mode 100644 index 00000000..9aae9b40 --- /dev/null +++ b/.claude/skills/subsystem-validate/SKILL.md @@ -0,0 +1,41 @@ +--- +name: subsystem-validate +description: Валидация структурной корректности подсистемы 1С — XML-формат, свойства, состав, дочерние подсистемы, командный интерфейс +argument-hint: [-MaxErrors 30] +allowed-tools: + - Bash + - Read + - Glob +--- + +# /subsystem-validate — валидация подсистемы 1С + +Проверяет структурную корректность XML-файла подсистемы из выгрузки конфигурации. + +## Параметры и команда + +| Параметр | Описание | +|----------|----------| +| `SubsystemPath` | Путь к XML-файлу подсистемы | +| `MaxErrors` | Максимум ошибок до остановки (по умолчанию 30) | +| `OutFile` | Записать результат в файл | + +```powershell +powershell.exe -NoProfile -File '.claude\skills\subsystem-validate\scripts\subsystem-validate.ps1' -SubsystemPath '<путь>' +``` + +## Проверки (13) + +1. XML well-formedness + root structure (MetaDataObject/Subsystem) +2. Properties — 9 обязательных свойств +3. Name — непустой, валидный идентификатор +4. Synonym — непустой (хотя бы один v8:item) +5. Булевы свойства — содержат true/false +6. Content — формат xr:Item, xsi:type +7. Content — нет дубликатов +8. ChildObjects — элементы непустые +9. ChildObjects — нет дубликатов +10. ChildObjects → файлы существуют +11. CommandInterface.xml — well-formedness +12. Picture — формат ссылки +13. UseOneCommand=true → ровно 1 элемент в Content diff --git a/.claude/skills/subsystem-validate/scripts/subsystem-validate.ps1 b/.claude/skills/subsystem-validate/scripts/subsystem-validate.ps1 new file mode 100644 index 00000000..75033983 --- /dev/null +++ b/.claude/skills/subsystem-validate/scripts/subsystem-validate.ps1 @@ -0,0 +1,314 @@ +# subsystem-validate v1.0 — Validate 1C subsystem XML structure +# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +param( + [Parameter(Mandatory)][string]$SubsystemPath, + [int]$MaxErrors = 30, + [string]$OutFile +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# --- Resolve path --- +if (-not [System.IO.Path]::IsPathRooted($SubsystemPath)) { + $SubsystemPath = Join-Path (Get-Location).Path $SubsystemPath +} +if (Test-Path $SubsystemPath -PathType Container) { + $dirName = Split-Path $SubsystemPath -Leaf + $candidate = Join-Path $SubsystemPath "$dirName.xml" + if (Test-Path $candidate) { $SubsystemPath = $candidate } + else { + Write-Host "[ERROR] No $dirName.xml found in directory: $SubsystemPath" + exit 1 + } +} +if (-not (Test-Path $SubsystemPath)) { + Write-Host "[ERROR] File not found: $SubsystemPath" + exit 1 +} +$resolvedPath = (Resolve-Path $SubsystemPath).Path + +# --- Output infrastructure --- +$script:errors = 0 +$script:warnings = 0 +$script:stopped = $false +$script:output = New-Object System.Text.StringBuilder 8192 + +function Out-Line([string]$msg) { $script:output.AppendLine($msg) | Out-Null } +function Report-OK([string]$msg) { 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" +} + +$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' +$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$' + +# --- 1. XML well-formedness + root structure --- +$xmlDoc = $null +try { + [xml]$xmlDoc = Get-Content -Path $resolvedPath -Encoding UTF8 +} catch { + Report-Error "1. XML parse error: $($_.Exception.Message)" + $script:stopped = $true +} + +if (-not $script:stopped) { + $ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable) + $ns.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses") + $ns.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core") + $ns.AddNamespace("xr", "http://v8.1c.ru/8.3/xcf/readable") + $ns.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance") + + $root = $xmlDoc.DocumentElement + $sub = $xmlDoc.SelectSingleNode("/md:MetaDataObject/md:Subsystem", $ns) + $version = $root.GetAttribute("version") + + if (-not $sub) { + Report-Error "1. Root structure: expected MetaDataObject/Subsystem, not found" + $script:stopped = $true + } else { + $uuid = $sub.GetAttribute("uuid") + if ($uuid -and $uuid -match $guidPattern) { + Report-OK "1. Root structure: MetaDataObject/Subsystem, uuid=$uuid, version $version" + } else { + Report-Error "1. Root structure: invalid or missing uuid" + } + } +} + +# --- Properties checks --- +if (-not $script:stopped) { + $props = $sub.SelectSingleNode("md:Properties", $ns) + if (-not $props) { + Report-Error "2. Properties: element not found" + $script:stopped = $true + } +} + +$subName = "" +if (-not $script:stopped) { + # --- 2. Required properties --- + $requiredProps = @("Name","Synonym","Comment","IncludeHelpInContents","IncludeInCommandInterface","UseOneCommand","Explanation","Picture","Content") + $missing = @() + foreach ($p in $requiredProps) { + $el = $props.SelectSingleNode("md:$p", $ns) + if (-not $el) { $missing += $p } + } + if ($missing.Count -eq 0) { + Report-OK "2. Properties: all 9 required properties present" + } else { + Report-Error "2. Properties: missing: $($missing -join ', ')" + } + + # --- 3. Name --- + $nameEl = $props.SelectSingleNode("md:Name", $ns) + $subName = if ($nameEl) { $nameEl.InnerText.Trim() } else { "" } + Out-Line "" + Out-Line "=== Validation: Subsystem.$subName ===" + # Re-insert header at position 0 + $headerLine = "=== Validation: Subsystem.$subName ===" + $script:output.Insert(0, "$headerLine`r`n`r`n") | Out-Null + + if ($subName -and $subName -match $identPattern) { + Report-OK "3. Name: `"$subName`" - valid identifier" + } elseif (-not $subName) { + Report-Error "3. Name: empty" + } else { + Report-Error "3. Name: `"$subName`" - invalid identifier" + } + + # --- 4. Synonym --- + $synEl = $props.SelectSingleNode("md:Synonym", $ns) + if ($synEl -and $synEl.HasChildNodes) { + $items = $synEl.SelectNodes("v8:item", $ns) + if ($items.Count -gt 0) { + $firstContent = "" + foreach ($item in $items) { + $c = $item.SelectSingleNode("v8:content", $ns) + if ($c -and $c.InnerText) { $firstContent = $c.InnerText; break } + } + Report-OK "4. Synonym: `"$firstContent`" ($($items.Count) lang(s))" + } else { + Report-Warn "4. Synonym: element exists but no v8:item children" + } + } else { + Report-Warn "4. Synonym: empty or missing" + } + + # --- 5. Boolean properties --- + $boolProps = @("IncludeHelpInContents","IncludeInCommandInterface","UseOneCommand") + $boolOk = $true + $boolVals = @{} + foreach ($bp in $boolProps) { + $el = $props.SelectSingleNode("md:$bp", $ns) + if ($el) { + $val = $el.InnerText.Trim() + $boolVals[$bp] = $val + if ($val -ne "true" -and $val -ne "false") { + Report-Error "5. Boolean property $bp = `"$val`" (expected true/false)" + $boolOk = $false + } + } + } + if ($boolOk) { Report-OK "5. Boolean properties: valid" } + + # --- 6. Content items format --- + $contentEl = $props.SelectSingleNode("md:Content", $ns) + $contentItems = @() + if ($contentEl -and $contentEl.HasChildNodes) { + $xrItems = $contentEl.SelectNodes("xr:Item", $ns) + $contentOk = $true + foreach ($item in $xrItems) { + $typeAttr = $item.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance") + $text = $item.InnerText.Trim() + $contentItems += $text + if ($typeAttr -ne "xr:MDObjectRef") { + Report-Error "6. Content item `"$text`": xsi:type=`"$typeAttr`" (expected xr:MDObjectRef)" + $contentOk = $false + } + if ($text -notmatch '^[A-Za-z]+\..+$' -and $text -notmatch $guidPattern) { + Report-Error "6. Content item `"$text`": invalid format (expected Type.Name or UUID)" + $contentOk = $false + } + } + if ($contentOk) { Report-OK "6. Content: $($xrItems.Count) items, all valid MDObjectRef format" } + } else { + Report-OK "6. Content: empty (no items)" + } + + # --- 7. Content duplicates --- + if ($contentItems.Count -gt 0) { + $dupes = $contentItems | Group-Object | Where-Object { $_.Count -gt 1 } + if ($dupes) { + $dupeNames = ($dupes | ForEach-Object { $_.Name }) -join ", " + Report-Warn "7. Content: duplicates found: $dupeNames" + } else { + Report-OK "7. Content: no duplicates" + } + } else { + Report-OK "7. Content: no duplicates (empty)" + } + + # --- 8. ChildObjects entries non-empty --- + $childObjs = $sub.SelectSingleNode("md:ChildObjects", $ns) + $childNames = @() + if ($childObjs -and $childObjs.HasChildNodes) { + $childOk = $true + foreach ($child in $childObjs.ChildNodes) { + if ($child.NodeType -ne 'Element') { continue } + if ($child.LocalName -ne "Subsystem") { + Report-Error "8. ChildObjects: unexpected element <$($child.LocalName)>" + $childOk = $false + } elseif (-not $child.InnerText.Trim()) { + Report-Error "8. ChildObjects: empty element" + $childOk = $false + } else { + $childNames += $child.InnerText.Trim() + } + } + if ($childOk) { Report-OK "8. ChildObjects: $($childNames.Count) entries, all non-empty" } + } else { + Report-OK "8. ChildObjects: empty (leaf subsystem)" + } + + # --- 9. ChildObjects duplicates --- + if ($childNames.Count -gt 0) { + $dupes = $childNames | Group-Object | Where-Object { $_.Count -gt 1 } + if ($dupes) { + $dupeNames = ($dupes | ForEach-Object { $_.Name }) -join ", " + Report-Error "9. ChildObjects: duplicates: $dupeNames" + } else { + Report-OK "9. ChildObjects: no duplicates" + } + } else { + Report-OK "9. ChildObjects: no duplicates (empty)" + } + + # --- 10. ChildObjects files exist --- + if ($childNames.Count -gt 0) { + $parentDir = [System.IO.Path]::GetDirectoryName($resolvedPath) + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($resolvedPath) + $subsDir = Join-Path (Join-Path $parentDir $baseName) "Subsystems" + $missingFiles = @() + foreach ($cn in $childNames) { + $childXml = Join-Path $subsDir "$cn.xml" + if (-not (Test-Path $childXml)) { $missingFiles += $cn } + } + if ($missingFiles.Count -eq 0) { + Report-OK "10. ChildObjects files: all $($childNames.Count) files exist" + } else { + Report-Warn "10. ChildObjects files: missing: $($missingFiles -join ', ')" + } + } else { + Report-OK "10. ChildObjects files: n/a (no children)" + } + + # --- 11. CommandInterface.xml --- + $parentDir2 = [System.IO.Path]::GetDirectoryName($resolvedPath) + $baseName2 = [System.IO.Path]::GetFileNameWithoutExtension($resolvedPath) + $ciPath = Join-Path (Join-Path (Join-Path $parentDir2 $baseName2) "Ext") "CommandInterface.xml" + if (Test-Path $ciPath) { + try { + [xml]$ciDoc = Get-Content -Path $ciPath -Encoding UTF8 + Report-OK "11. CommandInterface: exists, well-formed" + } catch { + Report-Warn "11. CommandInterface: exists but NOT well-formed: $($_.Exception.Message)" + } + } else { + Report-OK "11. CommandInterface: not present" + } + + # --- 12. Picture format --- + $picEl = $props.SelectSingleNode("md:Picture", $ns) + if ($picEl -and $picEl.HasChildNodes) { + $picRef = $picEl.SelectSingleNode("xr:Ref", $ns) + if ($picRef -and $picRef.InnerText) { + $refText = $picRef.InnerText + if ($refText -match '^CommonPicture\.') { + Report-OK "12. Picture: $refText" + } else { + Report-Warn "12. Picture: `"$refText`" (expected CommonPicture.XXX)" + } + } else { + Report-Warn "12. Picture: has children but no xr:Ref content" + } + } else { + Report-OK "12. Picture: empty (not set)" + } + + # --- 13. UseOneCommand constraint --- + $useOne = $boolVals["UseOneCommand"] + if ($useOne -eq "true") { + if ($contentItems.Count -eq 1) { + Report-OK "13. UseOneCommand: true, Content has exactly 1 item" + } else { + Report-Warn "13. UseOneCommand: true but Content has $($contentItems.Count) items (expected 1)" + } + } else { + Report-OK "13. UseOneCommand: false (no constraint)" + } +} + +# --- Finalize --- +Out-Line "---" +Out-Line "Errors: $($script:errors), Warnings: $($script:warnings)" + +$result = $script:output.ToString() +Write-Host $result + +if ($OutFile) { + if (-not [System.IO.Path]::IsPathRooted($OutFile)) { + $OutFile = Join-Path (Get-Location).Path $OutFile + } + $utf8Bom = New-Object System.Text.UTF8Encoding($true) + [System.IO.File]::WriteAllText($OutFile, $result, $utf8Bom) + Write-Host "Written to: $OutFile" +} + +if ($script:errors -gt 0) { exit 1 } else { exit 0 } diff --git a/README.md b/README.md index 05a94747..0b068f4b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ | Роли (Role) | 3 навыка `/role-*` | Анализ прав роли, создание из JSON DSL, валидация | [Подробнее](docs/role-guide.md) | | Схема компоновки (СКД) | 4 навыка `/skd-*` | Анализ, генерация из JSON DSL, точечное редактирование, валидация схем компоновки данных | [Подробнее](docs/skd-guide.md) | | Метаданные конфигурации | 4 навыка `/meta-*` | Создание, анализ, редактирование, валидация объектов метаданных (23 типа) | [Подробнее](docs/meta-guide.md) | +| Подсистемы (Subsystem) | 4 навыка `/subsystem-*` | Анализ, создание, редактирование, валидация подсистем конфигурации | — | +| Командный интерфейс (CI) | 2 навыка `/interface-*` | Редактирование и валидация CommandInterface.xml подсистем | — | | Утилиты | `/img-grid` | Наложение сетки на изображение для определения пропорций колонок | — | ## Требования @@ -51,6 +53,7 @@ - [Схема компоновки данных (DCS)](docs/1c-dcs-spec.md) — XML-формат DataCompositionSchema, 930 схем проанализировано - [SKD DSL](docs/skd-dsl-spec.md) — JSON-формат описания СКД для `/skd-compile` - [Объекты конфигурации](docs/1c-config-objects-spec.md) — XML-формат объектов метаданных конфигурации (23 типа) +- [Подсистемы и командный интерфейс](docs/1c-subsystem-spec.md) — XML-формат подсистем, CommandInterface.xml, секции видимости/размещения/порядка ## Структура репозитория @@ -90,6 +93,12 @@ ├── meta-compile/ # Создание объекта метаданных ├── meta-edit/ # Редактирование объекта метаданных ├── meta-validate/ # Валидация объекта метаданных +├── subsystem-info/ # Анализ структуры подсистемы +├── subsystem-compile/ # Создание подсистемы из JSON +├── subsystem-edit/ # Редактирование подсистемы +├── subsystem-validate/ # Валидация подсистемы +├── interface-edit/ # Редактирование CommandInterface.xml +├── interface-validate/ # Валидация CommandInterface.xml └── img-grid/ # Сетка для анализа изображений docs/ ├── epf-guide.md # Гайд: внешние обработки и отчёты @@ -111,5 +120,6 @@ docs/ ├── 1c-role-spec.md # Спецификация ролей (Rights.xml) ├── 1c-dcs-spec.md # Спецификация СКД (DataCompositionSchema) ├── skd-dsl-spec.md # Спецификация SKD DSL -└── role-dsl-spec.md # Спецификация Role DSL +├── role-dsl-spec.md # Спецификация Role DSL +└── 1c-subsystem-spec.md # Спецификация подсистем и командного интерфейса ```