feat(form-validate,cfe-borrow): проверка висячих привязок + идемпотентность re-borrow + добор по Field

Три доработки по форме, полезные модели, использующей навыки.

form-validate v1.7→v1.8 (5a): Check 5 генерализован с одного <DataPath> на все
8 тегов-привязок (DataPath/TitleDataPath/FooterDataPath/HeaderDataPath/
MultipleValue*DataPath/RowPicture*). Висячая привязка (корень не в <Attributes>)
теперь ловится при validate, а не всплывает на дорогом db-load. Skip-правила
(companion-элементы, базовые элементы id<1000000 в BaseForm, opaque-формы)
сохранены без изменений. Заодно фикс бага Check 12 в py-порте
(type_invalid → type_error_count, краш на невалидном cfg:-типе в config-контексте).

cfe-borrow v1.4→v1.5:
- #4: borrow_form переиспользует uuid обёртки Forms/<Name>.xml, если файл уже
  существует, вместо генерации нового → повторное заимствование формы
  байт-идемпотентно (агент может ретраить без дрейфа identity).
- #1: collect для -BorrowMainAttribute дополнен сканом <Field>Объект.X</Field>
  (поля фильтров/условного оформления/динсписков) — набор заимствованных
  реквизитов теперь совпадает с Конфигуратором (добавился УдалитьЮрФизЛицо).

