diff --git a/.claude/skills/cfe-borrow/scripts/cfe-borrow.ps1 b/.claude/skills/cfe-borrow/scripts/cfe-borrow.ps1 index a8531dd6..90c9d492 100644 --- a/.claude/skills/cfe-borrow/scripts/cfe-borrow.ps1 +++ b/.claude/skills/cfe-borrow/scripts/cfe-borrow.ps1 @@ -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("") | Out-Null $formMetaSb.AppendLine("") | Out-Null @@ -1040,6 +1055,22 @@ function Collect-FormDataPaths { } } + # Also scan Объект.X — 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, "[^<]*\bОбъект\.(\w+(?:\.\w+)*)") + 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 = @() diff --git a/.claude/skills/cfe-borrow/scripts/cfe-borrow.py b/.claude/skills/cfe-borrow/scripts/cfe-borrow.py index ebc213db..61a73b13 100644 --- a/.claude/skills/cfe-borrow/scripts/cfe-borrow.py +++ b/.claude/skills/cfe-borrow/scripts/cfe-borrow.py @@ -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 Объект.X — 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'[^<]*\bОбъект\.(\w+(?:\.\w+)*)', 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 = [ '', f'', diff --git a/.claude/skills/form-validate/scripts/form-validate.ps1 b/.claude/skills/form-validate/scripts/form-validate.ps1 index 9b543cca..b6c9d9b6 100644 --- a/.claude/skills/form-validate/scripts/form-validate.ps1 +++ b/.claude/skills/form-validate/scripts/form-validate.ps1 @@ -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 ). + $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:" — 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..CurrentData.... — 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..CurrentData.*" + # Opaque platform-internal shapes — not validatable from Form.xml alone: + # - bare numeric (e.g. "10", "1000003") — internal index + # - "N/M:" — 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..CurrentData.... — 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.
.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" } } diff --git a/.claude/skills/form-validate/scripts/form-validate.py b/.claude/skills/form-validate/scripts/form-validate.py index d90589f0..3c1ec3b7 100644 --- a/.claude/skills/form-validate/scripts/form-validate.py +++ b/.claude/skills/form-validate/scripts/form-validate.py @@ -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 ). + 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:" — 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..CurrentData.... \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.
.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:" — 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..CurrentData.... — 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.
.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 diff --git a/tests/skills/cases/cfe-borrow/form-bindings.json b/tests/skills/cases/cfe-borrow/form-bindings.json index d69a4479..48d37e2a 100644 --- a/tests/skills/cases/cfe-borrow/form-bindings.json +++ b/tests/skills/cases/cfe-borrow/form-bindings.json @@ -37,5 +37,6 @@ } ], "params": { "extensionPath": "ext", "object": "Catalog.Товары.Form.ФормаЭлемента" }, - "args_extra": ["-BorrowMainAttribute", "Form"] + "args_extra": ["-BorrowMainAttribute", "Form"], + "idempotent": true } diff --git a/tests/skills/cases/form-validate/dangling-binding.json b/tests/skills/cases/form-validate/dangling-binding.json new file mode 100644 index 00000000..78c670b0 --- /dev/null +++ b/tests/skills/cases/form-validate/dangling-binding.json @@ -0,0 +1,7 @@ +{ + "name": "Ошибка: висячая привязка данных (MultipleValueDataPath без реквизита)", + "setup": "fixture:broken-dangling-binding", + "params": { "formPath": "DataProcessors/Тест/Forms/Форма" }, + "expectError": true, + "expect": { "stdoutContains": "MultipleValueDataPath" } +} diff --git a/tests/skills/cases/form-validate/fixtures/broken-dangling-binding/DataProcessors/Тест/Forms/Форма/Ext/Form.xml b/tests/skills/cases/form-validate/fixtures/broken-dangling-binding/DataProcessors/Тест/Forms/Форма/Ext/Form.xml new file mode 100644 index 00000000..ed1d0d56 --- /dev/null +++ b/tests/skills/cases/form-validate/fixtures/broken-dangling-binding/DataProcessors/Тест/Forms/Форма/Ext/Form.xml @@ -0,0 +1,24 @@ + + + + true + + + + Метки + Метки.Значение + Метки.Представление + + + + + + + + cfg:DataProcessorObject.Тест + + true + true + + + \ No newline at end of file