Files
cc-1c-skills/.opencode/skills/role-validate/scripts/role-validate.py
T

625 lines
26 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
# role-validate v1.3 — Validate 1C role Rights.xml structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Validates role Rights.xml: root element, global flags, objects, rights, RLS, templates."""
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)
GUID_PATTERN = re.compile(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
re.IGNORECASE
)
RIGHTS_NS = 'http://v8.1c.ru/8.2/roles'
# --- Known rights per object type ---
KNOWN_RIGHTS = {
'Configuration': [
'Administration', 'DataAdministration', 'UpdateDataBaseConfiguration',
'ConfigurationExtensionsAdministration', 'ActiveUsers', 'EventLog', 'ExclusiveMode',
'ThinClient', 'ThickClient', 'WebClient', 'MobileClient', 'ExternalConnection',
'Automation', 'Output', 'SaveUserData', 'TechnicalSpecialistMode',
'InteractiveOpenExtDataProcessors', 'InteractiveOpenExtReports',
'AnalyticsSystemClient', 'CollaborationSystemInfoBaseRegistration',
'MainWindowModeNormal', 'MainWindowModeWorkplace',
'MainWindowModeEmbeddedWorkplace', 'MainWindowModeFullscreenWorkplace', 'MainWindowModeKiosk',
],
'Catalog': [
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
'InteractiveDelete', 'InteractiveDeleteMarked',
'InteractiveDeletePredefinedData', 'InteractiveSetDeletionMarkPredefinedData',
'InteractiveClearDeletionMarkPredefinedData', 'InteractiveDeleteMarkedPredefinedData',
'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
'UpdateDataHistoryOfMissingData', 'ReadDataHistoryOfMissingData',
'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
],
'Document': [
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
'Posting', 'UndoPosting',
'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
'InteractiveDelete', 'InteractiveDeleteMarked',
'InteractivePosting', 'InteractivePostingRegular', 'InteractiveUndoPosting',
'InteractiveChangeOfPosted',
'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
'UpdateDataHistoryOfMissingData', 'ReadDataHistoryOfMissingData',
'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
],
'InformationRegister': [
'Read', 'Update', 'View', 'Edit', 'TotalsControl',
'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
'UpdateDataHistoryOfMissingData', 'ReadDataHistoryOfMissingData',
'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
],
'AccumulationRegister': ['Read', 'Update', 'View', 'Edit', 'TotalsControl'],
'AccountingRegister': ['Read', 'Update', 'View', 'Edit', 'TotalsControl'],
'CalculationRegister': ['Read', 'View'],
'Constant': [
'Read', 'Update', 'View', 'Edit',
'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
],
'ChartOfAccounts': [
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
'InteractiveDelete',
'InteractiveDeletePredefinedData', 'InteractiveSetDeletionMarkPredefinedData',
'InteractiveClearDeletionMarkPredefinedData', 'InteractiveDeleteMarkedPredefinedData',
'ReadDataHistory', 'ReadDataHistoryOfMissingData',
'UpdateDataHistory', 'UpdateDataHistoryOfMissingData',
'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
],
'ChartOfCharacteristicTypes': [
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
'InteractiveDelete', 'InteractiveDeleteMarked',
'InteractiveDeletePredefinedData', 'InteractiveSetDeletionMarkPredefinedData',
'InteractiveClearDeletionMarkPredefinedData', 'InteractiveDeleteMarkedPredefinedData',
'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
'ReadDataHistoryOfMissingData', 'UpdateDataHistoryOfMissingData',
'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
],
'ChartOfCalculationTypes': [
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
'InteractiveDelete',
'InteractiveDeletePredefinedData', 'InteractiveSetDeletionMarkPredefinedData',
'InteractiveClearDeletionMarkPredefinedData', 'InteractiveDeleteMarkedPredefinedData',
],
'ExchangePlan': [
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
'InteractiveDelete', 'InteractiveDeleteMarked',
'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
'ReadDataHistoryOfMissingData', 'UpdateDataHistoryOfMissingData',
'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
],
'BusinessProcess': [
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
'Start', 'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
'InteractiveDelete', 'InteractiveActivate', 'InteractiveStart',
],
'Task': [
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
'Execute', 'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
'InteractiveDelete', 'InteractiveActivate', 'InteractiveExecute',
],
'DataProcessor': ['Use', 'View'],
'Report': ['Use', 'View'],
'CommonForm': ['View'],
'CommonCommand': ['View'],
'Subsystem': ['View'],
'FilterCriterion': ['View'],
'DocumentJournal': ['Read', 'View'],
'Sequence': ['Read', 'Update'],
'WebService': ['Use'],
'HTTPService': ['Use'],
'IntegrationService': ['Use'],
'SessionParameter': ['Get', 'Set'],
'CommonAttribute': ['View', 'Edit'],
'ExternalDataSource': [
'Use', 'Administration', 'StandardAuthenticationChange',
'SessionStandardAuthenticationChange', 'SessionOSAuthenticationChange',
],
}
# Виды вложенности (предпоследний сегмент пути) → допустимые права. Списки сняты с корпуса
# типовых конфигураций и с выгрузки роли, где права проставлены по всему дереву редактора:
# догадкам тут не место — закрытый список превращает промах в ложный отказ.
NESTED_KIND_RIGHTS = {
'Attribute': ['View', 'Edit'],
'StandardAttribute': ['View', 'Edit'],
'TabularSection': ['View', 'Edit'],
'StandardTabularSection': ['View', 'Edit'],
'Dimension': ['View', 'Edit'],
'Resource': ['View', 'Edit'],
'AccountingFlag': ['View', 'Edit'],
'ExtDimensionAccountingFlag': ['View', 'Edit'],
'AddressingAttribute': ['View', 'Edit'],
'Field': ['View', 'Edit'],
'Command': ['View'],
'Subsystem': ['View'],
'Operation': ['Use'],
'Method': ['Use'],
'IntegrationServiceChannel': ['Use'],
'Recalculation': ['Read', 'Update'],
'Cube': ['Read', 'View'],
'DimensionTable': ['Read', 'View'],
'Function': ['Use', 'View'],
'Table': [
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
'InteractiveInsert', 'InteractiveDelete',
],
}
# Виды, существующие только у одного типа-родителя: без этой привязки
# `Catalog.Товары.Field.Цена` прошёл бы как валидный вложенный объект.
KIND_OWNERS = {
'Table': 'ExternalDataSource',
'Cube': 'ExternalDataSource',
'Function': 'ExternalDataSource',
'Field': 'ExternalDataSource',
'DimensionTable': 'ExternalDataSource',
'Recalculation': 'CalculationRegister',
'Operation': 'WebService',
'Method': 'HTTPService',
'IntegrationServiceChannel': 'IntegrationService',
}
# Один и тот же вид под разными родителями имеет разный набор: измерение регистра —
# View + Edit, измерение куба внешнего источника — только View. Объединять нельзя,
# объединение молча разрешило бы Edit там, где платформа его не даёт.
NESTED_KIND_RIGHTS_BY_TYPE = {
'ExternalDataSource': {
'Dimension': ['View'],
'Resource': ['View'],
},
}
# Типы без прав в ролях (в дереве редактора ролей их нет). Таблица НЕ управляет поведением —
# отказ даёт отсутствие типа в KNOWN_RIGHTS; здесь только причина для сообщения.
NO_RIGHTS_TYPES = [
'Enum', 'CommonModule', 'DefinedType', 'CommonPicture', 'CommonTemplate', 'Language',
'FunctionalOption', 'FunctionalOptionsParameter', 'EventSubscription', 'ScheduledJob',
'StyleItem', 'Style', 'SettingsStorage', 'XDTOPackage', 'WSReference', 'DocumentNumerator',
]
def get_object_type(name):
dot_idx = name.find('.')
if dot_idx < 0:
return name
return name[:dot_idx]
def is_nested_object(name):
return name.count('.') >= 2
def get_nested_kind(name):
"""Вид вложенности — предпоследний сегмент: путь бывает и восьмисегментным
(ExternalDataSource.И.Cube.К.DimensionTable.Т.Field.П), считать от конца."""
parts = name.split('.')
if len(parts) < 3:
return None
return parts[-2]
def get_nested_rights(object_type, kind):
by_type = NESTED_KIND_RIGHTS_BY_TYPE.get(object_type)
if by_type and kind in by_type:
return by_type[kind]
return NESTED_KIND_RIGHTS.get(kind)
def find_similar(needle, haystack):
result = []
needle_lower = needle.lower()
for h in haystack:
h_lower = h.lower()
if needle_lower in h_lower or h_lower in needle_lower:
result.append(h)
if len(result) >= 3:
break
return result
def format_type_error(obj_name, object_type):
"""Запрещённый тип и незнакомый тип — разные диагнозы: первый документирован,
второй скорее опечатка или пробел в таблице."""
if object_type in NO_RIGHTS_TYPES:
return f"{obj_name}: тип '{object_type}' не имеет прав в роли — блок нужно удалить"
similar = find_similar(object_type, list(KNOWN_RIGHTS.keys()))
sug = f" Возможно: {', '.join(similar)}?" if similar else ''
return f"{obj_name}: неизвестный тип объекта '{object_type}'.{sug}"
def get_child_text(parent, local_name, ns):
"""Get text of first child element with given local name in namespace."""
for child in parent:
if not isinstance(child.tag, str):
continue
if etree.QName(child.tag).localname == local_name and etree.QName(child.tag).namespace == ns:
return child.text or ''
return None
def get_child_el(parent, local_name, ns):
"""Get first child element with given local name in namespace."""
for child in parent:
if not isinstance(child.tag, str):
continue
if etree.QName(child.tag).localname == local_name and etree.QName(child.tag).namespace == ns:
return child
return None
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description='Validate 1C role Rights.xml structure', allow_abbrev=False
)
parser.add_argument('-RightsPath', '-Path', dest='RightsPath', required=True)
parser.add_argument('-OutFile', dest='OutFile', default='')
parser.add_argument('-Detailed', dest='Detailed', action='store_true')
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
args = ci_parse_args(parser)
rights_path = args.RightsPath
out_file = args.OutFile
if not os.path.isabs(rights_path):
rights_path = os.path.join(os.getcwd(), rights_path)
# A: Directory → Ext/Rights.xml
if os.path.isdir(rights_path):
rights_path = os.path.join(rights_path, 'Ext', 'Rights.xml')
# B1: Missing Ext/
if not os.path.exists(rights_path):
fn = os.path.basename(rights_path)
if fn == 'Rights.xml':
c = os.path.join(os.path.dirname(rights_path), 'Ext', fn)
if os.path.exists(c):
rights_path = c
resolved_path = os.path.abspath(rights_path)
# Auto-detect metadata: Roles/Name/Ext/Rights.xml → Roles/Name.xml
ext_dir = os.path.dirname(resolved_path)
role_dir = os.path.dirname(ext_dir)
roles_dir = os.path.dirname(role_dir)
role_dir_name = os.path.basename(role_dir)
metadata_path = os.path.join(roles_dir, f'{role_dir_name}.xml')
# --- Output helpers ---
lines = []
errors = 0
warnings = 0
ok_count = 0
stopped = False
def report_ok(msg):
nonlocal ok_count
ok_count += 1
if args.Detailed:
lines.append(f'[OK] {msg}')
def report_warn(msg):
nonlocal warnings
warnings += 1
lines.append(f'[WARN] {msg}')
def report_error(msg):
nonlocal errors, stopped
errors += 1
lines.append(f'[ERROR] {msg}')
if errors >= args.MaxErrors:
stopped = True
# --- 3. Validate Rights.xml ---
def write_output(text):
if out_file:
out_path = out_file if os.path.isabs(out_file) else os.path.join(os.getcwd(), out_file)
out_dir = os.path.dirname(out_path)
if out_dir and not os.path.exists(out_dir):
os.makedirs(out_dir, exist_ok=True)
with open(out_path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(text)
print(f'Written to: {out_path}')
else:
print(text)
if not os.path.exists(rights_path):
report_error(f'File not found: {rights_path}')
result = '\n'.join(lines)
write_output(result)
sys.exit(1)
# 3a. Parse XML
xml_doc = None
try:
xml_parser = etree.XMLParser(remove_blank_text=False)
xml_doc = etree.parse(rights_path, xml_parser)
report_ok('XML well-formed')
except etree.XMLSyntaxError as e:
report_error(f'XML parse error: {e}')
result = '\n'.join(lines)
write_output(result)
sys.exit(1)
root = xml_doc.getroot()
root_local = etree.QName(root.tag).localname
root_ns = etree.QName(root.tag).namespace or ''
# 3b. Check root element
if root_local != 'Rights':
report_error(f"Root element is '{root_local}', expected 'Rights'")
elif root_ns != RIGHTS_NS:
report_warn(f"Namespace is '{root_ns}', expected '{RIGHTS_NS}'")
else:
report_ok('Root element: <Rights> with correct namespace')
# 3c. Global flags
flag_names = ['setForNewObjects', 'setForAttributesByDefault', 'independentRightsOfChildObjects']
flags_found = 0
for fn in flag_names:
nodes = root.findall(f'{{{RIGHTS_NS}}}{fn}')
if len(nodes) > 0:
val = nodes[0].text or ''
if val not in ('true', 'false'):
report_warn(f"{fn} = '{val}' (expected 'true' or 'false')")
flags_found += 1
else:
report_warn(f'Missing global flag: {fn}')
if flags_found == 3:
report_ok('3 global flags present')
# 3d. Objects
objects = root.findall(f'{{{RIGHTS_NS}}}object')
obj_count = len(objects)
right_count = 0
rls_count = 0
for obj in objects:
obj_name = ''
for child in obj:
if not isinstance(child.tag, str):
continue
if etree.QName(child.tag).localname == 'name' and etree.QName(child.tag).namespace == RIGHTS_NS:
obj_name = child.text or ''
break
if not obj_name:
report_error('Object without <name>')
continue
object_type = get_object_type(obj_name)
is_nested = is_nested_object(obj_name)
# Тип проверяется ВСЕГДА, включая вложенные пути: раньше `Enum.Х.Attribute.Y`
# не проверялся вообще — ветка «unknown» стояла под `not is_nested`.
nested_kind = get_nested_kind(obj_name) if is_nested else None
type_known = object_type in KNOWN_RIGHTS
if not type_known:
report_error(format_type_error(obj_name, object_type))
elif is_nested and nested_kind in KIND_OWNERS and object_type != KIND_OWNERS[nested_kind]:
report_error(f"{obj_name}: вид '{nested_kind}' бывает только у {KIND_OWNERS[nested_kind]}")
elif is_nested and get_nested_rights(object_type, nested_kind) is None:
report_error(f"{obj_name}: неизвестный вид вложенности '{nested_kind}'")
# Check rights
for child in obj:
if not isinstance(child.tag, str):
continue
if etree.QName(child.tag).localname != 'right' or etree.QName(child.tag).namespace != RIGHTS_NS:
continue
r_name = ''
r_value = ''
has_rls = False
for rc in child:
if not isinstance(rc.tag, str):
continue
rc_local = etree.QName(rc.tag).localname
rc_ns = etree.QName(rc.tag).namespace
if rc_ns != RIGHTS_NS:
continue
if rc_local == 'name':
r_name = rc.text or ''
elif rc_local == 'value':
r_value = rc.text or ''
elif rc_local == 'restrictionByCondition':
has_rls = True
rls_count += 1
# Check condition not empty
cond_node = get_child_el(rc, 'condition', RIGHTS_NS)
if cond_node is None or not (cond_node.text or ''):
report_warn(f"{obj_name}: RLS condition for '{r_name}' is empty")
if not r_name:
report_error(f'{obj_name}: <right> without <name>')
continue
if r_value not in ('true', 'false'):
report_error(f"{obj_name}: right '{r_name}' has invalid value '{r_value}'")
continue
right_count += 1
# Validate right name. Тип уже отвергнут выше — второй раз про него не пишем.
if not type_known:
pass
elif is_nested:
valid_nested = get_nested_rights(object_type, nested_kind)
if valid_nested is not None and r_name not in valid_nested:
report_error(f"{obj_name}: право '{r_name}' недопустимо для вида '{nested_kind}' (допустимо: {', '.join(valid_nested)})")
else:
valid_rights = KNOWN_RIGHTS[object_type]
if r_name not in valid_rights:
similar = find_similar(r_name, valid_rights)
sug_str = f" Возможно: {', '.join(similar)}?" if similar else ''
report_error(f"{obj_name}: право '{r_name}' не существует у типа '{object_type}'.{sug_str}")
report_ok(f'{obj_count} objects, {right_count} rights')
if rls_count > 0:
report_ok(f'{rls_count} RLS restrictions')
# 3e. Templates
templates = root.findall(f'{{{RIGHTS_NS}}}restrictionTemplate')
if len(templates) > 0:
tpl_names = []
for tpl in templates:
t_name = ''
t_cond = ''
for child in tpl:
if not isinstance(child.tag, str):
continue
local = etree.QName(child.tag).localname
ns = etree.QName(child.tag).namespace
if ns != RIGHTS_NS:
continue
if local == 'name':
t_name = child.text or ''
elif local == 'condition':
t_cond = child.text or ''
if not t_name:
report_warn('Restriction template without <name>')
else:
paren_idx = t_name.find('(')
short_name = t_name[:paren_idx] if paren_idx > 0 else t_name
tpl_names.append(short_name)
if not t_cond:
report_warn(f"Template '{t_name}': empty <condition>")
report_ok(f'{len(templates)} templates: {", ".join(tpl_names)}')
# --- 4. Validate metadata (optional) ---
inferred_role_name = ''
if os.path.isfile(metadata_path):
lines.append('')
try:
meta_parser = etree.XMLParser(remove_blank_text=False)
meta_xml = etree.parse(metadata_path, meta_parser)
meta_root = meta_xml.getroot()
# Find <Role> element anywhere
role_node = None
for el in meta_root.iter():
if isinstance(el.tag, str) and etree.QName(el.tag).localname == 'Role':
role_node = el
break
if role_node is None:
report_error('Metadata: <Role> element not found')
else:
uuid_val = role_node.get('uuid', '')
if GUID_PATTERN.match(uuid_val):
report_ok(f'Metadata: UUID valid ({uuid_val})')
else:
report_error(f"Metadata: invalid UUID format '{uuid_val}'")
# Find Name
name_node = None
for el in role_node.iter():
if isinstance(el.tag, str) and etree.QName(el.tag).localname == 'Name':
name_node = el
break
if name_node is not None and name_node.text:
report_ok(f'Metadata: Name = {name_node.text}')
inferred_role_name = name_node.text
else:
report_error('Metadata: <Name> is empty or missing')
# Find Synonym
syn_node = None
for el in role_node.iter():
if isinstance(el.tag, str) and etree.QName(el.tag).localname == 'Synonym':
syn_node = el
break
if syn_node is not None and len(syn_node) > 0:
report_ok('Metadata: Synonym present')
else:
report_warn('Metadata: <Synonym> is empty')
except etree.XMLSyntaxError as e:
report_error(f'Metadata XML parse error: {e}')
# --- 5. Check registration in Configuration.xml ---
config_dir = os.path.dirname(roles_dir) # config root
config_xml_path = os.path.join(config_dir, 'Configuration.xml')
if not inferred_role_name:
inferred_role_name = os.path.basename(role_dir)
if os.path.exists(config_xml_path):
lines.append('')
try:
cfg_parser = etree.XMLParser(remove_blank_text=False)
cfg_xml = etree.parse(config_xml_path, cfg_parser)
cfg_ns = {'md': 'http://v8.1c.ru/8.3/MDClasses'}
child_obj = cfg_xml.getroot().find('.//md:Configuration/md:ChildObjects', cfg_ns)
if child_obj is not None:
role_nodes = child_obj.findall('md:Role', cfg_ns)
found = False
for rn in role_nodes:
if (rn.text or '') == inferred_role_name:
found = True
break
if found:
report_ok(f'Configuration.xml: <Role>{inferred_role_name}</Role> registered')
else:
report_warn(f'Configuration.xml: <Role>{inferred_role_name}</Role> NOT found in ChildObjects')
except etree.XMLSyntaxError as e:
report_warn(f'Configuration.xml: parse error \u2014 {e}')
# --- 6. Summary ---
# Insert header at position 0
lines.insert(0, f'=== Validation: Role.{inferred_role_name} ===')
checks = ok_count + errors + warnings
if errors == 0 and warnings == 0 and not args.Detailed:
result = f'=== Validation OK: Role.{inferred_role_name} ({checks} checks) ==='
else:
lines.append('')
lines.append(f'=== Result: {errors} errors, {warnings} warnings ({checks} checks) ===')
result = '\n'.join(lines)
write_output(result)
sys.exit(1 if errors > 0 else 0)
if __name__ == '__main__':
main()