Files
cc-1c-skills/.claude/skills/form-validate/scripts/form-validate.py
T
Nick Shirokov af00aa4711 fix(form-validate,cfe-borrow): остаточные ложные ошибки на формах платформы
Корпусный прогон (УТ/БП/ERP, 21 097 форм) после понижения Command/Action
оставлял 8 форм с ошибками. Разобраны все, дефектов оказалось два.

1. Вложенная таблица. Путь Items.<Таблица>.CurrentData.<Поле> разрешался
   ОДНИМ шагом: если таблица сама привязана через Items.*, корнем
   оставался литерал «Items», и типовая форма объявлялась битой
   (НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP). Теперь
   разрешение идёт цепочкой, со страховкой от кольца ссылок.

2. AutoCommandBar с обычным id вместо -1. Это соглашение, а не требование:
   21 094 формы из 21 097 используют -1, но три платформа выгружает с
   обычным id и грузит их без нареканий. Понижено до предупреждения;
   ошибка осталась на случай, когда id вообще не число.

Оставшиеся три формы — дубли id элементов и команд. Это НЕ ложные
срабатывания: измерение по корпусу показало ровно по одному случаю на
21 097 форм, то есть опечатки вендора, а не структурное правило (были бы
пулы id раздельными, пересечений были бы тысячи). Плюс кейс duplicate-id
прямо требует считать дубль ошибкой.

Итого по корпусу: 283 формы с ошибками → 3.

Заодно дооформлена фикстура cfe-borrow/container-types (пространство имён
веб-сервису, документ журналу и последовательности): verify-snapshots по
cfe-borrow теперь 12/12.
2026-08-12 20:20:02 +03:00

