Diagram generator calibrated from 37 Oracle ref architectures + KB health tooling
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 25s
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 25s
Diagram generator (oci_diagram_gen.py): - Icon sizing calibrated: 63px target height, 45px min width (from 37 Oracle Architecture Center .drawio files: 12,617 cells, 234x 63x63 service icons) - Auto-sizing: containers grow from content, never overflow (validated by script) - DRG placed outside VCN, inside region (Oracle hub-spoke pattern) - Gateway stacking uses _calc_service_block_h to prevent label overlap - Edge labels: mxGeometry offset injection (15-20px away from midpoint) - Tight icon groups: edges connect to visible icon, not invisible wide group - VCN/subnet dash pattern corrected to "4 2", stroke to #aa643b (Oracle ref) - jettySize=auto, dual connection merge, container=1;collapsible=0 New tools: - scripts/validate-diagram.py: checks icon sizes, overlaps, container overflow - tools/refresh_arch_catalog.py --check-links: HTTP HEAD check on all 123 URLs - .gitea/workflows/kb-health.yaml: weekly link + freshness check (Mon 8am UTC) - kb/diagram/oracle-ref-measurements.md: exact measurements from Oracle refs - kb/CHANGELOG.md: KB change log shown in welcome banner SKILL.md: - Pre-flight: python→python3, silent errors, git fetch check for local users - Changelog banner in welcome flow - Anti-hallucination guardrails (from earlier commit, carried forward) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
184
scripts/validate-diagram.py
Normal file
184
scripts/validate-diagram.py
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diagram quality validator — checks generated .drawio files against
|
||||
Oracle Architecture Center visual standards.
|
||||
|
||||
Usage:
|
||||
python3 scripts/validate-diagram.py examples/output-demo-pharma-mx/opt02-architecture.drawio
|
||||
"""
|
||||
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
MIN_ICON_H = 45 # Minimum icon height (px) — Oracle refs use 50-60
|
||||
MIN_ICON_W = 30 # Minimum icon width (px)
|
||||
MAX_EMPTY_RATIO = 0.7 # Max empty space ratio in a container
|
||||
MIN_LABEL_FONT = 10 # Minimum label font size
|
||||
MIN_GAP_PX = 5 # Minimum gap between sibling elements
|
||||
|
||||
|
||||
def validate(filepath: str) -> list:
|
||||
"""Return list of (severity, message) tuples."""
|
||||
issues = []
|
||||
tree = ET.parse(filepath)
|
||||
root = tree.getroot()
|
||||
|
||||
icon_groups = []
|
||||
containers = []
|
||||
all_cells = {}
|
||||
|
||||
for cell in root.iter('mxCell'):
|
||||
cid = cell.get('id', '')
|
||||
style = cell.get('style', '')
|
||||
value = cell.get('value', '')
|
||||
geo = cell.find('mxGeometry')
|
||||
all_cells[cid] = cell
|
||||
|
||||
if geo is None:
|
||||
continue
|
||||
|
||||
w = float(geo.get('width', 0))
|
||||
h = float(geo.get('height', 0))
|
||||
x = float(geo.get('x', 0))
|
||||
y = float(geo.get('y', 0))
|
||||
|
||||
# Icon groups
|
||||
if 'group' in style and 'connectable=1' in style:
|
||||
icon_groups.append({
|
||||
'id': cid, 'x': x, 'y': y, 'w': w, 'h': h,
|
||||
'parent': cell.get('parent', ''),
|
||||
})
|
||||
if h < MIN_ICON_H:
|
||||
issues.append(('WARN', f'Icon {cid} too short: {h:.0f}px (min {MIN_ICON_H})'))
|
||||
if w < MIN_ICON_W:
|
||||
issues.append(('WARN', f'Icon {cid} too narrow: {w:.0f}px (min {MIN_ICON_W})'))
|
||||
|
||||
# Containers (regions, VCNs, subnets)
|
||||
if value and any(kw in value for kw in ['Region', 'VCN', 'Subnet', 'Oracle Cloud']):
|
||||
containers.append({
|
||||
'id': cid, 'name': value[:40], 'x': x, 'y': y, 'w': w, 'h': h,
|
||||
'parent': cell.get('parent', ''),
|
||||
})
|
||||
|
||||
# Check children overflow parents
|
||||
container_map = {}
|
||||
for cell in root.iter('mxCell'):
|
||||
cid = cell.get('id', '')
|
||||
val = cell.get('value', '')
|
||||
geo = cell.find('mxGeometry')
|
||||
if geo is not None and val:
|
||||
container_map[cid] = {
|
||||
'name': val[:50], 'x': float(geo.get('x', 0)),
|
||||
'y': float(geo.get('y', 0)), 'w': float(geo.get('width', 0)),
|
||||
'h': float(geo.get('height', 0)), 'parent': cell.get('parent', ''),
|
||||
}
|
||||
|
||||
for cid, c in container_map.items():
|
||||
parent = container_map.get(c['parent'])
|
||||
if parent:
|
||||
child_right = c['x'] + c['w']
|
||||
child_bottom = c['y'] + c['h']
|
||||
overflow_r = child_right - parent['w']
|
||||
overflow_b = child_bottom - parent['h']
|
||||
if overflow_r > 2:
|
||||
issues.append(('ERROR', f'{c["name"]} overflows {parent["name"]} RIGHT by {overflow_r:.0f}px'))
|
||||
if overflow_b > 2:
|
||||
issues.append(('ERROR', f'{c["name"]} overflows {parent["name"]} BOTTOM by {overflow_b:.0f}px'))
|
||||
|
||||
# Check for overlapping siblings (same parent)
|
||||
by_parent = {}
|
||||
for ig in icon_groups:
|
||||
by_parent.setdefault(ig['parent'], []).append(ig)
|
||||
|
||||
for parent_id, siblings in by_parent.items():
|
||||
siblings.sort(key=lambda s: (s['y'], s['x']))
|
||||
for i in range(len(siblings) - 1):
|
||||
a = siblings[i]
|
||||
b = siblings[i + 1]
|
||||
# Check vertical overlap (same column, x ranges overlap)
|
||||
x_overlap = not (a['x'] + a['w'] < b['x'] or b['x'] + b['w'] < a['x'])
|
||||
if x_overlap:
|
||||
gap = b['y'] - (a['y'] + a['h'])
|
||||
if gap < MIN_GAP_PX:
|
||||
issues.append(('ERROR', f'Overlap between icons in parent {parent_id}: gap={gap:.0f}px'))
|
||||
|
||||
# Check container utilization (warn on mostly empty containers)
|
||||
for cont in containers:
|
||||
children_area = 0
|
||||
for ig in icon_groups:
|
||||
if ig['parent'] == cont['id']:
|
||||
children_area += ig['w'] * ig['h']
|
||||
for child_cont in containers:
|
||||
if child_cont['parent'] == cont['id']:
|
||||
children_area += child_cont['w'] * child_cont['h']
|
||||
cont_area = cont['w'] * cont['h']
|
||||
if cont_area > 0:
|
||||
fill_ratio = children_area / cont_area
|
||||
if fill_ratio < (1 - MAX_EMPTY_RATIO):
|
||||
issues.append(('INFO', f'{cont["name"]}: {fill_ratio:.0%} filled ({cont["w"]:.0f}x{cont["h"]:.0f})'))
|
||||
|
||||
# Check edge labels have offset (mxPoint as="offset")
|
||||
edges_with_label = 0
|
||||
edges_with_offset = 0
|
||||
for cell in root.iter('mxCell'):
|
||||
if cell.get('edge') == '1' and cell.get('value', '').strip():
|
||||
edges_with_label += 1
|
||||
geo = cell.find('mxGeometry')
|
||||
if geo is not None:
|
||||
offset = geo.find('.//mxPoint[@as="offset"]')
|
||||
if offset is not None:
|
||||
edges_with_offset += 1
|
||||
|
||||
if edges_with_label > 0 and edges_with_offset < edges_with_label:
|
||||
issues.append(('WARN', f'{edges_with_label - edges_with_offset}/{edges_with_label} edge labels missing offset'))
|
||||
|
||||
# Check jettySize=auto present
|
||||
has_jetty = False
|
||||
for cell in root.iter('mxCell'):
|
||||
if 'jettySize=auto' in cell.get('style', ''):
|
||||
has_jetty = True
|
||||
break
|
||||
if not has_jetty:
|
||||
issues.append(('WARN', 'No edges with jettySize=auto'))
|
||||
|
||||
# Check container=1 present
|
||||
has_container = False
|
||||
for cell in root.iter('mxCell'):
|
||||
if 'container=1' in cell.get('style', ''):
|
||||
has_container = True
|
||||
break
|
||||
if not has_container:
|
||||
issues.append(('WARN', 'No containers with explicit container=1 style'))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <diagram.drawio>")
|
||||
sys.exit(1)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
issues = validate(filepath)
|
||||
|
||||
if not issues:
|
||||
print(f"✅ {filepath}: All checks passed")
|
||||
sys.exit(0)
|
||||
|
||||
errors = [i for i in issues if i[0] == 'ERROR']
|
||||
warns = [i for i in issues if i[0] == 'WARN']
|
||||
infos = [i for i in issues if i[0] == 'INFO']
|
||||
|
||||
print(f"📋 Diagram validation: {filepath}")
|
||||
print(f" {len(errors)} errors, {len(warns)} warnings, {len(infos)} info")
|
||||
print()
|
||||
|
||||
for severity, msg in issues:
|
||||
icon = {'ERROR': '❌', 'WARN': '⚠️', 'INFO': 'ℹ️'}.get(severity, '?')
|
||||
print(f" {icon} [{severity}] {msg}")
|
||||
|
||||
sys.exit(1 if errors else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user