feat(validate): brief output by default, -Detailed for verbose

All 10 validation skills (meta, epf, skd, cf, cfe, form, mxl, role,
subsystem, interface) now output a single summary line on success:
=== Validation OK: Type.Name (N checks) ===

Errors/warnings always shown. Full per-check [OK] output behind -Detailed flag.
Removed all N/A check lines. Unified role-validate output format.
Trimmed SKILL.md files from 42-119 to 51-67 lines.
Version bumps: meta-validate v1.2, all others v1.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-03-09 18:14:44 +03:00
co-authored by Claude Opus 4.6
parent 93e4130ff2
commit 422e397381
30 changed files with 685 additions and 816 deletions
@@ -1,4 +1,4 @@
# role-validate v1.0 — Validate 1C role structure
# role-validate v1.1 — Validate 1C role structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -6,7 +6,11 @@ param(
[string]$MetadataPath,
[string]$OutFile
[string]$OutFile,
[switch]$Detailed,
[int]$MaxErrors = 30
)
$ErrorActionPreference = "Stop"
@@ -132,25 +136,36 @@ $script:commandRights = @("View")
# --- 2. Output helpers ---
$script:lines = @()
$script:errors = 0
$script:warnings = 0
$script:okCount = 0
$script:stopped = $false
$script:output = New-Object System.Text.StringBuilder 8192
function Out-OK {
function Out-Line {
param([string]$msg)
$script:lines += " OK $msg"
$script:output.AppendLine($msg) | Out-Null
}
function Out-WARN {
function Report-OK {
param([string]$msg)
$script:warnings++
$script:lines += " WARN $msg"
$script:okCount++
if ($Detailed) { Out-Line "[OK] $msg" }
}
function Out-ERR {
function Report-Error {
param([string]$msg)
$script:errors++
$script:lines += " ERR $msg"
Out-Line "[ERROR] $msg"
if ($script:errors -ge $MaxErrors) {
$script:stopped = $true
}
}
function Report-Warn {
param([string]$msg)
$script:warnings++
Out-Line "[WARN] $msg"
}
function Get-ObjectType {
@@ -176,18 +191,17 @@ function Find-Similar {
# --- 3. Validate Rights.xml ---
$script:lines += "Validating: $RightsPath"
if (-not (Test-Path $RightsPath)) {
Out-ERR "File not found: $RightsPath"
$script:lines += "---"
$script:lines += "Result: $($script:errors) error(s), $($script:warnings) warning(s)"
$output = $script:lines -join "`n"
Report-Error "File not found: $RightsPath"
$result = $script:output.ToString()
Write-Host $result
if ($OutFile) {
$enc = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($OutFile, $output, $enc)
} else {
Write-Host $output
$outPath = if ([System.IO.Path]::IsPathRooted($OutFile)) { $OutFile } else { Join-Path (Get-Location) $OutFile }
$outDir = [System.IO.Path]::GetDirectoryName($outPath)
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::WriteAllText($outPath, $result, $utf8Bom)
Write-Host "Written to: $outPath"
}
exit 1
}
@@ -195,17 +209,18 @@ if (-not (Test-Path $RightsPath)) {
# 3a. Parse XML
try {
[xml]$xml = Get-Content -Path $RightsPath -Encoding UTF8
Out-OK "XML well-formed"
Report-OK "XML well-formed"
} catch {
Out-ERR "XML parse error: $($_.Exception.Message)"
$script:lines += "---"
$script:lines += "Result: $($script:errors) error(s), $($script:warnings) warning(s)"
$output = $script:lines -join "`n"
Report-Error "XML parse error: $($_.Exception.Message)"
$result = $script:output.ToString()
Write-Host $result
if ($OutFile) {
$enc = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($OutFile, $output, $enc)
} else {
Write-Host $output
$outPath = if ([System.IO.Path]::IsPathRooted($OutFile)) { $OutFile } else { Join-Path (Get-Location) $OutFile }
$outDir = [System.IO.Path]::GetDirectoryName($outPath)
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::WriteAllText($outPath, $result, $utf8Bom)
Write-Host "Written to: $outPath"
}
exit 1
}
@@ -215,11 +230,11 @@ $rightsNs = "http://v8.1c.ru/8.2/roles"
# 3b. Check root element
if ($root.LocalName -ne "Rights") {
Out-ERR "Root element is '$($root.LocalName)', expected 'Rights'"
Report-Error "Root element is '$($root.LocalName)', expected 'Rights'"
} elseif ($root.NamespaceURI -ne $rightsNs) {
Out-WARN "Namespace is '$($root.NamespaceURI)', expected '$rightsNs'"
Report-Warn "Namespace is '$($root.NamespaceURI)', expected '$rightsNs'"
} else {
Out-OK "Root element: <Rights> with correct namespace"
Report-OK "Root element: <Rights> with correct namespace"
}
# 3c. Global flags
@@ -230,15 +245,15 @@ foreach ($fn in $flagNames) {
if ($node.Count -gt 0) {
$val = $node[0].InnerText
if ($val -ne "true" -and $val -ne "false") {
Out-WARN "$fn = '$val' (expected 'true' or 'false')"
Report-Warn "$fn = '$val' (expected 'true' or 'false')"
}
$flagsFound++
} else {
Out-WARN "Missing global flag: $fn"
Report-Warn "Missing global flag: $fn"
}
}
if ($flagsFound -eq 3) {
Out-OK "3 global flags present"
Report-OK "3 global flags present"
}
# 3d. Objects
@@ -257,7 +272,7 @@ foreach ($obj in $objects) {
}
if (-not $objName) {
Out-ERR "Object without <name>"
Report-Error "Object without <name>"
continue
}
@@ -266,7 +281,7 @@ foreach ($obj in $objects) {
# Check object type is known
if (-not $isNested -and -not $script:knownRights.ContainsKey($objectType)) {
Out-WARN "${objName}: unknown object type '$objectType'"
Report-Warn "${objName}: unknown object type '$objectType'"
}
# Check rights
@@ -289,18 +304,18 @@ foreach ($obj in $objects) {
if ($rcc.LocalName -eq "condition") { $condNode = $rcc }
}
if (-not $condNode -or -not $condNode.InnerText) {
Out-WARN "${objName}: RLS condition for '$rName' is empty"
Report-Warn "${objName}: RLS condition for '$rName' is empty"
}
}
}
if (-not $rName) {
Out-ERR "${objName}: <right> without <name>"
Report-Error "${objName}: <right> without <name>"
continue
}
if ($rValue -ne "true" -and $rValue -ne "false") {
Out-ERR "${objName}: right '$rName' has invalid value '$rValue'"
Report-Error "${objName}: right '$rName' has invalid value '$rValue'"
continue
}
@@ -310,15 +325,15 @@ foreach ($obj in $objects) {
if ($isNested) {
if ($objName -match '\.Command\.') {
if ($rName -notin $script:commandRights) {
Out-WARN "${objName}: '$rName' not valid for commands (only: View)"
Report-Warn "${objName}: '$rName' not valid for commands (only: View)"
}
} elseif ($objName -match '\.IntegrationServiceChannel\.') {
if ($rName -notin $script:channelRights) {
Out-WARN "${objName}: '$rName' not valid for channels (only: Use)"
Report-Warn "${objName}: '$rName' not valid for channels (only: Use)"
}
} else {
if ($rName -notin $script:nestedRights) {
Out-WARN "${objName}: '$rName' not valid for nested objects (only: View, Edit)"
Report-Warn "${objName}: '$rName' not valid for nested objects (only: View, Edit)"
}
}
} elseif ($script:knownRights.ContainsKey($objectType)) {
@@ -326,15 +341,15 @@ foreach ($obj in $objects) {
if ($rName -notin $validRights) {
$similar = Find-Similar -needle $rName -haystack $validRights
$sugStr = if ($similar.Count -gt 0) { " Did you mean: $($similar -join ', ')?" } else { "" }
Out-WARN "${objName}: unknown right '$rName'.$sugStr"
Report-Warn "${objName}: unknown right '$rName'.$sugStr"
}
}
}
}
Out-OK "$objCount objects, $rightCount rights"
Report-OK "$objCount objects, $rightCount rights"
if ($rlsCount -gt 0) {
Out-OK "$rlsCount RLS restrictions"
Report-OK "$rlsCount RLS restrictions"
}
# 3e. Templates
@@ -349,56 +364,56 @@ if ($templates.Count -gt 0) {
if ($child.LocalName -eq "condition") { $tCond = $child.InnerText }
}
if (-not $tName) {
Out-WARN "Restriction template without <name>"
Report-Warn "Restriction template without <name>"
} else {
$parenIdx = $tName.IndexOf("(")
$shortName = if ($parenIdx -gt 0) { $tName.Substring(0, $parenIdx) } else { $tName }
$tplNames += $shortName
}
if (-not $tCond) {
Out-WARN "Template '$tName': empty <condition>"
Report-Warn "Template '$tName': empty <condition>"
}
}
Out-OK "$($templates.Count) templates: $($tplNames -join ', ')"
Report-OK "$($templates.Count) templates: $($tplNames -join ', ')"
}
# --- 4. Validate metadata (optional) ---
if ($MetadataPath) {
$script:lines += ""
Out-Line ""
if (-not (Test-Path $MetadataPath)) {
Out-ERR "Metadata file not found: $MetadataPath"
Report-Error "Metadata file not found: $MetadataPath"
} else {
try {
[xml]$metaXml = Get-Content -Path $MetadataPath -Encoding UTF8
$roleNode = $metaXml.DocumentElement.SelectSingleNode("//*[local-name()='Role']")
if (-not $roleNode) {
Out-ERR "Metadata: <Role> element not found"
Report-Error "Metadata: <Role> element not found"
} else {
$uuid = $roleNode.GetAttribute("uuid")
if ($uuid -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
Out-OK "Metadata: UUID valid ($uuid)"
Report-OK "Metadata: UUID valid ($uuid)"
} else {
Out-ERR "Metadata: invalid UUID format '$uuid'"
Report-Error "Metadata: invalid UUID format '$uuid'"
}
$nameNode = $roleNode.SelectSingleNode(".//*[local-name()='Name']")
if ($nameNode -and $nameNode.InnerText) {
Out-OK "Metadata: Name = $($nameNode.InnerText)"
Report-OK "Metadata: Name = $($nameNode.InnerText)"
} else {
Out-ERR "Metadata: <Name> is empty or missing"
Report-Error "Metadata: <Name> is empty or missing"
}
$synNode = $roleNode.SelectSingleNode(".//*[local-name()='Synonym']")
if ($synNode -and $synNode.InnerXml) {
Out-OK "Metadata: Synonym present"
Report-OK "Metadata: Synonym present"
} else {
Out-WARN "Metadata: <Synonym> is empty"
Report-Warn "Metadata: <Synonym> is empty"
}
}
} catch {
Out-ERR "Metadata XML parse error: $($_.Exception.Message)"
Report-Error "Metadata XML parse error: $($_.Exception.Message)"
}
}
}
@@ -425,7 +440,7 @@ if ($MetadataPath -and (Test-Path $MetadataPath)) {
}
if (Test-Path $configXmlPath2) {
$script:lines += ""
Out-Line ""
try {
[xml]$cfgXml = Get-Content -Path $configXmlPath2 -Encoding UTF8
$cfgNs = New-Object System.Xml.XmlNamespaceManager($cfgXml.NameTable)
@@ -441,22 +456,30 @@ if (Test-Path $configXmlPath2) {
}
}
if ($found) {
Out-OK "Configuration.xml: <Role>$inferredRoleName</Role> registered"
Report-OK "Configuration.xml: <Role>$inferredRoleName</Role> registered"
} else {
Out-WARN "Configuration.xml: <Role>$inferredRoleName</Role> NOT found in ChildObjects"
Report-Warn "Configuration.xml: <Role>$inferredRoleName</Role> NOT found in ChildObjects"
}
}
} catch {
Out-WARN "Configuration.xml: parse error — $($_.Exception.Message)"
Report-Warn "Configuration.xml: parse error — $($_.Exception.Message)"
}
}
# --- 6. Summary ---
$script:lines += "---"
$script:lines += "Result: $($script:errors) error(s), $($script:warnings) warning(s)"
# Insert header
$script:output.Insert(0, "=== Validation: Role.$inferredRoleName ===$([Environment]::NewLine)") | Out-Null
$output = $script:lines -join "`n"
$checks = $script:okCount + $script:errors + $script:warnings
if ($script:errors -eq 0 -and $script:warnings -eq 0 -and -not $Detailed) {
$result = "=== Validation OK: Role.$inferredRoleName ($checks checks) ==="
} else {
Out-Line ""
Out-Line "=== Result: $($script:errors) errors, $($script:warnings) warnings ($checks checks) ==="
$result = $script:output.ToString()
}
Write-Host $result
if ($OutFile) {
$outPath = if ([System.IO.Path]::IsPathRooted($OutFile)) { $OutFile } else { Join-Path (Get-Location) $OutFile }
@@ -464,11 +487,9 @@ if ($OutFile) {
if (-not (Test-Path $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
$enc = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($outPath, $output, $enc)
Write-Host "[OK] Validation result written to: $outPath"
} else {
Write-Host $output
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::WriteAllText($outPath, $result, $utf8Bom)
Write-Host "Written to: $outPath"
}
if ($script:errors -gt 0) { exit 1 } else { exit 0 }
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# role-validate v1.0 — Validate 1C role Rights.xml structure
# role-validate v1.1 — 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
@@ -182,6 +182,8 @@ def main():
parser.add_argument('-RightsPath', dest='RightsPath', required=True)
parser.add_argument('-MetadataPath', dest='MetadataPath', default='')
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 = parser.parse_args()
rights_path = args.RightsPath
@@ -195,41 +197,45 @@ def main():
lines = []
errors = 0
warnings = 0
ok_count = 0
stopped = False
def out_ok(msg):
lines.append(f' OK {msg}')
def report_ok(msg):
nonlocal ok_count
ok_count += 1
if args.Detailed:
lines.append(f'[OK] {msg}')
def out_warn(msg):
def report_warn(msg):
nonlocal warnings
warnings += 1
lines.append(f' WARN {msg}')
lines.append(f'[WARN] {msg}')
def out_err(msg):
nonlocal errors
def report_error(msg):
nonlocal errors, stopped
errors += 1
lines.append(f' ERR {msg}')
lines.append(f'[ERROR] {msg}')
if errors >= args.MaxErrors:
stopped = True
# --- 3. Validate Rights.xml ---
lines.append(f'Validating: {rights_path}')
def finalize():
lines.append('---')
lines.append(f'Result: {errors} error(s), {warnings} warning(s)')
output = '\n'.join(lines)
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(output)
print(f'[OK] Validation result written to: {out_path}')
f.write(text)
print(f'Written to: {out_path}')
else:
print(output)
print(text)
if not os.path.exists(rights_path):
out_err(f'File not found: {rights_path}')
finalize()
report_error(f'File not found: {rights_path}')
result = '\n'.join(lines)
write_output(result)
sys.exit(1)
# 3a. Parse XML
@@ -237,10 +243,11 @@ def main():
try:
xml_parser = etree.XMLParser(remove_blank_text=False)
xml_doc = etree.parse(rights_path, xml_parser)
out_ok('XML well-formed')
report_ok('XML well-formed')
except etree.XMLSyntaxError as e:
out_err(f'XML parse error: {e}')
finalize()
report_error(f'XML parse error: {e}')
result = '\n'.join(lines)
write_output(result)
sys.exit(1)
root = xml_doc.getroot()
@@ -249,11 +256,11 @@ def main():
# 3b. Check root element
if root_local != 'Rights':
out_err(f"Root element is '{root_local}', expected 'Rights'")
report_error(f"Root element is '{root_local}', expected 'Rights'")
elif root_ns != RIGHTS_NS:
out_warn(f"Namespace is '{root_ns}', expected '{RIGHTS_NS}'")
report_warn(f"Namespace is '{root_ns}', expected '{RIGHTS_NS}'")
else:
out_ok('Root element: <Rights> with correct namespace')
report_ok('Root element: <Rights> with correct namespace')
# 3c. Global flags
flag_names = ['setForNewObjects', 'setForAttributesByDefault', 'independentRightsOfChildObjects']
@@ -263,12 +270,12 @@ def main():
if len(nodes) > 0:
val = nodes[0].text or ''
if val not in ('true', 'false'):
out_warn(f"{fn} = '{val}' (expected 'true' or 'false')")
report_warn(f"{fn} = '{val}' (expected 'true' or 'false')")
flags_found += 1
else:
out_warn(f'Missing global flag: {fn}')
report_warn(f'Missing global flag: {fn}')
if flags_found == 3:
out_ok('3 global flags present')
report_ok('3 global flags present')
# 3d. Objects
objects = root.findall(f'{{{RIGHTS_NS}}}object')
@@ -286,7 +293,7 @@ def main():
break
if not obj_name:
out_err('Object without <name>')
report_error('Object without <name>')
continue
object_type = get_object_type(obj_name)
@@ -294,7 +301,7 @@ def main():
# Check object type is known
if not is_nested and object_type not in KNOWN_RIGHTS:
out_warn(f"{obj_name}: unknown object type '{object_type}'")
report_warn(f"{obj_name}: unknown object type '{object_type}'")
# Check rights
for child in obj:
@@ -325,14 +332,14 @@ def main():
# Check condition not empty
cond_node = get_child_el(rc, 'condition', RIGHTS_NS)
if cond_node is None or not (cond_node.text or ''):
out_warn(f"{obj_name}: RLS condition for '{r_name}' is empty")
report_warn(f"{obj_name}: RLS condition for '{r_name}' is empty")
if not r_name:
out_err(f'{obj_name}: <right> without <name>')
report_error(f'{obj_name}: <right> without <name>')
continue
if r_value not in ('true', 'false'):
out_err(f"{obj_name}: right '{r_name}' has invalid value '{r_value}'")
report_error(f"{obj_name}: right '{r_name}' has invalid value '{r_value}'")
continue
right_count += 1
@@ -341,23 +348,23 @@ def main():
if is_nested:
if '.Command.' in obj_name:
if r_name not in COMMAND_RIGHTS:
out_warn(f"{obj_name}: '{r_name}' not valid for commands (only: View)")
report_warn(f"{obj_name}: '{r_name}' not valid for commands (only: View)")
elif '.IntegrationServiceChannel.' in obj_name:
if r_name not in CHANNEL_RIGHTS:
out_warn(f"{obj_name}: '{r_name}' not valid for channels (only: Use)")
report_warn(f"{obj_name}: '{r_name}' not valid for channels (only: Use)")
else:
if r_name not in NESTED_RIGHTS:
out_warn(f"{obj_name}: '{r_name}' not valid for nested objects (only: View, Edit)")
report_warn(f"{obj_name}: '{r_name}' not valid for nested objects (only: View, Edit)")
elif object_type in KNOWN_RIGHTS:
valid_rights = KNOWN_RIGHTS[object_type]
if r_name not in valid_rights:
similar = find_similar(r_name, valid_rights)
sug_str = f' Did you mean: {", ".join(similar)}?' if similar else ''
out_warn(f"{obj_name}: unknown right '{r_name}'.{sug_str}")
report_warn(f"{obj_name}: unknown right '{r_name}'.{sug_str}")
out_ok(f'{obj_count} objects, {right_count} rights')
report_ok(f'{obj_count} objects, {right_count} rights')
if rls_count > 0:
out_ok(f'{rls_count} RLS restrictions')
report_ok(f'{rls_count} RLS restrictions')
# 3e. Templates
templates = root.findall(f'{{{RIGHTS_NS}}}restrictionTemplate')
@@ -378,14 +385,14 @@ def main():
elif local == 'condition':
t_cond = child.text or ''
if not t_name:
out_warn('Restriction template without <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:
out_warn(f"Template '{t_name}': empty <condition>")
out_ok(f'{len(templates)} templates: {", ".join(tpl_names)}')
report_warn(f"Template '{t_name}': empty <condition>")
report_ok(f'{len(templates)} templates: {", ".join(tpl_names)}')
# --- 4. Validate metadata (optional) ---
inferred_role_name = ''
@@ -396,7 +403,7 @@ def main():
metadata_path = os.path.join(os.getcwd(), metadata_path)
if not os.path.exists(metadata_path):
out_err(f'Metadata file not found: {metadata_path}')
report_error(f'Metadata file not found: {metadata_path}')
else:
try:
meta_parser = etree.XMLParser(remove_blank_text=False)
@@ -410,13 +417,13 @@ def main():
break
if role_node is None:
out_err('Metadata: <Role> element not found')
report_error('Metadata: <Role> element not found')
else:
uuid_val = role_node.get('uuid', '')
if GUID_PATTERN.match(uuid_val):
out_ok(f'Metadata: UUID valid ({uuid_val})')
report_ok(f'Metadata: UUID valid ({uuid_val})')
else:
out_err(f"Metadata: invalid UUID format '{uuid_val}'")
report_error(f"Metadata: invalid UUID format '{uuid_val}'")
# Find Name
name_node = None
@@ -426,10 +433,10 @@ def main():
break
if name_node is not None and name_node.text:
out_ok(f'Metadata: Name = {name_node.text}')
report_ok(f'Metadata: Name = {name_node.text}')
inferred_role_name = name_node.text
else:
out_err('Metadata: <Name> is empty or missing')
report_error('Metadata: <Name> is empty or missing')
# Find Synonym
syn_node = None
@@ -439,11 +446,11 @@ def main():
break
if syn_node is not None and len(syn_node) > 0:
out_ok('Metadata: Synonym present')
report_ok('Metadata: Synonym present')
else:
out_warn('Metadata: <Synonym> is empty')
report_warn('Metadata: <Synonym> is empty')
except etree.XMLSyntaxError as e:
out_err(f'Metadata XML parse error: {e}')
report_error(f'Metadata XML parse error: {e}')
# --- 5. Check registration in Configuration.xml ---
resolved_rights = os.path.abspath(rights_path)
@@ -487,14 +494,25 @@ def main():
found = True
break
if found:
out_ok(f'Configuration.xml: <Role>{inferred_role_name}</Role> registered')
report_ok(f'Configuration.xml: <Role>{inferred_role_name}</Role> registered')
else:
out_warn(f'Configuration.xml: <Role>{inferred_role_name}</Role> NOT found in ChildObjects')
report_warn(f'Configuration.xml: <Role>{inferred_role_name}</Role> NOT found in ChildObjects')
except etree.XMLSyntaxError as e:
out_warn(f'Configuration.xml: parse error \u2014 {e}')
report_warn(f'Configuration.xml: parse error \u2014 {e}')
# --- 6. Summary ---
finalize()
# 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)