967 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# form-validate v1.15 — Validate 1C managed form
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import os
import re
import sys
from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
F_NS = "http://v8.1c.ru/8.3/xcf/logform"
V8_NS = "http://v8.1c.ru/8.1/data/core"
NSMAP = {"f": F_NS, "v8": V8_NS}
KNOWN_INVALID_TYPES = {
'FormDataStructure', 'FormDataCollection', 'FormDataTree',
'FormDataTreeItem', 'FormDataCollectionItem',
'FormGroup', 'FormField', 'FormButton', 'FormDecoration', 'FormTable',
}
VALID_CLOSED_TYPES = {
'xs:boolean', 'xs:string', 'xs:decimal', 'xs:dateTime', 'xs:binary',
'v8:FillChecking', 'v8:Null', 'v8:StandardPeriod', 'v8:StandardBeginningDate', 'v8:Type',
'v8:TypeDescription', 'v8:UUID', 'v8:ValueListType', 'v8:ValueTable', 'v8:ValueTree',
'v8:Universal', 'v8:FixedArray', 'v8:FixedStructure',
'v8ui:Color', 'v8ui:Font', 'v8ui:FormattedString', 'v8ui:HorizontalAlign',
'v8ui:Picture', 'v8ui:SizeChangeMode', 'v8ui:VerticalAlign',
'dcsset:DataCompositionComparisonType', 'dcsset:DataCompositionFieldPlacement',
'dcsset:Filter', 'dcsset:SettingsComposer', 'dcsset:DataCompositionSettings',
'dcssch:DataCompositionSchema',
'dcscor:DataCompositionComparisonType', 'dcscor:DataCompositionGroupType',
'dcscor:DataCompositionPeriodAdditionType', 'dcscor:DataCompositionSortDirection', 'dcscor:Field',
'ent:AccountType', 'ent:AccumulationRecordType', 'ent:AccountingRecordType',
}
VALID_CFG_PREFIXES = {
'AccountingRegisterRecordSet', 'AccumulationRegisterRecordSet',
'BusinessProcessObject', 'BusinessProcessRef',
'CatalogObject', 'CatalogRef',
'ChartOfAccountsObject', 'ChartOfAccountsRef',
'ChartOfCalculationTypesObject', 'ChartOfCalculationTypesRef',
'ChartOfCharacteristicTypesObject', 'ChartOfCharacteristicTypesRef',
'ConstantsSet', 'DataProcessorObject', 'DocumentObject', 'DocumentRef',
'DynamicList', 'EnumRef', 'ExchangePlanObject', 'ExchangePlanRef',
'ExternalDataProcessorObject', 'ExternalReportObject',
'InformationRegisterRecordManager', 'InformationRegisterRecordSet',
'ReportObject', 'TaskObject', 'TaskRef',
}
# Корень автономной внешней обработки/отчёта. Копия общего эталона (семья
# support-guard: is_external_root, авторитет — cf-edit).
def _sg_is_external_root(xml_path):
if not os.path.isfile(xml_path):
return False
try:
mx = etree.parse(xml_path).getroot()
for child in mx:
if isinstance(child.tag, str):
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
except Exception:
return False
return False
# Версия формата выгрузки. Копия общего эталона (семья detect_format_version, авторитет —
# form-compile): та же ветка для автономной EPF/ERF, где версию несёт корень обработки.
def detect_format_version(d):
while d:
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
ext_path = d + ".xml"
if os.path.isfile(ext_path):
with open(ext_path, "r", encoding="utf-8-sig") as f:
ext_head = f.read(2000)
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
if m:
return m.group(1)
cfg_path = os.path.join(d, "Configuration.xml")
if os.path.isfile(cfg_path):
with open(cfg_path, "r", encoding="utf-8-sig") as f:
head = f.read(2000)
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
if m:
return m.group(1)
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return "2.17"
def localname(el):
return etree.QName(el.tag).localname
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Validate 1C managed form", allow_abbrev=False)
parser.add_argument("-FormPath", "-Path", required=True)
parser.add_argument("-Detailed", action="store_true")
parser.add_argument("-MaxErrors", type=int, default=30)
args = ci_parse_args(parser)
form_path = args.FormPath
detailed = args.Detailed
max_errors = args.MaxErrors
if not os.path.isabs(form_path):
form_path = os.path.join(os.getcwd(), form_path)
# A: Directory → Ext/Form.xml
if os.path.isdir(form_path):
form_path = os.path.join(form_path, 'Ext', 'Form.xml')
# B1: Missing Ext/ (e.g. Forms/Форма/Form.xml → Forms/Форма/Ext/Form.xml)
if not os.path.exists(form_path):
fn = os.path.basename(form_path)
if fn == 'Form.xml':
c = os.path.join(os.path.dirname(form_path), 'Ext', fn)
if os.path.exists(c):
form_path = c
# B2: Descriptor (Forms/Форма.xml → Forms/Форма/Ext/Form.xml)
if not os.path.exists(form_path) and form_path.endswith('.xml'):
stem = os.path.splitext(os.path.basename(form_path))[0]
parent = os.path.dirname(form_path)
c = os.path.join(parent, stem, 'Ext', 'Form.xml')
if os.path.exists(c):
form_path = c
if not os.path.isfile(form_path):
print(f"File not found: {form_path}", file=sys.stderr)
sys.exit(1)
# --- Load XML ---
try:
xml_parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(form_path, xml_parser)
except Exception as e:
print(f"[ERROR] XML parse error: {e}")
print()
print("---")
print("Errors: 1, Warnings: 0")
sys.exit(1)
root = tree.getroot()
# Detect context: config vs EPF/ERF
is_config_context = False
config_xml_path = ''
version_anchor = ''
walk_dir = os.path.dirname(os.path.abspath(form_path))
for _ in range(15):
parent = os.path.dirname(walk_dir)
if parent == walk_dir:
break
# Порядок проверок тот же, что у detect_format_version: сначала корень автономной обработки,
# потом Configuration.xml — иначе форма внутри EPF, лежащей в дереве конфигурации, взяла бы
# версию конфигурации.
ext_root = walk_dir + '.xml'
if not version_anchor:
if _sg_is_external_root(ext_root):
# Ближайший якорь побеждает: автономная обработка остаётся автономной, даже если её
# исходники лежат внутри дерева с Configuration.xml (типовая раскладка проекта:
# src/cf рядом с src/epf). Иначе её собственные External*-типы считались бы ошибкой.
version_anchor = ext_root
break
if os.path.isfile(os.path.join(walk_dir, 'Configuration.xml')):
is_config_context = True
config_xml_path = os.path.join(walk_dir, 'Configuration.xml')
if not version_anchor:
version_anchor = config_xml_path
break
walk_dir = parent
errors = 0
warnings = 0
ok_count = 0
stopped = False
output_lines = []
def report_ok(msg):
nonlocal ok_count
ok_count += 1
if detailed:
output_lines.append(f"[OK] {msg}")
def report_error(msg):
nonlocal errors, stopped
errors += 1
output_lines.append(f"[ERROR] {msg}")
if errors >= max_errors:
stopped = True
def report_warn(msg):
nonlocal warnings
warnings += 1
output_lines.append(f"[WARN] {msg}")
# --- Form name from path ---
form_name = os.path.splitext(os.path.basename(form_path))[0]
parent_dir = os.path.dirname(form_path)
if parent_dir:
ext_dir = os.path.basename(parent_dir)
if ext_dir == "Ext":
form_dir = os.path.dirname(parent_dir)
if form_dir:
form_name = os.path.basename(form_dir)
output_lines.append(f"=== Validation: Form.{form_name} ===")
output_lines.append("")
# Early BaseForm detection
has_base_form = root.find(f"{{{F_NS}}}BaseForm") is not None
# --- Check 1: Root element and version ---
if localname(root) != "Form":
report_error(f"Root element is '{localname(root)}', expected 'Form'")
else:
version = root.get("version", "")
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
if version in ("2.17", "2.18", "2.19", "2.20"):
report_ok(f"Root element: Form version={version}")
elif version:
report_warn(f"Form version='{version}' (expected 2.17-2.20)")
else:
report_warn("Form version attribute missing")
# --- Check 2: AutoCommandBar ---
if not stopped:
acb = root.find(f"{{{F_NS}}}AutoCommandBar")
if acb is not None:
acb_name = acb.get("name", "")
acb_id = acb.get("id", "")
# id=-1 — соглашение, а не требование: в корпусе УТ/БП/ERP так у 21 094 форм из 21 097,
# но три формы платформа выгружает с обычным id и грузит их без нареканий. Поэтому
# предупреждение; ошибка — только если id вовсе не число.
if acb_id == "-1":
report_ok(f"AutoCommandBar: name='{acb_name}', id={acb_id}")
elif re.match(r'^-?\d+$', acb_id):
report_warn(f"AutoCommandBar id='{acb_id}', usually '-1'")
else:
report_error(f"AutoCommandBar id='{acb_id}' is not a number")
else:
report_error("AutoCommandBar element missing")
# --- Collect all elements with IDs ---
element_ids = {} # id -> name
element_names = {} # name -> id (имена элементов уникальны в пределах формы)
all_elements = [] # list of dicts {Name, Tag, Id, ParentName, Node}
def collect_elements(node, parent_name):
nonlocal stopped
for child in node:
if not isinstance(child.tag, str):
continue
name = child.get("name", "")
eid = child.get("id", "")
if name and eid:
tag = localname(child)
all_elements.append({
"Name": name,
"Tag": tag,
"Id": eid,
"ParentName": parent_name,
"Node": child,
})
if eid != "-1":
if eid in element_ids:
report_error(f"Duplicate element id={eid}: '{name}' and '{element_ids[eid]}'")
else:
element_ids[eid] = name
# Имена элементов уникальны (требование 1С)
if name in element_names:
report_error(f"Duplicate element name '{name}': id={eid} and id={element_names[name]}")
else:
element_names[name] = eid
child_items = child.find(f"{{{F_NS}}}ChildItems")
if child_items is not None:
collect_elements(child_items, name)
child_items_root = root.find(f"{{{F_NS}}}ChildItems")
if child_items_root is not None:
collect_elements(child_items_root, "(root)")
acb = root.find(f"{{{F_NS}}}AutoCommandBar")
if acb is not None:
acb_children = acb.find(f"{{{F_NS}}}ChildItems")
if acb_children is not None:
collect_elements(acb_children, "\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c")
# --- Check 3: Unique element IDs ---
if not stopped:
# Duplicates already reported during collection
dup_count = 0
id_counts = {}
for el in all_elements:
eid = el["Id"]
if eid == "-1":
continue
id_counts[eid] = id_counts.get(eid, 0) + 1
dup_count = sum(1 for v in id_counts.values() if v > 1)
if dup_count == 0:
report_ok(f"Unique element IDs: {len(element_ids)} elements")
# --- Collect attributes (separate ID pool) ---
attr_map = {} # name -> node
attr_ids = {} # id -> name
attr_nodes_parent = root.find(f"{{{F_NS}}}Attributes")
attr_nodes = []
if attr_nodes_parent is not None:
attr_nodes = attr_nodes_parent.findall(f"{{{F_NS}}}Attribute")
for attr in attr_nodes:
attr_name = attr.get("name", "")
attr_id = attr.get("id", "")
if attr_name:
# Имена реквизитов уникальны среди реквизитов (отдельный неймспейс от элементов)
if attr_name in attr_map:
report_error(f"Duplicate attribute name '{attr_name}': id={attr_id} and id={attr_map[attr_name].get('id', '')}")
attr_map[attr_name] = attr
if attr_id:
if attr_id in attr_ids:
report_error(f"Duplicate attribute id={attr_id}: '{attr_name}' and '{attr_ids[attr_id]}'")
else:
attr_ids[attr_id] = attr_name
# Column IDs uniqueness within parent
col_ids = {}
col_names = {} # имена колонок уникальны в пределах своего реквизита
columns = attr.find(f"{{{F_NS}}}Columns")
if columns is not None:
for col in columns.findall(f"{{{F_NS}}}Column"):
col_id = col.get("id", "")
col_name = col.get("name", "")
if col_id:
if col_id in col_ids:
report_error(f"Duplicate column id={col_id} in '{attr_name}': '{col_name}' and '{col_ids[col_id]}'")
else:
col_ids[col_id] = col_name
if col_name:
if col_name in col_names:
report_error(f"Duplicate column name '{col_name}' in '{attr_name}': id={col_id} and id={col_names[col_name]}")
else:
col_names[col_name] = col_id
if not stopped:
if attr_ids:
report_ok(f"Unique attribute IDs: {len(attr_ids)} entries")
# --- Collect commands (separate ID pool) ---
cmd_map = {} # name -> node
cmd_ids = {} # id -> name
cmd_nodes_parent = root.find(f"{{{F_NS}}}Commands")
cmd_nodes = []
if cmd_nodes_parent is not None:
cmd_nodes = cmd_nodes_parent.findall(f"{{{F_NS}}}Command")
for cmd in cmd_nodes:
cmd_name = cmd.get("name", "")
cmd_id = cmd.get("id", "")
if cmd_name:
# Имена команд уникальны среди команд (отдельный неймспейс)
if cmd_name in cmd_map:
report_error(f"Duplicate command name '{cmd_name}': id={cmd_id} and id={cmd_map[cmd_name].get('id', '')}")
cmd_map[cmd_name] = cmd
if cmd_id:
if cmd_id in cmd_ids:
report_error(f"Duplicate command id={cmd_id}: '{cmd_name}' and '{cmd_ids[cmd_id]}'")
else:
cmd_ids[cmd_id] = cmd_name
if not stopped:
if cmd_ids:
report_ok(f"Unique command IDs: {len(cmd_ids)} entries")
# --- Collect parameters (separate name pool, без id) ---
param_names = {} # name -> True (имена параметров уникальны среди параметров)
params_parent = root.find(f"{{{F_NS}}}Parameters")
if params_parent is not None:
for param in params_parent.findall(f"{{{F_NS}}}Parameter"):
param_name = param.get("name", "")
if param_name:
if param_name in param_names:
report_error(f"Duplicate parameter name '{param_name}'")
else:
param_names[param_name] = True
# --- Check 4: Companion elements ---
companion_rules = {
"InputField": ["ContextMenu", "ExtendedTooltip"],
"CheckBoxField": ["ContextMenu", "ExtendedTooltip"],
"LabelDecoration": ["ContextMenu", "ExtendedTooltip"],
"LabelField": ["ContextMenu", "ExtendedTooltip"],
"PictureDecoration": ["ContextMenu", "ExtendedTooltip"],
"PictureField": ["ContextMenu", "ExtendedTooltip"],
"CalendarField": ["ContextMenu", "ExtendedTooltip"],
"UsualGroup": ["ExtendedTooltip"],
"Pages": ["ExtendedTooltip"],
"Page": ["ExtendedTooltip"],
"Button": ["ExtendedTooltip"],
"Table": ["ContextMenu", "AutoCommandBar", "SearchStringAddition", "ViewStatusAddition", "SearchControlAddition"],
}
if not stopped:
companion_errors = 0
companion_checked = 0
for el in all_elements:
if stopped:
break
tag = el["Tag"]
el_name = el["Name"]
node = el["Node"]
if tag not in companion_rules:
continue
required = companion_rules[tag]
companion_checked += 1
for comp_tag in required:
comp_node = node.find(f"{{{F_NS}}}{comp_tag}")
if comp_node is None:
report_error(f"[{tag}] '{el_name}': missing companion <{comp_tag}>")
companion_errors += 1
if companion_errors == 0 and companion_checked > 0:
report_ok(f"Companion elements: {companion_checked} elements checked")
# --- Check 5: DataPath -> Attribute references ---
if not stopped:
path_errors = 0
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:
if stopped:
break
tag = el["Tag"]
el_name = el["Name"]
node = el["Node"]
if tag in skip_tags:
continue
if has_base_form and el["Id"]:
try:
if int(el["Id"]) < 1000000:
path_base_skipped += 1
continue
except (ValueError, TypeError):
pass
for b_tag in binding_tags:
if stopped:
break
dp_node = node.find(f"{{{F_NS}}}{b_tag}")
if dp_node is None:
continue
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.
# Разрешаем ЦЕПОЧКОЙ: таблица во вложенной таблице сама привязана через Items.*, и один
# шаг оставлял корнем литерал «Items» — форма платформы объявлялась битой (типовые
# НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP).
items_hops = 0
items_broken = False
while root_attr == 'Items':
items_hops += 1
if items_hops > 10: # страховка от кольца ссылок
items_broken = True
break
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.*")
items_broken = True
break
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
items_broken = True
break
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():
items_broken = True
break
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
if table_dp.startswith('~'):
table_dp = table_dp[1:]
segments = table_dp.split(".")
root_attr = segments[0]
if items_broken:
continue
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
path_msg = ""
if path_checked > 0:
path_msg = f"{path_checked} paths checked"
if path_base_skipped > 0:
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"Data bindings: {path_msg}")
elif path_errors == 0:
report_ok("Data bindings: none")
# --- Check 6: Button command references ---
if not stopped:
cmd_errors = 0
cmd_checked = 0
for el in all_elements:
if stopped:
break
tag = el["Tag"]
el_name = el["Name"]
node = el["Node"]
if tag != "Button":
continue
cmd_node = node.find(f"{{{F_NS}}}CommandName")
if cmd_node is None:
continue
cmd_ref = (cmd_node.text or "").strip()
if not cmd_ref:
continue
m = re.match(r'^Form\.Command\.(.+)$', cmd_ref)
if m:
cmd_name_ref = m.group(1)
cmd_checked += 1
if cmd_name_ref not in cmd_map:
report_error(f"[Button] '{el_name}': CommandName='{cmd_ref}' \u2014 command '{cmd_name_ref}' not found in Commands")
cmd_errors += 1
if cmd_errors == 0 and cmd_checked > 0:
report_ok(f"Command references: {cmd_checked} buttons checked")
elif cmd_checked == 0:
report_ok("Command references: none")
# --- Check 7: Events have handler names ---
if not stopped:
event_errors = 0
event_checked = 0
# Form-level events
form_events = root.find(f"{{{F_NS}}}Events")
if form_events is not None:
for evt in form_events.findall(f"{{{F_NS}}}Event"):
evt_name = evt.get("name", "")
handler = (evt.text or "").strip()
event_checked += 1
if not handler:
report_error(f"Form event '{evt_name}': empty handler name")
event_errors += 1
# Element-level events
for el in all_elements:
if stopped:
break
tag = el["Tag"]
el_name = el["Name"]
node = el["Node"]
events_node = node.find(f"{{{F_NS}}}Events")
if events_node is None:
continue
for evt in events_node.findall(f"{{{F_NS}}}Event"):
evt_name = evt.get("name", "")
handler = (evt.text or "").strip()
event_checked += 1
if not handler:
report_error(f"[{tag}] '{el_name}' event '{evt_name}': empty handler name")
event_errors += 1
if event_errors == 0 and event_checked > 0:
report_ok(f"Event handlers: {event_checked} events checked")
elif event_checked == 0:
report_ok("Event handlers: none")
# --- Check 8: Command actions ---
if not stopped:
action_errors = 0
action_checked = 0
# Предупреждение, а не ошибка: <Action> может назначаться в рантайме
# (`Команда.Действие = "Подключаемый_…"` в ПриСозданииНаСервере) — приём типовых конфигураций
# там, где обработчик существует не во всякой сборке. Назначать может и чужой модуль
# (переопределяемый слой, подключаемые команды), так что по одному Form.xml не решить.
# Корпус УТ/БП/ERP: 406 таких команд на 275 формах, произведённых платформой.
for cmd in cmd_nodes:
if stopped:
break
cmd_name = cmd.get("name", "")
action_node = cmd.find(f"{{{F_NS}}}Action")
action_checked += 1
if action_node is None or not (action_node.text or "").strip():
report_warn(f"Command '{cmd_name}': no Action — handler must be assigned at runtime, otherwise the command does nothing")
action_errors += 1
if action_errors == 0 and action_checked > 0:
report_ok(f"Command actions: {action_checked} commands checked")
elif action_checked == 0:
report_ok("Command actions: none")
# --- Check 9: MainAttribute count ---
if not stopped:
main_count = 0
for attr in attr_nodes:
main_node = attr.find(f"{{{F_NS}}}MainAttribute")
if main_node is not None and (main_node.text or "") == "true":
main_count += 1
if main_count <= 1:
main_info = "1 main attribute" if main_count == 1 else "no main attribute"
report_ok(f"MainAttribute: {main_info}")
else:
report_error(f"Multiple MainAttribute=true ({main_count} found, expected 0 or 1)")
# --- Check 10: Title must be multilingual XML ---
if not stopped:
title_node = root.find(f"{{{F_NS}}}Title")
if title_node is not None:
v8_items = title_node.findall(f"{{{V8_NS}}}item")
if len(v8_items) == 0 and (title_node.text or "").strip():
report_error(f"Form Title is plain text ('{(title_node.text or '').strip()}') \u2014 must be multilingual XML (<v8:item>). Use top-level 'title' key in form-compile DSL.")
else:
report_ok("Title: multilingual XML")
# --- Check 11: Extension-specific validations ---
base_form_node = root.find(f"{{{F_NS}}}BaseForm")
is_extension = base_form_node is not None
if not stopped and is_extension:
# 11a. BaseForm version
bf_version = base_form_node.get("version", "")
if bf_version:
report_ok(f"BaseForm: version={bf_version}")
else:
report_warn("BaseForm: version attribute missing")
# 11b. callType values validation
valid_call_types = {"Before", "After", "Override"}
ct_errors = 0
ct_checked = 0
form_events_node = root.find(f"{{{F_NS}}}Events")
if form_events_node is not None:
for evt in form_events_node.findall(f"{{{F_NS}}}Event"):
ct = evt.get("callType", "")
if ct:
ct_checked += 1
if ct not in valid_call_types:
report_error(f"Form event '{evt.get('name', '')}': invalid callType='{ct}' (expected: Before, After, Override)")
ct_errors += 1
for el in all_elements:
if stopped:
break
events_node = el["Node"].find(f"{{{F_NS}}}Events")
if events_node is None:
continue
for evt in events_node.findall(f"{{{F_NS}}}Event"):
ct = evt.get("callType", "")
if ct:
ct_checked += 1
if ct not in valid_call_types:
report_error(f"[{el['Tag']}] '{el['Name']}' event '{evt.get('name', '')}': invalid callType='{ct}'")
ct_errors += 1
for cmd in cmd_nodes:
if stopped:
break
cmd_name = cmd.get("name", "")
for action in cmd.findall(f"{{{F_NS}}}Action"):
ct = action.get("callType", "")
if ct:
ct_checked += 1
if ct not in valid_call_types:
report_error(f"Command '{cmd_name}' Action: invalid callType='{ct}'")
ct_errors += 1
if not stopped and ct_errors == 0 and ct_checked > 0:
report_ok(f"callType values: {ct_checked} checked")
# 11c. Extension ID ranges
base_attr_names = set()
base_cmd_names = set()
bf_attrs = base_form_node.find(f"{{{F_NS}}}Attributes")
if bf_attrs is not None:
for b_attr in bf_attrs.findall(f"{{{F_NS}}}Attribute"):
ba_name = b_attr.get("name", "")
if ba_name:
base_attr_names.add(ba_name)
bf_cmds = base_form_node.find(f"{{{F_NS}}}Commands")
if bf_cmds is not None:
for b_cmd in bf_cmds.findall(f"{{{F_NS}}}Command"):
bc_name = b_cmd.get("name", "")
if bc_name:
base_cmd_names.add(bc_name)
id_warn_count = 0
for attr in attr_nodes:
a_name = attr.get("name", "")
a_id = attr.get("id", "")
if a_name and a_name not in base_attr_names and a_id:
try:
int_id = int(a_id)
if int_id < 1000000:
report_warn(f"Attribute '{a_name}' (id={a_id}): extension-added attribute has id < 1000000")
id_warn_count += 1
except (ValueError, TypeError):
pass
for cmd in cmd_nodes:
c_name = cmd.get("name", "")
c_id = cmd.get("id", "")
if c_name and c_name not in base_cmd_names and c_id:
try:
int_id = int(c_id)
if int_id < 1000000:
report_warn(f"Command '{c_name}' (id={c_id}): extension-added command has id < 1000000")
id_warn_count += 1
except (ValueError, TypeError):
pass
if not stopped and id_warn_count == 0:
ext_attr_count = sum(1 for a in attr_nodes if a.get("name", "") not in base_attr_names)
ext_cmd_count = sum(1 for c in cmd_nodes if c.get("name", "") not in base_cmd_names)
if (ext_attr_count + ext_cmd_count) > 0:
report_ok(f"Extension ID ranges: {ext_attr_count} attr(s), {ext_cmd_count} cmd(s) \u2014 all >= 1000000")
# 11d. \u041f\u0443\u0442\u0438 \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442, \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0444\u043e\u0440\u043c\u0430 \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u044f\u0435\u0442.
# Check 5 \u0442\u0430\u043a\u043e\u0435 \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442: \u0443 \u0437\u0430\u0438\u043c\u0441\u0442\u0432\u043e\u0432\u0430\u043d\u043d\u043e\u0439 \u0444\u043e\u0440\u043c\u044b \u043e\u043d \u043d\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u0442 \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b (id < 1000000),
# \u0430 \u043f\u0440\u0438\u0432\u044f\u0437\u043a\u0438 \u0432 <xr:Link> \u0432\u043e\u043e\u0431\u0449\u0435 \u0432\u043d\u0435 \u0435\u0433\u043e \u0441\u043f\u0438\u0441\u043a\u0430 \u0442\u0435\u0433\u043e\u0432. \u041c\u0435\u0436\u0434\u0443 \u0442\u0435\u043c \u044d\u0442\u043e \u0440\u043e\u0432\u043d\u043e \u0442\u043e\u0442 \u0441\u043b\u0443\u0447\u0430\u0439, \u043d\u0430 \u043a\u043e\u0442\u043e\u0440\u043e\u043c
# \u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u0430 \u043e\u0442\u0432\u0435\u0440\u0433\u0430\u0435\u0442 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0443: \u00ab\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0443\u0442\u044c \u043a \u043f\u043e\u043b\u044e - \u041e\u0431\u044a\u0435\u043a\u0442.X\u00bb. \u041f\u0440\u0430\u0432\u0438\u043b\u043e: \u0435\u0441\u043b\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442
# \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d \u0432 <Attributes> \u0444\u043e\u0440\u043c\u044b, \u043b\u044e\u0431\u043e\u0439 \u043f\u0443\u0442\u044c \u0441 \u043a\u043e\u0440\u043d\u0435\u043c \u00ab\u041e\u0431\u044a\u0435\u043a\u0442\u00bb \u043d\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0441\u044f.
main_attr_declared = False
for attr in attr_nodes:
ma_node = attr.find(f"{{{F_NS}}}MainAttribute")
if ma_node is not None and (ma_node.text or "").strip() == "true":
main_attr_declared = True
break
if not main_attr_declared:
# \u0417\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u0440\u0438\u0432\u044f\u0437\u043e\u043a \u0438\u0449\u0435\u043c \u0442\u0435\u043a\u0441\u0442\u043e\u043c: \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u044e\u0442 \u0438 \u043e\u0431\u044b\u0447\u043d\u044b\u0435 \u0442\u0435\u0433\u0438, \u0438 <xr:DataPath> \u0432\u043d\u0443\u0442\u0440\u0438
# <ChoiceParameterLinks>, \u0430 \u0442\u0435 \u0436\u0438\u0432\u0443\u0442 \u0432 \u0447\u0443\u0436\u043e\u043c \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0435 \u0438\u043c\u0451\u043d.
with open(form_path, "r", encoding="utf-8-sig") as fh:
raw_form = fh.read()
main_base = base_form_node.find(f"{{{F_NS}}}Attributes/{{{F_NS}}}Attribute[{{{F_NS}}}MainAttribute='true']")
dangling_paths = set(re.findall(r'<(?:\w+:)?\w*DataPath[^>]*>(\u041e\u0431\u044a\u0435\u043a\u0442\.[^<]+)</(?:\w+:)?\w*DataPath>', raw_form))
if dangling_paths:
shown = sorted(dangling_paths)
sample = ", ".join(shown[:3])
suffix = f" (\u0438 \u0435\u0449\u0451 {len(shown) - 3})" if len(shown) > 3 else ""
report_error(f"Path(s) rooted at '\u041e\u0431\u044a\u0435\u043a\u0442' but the form declares no MainAttribute: {sample}{suffix}")
elif main_base is not None:
report_ok("Object paths: none dangling (MainAttribute not declared)")
# Check callType without BaseForm
if not stopped and not is_extension:
call_type_without_base = False
fe_node = root.find(f"{{{F_NS}}}Events")
if fe_node is not None:
for evt in fe_node.findall(f"{{{F_NS}}}Event"):
if evt.get("callType"):
call_type_without_base = True
break
if not call_type_without_base:
for cmd in cmd_nodes:
for action in cmd.findall(f"{{{F_NS}}}Action"):
if action.get("callType"):
call_type_without_base = True
break
if call_type_without_base:
break
if call_type_without_base:
report_warn("callType attributes found but no BaseForm \u2014 possible incorrect structure")
# --- Check 12: Type validation ---
if not stopped:
type_nodes = root.xpath('//v8:Type', namespaces={'v8': V8_NS})
type_error_count = 0
type_warn_count = 0
type_count = len(type_nodes)
for tn in type_nodes:
if stopped:
break
tv = (tn.text or "").strip()
if not tv:
continue
if tv in KNOWN_INVALID_TYPES:
report_error(f'12. Type "{tv}": invalid runtime/UI type (not valid in XDTO schema)')
type_error_count += 1
elif tv in VALID_CLOSED_TYPES:
pass # OK
elif tv.startswith("cfg:"):
suffix = tv[4:] # after "cfg:"
prefix = suffix.split(".")[0]
if prefix in VALID_CFG_PREFIXES or suffix == "DynamicList":
# 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_error_count += 1
else:
report_warn(f'12. Type "{tv}": unrecognized cfg prefix')
type_warn_count += 1
elif ":" in tv:
pass # unknown namespace, pass through
else:
report_warn(f'12. Type "{tv}": bare type without namespace prefix')
type_warn_count += 1
if type_error_count == 0 and type_warn_count == 0:
if type_count > 0:
report_ok(f'12. Types: {type_count} values, all valid')
else:
report_ok('12. Types: no type values to check')
# --- Check 13: префиксы в значениях объявлены в самом файле ---
# `cfg:DataProcessorObject.X` в <v8:Type> при незадекларированном xmlns:cfg — валидный XML, который
# платформа не читает вовсе: «Исключение XDTO произошло при чтении файла». Ошибка типична для
# рукописного XML: префикс скопирован из чужой формы, а объявление в корне забыто. Область видимости
# считаем по узлу (nsmap элемента), а не по корню: локальная xmlns на элементе законна.
if not stopped:
prefix_errors = 0
prefix_checked = 0
prefix_re = re.compile(r'^([A-Za-z_][A-Za-z0-9_.-]*):.+$')
for node in root.iter():
if not isinstance(node.tag, str):
continue
ln = localname(node)
values = []
if ln in ('Type', 'TypeSet'):
values.append((node.text or '').strip())
xsi_type = node.get(f'{{{"http://www.w3.org/2001/XMLSchema-instance"}}}type')
if xsi_type:
values.append(xsi_type.strip())
for val in values:
if not val:
continue
m = prefix_re.match(val)
if not m:
continue
prefix_checked += 1
pfx = m.group(1)
if pfx not in node.nsmap:
kind = "xsi:type" if val == xsi_type else "Type"
report_error(f"13. {kind} '{val}': namespace prefix '{pfx}:' is not declared "
"— the platform cannot read the file (XDTO)")
prefix_errors += 1
if prefix_checked == 0:
report_ok('13. Namespace prefixes: nothing to check')
elif prefix_errors == 0:
report_ok(f'13. Namespace prefixes: {prefix_checked} values, all declared')
# --- Check 14: версия формата формы совпадает с версией выгрузки ---
# Версию задаёт платформа, которой выгружали, и в пределах одной выгрузки она едина. Форма из
# другой версии — «Неизвестная версия формата N загружаемого файла»: платформа не читает файл,
# который новее её самой. Источник версии ищем общим helper-ом: он же покрывает автономную
# внешнюю обработку/отчёт, где Configuration.xml нет и версию несёт корень самой обработки.
if not stopped and version_anchor:
form_ver = root.get('version', '')
dump_ver = detect_format_version(os.path.dirname(os.path.abspath(form_path)))
if not form_ver:
report_ok('14. Format version: not comparable')
elif form_ver != dump_ver:
report_error(f'14. Format version {form_ver} differs from the dump ({dump_ver}) '
'— a dump carries one version, the platform refuses a file it cannot read')
else:
report_ok(f'14. Format version: {form_ver}, matches the dump')
# --- Finalize ---
checks = ok_count + errors + warnings
if errors == 0 and warnings == 0 and not detailed:
result = f"=== Validation OK: Form.{form_name} ({checks} checks) ==="
else:
output_lines.append("")
output_lines.append(f"=== Result: {errors} errors, {warnings} warnings ({checks} checks) ===")
result = "\n".join(output_lines)
print(result)
if errors > 0:
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()