Тесты: form-validate/dangling-binding (фикстура broken-dangling-binding,
expectError+stdoutContains); cfe-borrow/form-bindings + idempotent:true.
Регресс 6/6 cfe-borrow + 11/11 form-validate на обоих рантаймах, E2E-load OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-06-23 12:38:39 +03:00
parent 4767800fce
commit 36c2fd9233
7 changed files with 213 additions and 107 deletions
@@ -1,4 +1,4 @@
# cfe-borrow v1.4 — Borrow objects from configuration into extension (CFE)
# cfe-borrow v1.5 — Borrow objects from configuration into extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)][string]$ExtensionPath,
@@ -506,8 +506,23 @@ function Borrow-Form {
}
$srcFormContent = [System.IO.File]::ReadAllText($srcFormXmlPath, $enc)
# 3. Generate form metadata XML (ФормаЭлемента.xml)
$newFormUuid = [guid]::NewGuid().ToString()
# 3. Generate form metadata XML (ФормаЭлемента.xml).
# If the wrapper was already borrowed, reuse its uuid so re-borrow is idempotent
# (regenerating it would churn the form's identity on every rerun).
$formMetaFileExisting = Join-Path (Join-Path (Join-Path (Join-Path $extDir $dirName) $objName) "Forms") "${formName}.xml"
$newFormUuid = ""
if (Test-Path $formMetaFileExisting) {
try {
$existingDoc = New-Object System.Xml.XmlDocument
$existingDoc.Load($formMetaFileExisting)
$existingFormNode = $existingDoc.DocumentElement.SelectSingleNode("*[local-name()='Form']")
if ($existingFormNode) {
$existingUuid = $existingFormNode.GetAttribute("uuid")
if ($existingUuid) { $newFormUuid = $existingUuid }
}
} catch { }
}
if (-not $newFormUuid) { $newFormUuid = [guid]::NewGuid().ToString() }
$formMetaSb = New-Object System.Text.StringBuilder
$formMetaSb.AppendLine("<?xml version=`"1.0`" encoding=`"UTF-8`"?>") | Out-Null
$formMetaSb.AppendLine("<MetaDataObject $($script:xmlnsDecl) version=`"$($script:formatVersion)`">") | Out-Null
@@ -1040,6 +1055,22 @@ function Collect-FormDataPaths {
}
}
# Also scan <Field>Объект.X</Field> — object attributes referenced by filter/conditional-appearance
# fields (and dynamic lists), not via a *DataPath binding (e.g. УдалитьЮрФизЛицо). Designer borrows these too.
$fieldMatches = [regex]::Matches($content, "<Field>[^<]*\bОбъект\.(\w+(?:\.\w+)*)</Field>")
foreach ($m in $fieldMatches) {
$path = $m.Groups[1].Value
$segments = $path.Split(".")
$seg0 = $segments[0]
if ($script:standardFields -contains $seg0) { continue }
$firstLevel[$seg0] = $true
if ($segments.Count -ge 2) {
$seg1 = $segments[1]
if ($script:standardFields -contains $seg1) { continue }
$deepPaths += @{ ObjectAttr = $seg0; SubAttr = $seg1 }
}
}
# Deduplicate deep paths
$seen = @{}
$uniqueDeep = @()
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# cfe-borrow v1.4 — Borrow objects from configuration into extension (CFE)
# cfe-borrow v1.5 — Borrow objects from configuration into extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -690,6 +690,21 @@ def main():
continue
deep_paths.append({"ObjectAttr": seg0, "SubAttr": seg1})
# Also scan <Field>Объект.X</Field> — object attributes referenced by filter/conditional-appearance
# fields (and dynamic lists), not via a *DataPath binding (e.g. УдалитьЮрФизЛицо). Designer borrows these too.
for m in re.finditer(r'<Field>[^<]*\bОбъект\.(\w+(?:\.\w+)*)</Field>', content):
path = m.group(1)
segments = path.split(".")
seg0 = segments[0]
if seg0 in STANDARD_FIELDS:
continue
first_level[seg0] = True
if len(segments) >= 2:
seg1 = segments[1]
if seg1 in STANDARD_FIELDS:
continue
deep_paths.append({"ObjectAttr": seg0, "SubAttr": seg1})
# Deduplicate deep paths
seen = set()
unique_deep = []
@@ -1138,8 +1153,22 @@ def main():
with open(src_form_xml_path, "r", encoding="utf-8-sig") as fh:
src_form_content = fh.read()
# 3. Generate form metadata XML
new_form_uuid = new_guid()
# 3. Generate form metadata XML.
# If the wrapper was already borrowed, reuse its uuid so re-borrow is idempotent
# (regenerating it would churn the form's identity on every rerun).
existing_wrapper = os.path.join(ext_dir, dir_name, obj_name, "Forms", f"{form_name}.xml")
new_form_uuid = ""
if os.path.isfile(existing_wrapper):
try:
existing_root = etree.parse(existing_wrapper).getroot()
for c in existing_root:
if isinstance(c.tag, str) and localname(c) == "Form":
new_form_uuid = c.get("uuid", "") or ""
break
except Exception:
new_form_uuid = ""
if not new_form_uuid:
new_form_uuid = new_guid()
form_meta_lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
f'<MetaDataObject {XMLNS_DECL} version="{format_version}">',
@@ -1,4 +1,4 @@
# form-validate v1.7 — Validate 1C managed form
# form-validate v1.8 — Validate 1C managed form
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -382,6 +382,10 @@ if (-not $stopped) {
$pathChecked = 0
$pathBaseSkipped = 0
# All data-binding tags whose value is an attribute path (root must exist in <Attributes>).
$bindingTags = @('DataPath','TitleDataPath','FooterDataPath','HeaderDataPath',
'MultipleValueDataPath','MultipleValuePresentDataPath','RowPictureDataPath','MultipleValuePictureDataPath')
foreach ($el in $allElements) {
if ($stopped) { break }
$tag = $el.Tag
@@ -398,60 +402,63 @@ if (-not $stopped) {
try { if ([int]$el.Id -lt 1000000) { $pathBaseSkipped++; continue } } catch {}
}
$dpNode = $node.SelectSingleNode("f:DataPath", $nsMgr)
if (-not $dpNode) { continue }
foreach ($bTag in $bindingTags) {
if ($stopped) { break }
$dpNode = $node.SelectSingleNode("f:$bTag", $nsMgr)
if (-not $dpNode) { continue }
$dataPath = $dpNode.InnerText.Trim()
if (-not $dataPath) { continue }
$dataPath = $dpNode.InnerText.Trim()
if (-not $dataPath) { continue }
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
# - bare numeric (e.g. "10", "1000003") — internal index
# - "N/M:<uuid>" — metadata reference by UUID
if ($dataPath -match '^\d+$' -or $dataPath -match '^\d+/\d+:[0-9a-fA-F-]+$') {
continue
}
$pathChecked++
# Extract root segment of path, strip array indices like [0]
$cleanPath = $dataPath -replace '\[\d+\]', ''
# Strip leading '~' (current row of DynamicList: ~Список.Поле)
if ($cleanPath.StartsWith('~')) { $cleanPath = $cleanPath.Substring(1) }
$segments = $cleanPath -split '\.'
$rootAttr = $segments[0]
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
if ($rootAttr -eq 'Items') {
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
Report-Warn "[$tag] '$elName': DataPath='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
# Opaque platform-internal shapes — not validatable from Form.xml alone:
# - bare numeric (e.g. "10", "1000003") — internal index
# - "N/M:<uuid>" — metadata reference by UUID
if ($dataPath -match '^\d+$' -or $dataPath -match '^\d+/\d+:[0-9a-fA-F-]+$') {
continue
}
$tableName = $segments[1]
$tableEl = $null
foreach ($candidate in $allElements) {
if ($candidate.Tag -eq 'Table' -and $candidate.Name -eq $tableName) {
$tableEl = $candidate
break
$pathChecked++
# Extract root segment of path, strip array indices like [0]
$cleanPath = $dataPath -replace '\[\d+\]', ''
# Strip leading '~' (current row of DynamicList: ~Список.Поле)
if ($cleanPath.StartsWith('~')) { $cleanPath = $cleanPath.Substring(1) }
$segments = $cleanPath -split '\.'
$rootAttr = $segments[0]
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
if ($rootAttr -eq 'Items') {
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
Report-Warn "[$tag] '$elName': $bTag='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
continue
}
$tableName = $segments[1]
$tableEl = $null
foreach ($candidate in $allElements) {
if ($candidate.Tag -eq 'Table' -and $candidate.Name -eq $tableName) {
$tableEl = $candidate
break
}
}
if (-not $tableEl) {
Report-Error "[$tag] '$elName': $bTag='$dataPath' — table element '$tableName' not found"
$pathErrors++
continue
}
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
# Table without DataPath — can't resolve further, accept silently
continue
}
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
$rootAttr = ($tableDp -split '\.')[0]
}
if (-not $tableEl) {
Report-Error "[$tag] '$elName': DataPath='$dataPath' — table element '$tableName' not found"
$pathErrors++
continue
}
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
# Table without DataPath — can't resolve further, accept silently
continue
}
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
$rootAttr = ($tableDp -split '\.')[0]
}
if (-not $attrMap.ContainsKey($rootAttr)) {
Report-Error "[$tag] '$elName': DataPath='$dataPath' — attribute '$rootAttr' not found"
$pathErrors++
if (-not $attrMap.ContainsKey($rootAttr)) {
Report-Error "[$tag] '$elName': $bTag='$dataPath' — attribute '$rootAttr' not found"
$pathErrors++
}
}
}
@@ -462,9 +469,9 @@ if (-not $stopped) {
$pathMsg = if ($pathMsg) { "$pathMsg, $skipNote" } else { $skipNote }
}
if ($pathErrors -eq 0 -and $pathMsg) {
Report-OK "DataPath references: $pathMsg"
Report-OK "Data bindings: $pathMsg"
} elseif ($pathErrors -eq 0) {
Report-OK "DataPath references: none"
Report-OK "Data bindings: none"
}
}
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# form-validate v1.7 — Validate 1C managed form
# form-validate v1.8 — Validate 1C managed form
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -379,6 +379,10 @@ def main():
path_checked = 0
path_base_skipped = 0
# All data-binding tags whose value is an attribute path (root must exist in <Attributes>).
binding_tags = ["DataPath", "TitleDataPath", "FooterDataPath", "HeaderDataPath",
"MultipleValueDataPath", "MultipleValuePresentDataPath", "RowPictureDataPath", "MultipleValuePictureDataPath"]
skip_tags = {"ContextMenu", "ExtendedTooltip", "AutoCommandBar", "SearchStringAddition", "ViewStatusAddition", "SearchControlAddition"}
for el in all_elements:
@@ -399,55 +403,58 @@ def main():
except (ValueError, TypeError):
pass
dp_node = node.find(f"{{{F_NS}}}DataPath")
if dp_node is None:
continue
data_path = (dp_node.text or "").strip()
if not data_path:
continue
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
# - bare numeric (e.g. "10", "1000003") — internal index
# - "N/M:<uuid>" — metadata reference by UUID
if re.match(r'^\d+$', data_path) or re.match(r'^\d+/\d+:[0-9a-fA-F-]+$', data_path):
continue
path_checked += 1
clean_path = re.sub(r'\[\d+\]', '', data_path)
# Strip leading '~' (current row of DynamicList: ~\u0421\u043f\u0438\u0441\u043e\u043a.\u041f\u043e\u043b\u0435)
if clean_path.startswith('~'):
clean_path = clean_path[1:]
segments = clean_path.split(".")
root_attr = segments[0]
# Resolve Items.<TableName>.CurrentData.<Field>... \u2014 table element, not attribute
if root_attr == 'Items':
if len(segments) < 3 or segments[2] != 'CurrentData':
report_warn(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 unknown Items.* shape, expected Items.<Table>.CurrentData.*")
for b_tag in binding_tags:
if stopped:
break
dp_node = node.find(f"{{{F_NS}}}{b_tag}")
if dp_node is None:
continue
table_name = segments[1]
table_el = None
for candidate in all_elements:
if candidate["Tag"] == 'Table' and candidate["Name"] == table_name:
table_el = candidate
break
if table_el is None:
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 table element '{table_name}' not found")
data_path = (dp_node.text or "").strip()
if not data_path:
continue
# Opaque platform-internal shapes — not validatable from Form.xml alone:
# - bare numeric (e.g. "10", "1000003") — internal index
# - "N/M:<uuid>" — metadata reference by UUID
if re.match(r'^\d+$', data_path) or re.match(r'^\d+/\d+:[0-9a-fA-F-]+$', data_path):
continue
path_checked += 1
clean_path = re.sub(r'\[\d+\]', '', data_path)
# Strip leading '~' (current row of DynamicList: ~Список.Поле)
if clean_path.startswith('~'):
clean_path = clean_path[1:]
segments = clean_path.split(".")
root_attr = segments[0]
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
if root_attr == 'Items':
if len(segments) < 3 or segments[2] != 'CurrentData':
report_warn(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — unknown Items.* shape, expected Items.<Table>.CurrentData.*")
continue
table_name = segments[1]
table_el = None
for candidate in all_elements:
if candidate["Tag"] == 'Table' and candidate["Name"] == table_name:
table_el = candidate
break
if table_el is None:
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — table element '{table_name}' not found")
path_errors += 1
continue
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
if table_dp_node is None or not (table_dp_node.text or "").strip():
continue
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
if table_dp.startswith('~'):
table_dp = table_dp[1:]
root_attr = table_dp.split(".")[0]
if root_attr not in attr_map:
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — attribute '{root_attr}' not found")
path_errors += 1
continue
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
if table_dp_node is None or not (table_dp_node.text or "").strip():
continue
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
if table_dp.startswith('~'):
table_dp = table_dp[1:]
root_attr = table_dp.split(".")[0]
if root_attr not in attr_map:
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 attribute '{root_attr}' not found")
path_errors += 1
path_msg = ""
if path_checked > 0:
@@ -456,7 +463,7 @@ def main():
skip_note = f"{path_base_skipped} base skipped"
path_msg = f"{path_msg}, {skip_note}" if path_msg else skip_note
if path_errors == 0 and path_msg:
report_ok(f"DataPath references: {path_msg}")
report_ok(f"Data bindings: {path_msg}")
# --- Check 6: Button command references ---
if not stopped:
@@ -724,7 +731,7 @@ def main():
# ExternalDataProcessorObject/ExternalReportObject valid only in EPF/ERF context
if is_config_context and prefix in ('ExternalDataProcessorObject', 'ExternalReportObject'):
report_error(f'12. Type "{tv}": External* type in configuration context (use DataProcessorObject/ReportObject instead)')
type_invalid += 1
type_error_count += 1
else:
report_warn(f'12. Type "{tv}": unrecognized cfg prefix')
type_warn_count += 1
@@ -37,5 +37,6 @@
}
],
"params": { "extensionPath": "ext", "object": "Catalog.Товары.Form.ФормаЭлемента" },
"args_extra": ["-BorrowMainAttribute", "Form"]
"args_extra": ["-BorrowMainAttribute", "Form"],
"idempotent": true
}
@@ -0,0 +1,7 @@
{
"name": "Ошибка: висячая привязка данных (MultipleValueDataPath без реквизита)",
"setup": "fixture:broken-dangling-binding",
"params": { "formPath": "DataProcessors/Тест/Forms/Форма" },
"expectError": true,
"expect": { "stdoutContains": "MultipleValueDataPath" }
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<Form xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
<Autofill>true</Autofill>
</AutoCommandBar>
<ChildItems>
<InputField name="ПолеМеток" id="2">
<DataPath>Метки</DataPath>
<MultipleValueDataPath>Метки.Значение</MultipleValueDataPath>
<MultipleValuePresentDataPath>Метки.Представление</MultipleValuePresentDataPath>
<ContextMenu name="ПолеМетокКонтекстноеМеню" id="3"/>
<ExtendedTooltip name="ПолеМетокРасширеннаяПодсказка" id="4"/>
</InputField>
</ChildItems>
<Attributes>
<Attribute name="Объект" id="1">
<Type>
<v8:Type>cfg:DataProcessorObject.Тест</v8:Type>
</Type>
<MainAttribute>true</MainAttribute>
<SavedData>true</SavedData>
</Attribute>
</Attributes>
</Form>