diff --git a/.claude/skills/meta-remove/SKILL.md b/.claude/skills/meta-remove/SKILL.md index 11a7750ed..168352119 100644 --- a/.claude/skills/meta-remove/SKILL.md +++ b/.claude/skills/meta-remove/SKILL.md @@ -27,7 +27,7 @@ allowed-tools: | Object | да | Тип и имя объекта: `Catalog.Товары`, `Document.Заказ` и т.д. | | DryRun | нет | Только показать что будет удалено, без изменений | | KeepFiles | нет | Не удалять файлы, только дерегистрировать | -| Force | нет | Удалить несмотря на найденные ссылки | +| Force | нет | Удалить несмотря на найденные ссылки; ссылки на формы объекта при этом очищаются | ## Команда diff --git a/.claude/skills/meta-remove/scripts/meta-remove.ps1 b/.claude/skills/meta-remove/scripts/meta-remove.ps1 index d632da567..85f2deb52 100644 --- a/.claude/skills/meta-remove/scripts/meta-remove.ps1 +++ b/.claude/skills/meta-remove/scripts/meta-remove.ps1 @@ -1,4 +1,4 @@ -# meta-remove v1.9 — Remove metadata object from 1C configuration dump +# meta-remove v1.10 — Remove metadata object from 1C configuration dump # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -72,6 +72,10 @@ if (-not (Test-Path $ConfigDir -PathType Container)) { exit 1 } +# Длинная форма пути: Resolve-Path/параметр могут нести короткое имя 8.3 (NSHIRO~1), а +# перечисление файлов отдаёт длинное (nshirokov) — сравнение путей молча не совпадало. +$ConfigDir = (Get-Item -LiteralPath $ConfigDir -Force).FullName + $configXml = Join-Path $ConfigDir "Configuration.xml" if (-not (Test-Path $configXml)) { Write-Host "[ERROR] Configuration.xml not found in: $ConfigDir" @@ -238,6 +242,51 @@ if ($DryRun) { $actions = 0 $errors = 0 +# Копия из form-remove: одна задача — одна реализация, расходиться им нельзя. +function Remove-NodeWithIndent { + param([System.Xml.XmlNode]$node) + $parent = $node.ParentNode + if (-not $parent) { return } + $prev = $node.PreviousSibling + if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) { + $parent.RemoveChild($prev) | Out-Null + } + $parent.RemoveChild($node) | Out-Null + # Опустевший контейнер: остаётся отступ-whitespace, и XmlWriter пишет пару + # \n\t\t. Платформа пишет только . + if ($parent.SelectNodes("*").Count -eq 0) { $parent.IsEmpty = $true } +} + +function Save-XmlPreservingStyle { + param([System.Xml.XmlDocument]$doc, [string]$path) + + $encBom = New-Object System.Text.UTF8Encoding($true) + $settings = New-Object System.Xml.XmlWriterSettings + $settings.Encoding = $encBom + $settings.Indent = $false + $settings.NewLineHandling = [System.Xml.NewLineHandling]::None + + # Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки. + $memStream = New-Object System.IO.MemoryStream + $writer = [System.Xml.XmlWriter]::Create($memStream, $settings) + $doc.Save($writer) + $writer.Flush(); $writer.Close() + + $xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray()) + $memStream.Close() + if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) } + $xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"') + # Пустой элемент: XmlWriter отдаёт ``, Конфигуратор пишет ``. Внутри + # CDATA/комментария ` />` может быть содержимым (там `>` не экранируется), + # поэтому они идут первыми ветками альтернации и возвращаются как есть. + $xmlText = [regex]::Replace($xmlText, '(?s)||(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } }) + # Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47), + # новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту. + $targetEol = if ((Test-Path -LiteralPath $path) -and ([System.IO.File]::ReadAllText($path) -notmatch "`r`n")) { "`n" } else { "`r`n" } + $xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol + [System.IO.File]::WriteAllText($path, $xmlText, $encBom) +} + # --- 1. Find object files --- $typeDir = Join-Path $ConfigDir $typePlural @@ -357,65 +406,71 @@ if ($hasDir) { $excludeDirs += $objDir } $excludeFile = "" if ($hasXml) { $excludeFile = $objXml } +# Ссылки на формы удаляемого объекта: слоты вида , , +# , элемент начальной страницы. Их, в отличие от типов и вызовов в .bsl, +# можно починить однозначно — пустой слот легален, — поэтому -Force их чистит. +$formSlotRe = [regex]("<([A-Za-z0-9_.]+)>(" + [regex]::Escape("${objType}.${objName}") + "\.Form\.[^<]+|" + [regex]::Escape("CommonForm.${objName}") + ")$($m.Groups[2].Value)"; FormSlot = $true } + } } - if (-not $alreadyFound) { - $references += @{ File = $relPath; Pattern = $typeNameRef } + } + + if ($isAutoCleaned) { continue } + + # Общие паттерны ищем в тексте БЕЗ form-слотов: «Catalog.Товары» есть внутри + # «Catalog.Товары.Form.X», и файл со слотом попадал бы в список дважды. Вырезаем слоты, + # а не пропускаем файл целиком — иначе настоящая ссылка рядом со слотом осталась бы + # незамеченной, а её, в отличие от слота, автоматически не починить. + $contentNoSlots = if ($formSlotFiles.ContainsKey($filePath)) { $formSlotRe.Replace($content, "") } else { $content } + + $matched = $false + foreach ($pat in $searchPatterns) { + if ($contentNoSlots.Contains($pat)) { + $references += @{ File = $relPath; Pattern = $pat } + $matched = $true + break # one match per file is enough } } + if ($ext -eq ".xml" -and -not $matched -and $contentNoSlots.Contains($typeNameRef)) { + $references += @{ File = $relPath; Pattern = $typeNameRef } + } } if ($references.Count -gt 0) { @@ -438,7 +493,8 @@ if ($references.Count -gt 0) { if (-not $Force) { Write-Host "[ERROR] Cannot remove: object has $($references.Count) reference(s)." - Write-Host " Use -Force to remove anyway, or fix references first." + Write-Host " The user decides: fix the references, keep the object, or" + Write-Host " re-run with -Force — form references are cleared." exit 1 } else { Write-Host "[WARN] -Force specified, proceeding despite references" @@ -622,6 +678,52 @@ if (Test-Path $subsystemsDir -PathType Container) { Write-Host "[OK] No Subsystems directory" } +# --- 4b. Clear form slots pointing at this object's forms --- + +# Только слоты форм: пустой слот легален (164 508 пустых на корпус), поэтому замена +# однозначна. Ссылки на типы и вызовы в .bsl не трогаем — чем их заменить, неизвестно. +if ($formSlotFiles.Count -gt 0) { + Write-Host "" + Write-Host "--- Form slots ---" + foreach ($slotPath in ($formSlotFiles.Keys | Sort-Object)) { + if ($DryRun) { + Write-Host "[DRY-RUN] Would clear form slot(s) in $($formSlotFiles[$slotPath])" + continue + } + $slotDoc = New-Object System.Xml.XmlDocument + $slotDoc.PreserveWhitespace = $true + $slotDoc.Load($slotPath) + $isFormFile = $slotDoc.DocumentElement -and $slotDoc.DocumentElement.LocalName -eq "Form" + $touched = @() + foreach ($node in @($slotDoc.SelectNodes("//*"))) { + if ($node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue } + if ($node.SelectNodes("*").Count -gt 0) { continue } + $val = $node.InnerText.Trim() + if (-not $val) { continue } + # Сравнение регистронезависимое — как у платформы (в py-порту .lower()). + if ($val -ne "CommonForm.$objName" -and -not $val.StartsWith("${objType}.${objName}.Form.")) { continue } + + $parent = $node.ParentNode + if ($node.LocalName -eq "Form" -and $parent -and $parent.LocalName -eq "Item") { + $touched += "$($parent.LocalName)/$($node.LocalName)" + Remove-NodeWithIndent $parent + } elseif ($isFormFile) { + # Внутри Ext/Form.xml пустых и нет ни одного — + # каноничное «не задано» там это отсутствие тега. + $touched += $node.LocalName + Remove-NodeWithIndent $node + } else { + # IsEmpty, а не InnerText="": Конфигуратор пустых пар не пишет. + $touched += $node.LocalName + $node.IsEmpty = $true + } + } + if ($touched.Count -eq 0) { continue } + Save-XmlPreservingStyle $slotDoc $slotPath + Write-Host "[OK] Cleared in $($formSlotFiles[$slotPath]): $(($touched | Sort-Object -Unique) -join ', ')" + } +} + # --- 5. Delete object files --- Write-Host "" diff --git a/.claude/skills/meta-remove/scripts/meta-remove.py b/.claude/skills/meta-remove/scripts/meta-remove.py index 67224a18b..7e2e307e6 100644 --- a/.claude/skills/meta-remove/scripts/meta-remove.py +++ b/.claude/skills/meta-remove/scripts/meta-remove.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# meta-remove v1.9 — Remove metadata object from 1C configuration dump +# meta-remove v1.10 — Remove metadata object from 1C configuration dump # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -293,6 +293,25 @@ V8_NS = "http://v8.1c.ru/8.1/data/core" NSMAP = {"md": MD_NS, "v8": V8_NS} +def remove_node_with_indent(node): + """Удалить элемент вместе с предшествующим whitespace; опустевший контейнер сделать + самозакрывающимся. Копия из form-remove: одна задача — одна реализация.""" + parent = node.getparent() + if parent is None: + return + # В DOM (PS) whitespace — отдельные узлы: удаляются предшествующий и сам элемент, а + # whitespace ПОСЛЕ элемента остаётся. В lxml он лежит в node.tail и ушёл бы вместе с + # узлом, поэтому его надо передать предшественнику. + prev = node.getprevious() + if prev is not None: + prev.tail = node.tail + else: + parent.text = node.tail + parent.remove(node) + if len(parent) == 0 and not (parent.text or "").strip(): + parent.text = None + + def localname(el): return etree.QName(el.tag).localname @@ -459,10 +478,21 @@ def main(): exclude_dirs.append(obj_dir) exclude_file = obj_xml if has_xml else "" + # Ссылки на формы удаляемого объекта: слоты вида , , + # , элемент начальной страницы. Их, в отличие от типов и вызовов в .bsl, + # можно починить однозначно — пустой слот легален, — поэтому -Force их чистит. + form_slot_re = re.compile( + r"<([A-Za-z0-9_.]+)>(" + re.escape(f"{obj_type}.{obj_name}") + r"\.Form\.[^<]+|" + + re.escape(f"CommonForm.{obj_name}") + r"){m.group(2)}"}) + + if is_auto_cleaned: + continue + + # Общие паттерны ищем в тексте БЕЗ form-слотов: «Catalog.Товары» есть внутри + # «Catalog.Товары.Form.X», и файл со слотом попадал бы в список дважды. Вырезаем + # слоты, а не пропускаем файл целиком — иначе настоящая ссылка рядом со слотом + # осталась бы незамеченной, а её, в отличие от слота, автоматически не починить. + content_no_slots = form_slot_re.sub("", content) if full_path in form_slot_files else content + + matched = False for pat in search_patterns: - if pat in content: + if pat in content_no_slots: references.append({"File": rel_path, "Pattern": pat}) + matched = True break - - # Also check Type.Name references - type_name_ref = f"{obj_type}.{obj_name}" - already_found_files = {r["File"] for r in references} - - for root_path, dirs, files in os.walk(config_dir): - for fname in files: - if not fname.lower().endswith(".xml"): - continue - full_path = os.path.join(root_path, fname) - - if exclude_file and os.path.normcase(full_path) == os.path.normcase(exclude_file): - continue - skip = False - for ed in exclude_dirs: - if os.path.normcase(full_path).startswith(os.path.normcase(ed + os.sep)) or os.path.normcase(full_path) == os.path.normcase(ed): - skip = True - break - if skip: - continue - - rel_path = os.path.relpath(full_path, config_dir) - rel_path_fwd = rel_path.replace("\\", "/") - - if rel_path_fwd == "Configuration.xml" or rel_path_fwd == "ConfigDumpInfo.xml" or rel_path_fwd.startswith("Subsystems"): - continue - - if rel_path in already_found_files: - continue - - try: - with open(full_path, "r", encoding="utf-8-sig") as fh: - content = fh.read() - except Exception: - continue - - if type_name_ref in content: + if ext == ".xml" and not matched and type_name_ref in content_no_slots: references.append({"File": rel_path, "Pattern": type_name_ref}) if references: @@ -555,7 +570,8 @@ def main(): if not args.Force: print(f"[ERROR] Cannot remove: object has {len(references)} reference(s).") - print(" Use -Force to remove anyway, or fix references first.") + print(" The user decides: fix the references, keep the object, or") + print(" re-run with -Force — form references are cleared.") sys.exit(1) else: print("[WARN] -Force specified, proceeding despite references") @@ -584,15 +600,9 @@ def main(): if localname(child) == obj_type and (child.text or "").strip() == obj_name: found = True if not args.DryRun: - # Remove preceding whitespace (tail of previous sibling or text of parent) - prev = child.getprevious() - if prev is not None: - if prev.tail and prev.tail.strip() == "": - prev.tail = prev.tail.rsplit("\n", 1)[0] + "\n" if "\n" in prev.tail else "" - if not prev.tail.strip(): - # Keep just the last newline+indent before the next element - pass - child_objects.remove(child) + # Общий помощник — зеркало DOM-поведения PS. Прежняя ветка теряла + # отступ следующего элемента, если удалялся ПЕРВЫЙ ребёнок. + remove_node_with_indent(child) print(f"[OK] Removed <{obj_type}>{obj_name} from ChildObjects") actions += 1 break @@ -682,6 +692,53 @@ def main(): else: print("[OK] No Subsystems directory") + # --- 4b. Clear form slots pointing at this object's forms --- + + # Только слоты форм: пустой слот легален (164 508 пустых на корпус), поэтому замена + # однозначна. Ссылки на типы и вызовы в .bsl не трогаем — чем их заменить, неизвестно. + if form_slot_files: + print() + print("--- Form slots ---") + slot_prefix = f"{obj_type}.{obj_name}.Form." + common_form_ref = f"CommonForm.{obj_name}" + for slot_path in sorted(form_slot_files): + if args.DryRun: + print(f"[DRY-RUN] Would clear form slot(s) in {form_slot_files[slot_path]}") + continue + slot_parser = etree.XMLParser(remove_blank_text=False) + slot_tree = etree.parse(slot_path, slot_parser) + slot_root = slot_tree.getroot() + is_form_file = localname(slot_root) == "Form" + touched = [] + for el in list(slot_root.iter()): + if not isinstance(el.tag, str) or len(el) > 0: + continue + val = (el.text or "").strip() + if not val: + continue + # Сравнение регистронезависимое — как у платформы (в PS -eq регистр не различает). + if val.lower() != common_form_ref.lower() and not val.lower().startswith(slot_prefix.lower()): + continue + + parent = el.getparent() + ln = localname(el) + if ln == "Form" and parent is not None and localname(parent) == "Item": + touched.append(f"{localname(parent)}/{ln}") + remove_node_with_indent(parent) + elif is_form_file: + # Внутри Ext/Form.xml пустых и нет ни + # одного — каноничное «не задано» там это отсутствие тега. + touched.append(ln) + remove_node_with_indent(el) + else: + # text=None, а не "": Конфигуратор пустых пар не пишет. + touched.append(ln) + el.text = None + if not touched: + continue + save_xml_bom(slot_tree, slot_path) + print(f"[OK] Cleared in {form_slot_files[slot_path]}: {', '.join(sorted(set(touched)))}") + # --- 5. Delete object files --- print() print("--- Files ---")