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}") + ")")
+$formSlotFiles = @{}
+
# Search all XML and BSL files
$references = @()
-$searchExtensions = @("*.xml", "*.bsl")
+$searchExtensions = @(".xml", ".bsl")
-foreach ($ext in $searchExtensions) {
- $files = @(Get-ChildItem $ConfigDir -Filter $ext -Recurse -File -ErrorAction SilentlyContinue)
- foreach ($file in $files) {
- # Skip own files
- if ($excludeFile -and $file.FullName -eq $excludeFile) { continue }
- if ($excludeDirs.Count -gt 0) {
- $skip = $false
- foreach ($ed in $excludeDirs) {
- if ($file.FullName.StartsWith($ed)) { $skip = $true; break }
- }
- if ($skip) { continue }
- }
- # Skip auto-cleaned files (Configuration.xml, ConfigDumpInfo.xml, Subsystems)
- $relPath = $file.FullName.Substring($ConfigDir.Length + 1)
- if ($relPath -eq "Configuration.xml" -or $relPath -eq "ConfigDumpInfo.xml" -or $relPath.StartsWith("Subsystems")) { continue }
-
- $content = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
- foreach ($pat in $searchPatterns) {
- if ($content.Contains($pat)) {
- $references += @{ File = $relPath; Pattern = $pat }
- break # one match per file is enough
- }
- }
- }
-}
-
-# Also check for Type.Name references (subsystem content, doc journal, etc.) — but NOT in own files
+# EnumerateFiles одним проходом, а не Get-ChildItem -Recurse дважды: на ERP (73 904 XML)
+# обход обёртками занимает 180 с против 47 с, а проходов было два.
$typeNameRef = "${objType}.${objName}"
-$files = @(Get-ChildItem $ConfigDir -Filter "*.xml" -Recurse -File -ErrorAction SilentlyContinue)
-foreach ($file in $files) {
- if ($excludeFile -and $file.FullName -eq $excludeFile) { continue }
+foreach ($filePath in [System.IO.Directory]::EnumerateFiles($ConfigDir, "*.*", [System.IO.SearchOption]::AllDirectories)) {
+ $ext = [System.IO.Path]::GetExtension($filePath).ToLowerInvariant()
+ if ($searchExtensions -notcontains $ext) { continue }
+
+ # Skip own files
+ if ($excludeFile -and $filePath -eq $excludeFile) { continue }
if ($excludeDirs.Count -gt 0) {
$skip = $false
foreach ($ed in $excludeDirs) {
- if ($file.FullName.StartsWith($ed)) { $skip = $true; break }
+ if ($filePath.StartsWith($ed)) { $skip = $true; break }
}
if ($skip) { continue }
}
- # Skip Configuration.xml and Subsystems — they will be cleaned automatically
- $relPath = $file.FullName.Substring($ConfigDir.Length + 1)
- if ($relPath -eq "Configuration.xml") { continue }
- if ($relPath -eq "ConfigDumpInfo.xml") { continue }
- if ($relPath.StartsWith("Subsystems")) { continue }
- $content = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
- if ($content.Contains($typeNameRef)) {
- # Check it's not already in references
- $alreadyFound = $false
- foreach ($r in $references) {
- if ($r.File -eq $relPath) { $alreadyFound = $true; break }
+ $relPath = $filePath.Substring($ConfigDir.Length + 1)
+ # Auto-cleaned: ChildObjects в Configuration.xml и состав подсистем. Сам Configuration.xml
+ # при этом НЕ слепая зона — его form-слоты (DefaultReportForm и соседи) не чистятся
+ # автоматически и раньше терялись молча.
+ $isConfigXml = ($relPath -eq "Configuration.xml")
+ $isAutoCleaned = $isConfigXml -or ($relPath -eq "ConfigDumpInfo.xml") -or $relPath.StartsWith("Subsystems")
+
+ $content = [System.IO.File]::ReadAllText($filePath, [System.Text.Encoding]::UTF8)
+
+ if ($ext -eq ".xml") {
+ $slotMatches = $formSlotRe.Matches($content)
+ if ($slotMatches.Count -gt 0) {
+ $formSlotFiles[$filePath] = $relPath
+ foreach ($m in $slotMatches) {
+ $references += @{ File = $relPath; Pattern = "<$($m.Groups[1].Value)>$($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")")
+ form_slot_files = {}
+
# Search all XML and BSL files
references = []
search_extensions = (".xml", ".bsl")
+ # Один проход вместо двух: раньше конфигурация обходилась дважды и каждый файл читался
+ # по два раза. Зеркало EnumerateFiles-прохода в PS.
+ type_name_ref = f"{obj_type}.{obj_name}"
for root_path, dirs, files in os.walk(config_dir):
for fname in files:
ext = os.path.splitext(fname)[1].lower()
@@ -485,9 +515,11 @@ def main():
rel_path = os.path.relpath(full_path, config_dir)
rel_path_fwd = rel_path.replace("\\", "/")
- # Skip auto-cleaned files
- if rel_path_fwd == "Configuration.xml" or rel_path_fwd == "ConfigDumpInfo.xml" or rel_path_fwd.startswith("Subsystems"):
- continue
+ # Auto-cleaned: ChildObjects в Configuration.xml и состав подсистем. Сам
+ # Configuration.xml при этом НЕ слепая зона — его form-слоты (DefaultReportForm
+ # и соседи) не чистятся автоматически и раньше терялись молча.
+ is_auto_cleaned = (rel_path_fwd in ("Configuration.xml", "ConfigDumpInfo.xml")
+ or rel_path_fwd.startswith("Subsystems"))
try:
with open(full_path, "r", encoding="utf-8-sig") as fh:
@@ -495,47 +527,30 @@ def main():
except Exception:
continue
+ if ext == ".xml":
+ slot_matches = list(form_slot_re.finditer(content))
+ if slot_matches:
+ form_slot_files[full_path] = rel_path
+ for m in slot_matches:
+ references.append({"File": rel_path,
+ "Pattern": f"<{m.group(1)}>{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}{obj_type}> 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 ---")