#!/usr/bin/env python3 # cfe-validate v1.8 — Validate 1C configuration extension XML structure (CFE) (полнота GeneratedType, ТЧ из AdditionalColumns, сверка путей с -ConfigPath) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills """Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects.""" import sys, os, argparse, re 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) NS = { 'md': 'http://v8.1c.ru/8.3/MDClasses', 'v8': 'http://v8.1c.ru/8.1/data/core', 'xr': 'http://v8.1c.ru/8.3/xcf/readable', 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', 'xs': 'http://www.w3.org/2001/XMLSchema', 'app': 'http://v8.1c.ru/8.2/managed-application/core', } GUID_PATTERN = re.compile( r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' ) IDENT_PATTERN = re.compile( r'^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_]' r'[A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$' ) # 7 fixed ClassIds for Configuration VALID_CLASS_IDS = [ '9cd510cd-abfc-11d4-9434-004095e12fc7', '9fcd25a0-4822-11d4-9414-008048da11f9', 'e3687481-0a87-462c-a166-9f34594f9bba', '9de14907-ec23-4a07-96f0-85521cb6b53b', '51f2d5d8-ea4d-4064-8892-82951750031e', 'e68182ea-4237-4383-967f-90c1e3370bc7', 'fb282519-d103-4dd3-bc12-cb271d631dfc', ] # 44 types in canonical order CHILD_OBJECT_TYPES = [ 'Language', 'Subsystem', 'StyleItem', 'Style', 'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate', 'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan', 'XDTOPackage', 'WebService', 'HTTPService', 'WSReference', 'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption', 'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup', 'Constant', 'CommonForm', 'Catalog', 'Document', 'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum', 'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister', 'ChartOfCharacteristicTypes', 'ChartOfAccounts', 'AccountingRegister', 'ChartOfCalculationTypes', 'CalculationRegister', 'BusinessProcess', 'Task', 'IntegrationService', ] # Type -> directory mapping CHILD_TYPE_DIR_MAP = { 'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles', 'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles', 'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules', 'Bot': 'Bots', 'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages', 'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences', 'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs', 'SettingsStorage': 'SettingsStorages', 'FunctionalOption': 'FunctionalOptions', 'FunctionalOptionsParameter': 'FunctionalOptionsParameters', 'DefinedType': 'DefinedTypes', 'CommonCommand': 'CommonCommands', 'CommandGroup': 'CommandGroups', 'Constant': 'Constants', 'CommonForm': 'CommonForms', 'Catalog': 'Catalogs', 'Document': 'Documents', 'DocumentNumerator': 'DocumentNumerators', 'Sequence': 'Sequences', 'DocumentJournal': 'DocumentJournals', 'Enum': 'Enums', 'Report': 'Reports', 'DataProcessor': 'DataProcessors', 'InformationRegister': 'InformationRegisters', 'AccumulationRegister': 'AccumulationRegisters', 'ChartOfCharacteristicTypes': 'ChartsOfCharacteristicTypes', 'ChartOfAccounts': 'ChartsOfAccounts', 'AccountingRegister': 'AccountingRegisters', 'ChartOfCalculationTypes': 'ChartsOfCalculationTypes', 'CalculationRegister': 'CalculationRegisters', 'BusinessProcess': 'BusinessProcesses', 'Task': 'Tasks', 'IntegrationService': 'IntegrationServices', } # Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации). # Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один # или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули, # подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется. GENERATED_TYPE_CATEGORIES = { 'Catalog': ['Object', 'Ref', 'Selection', 'List', 'Manager'], 'Document': ['Object', 'Ref', 'Selection', 'List', 'Manager'], 'Enum': ['Ref', 'Manager', 'List'], 'Constant': ['Manager', 'ValueManager', 'ValueKey'], 'Report': ['Object', 'Manager'], 'DataProcessor': ['Object', 'Manager'], 'ExchangePlan': ['Object', 'Ref', 'Selection', 'List', 'Manager'], 'Task': ['Object', 'Ref', 'Selection', 'List', 'Manager'], 'BusinessProcess': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'RoutePointRef'], 'ChartOfCharacteristicTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'Characteristic'], 'ChartOfAccounts': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'ExtDimensionTypes', 'ExtDimensionTypesRow'], 'ChartOfCalculationTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'DisplacingCalculationTypes', 'DisplacingCalculationTypesRow', 'BaseCalculationTypes', 'BaseCalculationTypesRow', 'LeadingCalculationTypes', 'LeadingCalculationTypesRow'], 'InformationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'RecordManager'], 'AccumulationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey'], 'AccountingRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'ExtDimensions'], 'CalculationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'Recalcs'], 'DocumentJournal': ['Selection', 'List', 'Manager'], 'Sequence': ['Record', 'Manager', 'RecordSet'], 'FilterCriterion': ['Manager', 'List'], 'SettingsStorage': ['Manager'], 'IntegrationService': ['Manager'], 'WSReference': ['Manager'], 'DefinedType': ['DefinedType'], } # Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны. # Имена зависят от варианта встроенного языка, поэтому держим оба написания. STANDARD_OBJECT_FIELDS = { 'Code', 'Description', 'Ref', 'Parent', 'Owner', 'DeletionMark', 'Predefined', 'IsFolder', 'LineNumber', 'Number', 'Date', 'Posted', 'PredefinedDataName', 'RegisterRecords', 'DataVersion', 'RowsCount', 'Код', 'Наименование', 'Ссылка', 'Родитель', 'Владелец', 'ПометкаУдаления', 'Предопределенный', 'ЭтоГруппа', 'НомерСтроки', 'Номер', 'Дата', 'Проведен', 'ИмяПредопределенныхДанных', 'Движения', 'ВерсияДанных', 'КоличествоСтрок', } # Valid enum values for extension properties VALID_ENUM_VALUES = { 'ConfigurationExtensionCompatibilityMode': [ 'DontUse', 'Version8_1', 'Version8_2_13', 'Version8_2_16', 'Version8_3_1', 'Version8_3_2', 'Version8_3_3', 'Version8_3_4', 'Version8_3_5', 'Version8_3_6', 'Version8_3_7', 'Version8_3_8', 'Version8_3_9', 'Version8_3_10', 'Version8_3_11', 'Version8_3_12', 'Version8_3_13', 'Version8_3_14', 'Version8_3_15', 'Version8_3_16', 'Version8_3_17', 'Version8_3_18', 'Version8_3_19', 'Version8_3_20', 'Version8_3_21', 'Version8_3_22', 'Version8_3_23', 'Version8_3_24', 'Version8_3_25', 'Version8_3_26', 'Version8_3_27', 'Version8_3_28', 'Version8_5_1', ], 'DefaultRunMode': ['ManagedApplication', 'OrdinaryApplication', 'Auto'], 'ScriptVariant': ['Russian', 'English'], 'InterfaceCompatibilityMode': [ 'Version8_2', 'Version8_2EnableTaxi', 'Taxi', 'TaxiEnableVersion8_2', 'TaxiEnableVersion8_5', 'Version8_5EnableTaxi', 'Version8_5', ], } EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses' class Reporter: def __init__(self, max_errors, detailed=False): self.errors = 0 self.warnings = 0 self.ok_count = 0 self.stopped = False self.max_errors = max_errors self.detailed = detailed self.lines = [] self.obj_name = '(unknown)' def out(self, msg=''): self.lines.append(msg) def ok(self, msg): self.ok_count += 1 if self.detailed: self.lines.append(f'[OK] {msg}') def error(self, msg): self.errors += 1 self.lines.append(f'[ERROR] {msg}') if self.errors >= self.max_errors: self.stopped = True def warn(self, msg): self.warnings += 1 self.lines.append(f'[WARN] {msg}') def text(self): return '\r\n'.join(self.lines) + '\r\n' def finalize(self, out_file): checks = self.ok_count + self.errors + self.warnings if self.errors == 0 and self.warnings == 0 and not self.detailed: result = f'=== Validation OK: Extension.{self.obj_name} ({checks} checks) ===' else: self.out('') self.out(f'=== Result: {self.errors} errors, {self.warnings} warnings ({checks} checks) ===') result = self.text() print(result, end='' if '\r\n' in result else '\n') if out_file: with open(out_file, 'w', encoding='utf-8-sig', newline='') as f: f.write(result) print(f'Written to: {out_file}') def main(): sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser( description='Validate 1C configuration extension XML structure (CFE)', allow_abbrev=False ) parser.add_argument('-ExtensionPath', '-Path', dest='ExtensionPath', required=True) parser.add_argument('-Detailed', action='store_true') parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30) parser.add_argument('-OutFile', dest='OutFile', default='') # Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией, # пропускаются (о чём сказано в отчёте), остальные работают как раньше. parser.add_argument('-ConfigPath', dest='ConfigPath', default='') args = ci_parse_args(parser) extension_path = args.ExtensionPath max_errors = args.MaxErrors out_file = args.OutFile config_path_arg = args.ConfigPath # --- Resolve path --- if not os.path.isabs(extension_path): extension_path = os.path.join(os.getcwd(), extension_path) if os.path.isdir(extension_path): candidate = os.path.join(extension_path, 'Configuration.xml') if os.path.exists(candidate): extension_path = candidate else: print(f'[ERROR] No Configuration.xml found in directory: {extension_path}') sys.exit(1) if not os.path.exists(extension_path): print(f'[ERROR] File not found: {extension_path}') sys.exit(1) resolved_path = os.path.abspath(extension_path) config_dir = os.path.dirname(resolved_path) if out_file and not os.path.isabs(out_file): out_file = os.path.join(os.getcwd(), out_file) r = Reporter(max_errors, detailed=args.Detailed) r.out('') # --- 1. Parse XML --- xml_doc = None try: xml_parser = etree.XMLParser(remove_blank_text=False) xml_doc = etree.parse(resolved_path, xml_parser) except etree.XMLSyntaxError as e: r.lines.insert(0, '=== Validation: Extension (parse failed) ===') r.out('') r.error(f'1. XML parse failed: {e}') r.finalize(out_file) sys.exit(1) root = xml_doc.getroot() # --- Check 1: Root structure --- check1_ok = True root_local = etree.QName(root.tag).localname root_ns = etree.QName(root.tag).namespace or '' if root_local != 'MetaDataObject': r.error(f"1. Root element is '{root_local}', expected 'MetaDataObject'") r.finalize(out_file) sys.exit(1) if root_ns != EXPECTED_NS: r.error(f"1. Root namespace is '{root_ns}', expected '{EXPECTED_NS}'") check1_ok = False version = root.get('version', '') if not version: r.warn('1. Missing version attribute on MetaDataObject') elif version not in ('2.17', '2.18', '2.19', '2.20', '2.21'): # Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27). r.warn(f"1. Unusual version '{version}' (expected 2.17-2.20 or 2.21)") # Must have Configuration child cfg_node = None for child in root: if not isinstance(child.tag, str): continue if etree.QName(child.tag).localname == 'Configuration' and etree.QName(child.tag).namespace == EXPECTED_NS: cfg_node = child break if cfg_node is None: r.error('1. No element found inside MetaDataObject') r.finalize(out_file) sys.exit(1) # UUID cfg_uuid = cfg_node.get('uuid', '') if not cfg_uuid: r.error('1. Missing uuid on ') check1_ok = False elif not GUID_PATTERN.match(cfg_uuid): r.error(f"1. Invalid uuid '{cfg_uuid}' on ") check1_ok = False # Get name early for header props_node = cfg_node.find('md:Properties', NS) name_node = props_node.find('md:Name', NS) if props_node is not None else None obj_name = (name_node.text or '') if name_node is not None and name_node.text else '(unknown)' r.obj_name = obj_name r.lines.insert(0, f'=== Validation: Extension.{obj_name} ===') if check1_ok: r.ok(f'1. Root structure: MetaDataObject/Configuration, version {version}') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 2: InternalInfo --- internal_info = cfg_node.find('md:InternalInfo', NS) check2_ok = True if internal_info is None: r.error('2. InternalInfo: missing') else: contained = internal_info.findall('xr:ContainedObject', NS) if len(contained) != 7: r.warn(f'2. InternalInfo: expected 7 ContainedObject, found {len(contained)}') found_class_ids = {} for co in contained: class_id_el = co.find('xr:ClassId', NS) object_id_el = co.find('xr:ObjectId', NS) if class_id_el is None or not (class_id_el.text or ''): r.error('2. ContainedObject missing ClassId') check2_ok = False continue cid = class_id_el.text if cid not in VALID_CLASS_IDS: r.error(f'2. Unknown ClassId: {cid}') check2_ok = False if cid in found_class_ids: r.error(f'2. Duplicate ClassId: {cid}') check2_ok = False found_class_ids[cid] = True if object_id_el is None or not (object_id_el.text or ''): r.error(f'2. ContainedObject missing ObjectId for ClassId {cid}') check2_ok = False elif not GUID_PATTERN.match(object_id_el.text): r.error(f"2. Invalid ObjectId '{object_id_el.text}' for ClassId {cid}") check2_ok = False missing_ids = [cid for cid in VALID_CLASS_IDS if cid not in found_class_ids] if len(missing_ids) > 0: r.warn(f'2. Missing ClassIds: {len(missing_ids)} of 7') if check2_ok: r.ok(f'2. InternalInfo: {len(contained)} ContainedObject, all ClassIds valid') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 3: Extension-specific properties --- def_lang = '' if props_node is None: r.error('3. Properties block missing') else: check3_ok = True # ObjectBelonging = Adopted ob_node = props_node.find('md:ObjectBelonging', NS) ob_val = (ob_node.text or '') if ob_node is not None else '' if ob_val != 'Adopted': r.error(f"3. ObjectBelonging must be 'Adopted', got '{ob_val}'") check3_ok = False # Name if name_node is None or not (name_node.text or ''): r.error('3. Name is missing or empty') check3_ok = False else: name_val = name_node.text if not IDENT_PATTERN.match(name_val): r.error(f"3. Name '{name_val}' is not a valid 1C identifier") check3_ok = False # ConfigurationExtensionPurpose purpose_node = props_node.find('md:ConfigurationExtensionPurpose', NS) valid_purposes = ['Patch', 'Customization', 'AddOn'] if purpose_node is None or not (purpose_node.text or ''): r.error('3. ConfigurationExtensionPurpose is missing') check3_ok = False elif purpose_node.text not in valid_purposes: r.error(f"3. ConfigurationExtensionPurpose '{purpose_node.text}' invalid (expected: Patch, Customization, AddOn)") check3_ok = False # NamePrefix prefix_node = props_node.find('md:NamePrefix', NS) if prefix_node is None or not (prefix_node.text or ''): r.warn('3. NamePrefix is empty') # KeepMappingToExtendedConfigurationObjectsByIDs keep_map_node = props_node.find('md:KeepMappingToExtendedConfigurationObjectsByIDs', NS) if keep_map_node is None: r.warn('3. KeepMappingToExtendedConfigurationObjectsByIDs is missing') # DefaultLanguage def_lang_node = props_node.find('md:DefaultLanguage', NS) def_lang = (def_lang_node.text or '') if def_lang_node is not None else '' if check3_ok: purpose_val = purpose_node.text if purpose_node is not None and purpose_node.text else '?' prefix_val = (prefix_node.text or '') if prefix_node is not None and prefix_node.text else '(empty)' r.ok(f'3. Extension properties: Name="{obj_name}", Purpose={purpose_val}, Prefix={prefix_val}') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 4: Enum property values --- if props_node is not None: enum_checked = 0 check4_ok = True for prop_name, allowed in VALID_ENUM_VALUES.items(): prop_node = props_node.find(f'md:{prop_name}', NS) if prop_node is not None and prop_node.text: val = prop_node.text if val not in allowed: r.error(f"4. Property '{prop_name}' has invalid value '{val}'") check4_ok = False enum_checked += 1 if check4_ok: r.ok(f'4. Property values: {enum_checked} enum properties checked') else: r.warn('4. No Properties block to check') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 5: ChildObjects -- valid types, no duplicates, order --- child_obj_node = cfg_node.find('md:ChildObjects', NS) if child_obj_node is None: r.error('5. ChildObjects block missing') else: check5_ok = True total_count = 0 child_object_index = {} duplicates = {} type_first_index = {} last_type_order = -1 order_ok = True for child in child_obj_node: if not isinstance(child.tag, str): continue type_name = etree.QName(child.tag).localname obj_name_val = child.text or '' if type_name in CHILD_OBJECT_TYPES: type_idx = CHILD_OBJECT_TYPES.index(type_name) else: type_idx = -1 if type_idx < 0: r.error(f"5. Unknown type '{type_name}' in ChildObjects") check5_ok = False else: if type_name not in type_first_index: type_first_index[type_name] = type_idx if type_idx < last_type_order: r.warn(f"5. Type '{type_name}' is out of canonical order (after type at position {last_type_order})") order_ok = False last_type_order = type_idx if type_name not in child_object_index: child_object_index[type_name] = {} if obj_name_val in child_object_index[type_name]: dup_key = f'{type_name}.{obj_name_val}' if dup_key not in duplicates: r.error(f'5. Duplicate: {dup_key}') duplicates[dup_key] = True check5_ok = False else: child_object_index[type_name][obj_name_val] = True total_count += 1 type_count = len(child_object_index) if check5_ok: order_info = ', order correct' if order_ok else '' r.ok(f'5. ChildObjects: {type_count} types, {total_count} objects{order_info}') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 6: DefaultLanguage references existing Language in ChildObjects --- if def_lang and child_obj_node is not None: lang_name = def_lang if lang_name.startswith('Language.'): lang_name = lang_name[9:] found = False for child in child_obj_node: if not isinstance(child.tag, str): continue if etree.QName(child.tag).localname == 'Language' and (child.text or '') == lang_name: found = True break if found: r.ok(f'6. DefaultLanguage "{def_lang}" found in ChildObjects') else: r.error(f'6. DefaultLanguage "{def_lang}" not found in ChildObjects') else: if not def_lang: r.warn('6. Cannot check DefaultLanguage (empty)') else: r.warn('6. Cannot check DefaultLanguage (no ChildObjects)') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 7: Language files exist --- if child_obj_node is not None: lang_names = [] for child in child_obj_node: if not isinstance(child.tag, str): continue if etree.QName(child.tag).localname == 'Language': lang_names.append(child.text or '') if len(lang_names) > 0: exist_count = 0 for ln in lang_names: lang_file = os.path.join(config_dir, 'Languages', ln + '.xml') if os.path.exists(lang_file): exist_count += 1 else: r.warn(f'7. Language file missing: Languages/{ln}.xml') if exist_count == len(lang_names): r.ok(f'7. Language files: {exist_count}/{len(lang_names)} exist') else: r.warn('7. No Language entries in ChildObjects') else: r.warn('7. Cannot check language files (no ChildObjects)') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 8: Object directories exist --- if child_obj_node is not None: dirs_to_check = {} for child in child_obj_node: if not isinstance(child.tag, str): continue type_name = etree.QName(child.tag).localname if type_name == 'Language': continue if type_name in CHILD_TYPE_DIR_MAP: dir_name = CHILD_TYPE_DIR_MAP[type_name] dirs_to_check[dir_name] = dirs_to_check.get(dir_name, 0) + 1 missing_dirs = [] for dir_name, count in dirs_to_check.items(): dir_path = os.path.join(config_dir, dir_name) if not os.path.isdir(dir_path): missing_dirs.append(f'{dir_name} ({count} objects)') if len(missing_dirs) == 0: r.ok(f'8. Object directories: {len(dirs_to_check)} directories, all exist') else: for md in missing_dirs: r.warn(f'8. Missing directory: {md}') else: pass # no ChildObjects if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 9: Borrowed objects + Check 10: Sub-items --- MD = NS['md'] XR = NS['xr'] enum_values_index = {} borrowed_ts_index = {} form_list = [] def is_borrowed_sub_item(sub_item): """Check if sub-item has explicit borrowed metadata (ObjectBelonging or ExtendedConfigurationObject).""" sub_props = sub_item.find(f'{{{MD}}}Properties') if sub_props is None: return False sub_ob = sub_props.find(f'{{{MD}}}ObjectBelonging') if sub_ob is not None and (sub_ob.text or ''): return True sub_ext = sub_props.find(f'{{{MD}}}ExtendedConfigurationObject') return sub_ext is not None and bool(sub_ext.text or '') def validate_borrowed_sub_item(check_num, context, sub_type, sub_item): """Validate a borrowed Attribute/EnumValue/TabularSection sub-item.""" sub_props = sub_item.find(f'{{{MD}}}Properties') if sub_props is None: r.error(f'{check_num}. {context}: {sub_type} missing Properties') return False ok = True sub_ob = sub_props.find(f'{{{MD}}}ObjectBelonging') if sub_ob is None or (sub_ob.text or '') != 'Adopted': r.error(f"{check_num}. {context}: {sub_type} ObjectBelonging must be 'Adopted'") ok = False sub_name = sub_props.find(f'{{{MD}}}Name') if sub_name is None or not (sub_name.text or ''): r.error(f'{check_num}. {context}: {sub_type} missing Name') ok = False sub_ext = sub_props.find(f'{{{MD}}}ExtendedConfigurationObject') sub_name_val = (sub_name.text or '') if sub_name is not None else '?' if sub_ext is None or not (sub_ext.text or ''): r.error(f'{check_num}. {context}: {sub_type}.{sub_name_val} missing ExtendedConfigurationObject') ok = False elif not GUID_PATTERN.match(sub_ext.text): r.error(f'{check_num}. {context}: {sub_type}.{sub_name_val} invalid ExtendedConfigurationObject') ok = False return ok if child_obj_node is not None: borrowed_count = 0 borrowed_ok_count = 0 check9_ok = True check10_ok = True sub_item_count = 0 for child in child_obj_node: if not isinstance(child.tag, str): continue type_name = etree.QName(child.tag).localname child_name = child.text or '' if type_name == 'Language': continue if type_name not in CHILD_TYPE_DIR_MAP: continue dir_name = CHILD_TYPE_DIR_MAP[type_name] obj_file = os.path.join(config_dir, dir_name, child_name + '.xml') if not os.path.exists(obj_file): continue # Parse object XML try: obj_parser = etree.XMLParser(remove_blank_text=False) obj_doc = etree.parse(obj_file, obj_parser) except etree.XMLSyntaxError as e: r.warn(f'9. Cannot parse {dir_name}/{child_name}.xml: {e}') continue obj_root = obj_doc.getroot() # Find the object element (Catalog, Document, etc.) obj_el = None for c in obj_root: if isinstance(c.tag, str): obj_el = c break if obj_el is None: continue obj_props = obj_el.find(f'{{{MD}}}Properties') if obj_props is None: continue # --- Check 9: ObjectBelonging + ExtendedConfigurationObject --- ob_node = obj_props.find(f'{{{MD}}}ObjectBelonging') if ob_node is not None and (ob_node.text or '') == 'Adopted': borrowed_count += 1 ext_obj = obj_props.find(f'{{{MD}}}ExtendedConfigurationObject') if ext_obj is None or not (ext_obj.text or ''): r.error(f'9. Borrowed {type_name}.{child_name}: missing ExtendedConfigurationObject') check9_ok = False elif not GUID_PATTERN.match(ext_obj.text): r.error(f"9. Borrowed {type_name}.{child_name}: invalid ExtendedConfigurationObject UUID '{ext_obj.text}'") check9_ok = False else: borrowed_ok_count += 1 # Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором # («отсутствует один или более типов объекта ChartOfCharacteristicTypes») expected_cats = GENERATED_TYPE_CATEGORIES.get(type_name) if expected_cats: obj_info = obj_el.find(f'{{{MD}}}InternalInfo') found_cats = set() if obj_info is not None: for gt in obj_info.findall(f'{{{XR}}}GeneratedType'): cat = gt.get('category') if cat: found_cats.add(cat) missing_cats = [c for c in expected_cats if c not in found_cats] if missing_cats: word = 'category' if len(missing_cats) == 1 else 'categories' r.error(f"9. Borrowed {type_name}.{child_name}: missing GeneratedType {word} {', '.join(missing_cats)}") check9_ok = False # --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) --- obj_child_objects = obj_el.find(f'{{{MD}}}ChildObjects') if obj_child_objects is not None: ctx = f'{type_name}.{child_name}' for sub_item in obj_child_objects: if not isinstance(sub_item.tag, str): continue sub_type = etree.QName(sub_item.tag).localname if sub_type == 'Attribute': if not is_borrowed_sub_item(sub_item): continue sub_item_count += 1 if not validate_borrowed_sub_item('10', ctx, 'Attribute', sub_item): check10_ok = False elif sub_type == 'TabularSection': if not is_borrowed_sub_item(sub_item): continue sub_item_count += 1 if not validate_borrowed_sub_item('10', ctx, 'TabularSection', sub_item): check10_ok = False else: # Check InternalInfo GeneratedTypes ts_info = sub_item.find(f'{{{MD}}}InternalInfo') ts_name_el = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name') ts_label = (ts_name_el.text or '?') if ts_name_el is not None else '?' # Индекс заимствованных ТЧ — по нему Check 12 сверяет if ts_name_el is not None and ts_name_el.text: borrowed_ts_index.setdefault(f'{type_name}.{child_name}', {})[ts_name_el.text.strip()] = True if ts_info is None: r.error(f'10. {ctx}: TabularSection.{ts_label} missing InternalInfo') check10_ok = False else: gt_nodes = ts_info.findall(f'{{{XR}}}GeneratedType') has_ts = any(gt.get('category') == 'TabularSection' for gt in gt_nodes) has_tsr = any(gt.get('category') == 'TabularSectionRow' for gt in gt_nodes) if not has_ts or not has_tsr: r.error(f'10. {ctx}: TabularSection.{ts_label} missing GeneratedType (need TabularSection + TabularSectionRow)') check10_ok = False # Recurse into TS ChildObjects/Attribute ts_child_objs = sub_item.find(f'{{{MD}}}ChildObjects') if ts_child_objs is not None: for ts_attr in ts_child_objs: if not isinstance(ts_attr.tag, str): continue if etree.QName(ts_attr.tag).localname != 'Attribute': continue if not is_borrowed_sub_item(ts_attr): continue sub_item_count += 1 if not validate_borrowed_sub_item('10', f'{ctx}.ТЧ.{ts_label}', 'Attribute', ts_attr): check10_ok = False elif sub_type == 'EnumValue' and type_name == 'Enum': if not is_borrowed_sub_item(sub_item): continue sub_item_count += 1 if validate_borrowed_sub_item('10', ctx, 'EnumValue', sub_item): ev_name = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name') if ev_name is not None and (ev_name.text or ''): if child_name not in enum_values_index: enum_values_index[child_name] = {} enum_values_index[child_name][ev_name.text] = True else: check10_ok = False elif sub_type == 'Form': form_name = sub_item.text or '' if form_name: form_meta_file = os.path.join(config_dir, dir_name, child_name, 'Forms', form_name + '.xml') if not os.path.exists(form_meta_file): r.error(f'10. {ctx}: Form.{form_name} metadata file missing') check10_ok = False form_list.append({ 'TypeName': type_name, 'ObjName': child_name, 'FormName': form_name, 'DirName': dir_name, }) sub_item_count += 1 if r.stopped: break if borrowed_count == 0: r.ok('9. Borrowed objects: none found') elif check9_ok: r.ok(f'9. Borrowed objects: {borrowed_ok_count}/{borrowed_count} validated') if sub_item_count == 0: r.ok('10. Sub-items: none found') elif check10_ok: r.ok(f'10. Sub-items: {sub_item_count} validated (Attributes, TabularSections, EnumValues, Forms)') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 11: Borrowed form structure --- borrowed_forms_with_tree = [] check11_ok = True form_count = 0 for fi in form_list: form_count += 1 form_base = os.path.join(config_dir, fi['DirName'], fi['ObjName'], 'Forms', fi['FormName']) form_meta_file = os.path.join(os.path.dirname(form_base), fi['FormName'] + '.xml') form_xml_file = os.path.join(form_base, 'Ext', 'Form.xml') module_bsl_file = os.path.join(form_base, 'Ext', 'Form', 'Module.bsl') ctx = f"{fi['TypeName']}.{fi['ObjName']}.Form.{fi['FormName']}" # Validate form metadata XML if os.path.exists(form_meta_file): try: fm_doc = etree.parse(form_meta_file, etree.XMLParser(remove_blank_text=False)) fm_root = fm_doc.getroot() fm_el = None for c in fm_root: if isinstance(c.tag, str): fm_el = c break if fm_el is not None: fm_props = fm_el.find(f'{{{MD}}}Properties') if fm_props is not None: fm_ob = fm_props.find(f'{{{MD}}}ObjectBelonging') is_borrowed = fm_ob is not None and (fm_ob.text or '') == 'Adopted' if is_borrowed: fm_ext = fm_props.find(f'{{{MD}}}ExtendedConfigurationObject') if fm_ext is None or not (fm_ext.text or '') or not GUID_PATTERN.match(fm_ext.text or ''): r.error(f'11. {ctx}: invalid/missing ExtendedConfigurationObject') check11_ok = False fm_type = fm_props.find(f'{{{MD}}}FormType') if fm_type is not None and (fm_type.text or '') != 'Managed': r.error(f"11. {ctx}: FormType must be 'Managed', got '{fm_type.text}'") check11_ok = False except etree.XMLSyntaxError as e: r.warn(f'11. {ctx}: Cannot parse metadata: {e}') # Form.xml must exist if not os.path.exists(form_xml_file): r.error(f'11. {ctx}: Ext/Form.xml missing') check11_ok = False continue # Module.bsl should exist if not os.path.exists(module_bsl_file): r.warn(f'11. {ctx}: Ext/Form/Module.bsl missing') # Read Form.xml as raw text for BaseForm checks with open(form_xml_file, 'r', encoding='utf-8-sig') as f: form_raw_text = f.read() if ']+version=', form_raw_text): r.warn(f'11. {ctx}: missing version attribute') borrowed_forms_with_tree.append({ 'Path': form_xml_file, 'RawText': form_raw_text, 'Context': ctx, }) if form_count == 0: r.ok('11. Borrowed forms: none found') elif check11_ok: bf_count = len(borrowed_forms_with_tree) r.ok(f'11. Borrowed forms: {form_count} validated ({bf_count} with BaseForm)') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 12: Form dependency references --- PLATFORM_STYLE_ITEMS = { 'TableHeaderBackColor', 'AccentColor', 'NormalTextFont', 'FormBackColor', 'ToolTipBackColor', 'BorderColor', 'FieldBackColor', 'FieldTextColor', 'ButtonBackColor', 'ButtonTextColor', 'AlternateRowColor', 'SpecialTextColor', 'TextFont', 'ImportantColor', 'FormTextColor', 'SmallTextFont', 'ExtraLargeTextFont', 'LargeTextFont', 'NormalTextColor', 'GroupHeaderBackColor', 'GroupHeaderFont', 'ErrorColor', 'SuccessColor', 'WarningColor', } check12_ok = True dep_check_count = 0 for bf in borrowed_forms_with_tree: raw = bf['RawText'] ctx = bf['Context'] missing_items = [] # CommonPicture references cp_refs = {} for m in re.finditer(r'CommonPicture\.(\w+)', raw): cp_refs[m.group(1)] = True cp_index = child_object_index.get('CommonPicture', {}) for cp_name in cp_refs: dep_check_count += 1 if cp_name not in cp_index: missing_items.append(f'CommonPicture.{cp_name}') # StyleItem references si_refs = {} for m in re.finditer(r'style:([A-Za-z\u0410-\u044F\u0401\u0451_][A-Za-z0-9\u0410-\u044F\u0401\u0451_]*)', raw): si_refs[m.group(1)] = True si_index = child_object_index.get('StyleItem', {}) for si_name in si_refs: dep_check_count += 1 if si_name in PLATFORM_STYLE_ITEMS: continue if si_name not in si_index: missing_items.append(f'StyleItem.{si_name}') # Enum DesignTimeRef references enum_refs = {} for m in re.finditer(r'xr:DesignTimeRef">Enum\.(\w+)\.EnumValue\.(\w+)', raw): e_key = f'{m.group(1)}.{m.group(2)}' enum_refs[e_key] = {'Enum': m.group(1), 'Value': m.group(2)} e_index = child_object_index.get('Enum', {}) for entry in enum_refs.values(): dep_check_count += 1 if entry['Enum'] not in e_index: missing_items.append(f"Enum.{entry['Enum']}") elif entry['Enum'] not in enum_values_index or entry['Value'] not in enum_values_index.get(entry['Enum'], {}): missing_items.append(f"Enum.{entry['Enum']}.EnumValue.{entry['Value']}") # — доп. колонки табличной части, объявленные в самой форме. # Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к # данным» плюс «Колонки не могут быть добавлены к реквизиту». # Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там # предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое, # поэтому ошибка. ac_tables = set(re.findall(r' — TabularSection.{tbl_name} not borrowed in extension') check12_ok = False for mi in missing_items: r.warn(f'12. {ctx}: references {mi} not borrowed in extension') check12_ok = False if len(borrowed_forms_with_tree) == 0: r.ok('12. Form dependencies: no borrowed forms with tree') elif check12_ok: r.ok(f'12. Form dependencies: {dep_check_count} references checked') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Check 13: TypeLink with human-readable paths --- check13_ok = True type_link_count = 0 for bf in borrowed_forms_with_tree: raw = bf['RawText'] ctx = bf['Context'] matches = re.findall(r'\s*Items\.[^<]*', raw) if matches: type_link_count += len(matches) r.warn(f'13. {ctx}: {len(matches)} TypeLink(s) with human-readable Items.* DataPath (should be stripped)') check13_ok = False if len(borrowed_forms_with_tree) == 0: r.ok('13. TypeLink: no borrowed forms with tree') elif check13_ok: r.ok('13. TypeLink: clean') # --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника --- # Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту. # «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул» # не разрешится нигде, если Артикул — не колонка ТЧ и не колонка из самой формы. # Такой путь платформа отвергает на загрузке: «Неверный путь к данным». if not r.stopped and borrowed_forms_with_tree: if not config_path_arg: r.out('[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath') else: cfg_root = config_path_arg if not os.path.isabs(cfg_root): cfg_root = os.path.join(os.getcwd(), cfg_root) if os.path.exists(cfg_root) and not os.path.isdir(cfg_root): cfg_root = os.path.dirname(cfg_root) if not os.path.isfile(os.path.join(cfg_root, 'Configuration.xml')): r.warn(f"14. -ConfigPath '{config_path_arg}': Configuration.xml не найден — проверка путей пропущена") else: check14_ok = True path_check_count = 0 for bf in borrowed_forms_with_tree: raw = bf['RawText'] ctx = bf['Context'] owner_key = ctx.split('.Form.')[0] owner_parts = owner_key.split('.', 1) if len(owner_parts) < 2: continue owner_type, owner_name = owner_parts owner_dir = CHILD_TYPE_DIR_MAP.get(owner_type) if not owner_dir: continue src_obj_file = os.path.join(cfg_root, owner_dir, f'{owner_name}.xml') if not os.path.isfile(src_obj_file): r.warn(f'14. {ctx}: объект-источник не найден в конфигурации ({owner_dir}/{owner_name}.xml)') continue # Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника. # Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там # и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары). src_names = set() src_ts_columns = {} src_tree = etree.parse(src_obj_file, etree.XMLParser(remove_blank_text=True)) src_obj_el = None for c in src_tree.getroot(): if isinstance(c.tag, str): src_obj_el = c break src_child_objects = src_obj_el.find(f'{{{MD}}}ChildObjects') if src_obj_el is not None else None if src_child_objects is not None: for sub in src_child_objects: if not isinstance(sub.tag, str): continue sub_ln = etree.QName(sub.tag).localname if sub_ln not in ('Attribute', 'TabularSection'): continue name_el = sub.find(f'{{{MD}}}Properties/{{{MD}}}Name') if name_el is None or not name_el.text: continue sub_name = name_el.text.strip() src_names.add(sub_name) if sub_ln != 'TabularSection': continue cols = set() for col_name in sub.findall(f'{{{MD}}}ChildObjects/{{{MD}}}Attribute/{{{MD}}}Properties/{{{MD}}}Name'): if col_name.text: cols.add(col_name.text.strip()) src_ts_columns[sub_name] = cols # Плюс колонки, объявленные в самой форме через / for acm in re.finditer(r'(.*?)', raw, re.DOTALL): tbl = acm.group(1) cols = src_ts_columns.setdefault(tbl, set()) for cm in re.finditer(r']*>Объект\.([^<]+)', raw): segments = m.group(1).split('.') seg0 = segments[0] path_check_count += 1 if seg0 in STANDARD_OBJECT_FIELDS: continue if seg0 not in src_names: bad_paths[f'Объект.{seg0}'] = f'у {owner_key} нет такого реквизита или табличной части' continue # Второй сегмент проверяем только для табличных частей: у ссылочного реквизита # он ведёт в чужой объект, и это уже другая проверка. if len(segments) < 2 or seg0 not in src_ts_columns: continue seg1 = segments[1] if seg1 in STANDARD_OBJECT_FIELDS: continue # Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен if seg1.startswith('Total') and seg1[5:] in src_ts_columns[seg0]: continue if seg1 not in src_ts_columns[seg0]: bad_paths[f'Объект.{seg0}.{seg1}'] = f'у табличной части {seg0} нет колонки {seg1}, и формы её не объявляет' for bad in sorted(bad_paths): r.error(f"14. {ctx}: путь '{bad}' — {bad_paths[bad]}") check14_ok = False if check14_ok: r.ok(f'14. Object paths vs source config: {path_check_count} checked') if r.stopped: r.finalize(out_file) sys.exit(1) # --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here --- ctrl_count = 0 for dp, _dn, files in os.walk(config_dir): for fn in files: if fn.endswith('.bsl'): try: with open(os.path.join(dp, fn), 'r', encoding='utf-8-sig') as f: for ln in f: if re.match(r'^\s*&ИзменениеИКонтроль\(', ln): ctrl_count += 1 except OSError: pass if ctrl_count > 0: r.out('[INFO] Контролируемых методов (&ИзменениеИКонтроль): %d — их актуальность здесь не проверяется. Сверьте: /cfe-patch-method -Check -ExtensionPath -ConfigPath ' % ctrl_count) # --- Final output --- r.finalize(out_file) sys.exit(1 if r.errors > 0 else 0) if __name__ == '__main__': main()