# skd-validate v1.7 — Validate 1C DCS structure (Python port) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import os import sys from lxml import etree sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") # Регистронезависимый ввод — паритет с 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) # ── arg parsing ────────────────────────────────────────────── parser = argparse.ArgumentParser(allow_abbrev=False) parser.add_argument("-TemplatePath", "-Path", required=True) parser.add_argument("-Detailed", action="store_true") parser.add_argument("-MaxErrors", type=int, default=20) parser.add_argument("-OutFile", default="") args = ci_parse_args(parser) template_path = args.TemplatePath detailed = args.Detailed max_errors = args.MaxErrors out_file = args.OutFile # ── resolve path ───────────────────────────────────────────── # A: Directory → Ext/Template.xml if os.path.isdir(template_path): template_path = os.path.join(template_path, 'Ext', 'Template.xml') # B1: Missing Ext/ (e.g. Templates/СКД/Template.xml → Templates/СКД/Ext/Template.xml) if not os.path.exists(template_path): fn = os.path.basename(template_path) if fn == 'Template.xml': c = os.path.join(os.path.dirname(template_path), 'Ext', fn) if os.path.exists(c): template_path = c # B2: Descriptor (.xml → dir/Ext/Template.xml) if not os.path.exists(template_path) and template_path.endswith('.xml'): stem = os.path.splitext(os.path.basename(template_path))[0] parent = os.path.dirname(template_path) c = os.path.join(parent, stem, 'Ext', 'Template.xml') if os.path.exists(c): template_path = c if not os.path.exists(template_path): print(f"File not found: {template_path}", file=sys.stderr) sys.exit(1) resolved_path = os.path.abspath(template_path) file_name = os.path.basename(resolved_path) # ── output infrastructure ──────────────────────────────────── errors = 0 warnings = 0 ok_count = 0 stopped = False # Проверка, которая не выполнялась, — в итоговую строку: без этого «Validation OK» читается как # «проверено всё» (модель так и докладывала пользователю при нарушенном порядке) xsd_note = None output_lines = [] def out_line(msg): output_lines.append(msg) def report_ok(msg): global ok_count ok_count += 1 if detailed: out_line(f"[OK] {msg}") def report_error(msg): global errors, stopped errors += 1 out_line(f"[ERROR] {msg}") if errors >= max_errors: stopped = True def report_warn(msg): global warnings warnings += 1 out_line(f"[WARN] {msg}") def finalize(): checks = ok_count + errors + warnings if errors == 0 and warnings == 0 and not detailed: note = f"; {xsd_note}" if xsd_note else "" result = f"=== Validation OK: {file_name} ({checks} checks{note}) ===" else: out_line("") note = f"; {xsd_note}" if xsd_note else "" out_line(f"=== Result: {errors} errors, {warnings} warnings ({checks} checks{note}) ===") result = "\n".join(output_lines) print(result) if out_file: with open(out_file, "w", encoding="utf-8-sig") as f: f.write(result) print(f"Written to: {out_file}") out_line(f"=== Validation: {file_name} ===") out_line("") # ── 1. Parse XML ───────────────────────────────────────────── NS = { "s": "http://v8.1c.ru/8.1/data-composition-system/schema", "dcscom": "http://v8.1c.ru/8.1/data-composition-system/common", "dcscor": "http://v8.1c.ru/8.1/data-composition-system/core", "dcsset": "http://v8.1c.ru/8.1/data-composition-system/settings", "v8": "http://v8.1c.ru/8.1/data/core", "v8ui": "http://v8.1c.ru/8.1/data/ui", "xs": "http://www.w3.org/2001/XMLSchema", "xsi": "http://www.w3.org/2001/XMLSchema-instance", "dcsat": "http://v8.1c.ru/8.1/data-composition-system/area-template", } XSI_TYPE = f"{{{NS['xsi']}}}type" tree = None try: parser_xml = etree.XMLParser(remove_blank_text=False) tree = etree.parse(resolved_path, parser_xml) report_ok("XML parsed successfully") except Exception as e: report_error(f"XML parse failed: {e}") result = "\n".join(output_lines) print(result) if out_file: with open(out_file, "w", encoding="utf-8-sig") as f: f.write(result) sys.exit(1) root = tree.getroot() def local_name(node): return etree.QName(node.tag).localname def find(parent, xpath): """XPath find with namespaces, returns first match or None.""" r = parent.xpath(xpath, namespaces=NS) return r[0] if r else None def find_all(parent, xpath): """XPath findall with namespaces.""" return parent.xpath(xpath, namespaces=NS) def text_of(node): """Return stripped text or empty string.""" if node is None: return "" return (node.text or "").strip() def inner_text(node): """Return text (non-stripped) or empty string.""" if node is None: return "" return node.text or "" # ── 3. Root element checks ─────────────────────────────────── # Строчную (dataCompositionSchema — имя из XSD, так пишет сериализатор XDTO) платформа загружает, # но конфигуратор всегда пишет DataCompositionSchema — это предупреждение, не ошибка. if local_name(root) == "DataCompositionSchema": report_ok("Root element: DataCompositionSchema") elif local_name(root).lower() == "datacompositionschema": report_warn(f"Root element is '{local_name(root)}' — the platform loads it, but the Designer writes 'DataCompositionSchema'") else: report_error(f"Root element is '{local_name(root)}', expected 'DataCompositionSchema'") expected_ns = "http://v8.1c.ru/8.1/data-composition-system/schema" root_ns = etree.QName(root.tag).namespace or "" if root_ns != expected_ns: report_error(f"Default namespace is '{root_ns}', expected '{expected_ns}'") else: report_ok("Default namespace correct") if stopped: finalize() sys.exit(1) # ── 4. Collect inventories ─────────────────────────────────── # DataSources data_source_nodes = find_all(root, "s:dataSource") data_source_names = {} for dsn in data_source_nodes: name = find(dsn, "s:name") if name is not None: data_source_names[inner_text(name)] = True # DataSets (recursive for unions) data_set_nodes = find_all(root, "s:dataSet") data_set_names = {} all_field_paths = {} # dataPath -> dataSet name def collect_data_set_fields(ds_node, ds_name): fields = find_all(ds_node, "s:field") local_paths = {} for f in fields: dp = find(f, "s:dataPath") if dp is not None: path = inner_text(dp) local_paths[path] = True all_field_paths[path] = ds_name # Union items items = find_all(ds_node, "s:item") for item in items: item_name = find(item, "s:name") if item_name is not None: collect_data_set_fields(item, inner_text(item_name)) return local_paths data_set_field_map = {} for ds in data_set_nodes: name_node = find(ds, "s:name") if name_node is not None: ds_name = inner_text(name_node) data_set_names[ds_name] = True data_set_field_map[ds_name] = collect_data_set_fields(ds, ds_name) # CalculatedFields calc_field_nodes = find_all(root, "s:calculatedField") calc_field_paths = {} for cf in calc_field_nodes: dp = find(cf, "s:dataPath") if dp is not None: calc_field_paths[inner_text(dp)] = True # TotalFields total_field_nodes = find_all(root, "s:totalField") # Parameters param_nodes = find_all(root, "s:parameter") param_names = {} for p in param_nodes: name_node = find(p, "s:name") if name_node is not None: param_names[inner_text(name_node)] = True # Templates template_nodes = find_all(root, "s:template") template_names = {} for t in template_nodes: name_node = find(t, "s:name") if name_node is not None: template_names[inner_text(name_node)] = True # GroupTemplates group_template_nodes = find_all(root, "s:groupTemplate") # SettingsVariants variant_nodes = find_all(root, "s:settingsVariant") # Known fields = dataset fields + calculated fields known_fields = {} for key in all_field_paths: known_fields[key] = True for key in calc_field_paths: known_fields[key] = True # ── 5. DataSource checks ───────────────────────────────────── if len(data_source_nodes) == 0: report_warn("No dataSource elements found (settings-only DCS?)") else: ds_names_seen = {} ds_ok = True for dsn in data_source_nodes: name = find(dsn, "s:name") typ = find(dsn, "s:dataSourceType") if name is None or not inner_text(name): report_error("DataSource has empty name") ds_ok = False elif inner_text(name) in ds_names_seen: report_error(f"Duplicate dataSource name: {inner_text(name)}") ds_ok = False else: ds_names_seen[inner_text(name)] = True if typ is not None: tv = inner_text(typ) if tv not in ("Local", "External"): report_warn(f"DataSource '{inner_text(name)}' has unusual type: {tv}") if ds_ok: report_ok(f"{len(data_source_nodes)} dataSource(s) found, names unique") if stopped: finalize() sys.exit(1) # ── 6. DataSet checks ──────────────────────────────────────── valid_ds_types = ("DataSetQuery", "DataSetObject", "DataSetUnion") if len(data_set_nodes) == 0: report_warn("No dataSet elements found (settings-only DCS?)") else: ds_names_seen = {} ds_ok = True for ds in data_set_nodes: xsi_type = ds.get(XSI_TYPE, "") name_node = find(ds, "s:name") ds_name = inner_text(name_node) if name_node is not None else "(unnamed)" if name_node is None or not inner_text(name_node): report_error("DataSet has empty name") ds_ok = False elif ds_name in ds_names_seen: report_error(f"Duplicate dataSet name: {ds_name}") ds_ok = False else: ds_names_seen[ds_name] = True if not xsi_type: report_error(f"DataSet '{ds_name}' missing xsi:type") ds_ok = False elif xsi_type not in valid_ds_types: report_warn(f"DataSet '{ds_name}' has unusual xsi:type: {xsi_type}") # Check dataSource reference if xsi_type != "DataSetUnion": src_node = find(ds, "s:dataSource") if src_node is not None and inner_text(src_node): if inner_text(src_node) not in data_source_names: report_error(f"DataSet '{ds_name}' references unknown dataSource: {inner_text(src_node)}") ds_ok = False # Check query not empty for Query type if xsi_type == "DataSetQuery": query_node = find(ds, "s:query") if query_node is None or not text_of(query_node): report_warn(f"DataSet '{ds_name}' (Query) has empty query") # Check objectName for Object type if xsi_type == "DataSetObject": obj_node = find(ds, "s:objectName") if obj_node is None or not text_of(obj_node): report_error(f"DataSet '{ds_name}' (Object) has empty objectName") ds_ok = False if ds_ok: report_ok(f"{len(data_set_nodes)} dataSet(s) found, names unique") if stopped: finalize() sys.exit(1) # ── 7. Field checks ────────────────────────────────────────── def check_data_set_fields(ds_node, ds_name): global stopped fields = find_all(ds_node, "s:field") if len(fields) == 0: return paths_seen = {} field_ok = True for f in fields: dp = find(f, "s:dataPath") fn = find(f, "s:field") if dp is None or not inner_text(dp): report_error(f"DataSet '{ds_name}': field has empty dataPath") field_ok = False continue path = inner_text(dp) if path in paths_seen: report_warn(f"DataSet '{ds_name}': duplicate dataPath '{path}'") else: paths_seen[path] = True if fn is None or not inner_text(fn): report_warn(f"DataSet '{ds_name}': field '{path}' has empty element") if field_ok: report_ok(f'DataSet "{ds_name}": {len(fields)} fields, dataPath unique') # Check union items recursively items = find_all(ds_node, "s:item") for item in items: item_name = find(item, "s:name") i_name = inner_text(item_name) if item_name is not None else "(unnamed item)" check_data_set_fields(item, i_name) for ds in data_set_nodes: name_node = find(ds, "s:name") ds_name = inner_text(name_node) if name_node is not None else "(unnamed)" check_data_set_fields(ds, ds_name) if stopped: finalize() sys.exit(1) # ── 8. DataSetLink checks ──────────────────────────────────── link_nodes = find_all(root, "s:dataSetLink") if len(link_nodes) > 0: link_ok = True for link in link_nodes: src = find(link, "s:sourceDataSet") dst = find(link, "s:destinationDataSet") src_expr = find(link, "s:sourceExpression") dst_expr = find(link, "s:destinationExpression") if src is not None and inner_text(src) and inner_text(src) not in data_set_names: report_error(f"DataSetLink: sourceDataSet '{inner_text(src)}' not found") link_ok = False if dst is not None and inner_text(dst) and inner_text(dst) not in data_set_names: report_error(f"DataSetLink: destinationDataSet '{inner_text(dst)}' not found") link_ok = False if src_expr is None or not text_of(src_expr): report_error("DataSetLink: empty sourceExpression") link_ok = False if dst_expr is None or not text_of(dst_expr): report_error("DataSetLink: empty destinationExpression") link_ok = False if link_ok: report_ok(f"{len(link_nodes)} dataSetLink(s): references valid") if stopped: finalize() sys.exit(1) # ── 9. CalculatedField checks ──────────────────────────────── if len(calc_field_nodes) > 0: cf_ok = True cf_seen = {} # Collect totalField dataPaths — an empty calculatedField is legitimate if a # totalField with the same dataPath provides the expression (real-world # pattern in vendor ERP/БП reports for fields visible only in totals). tf_paths = set() for tf in total_field_nodes: tf_dp = find(tf, "s:dataPath") if tf_dp is not None and inner_text(tf_dp): tf_paths.add(inner_text(tf_dp)) for cf in calc_field_nodes: dp = find(cf, "s:dataPath") expr = find(cf, "s:expression") if dp is None or not inner_text(dp): report_error("CalculatedField has empty dataPath") cf_ok = False continue path = inner_text(dp) if path in cf_seen: report_error(f"Duplicate calculatedField dataPath: {path}") cf_ok = False else: cf_seen[path] = True if expr is None or not text_of(expr): # Empty expression is legitimate in several vendor patterns: # - totalField with same dataPath provides the calculation # - groupTemplate uses the field as group name (declarative only) # - field is referenced only by settingsVariants for grouping # Surface as warning, not error, to avoid false positives on real # ERP/БП reports while still flagging the unusual shape. if path not in tf_paths: report_warn(f"CalculatedField '{path}' has empty expression (declarative-only?)") # Warn if collides with a dataset field if path in all_field_paths: report_warn(f"CalculatedField '{path}' shadows dataSet field in '{all_field_paths[path]}'") if cf_ok: report_ok(f"{len(calc_field_nodes)} calculatedField(s): dataPath and expression valid") if stopped: finalize() sys.exit(1) # ── 10. TotalField checks ──────────────────────────────────── if len(total_field_nodes) > 0: tf_ok = True for tf in total_field_nodes: dp = find(tf, "s:dataPath") expr = find(tf, "s:expression") if dp is None or not inner_text(dp): report_error("TotalField has empty dataPath") tf_ok = False continue if expr is None or not text_of(expr): report_error(f"TotalField '{inner_text(dp)}' has empty expression") tf_ok = False if tf_ok: report_ok(f"{len(total_field_nodes)} totalField(s): dataPath and expression present") if stopped: finalize() sys.exit(1) # ── 11. Parameter checks ───────────────────────────────────── if len(param_nodes) > 0: param_ok = True param_seen = {} for p in param_nodes: name_node = find(p, "s:name") if name_node is None or not inner_text(name_node): report_error("Parameter has empty name") param_ok = False continue p_name = inner_text(name_node) if p_name in param_seen: report_error(f"Duplicate parameter name: {p_name}") param_ok = False else: param_seen[p_name] = True if param_ok: report_ok(f"{len(param_nodes)} parameter(s): names unique") if stopped: finalize() sys.exit(1) # ── 12. Template checks ────────────────────────────────────── if len(template_nodes) > 0: tpl_ok = True tpl_seen = {} for t in template_nodes: name_node = find(t, "s:name") if name_node is None or not inner_text(name_node): report_error("Template has empty name") tpl_ok = False continue t_name = inner_text(name_node) if t_name in tpl_seen: # Vendor configs (ERP/БП) ship templates with repeating names — the # platform identifies them by position/context, not by . Demote # to warning so the check still surfaces the collision without failing. report_warn(f"Duplicate template name: {t_name} (allowed by platform but ambiguous)") else: tpl_seen[t_name] = True if tpl_ok: report_ok(f"{len(template_nodes)} template(s) found") # ── 13. GroupTemplate checks ───────────────────────────────── if len(group_template_nodes) > 0: gt_ok = True valid_tpl_types = ("Header", "Footer", "Overall", "OverallHeader", "OverallFooter") for gt in group_template_nodes: tpl_ref = find(gt, "s:template") tpl_type = find(gt, "s:templateType") if tpl_ref is not None and inner_text(tpl_ref) and inner_text(tpl_ref) not in template_names: report_error(f"GroupTemplate references unknown template: {inner_text(tpl_ref)}") gt_ok = False if tpl_type is not None and inner_text(tpl_type) not in valid_tpl_types: report_warn(f"GroupTemplate has unusual templateType: {inner_text(tpl_type)}") if gt_ok: report_ok(f"{len(group_template_nodes)} groupTemplate(s): references valid") if stopped: finalize() sys.exit(1) # ── 14. Settings helper functions ───────────────────────────── valid_comparison_types = ( "Equal", "NotEqual", "Greater", "GreaterOrEqual", "Less", "LessOrEqual", "InList", "NotInList", "InHierarchy", "NotInHierarchy", "InListByHierarchy", "NotInListByHierarchy", "Contains", "NotContains", "BeginsWith", "NotBeginsWith", "Filled", "NotFilled", ) valid_structure_types = ( "dcsset:StructureItemGroup", "dcsset:StructureItemTable", "dcsset:StructureItemChart", "dcsset:StructureItemNestedObject", ) def check_filter_items(parent_node, variant_name): global stopped filter_items = find_all(parent_node, "dcsset:filter/dcsset:item") for fi in filter_items: if stopped: return xsi_type = fi.get(XSI_TYPE, "") if xsi_type == "dcsset:FilterItemComparison": comp_type = find(fi, "dcsset:comparisonType") if comp_type is not None and inner_text(comp_type) not in valid_comparison_types: report_error(f"Variant '{variant_name}' filter: invalid comparisonType '{inner_text(comp_type)}'") elif xsi_type == "dcsset:FilterItemGroup": group_type = find(fi, "dcsset:groupType") if group_type is not None: valid_group_types = ("AndGroup", "OrGroup", "NotGroup") if inner_text(group_type) not in valid_group_types: report_warn(f"Variant '{variant_name}' filter group: unusual groupType '{inner_text(group_type)}'") # Recurse into nested items nested_items = find_all(fi, "dcsset:item") for ni in nested_items: ni_type = ni.get(XSI_TYPE, "") if ni_type == "dcsset:FilterItemComparison": comp_type = find(ni, "dcsset:comparisonType") if comp_type is not None and inner_text(comp_type) not in valid_comparison_types: report_error(f"Variant '{variant_name}' filter: invalid comparisonType '{inner_text(comp_type)}'") def check_structure_item(item_node, variant_name): global stopped if stopped: return xsi_type = item_node.get(XSI_TYPE, "") if not xsi_type: report_error(f"Variant '{variant_name}': structure item missing xsi:type") return if xsi_type not in valid_structure_types: report_warn(f"Variant '{variant_name}': unusual structure item type '{xsi_type}'") # Recurse into nested items (groups can contain groups) nested_items = find_all(item_node, "dcsset:item") for ni in nested_items: check_structure_item(ni, variant_name) # Check column/row in tables if xsi_type == "dcsset:StructureItemTable": columns = find_all(item_node, "dcsset:column") rows = find_all(item_node, "dcsset:row") if len(columns) == 0: report_warn(f"Variant '{variant_name}': table has no columns") if len(rows) == 0: report_warn(f"Variant '{variant_name}': table has no rows") def check_settings(settings_node, variant_name): global stopped if stopped: return # Selection sel_items = find_all(settings_node, "dcsset:selection/dcsset:item") for si in sel_items: xsi_type = si.get(XSI_TYPE, "") if xsi_type == "dcsset:SelectedItemField": field = find(si, "dcsset:field") if field is not None and inner_text(field) and inner_text(field) != "SystemFields.Number": base_path = inner_text(field).split(".")[0] if inner_text(field) not in known_fields and base_path not in known_fields: pass # Soft check — autoFillFields may add fields not listed explicitly # Filter check_filter_items(settings_node, variant_name) # Order order_items = find_all(settings_node, "dcsset:order/dcsset:item") for oi in order_items: xsi_type = oi.get(XSI_TYPE, "") if xsi_type == "dcsset:OrderItemField": order_type = find(oi, "dcsset:orderType") if order_type is not None and inner_text(order_type) not in ("Asc", "Desc"): report_warn(f"Variant '{variant_name}' order: invalid orderType '{inner_text(order_type)}'") # Structure items struct_items = find_all(settings_node, "dcsset:item") for si in struct_items: check_structure_item(si, variant_name) # ── 15. SettingsVariant checks ──────────────────────────────── if len(variant_nodes) == 0: report_warn("No settingsVariant elements found") else: v_ok = True v_idx = 0 for v in variant_nodes: v_idx += 1 v_name = find(v, "dcsset:name") if v_name is None or not inner_text(v_name): report_error(f"SettingsVariant #{v_idx} has empty name") v_ok = False settings = find(v, "dcsset:settings") if settings is None: report_error(f"SettingsVariant '{inner_text(v_name) if v_name is not None else ''}' has no settings element") v_ok = False continue # Check settings internals check_settings(settings, inner_text(v_name) if v_name is not None else "") if v_ok: report_ok(f"{len(variant_nodes)} settingsVariant(s) found") # ── 16. valueType structural checks ─────────────────────────── # Catches broken XDTO that XML/structural checks miss (decimal without xs:, # missing qualifiers, mismatched qualifier blocks, unknown sign/length tokens). import re as _re_vt _VALID_TYPE_QUALIFIER = { 'xs:decimal': 'v8:NumberQualifiers', 'xs:string': 'v8:StringQualifiers', 'xs:dateTime': 'v8:DateQualifiers', 'xs:boolean': '', 'v8:StandardPeriod': '', 'v8:UUID': '', 'v8:Null': '', 'v8:Type': '', 'v8:ValueStorage': '', } _VALID_SIGN = ('Any', 'Nonnegative', 'Negative') _VALID_LENGTH = ('Variable', 'Fixed') _VALID_FRACTIONS = ('Date', 'DateTime', 'Time') _V8_NS_URI = 'http://v8.1c.ru/8.1/data/core' _CONFIG_NS_URI = 'http://v8.1c.ru/8.1/data/enterprise/current-config' # DCS supports composite types: multiple blocks may share a single # trailing qualifier block (e.g. xs:string + CatalogRef.X + StringQualifiers). # So we collect all types and qualifiers per valueType, then check consistency. _QUALIFIER_PRODUCERS = { 'v8:NumberQualifiers': 'xs:decimal', 'v8:StringQualifiers': 'xs:string', 'v8:DateQualifiers': 'xs:dateTime', } vt_nodes = find_all(root, "//s:valueType") vt_checked = 0 vt_ok = True for vt in vt_nodes: vt_checked += 1 types = [] # short type strings; '' marks a ref type qualifiers = [] # list of (qName, node) for child in vt: if not isinstance(child.tag, str): continue qn = etree.QName(child.tag) if qn.namespace != _V8_NS_URI: continue local = qn.localname if local == 'Type': t = (child.text or '').strip() if not t: report_error("valueType: is empty") vt_ok = False continue m = _re_vt.match(r'^([A-Za-z][A-Za-z0-9]*):(.+)$', t) if not m: report_error(f"valueType: type '{t}' has no namespace prefix (expected xs:/v8:/d5p1: — e.g. xs:decimal not decimal)") vt_ok = False continue prefix, local_t = m.group(1), m.group(2) if prefix in ('xs', 'v8'): if t not in _VALID_TYPE_QUALIFIER: report_error(f"valueType: unknown type '{t}' (allowed: xs:decimal/xs:string/xs:dateTime/xs:boolean/v8:StandardPeriod or :*Ref.X)") vt_ok = False else: types.append(t) else: prefix_ns = child.nsmap.get(prefix) if prefix_ns == _CONFIG_NS_URI: if not _re_vt.match(r'^[A-Za-z]+(Ref)?\.', local_t): report_error(f"valueType: ref type '{t}' must look like ':.' (e.g. d5p1:CatalogRef.X)") vt_ok = False else: types.append('') # ref — no qualifier needed elif prefix_ns == 'http://v8.1c.ru/8.1/data/enterprise': # System types: AccumulationRecordType etc. — no qualifiers if not _re_vt.match(r'^[A-Za-z][A-Za-z0-9]*$', local_t): report_error(f"valueType: system type '{t}' has unexpected local-name shape") vt_ok = False else: types.append('') else: report_error(f"valueType: type '{t}' uses prefix '{prefix}' bound to unexpected namespace '{prefix_ns}'") vt_ok = False elif local.endswith('Qualifiers'): q_name = f"v8:{local}" qualifiers.append((q_name, child)) if q_name == 'v8:NumberQualifiers': digits = find(child, "v8:Digits") frac = find(child, "v8:FractionDigits") sign = find(child, "v8:AllowedSign") if digits is None or not _re_vt.match(r'^\d+$', text_of(digits)): report_error("v8:NumberQualifiers: missing or not a non-negative integer") vt_ok = False if frac is None or not _re_vt.match(r'^\d+$', text_of(frac)): report_error("v8:NumberQualifiers: missing or not a non-negative integer") vt_ok = False if sign is not None and text_of(sign) and text_of(sign) not in _VALID_SIGN: report_error(f"v8:NumberQualifiers: {text_of(sign)} — must be one of: {', '.join(_VALID_SIGN)}") vt_ok = False elif q_name == 'v8:StringQualifiers': length = find(child, "v8:Length") al = find(child, "v8:AllowedLength") if length is None or not _re_vt.match(r'^\d+$', text_of(length)): report_error("v8:StringQualifiers: missing or not a non-negative integer") vt_ok = False if al is not None and text_of(al) and text_of(al) not in _VALID_LENGTH: report_error(f"v8:StringQualifiers: {text_of(al)} — must be one of: {', '.join(_VALID_LENGTH)}") vt_ok = False elif q_name == 'v8:DateQualifiers': df = find(child, "v8:DateFractions") if df is not None and text_of(df) and text_of(df) not in _VALID_FRACTIONS: report_error(f"v8:DateQualifiers: {text_of(df)} — must be one of: {', '.join(_VALID_FRACTIONS)}") vt_ok = False # Cross-check: every qualifier must have a matching scalar type in this valueType for q_name, _ in qualifiers: producer = _QUALIFIER_PRODUCERS.get(q_name) if not producer: continue if producer not in types: report_error(f"valueType: <{q_name}> has no matching {producer} in this valueType") vt_ok = False if vt_checked > 0 and vt_ok: report_ok(f"{vt_checked} valueType block(s): structure and qualifiers OK") if stopped: finalize() sys.exit(1) # ── 17. value content checks ────────────────────────────────── # Catches literal placeholders ('_') and empty strings in DesignTimeValue refs # that XDTO would reject at db-load-xml. value_nodes = find_all(root, "//s:value[@xsi:type]") + find_all(root, "//dcscor:value[@xsi:type]") v_checked = 0 v_ok = True for vn in value_nodes: if vn is None: continue v_checked += 1 xsi_type = vn.get(XSI_TYPE) or '' text = vn.text or '' if xsi_type == 'dcscor:DesignTimeValue': stripped = text.strip() if not stripped or stripped == '_': report_error(f"{text} — DesignTimeValue must be a reference path (e.g. Перечисление.X.Y), not '{text}'") v_ok = False elif not _re_vt.match(r'^[A-Za-zА-Яа-яЁё]+\.[A-Za-zА-Яа-яЁё0-9_]+', stripped): report_warn(f"{text} — doesn't look like a typical ref path") elif xsi_type == 'xs:boolean' and text.strip() not in ('true', 'false', '1', '0'): # Платформа прощает True/False, чтение по схеме (XDTO) — нет report_error(f"{text} — boolean must be true or false (lowercase)") v_ok = False if v_checked > 0 and v_ok: report_ok(f"{v_checked} element(s) with xsi:type: content OK") if stopped: finalize() sys.exit(1) # ── 18. XSD (schemas of the platform, /v8-xsd-fetch) ────────── # Порядок элементов, типы и значения — по XSD платформы, если они есть в проекте. Нет схем — # нет проверки. Схемы XDTO совпадают с выгрузкой конфигуратора только в СКД, и то кроме # нескольких мест, которые платформа пишет иначе, — они отсекаются ниже (xsd_platform_noise). import json import re import shutil import tempfile from pathlib import Path def _sg_find_v8project(start_dir): d = start_dir for _ in range(20): if not d: break pj = os.path.join(d, ".v8-project.json") if os.path.isfile(pj): return pj parent = os.path.dirname(d) if parent == d: break d = parent return None # Штамп версии формата — атрибут version КОРНЕВОГО элемента файла. Именно корневого: в Form.xml # расширения ниже стоит . Читается только заголовок, без разбора файла. def root_version(xml_path): if not os.path.isfile(xml_path): return None with open(xml_path, "rb") as f: head = f.read(4096).decode("utf-8", errors="ignore") m = re.search(r'<[A-Za-z_][\w.:-]*(\s[^>]*)?/?>', head) if not m: return None v = re.search(r'(?:^|\s)version="([^"]*)"', m.group(1) or "") if v: return v.group(1) return None # Корень автономной внешней обработки/отчёта. Копия общего эталона (семья 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 # Якорь выгрузки, в чьём дереве лежит файл: корень автономной EPF/ERF либо Configuration.xml, # ближайший вверх. Корень обработки проверяется первым — иначе обработка, лежащая в дереве # конфигурации, сверялась бы с конфигурацией. Нет якоря — сверять не с чем. def find_dump_anchor(start_dir): d = start_dir for _ in range(15): if not d: break if _sg_is_external_root(d + ".xml"): return d + ".xml" cfg = os.path.join(d, "Configuration.xml") if os.path.exists(cfg): return cfg parent = os.path.dirname(d) if not parent or parent == d: break d = parent return None def format_rank(ver): """"2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17").""" m = re.match(r'^(\d+)\.(\d+)$', ver or '') return int(m.group(1)) * 100 + int(m.group(2)) if m else 0 XSD_DCS_NS = "http://v8.1c.ru/8.1/data-composition-system/schema" XS_NS = "http://www.w3.org/2001/XMLSchema" def xsd_target_ns(path): """targetNamespace схемы — из заголовка файла, без разбора.""" with open(path, "rb") as f: head = f.read(4096).decode("utf-8", errors="ignore") m = re.search(r'targetNamespace="([^"]*)"', head) return m.group(1) if m else None def xsd_index(vdir): """namespace → файл схемы в каталоге версии.""" idx = {} for name in sorted(os.listdir(vdir)): fp = os.path.join(vdir, name) if name.lower().endswith(".xsd") and os.path.isfile(fp): ns = xsd_target_ns(fp) if ns and ns not in idx: idx[ns] = fp return idx def resolve_xsd_root(template_dir): """Каталог схем: .v8-project.json — сначала от шаблона (проект, которому он принадлежит), потом от текущего каталога; xsdPath из него, иначе .v8-xsd рядом с ним. Нет проекта — нет схем.""" pj = _sg_find_v8project(template_dir) or _sg_find_v8project(os.getcwd()) if not pj: return None pj_dir = os.path.dirname(pj) rel = ".v8-xsd" try: with open(pj, encoding="utf-8-sig") as f: cfg = json.load(f) if isinstance(cfg, dict) and cfg.get("xsdPath"): rel = str(cfg["xsdPath"]) except Exception: pass d = rel if os.path.isabs(rel) else os.path.join(pj_dir, rel) return d if os.path.isdir(d) else None def xsd_platform_noise(info): """Места, которые платформа пишет не так, как описывает XSD, — сверено на всех схемах компоновки выгрузок БП и УНФ. Правило — по месту (элемент, родитель, xsi:type), не по тексту: текст зависит от движка и языка.""" e, p = info["elem"], info["parent"] if p in ("groupTemplate", "groupHeaderTemplate") and e in ("templateType", "groupName"): return True if p == "right" and e == "lastId": return True if p == "nestedSchema" and e == "schema": return True if info["xsiNs"] == "http://v8.1c.ru/8.2/data/chart": return True # Пустое выражение параметра макета платформа не пишет, схема требует if e == "parameter" and info["xsiLocal"] == "ExpressionAreaTemplateParameter": return True return False def _xsd_build_schema(vdir, tmp): """Схема для lxml. У import в схемах платформы нет schemaLocation, а libxml2 сам по namespace не ищет, — корневая схема импортирует замыкание зависимостей СКД с путями, листья первыми. Корень DataCompositionSchema в схеме объявлен как dataCompositionSchema (строчная) — схему СКД подключаем через обёртку: xs:include настоящего файла плюс элемент с нужным именем.""" idx = xsd_index(vdir) order, seen = [], set() def visit(ns): if ns in seen or ns not in idx: return seen.add(ns) doc = etree.parse(idx[ns]) for imp in doc.getroot().iter("{%s}import" % XS_NS): visit(imp.get("namespace")) order.append(ns) # Сначала замыкание СКД, затем остальные схемы каталога: значения в выгрузке ссылаются через # xsi:type на типы, которые схема СКД не импортирует (data/enterprise: LinkedValueChangeMode, # FoldersAndItemsUse …) — как и в PS-мастере, где в набор кладутся все схемы. visit(XSD_DCS_NS) for ns in idx: visit(ns) dcs_doc = etree.parse(idx[XSD_DCS_NS]) has_root = any(el.get("name") == "DataCompositionSchema" for el in dcs_doc.getroot().findall("{%s}element" % XS_NS)) locations = {ns: Path(idx[ns]).as_uri() for ns in order} if not has_root: wrapper = os.path.join(tmp, "dcs-root.xsd") with open(wrapper, "w", encoding="utf-8") as f: f.write('\n' '\n' ' \n' ' \n' '\n' % (XS_NS, XSD_DCS_NS, XSD_DCS_NS, locations[XSD_DCS_NS])) locations[XSD_DCS_NS] = Path(wrapper).as_uri() root = os.path.join(tmp, "root.xsd") with open(root, "w", encoding="utf-8") as f: f.write('\n\n' % XS_NS) for ns in order: f.write(' \n' % (ns, locations[ns])) f.write('\n') return etree.XMLSchema(etree.parse(root)) def _xsd_elem_info(el): if el is None or not isinstance(el.tag, str): return {"elem": "", "parent": "", "xsiNs": "", "xsiLocal": ""} par = el.getparent() info = {"elem": etree.QName(el).localname, "parent": etree.QName(par).localname if par is not None and isinstance(par.tag, str) else "", "xsiNs": "", "xsiLocal": ""} xt = el.get(XSI_TYPE) if xt: pp = xt.split(":") info["xsiLocal"] = pp[-1] if len(pp) > 1: info["xsiNs"] = el.nsmap.get(pp[0]) or "" return info def _xsd_error_element(err): """Элемент нарушения по error.path. Путь несёт префиксы документа (dcsset:settings) — xpath получает их из корня; префикс, объявленный глубже, не разрешится — тогда ищем по строке и имени последнего шага.""" if not err.path: return None ns = {k: v for k, v in tree.getroot().nsmap.items() if k} try: hits = tree.xpath(err.path, namespaces=ns) if hits and isinstance(hits[0], etree._Element): return hits[0] except Exception: pass last = re.sub(r'\[\d+\]$', '', err.path.rsplit('/', 1)[-1]).split(':')[-1] for el in tree.iter(): if isinstance(el.tag, str) and el.sourceline == err.line and etree.QName(el).localname == last: return el return None # ── Подсказка по схеме: что именно исправить ── # Текст валидатора про порядок («недопустимый дочерний X, ожидается A, B») читается как «добавь A», # а означает «X стоит слишком поздно». Поэтому для нарушений порядка строим свою подсказку: тип # родителя и порядок его детей берём из XSD, сравниваем с соседями нарушителя в документе. Обход # схемы — свой, по XSD-документам, как в PS-мастере: так подсказки портов совпадают. def xsd_model(vdir): m = {"types": {}, "elems": {}, "orders": {}} for name in sorted(os.listdir(vdir)): fp = os.path.join(vdir, name) if not (name.lower().endswith(".xsd") and os.path.isfile(fp)): continue root_el = etree.parse(fp).getroot() tns = root_el.get("targetNamespace") or "" for n in root_el: if not isinstance(n.tag, str) or etree.QName(n).namespace != XS_NS or not n.get("name"): continue key = f"{tns}|{n.get('name')}" ln = etree.QName(n).localname if ln == "complexType": m["types"].setdefault(key, n) elif ln == "element": m["elems"].setdefault(key, n) return m def xsd_resolve_qname(node, qn): """QName из атрибута схемы → "ns|имя" по пространствам имён узла XSD.""" if not qn: return None pp = qn.split(":") prefix = pp[0] if len(pp) > 1 else None return f"{node.nsmap.get(prefix) or ''}|{pp[-1]}" def xsd_child_order(model, type_key): """Дети типа по порядку: {name, type, maxOne}. База расширения — первой. Порядок не определён (choice, all, any, group) или тип неизвестен — None.""" if not type_key: return None if type_key in model["orders"]: return model["orders"][type_key] model["orders"][type_key] = None # защита от циклов ct = model["types"].get(type_key) if ct is None: return None lst = [] holder = ct for c in ct: if not isinstance(c.tag, str): continue ln = etree.QName(c).localname if ln == "simpleContent": model["orders"][type_key] = lst return lst if ln == "complexContent": ext = None for x in c: if isinstance(x.tag, str) and etree.QName(x).localname == "extension": ext = x if ext is None: return None base_key = xsd_resolve_qname(ext, ext.get("base")) base = xsd_child_order(model, base_key) # База известна, но порядок у неё не определён — не определён и здесь if base is None and base_key in model["types"]: return None if base: lst.extend(base) holder = ext for c in holder: if not isinstance(c.tag, str): continue ln = etree.QName(c).localname if ln == "sequence": if not _xsd_add_sequence(model, c, lst): return None elif ln in ("choice", "all", "group", "any"): return None model["orders"][type_key] = lst return lst def _xsd_add_sequence(model, seq, lst): for c in seq: if not isinstance(c.tag, str): continue ln = etree.QName(c).localname if ln == "element": ref = c.get("ref") if ref: rk = xsd_resolve_qname(c, ref) ge = model["elems"].get(rk) name = rk.split("|")[-1] typ = xsd_resolve_qname(ge, ge.get("type")) if ge is not None and ge.get("type") else None else: name = c.get("name") typ = xsd_resolve_qname(c, c.get("type")) if c.get("type") else None mx = c.get("maxOccurs") lst.append({"name": name, "type": typ, "maxOne": (not mx or mx == "1")}) elif ln == "sequence": if not _xsd_add_sequence(model, c, lst): return False elif ln == "annotation": continue else: return False return True def _xsd_ancestor(el): """Предок для спуска по схеме: {name, ns, xsiKey}.""" q = etree.QName(el) e = {"name": q.localname, "ns": q.namespace or "", "xsiKey": None} xt = el.get(XSI_TYPE) if xt: pp = xt.split(":") prefix = pp[0] if len(pp) > 1 else None e["xsiKey"] = f"{el.nsmap.get(prefix) or ''}|{pp[-1]}" return e def xsd_parent_type(model, ancestors): """Тип родителя нарушителя: спуск от корня по цепочке предков.""" if not ancestors: return None root = ancestors[0] rk = f"{root['ns']}|{root['name']}" if root["xsiKey"]: typ = root["xsiKey"] elif rk in model["elems"] and model["elems"][rk].get("type"): typ = xsd_resolve_qname(model["elems"][rk], model["elems"][rk].get("type")) elif rk in model["types"]: typ = rk else: return None for a in ancestors[1:]: if a["xsiKey"]: typ = a["xsiKey"] continue order = xsd_child_order(model, typ) if order is None: return None hit = next((o for o in order if o["name"] == a["name"]), None) if hit is None or not hit["type"]: return None typ = hit["type"] return typ def xsd_order_hint(model, ancestors, name, preceding): order = xsd_child_order(model, xsd_parent_type(model, ancestors)) if order is None: return None names = [o["name"] for o in order] if name not in names: return f"not allowed in <{ancestors[-1]['name']}>" idx = names.index(name) if order[idx]["maxOne"] and name in preceding: return f"duplicate — only one <{name}> allowed" for sib in preceding: if sib in names and names.index(sib) > idx: return f"must come before <{sib}>" return None def compress_xsd_message(m): """Текст валидатора без пространств имён: остаются имена и значения.""" m = re.sub(r'[\r\n]+', ' ', m) m = re.sub(r'\s(в пространстве имен|in namespace)\s"[^"]*"', '', m) m = re.sub(r"\s(в пространстве имен|in namespace)\s'[^']*'", '', m) m = re.sub(r'\{[^}]*\}', '', m) m = re.sub(r'(["\'])https?://[^"\']*:', r'\1', m) return ' '.join(m.split()) def xsd_check(): xsd_root = resolve_xsd_root(os.path.dirname(resolved_path)) global xsd_note if not xsd_root: xsd_note = "XSD not checked: no schemas (/v8-xsd-fetch)" return anchor = find_dump_anchor(os.path.dirname(resolved_path)) ver = root_version(anchor) if anchor else None if not ver or format_rank(ver) == 0: xsd_note = "XSD not checked: template outside a dump" return # Точная версия — ошибки; ближайшая более новая — предупреждения; только старше — пропуск cands = sorted((d for d in os.listdir(xsd_root) if re.match(r'^\d+\.\d+$', d) and os.path.isdir(os.path.join(xsd_root, d)) and format_rank(d) >= format_rank(ver)), key=format_rank) cands = [d for d in cands if XSD_DCS_NS in xsd_index(os.path.join(xsd_root, d))] if not cands: report_warn(f"XSD: no schemas for format {ver} in '{xsd_root}' — not checked") return use_ver = cands[0] exact = use_ver == ver tmp = tempfile.mkdtemp(prefix="skd-xsd-") try: try: schema = _xsd_build_schema(os.path.join(xsd_root, use_ver), tmp) # Модель — после компиляции, как в PS-мастере: битый .xsd даёт предупреждение, а не трейсбэк model = xsd_model(os.path.join(xsd_root, use_ver)) except Exception as ex: report_warn(f"XSD: schemas in '{os.path.join(xsd_root, use_ver)}' do not compile — not checked: {ex}") return finally: shutil.rmtree(tmp, ignore_errors=True) tag = f"XSD {use_ver}" if exact else f"XSD {use_ver} (no schemas for {ver})" schema.validate(tree) found = 0 for err in schema.error_log: if stopped: break el = _xsd_error_element(err) info = _xsd_elem_info(el) if xsd_platform_noise(info): continue # Недопустимое булево значение уже сообщила собственная проверка значений (раздел 17) if info["elem"] == "value" and info["xsiNs"] == XS_NS and info["xsiLocal"] == "boolean": continue found += 1 hint = None # «Лишний/не на месте» у libxml2 — «This element is not expected» (текст не локализуется) if el is not None and "This element is not expected" in err.message: ancestors = [_xsd_ancestor(a) for a in reversed(list(el.iterancestors()))] preceding = [etree.QName(x).localname for x in reversed(list(el.itersiblings(preceding=True))) if isinstance(x.tag, str)] hint = xsd_order_hint(model, ancestors, etree.QName(el).localname, preceding) text = hint or compress_xsd_message(err.message) msg = f"{tag}, line {err.line}: {info['parent']}/{info['elem']}: {text}" if exact: report_error(msg) else: report_warn(msg) if found == 0: report_ok(f"{tag}: order, types and values OK") xsd_check() if stopped: finalize() sys.exit(1) # ── Final output ────────────────────────────────────────────── finalize() if errors > 0: sys.exit(1) sys.exit(0)