feat: sync 12 new skills into catalog (total 31 skills), update manifest and README
This commit is contained in:
340
skills/drawio-skill/scripts/validate.py
Normal file
340
skills/drawio-skill/scripts/validate.py
Normal file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic structural linter for .drawio files.
|
||||
|
||||
Catches the class of mistakes a vision self-check is slow and unreliable at:
|
||||
dangling edge endpoints, duplicate or reserved ids, broken parent references,
|
||||
and (as warnings) off-grid geometry, overlapping sibling nodes, and edge
|
||||
routing defects. Runs without launching draw.io, so it is a fast pre-check
|
||||
before the visual review step.
|
||||
|
||||
python3 validate.py diagram.drawio
|
||||
|
||||
Edge routing checks (warnings): an edge segment crossing a non-incident leaf
|
||||
vertex ("routes through vertex"), and two edges crossing each other ("edges X
|
||||
and Y cross") — the two defects the SKILL.md step-5 self-check looks for
|
||||
("Edge-shape overlap", "Stacked edges"), but caught here deterministically.
|
||||
|
||||
Routing is only knowable from the XML when an edge carries explicit waypoints
|
||||
(``<Array as="points">``) — exactly the hand-routed case the SKILL.md tells
|
||||
authors to use to route around shapes. Edges with no waypoints are auto-routed
|
||||
by draw.io at render time (the path is not stored), so they are NOT geometry-
|
||||
checked here, keeping these warnings free of false positives. Endpoints honour
|
||||
``exitX/exitY``/``entryX/entryY`` when present, else the node centre, and
|
||||
absolute positions are resolved through parent containers.
|
||||
|
||||
Exit status is non-zero when any error (or, with --strict, any warning) is
|
||||
found, so it can gate a workflow. Compressed (non-XML) diagram pages are
|
||||
skipped with a warning — this skill always writes uncompressed XML.
|
||||
|
||||
Usage: python3 validate.py <file.drawio> [--strict]
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
RESERVED = {"0", "1"}
|
||||
|
||||
|
||||
def rect(cell):
|
||||
"""Return (x, y, w, h) floats for a cell's geometry, or None if absent/bad.
|
||||
|
||||
x/y default to 0 when omitted: draw.io treats a missing position as the
|
||||
origin, and container-managed children (table rows, swimlane/UML-class
|
||||
lines under tableLayout) legitimately omit x/y while keeping width/height.
|
||||
Only width/height are required to be present and numeric.
|
||||
"""
|
||||
g = cell.find("mxGeometry")
|
||||
if g is None:
|
||||
return None
|
||||
try:
|
||||
return (float(g.get("x", "0")), float(g.get("y", "0")),
|
||||
float(g.get("width", "nan")), float(g.get("height", "nan")))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_edge_label(cell):
|
||||
"""True for a draw.io edge label / relative-positioned child vertex.
|
||||
|
||||
These legitimately omit width/height: their position is given relative to a
|
||||
parent edge (style ``edgeLabel``) or via ``relative="1"`` geometry. Treating
|
||||
them as normal vertices wrongly flags them as missing/invalid geometry.
|
||||
"""
|
||||
if "edgeLabel" in (cell.get("style") or ""):
|
||||
return True
|
||||
g = cell.find("mxGeometry")
|
||||
return g is not None and g.get("relative") == "1"
|
||||
|
||||
|
||||
def overlap(a, b):
|
||||
ax, ay, aw, ah = a
|
||||
bx, by, bw, bh = b
|
||||
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
|
||||
|
||||
|
||||
# --- Edge routing geometry -------------------------------------------------
|
||||
#
|
||||
# These helpers reason about edge paths. They only apply to edges with explicit
|
||||
# waypoints (the route is otherwise computed by draw.io at render time and not
|
||||
# stored in the XML), so the checks never guess an auto-routed path.
|
||||
|
||||
def style_num(style, key):
|
||||
"""Return float value of ``key=`` in a draw.io style string, or None."""
|
||||
for part in (style or "").split(";"):
|
||||
if part.startswith(key + "="):
|
||||
try:
|
||||
return float(part.split("=", 1)[1])
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def abs_rect(cell, by_id):
|
||||
"""Absolute (x, y, w, h) of a vertex, summing parent-container offsets.
|
||||
|
||||
Children of a container use coordinates relative to the container origin, so
|
||||
an edge spanning containers needs absolute positions to be compared.
|
||||
"""
|
||||
r = rect(cell)
|
||||
if r is None or any(v != v for v in r):
|
||||
return None
|
||||
x, y, w, h = r
|
||||
parent, seen = cell.get("parent"), set()
|
||||
while parent and parent in by_id and parent not in seen:
|
||||
seen.add(parent)
|
||||
p = by_id[parent]
|
||||
if p.get("vertex") == "1":
|
||||
pr = rect(p)
|
||||
if pr and not any(v != v for v in pr):
|
||||
x += pr[0]
|
||||
y += pr[1]
|
||||
parent = p.get("parent")
|
||||
return (x, y, w, h)
|
||||
|
||||
|
||||
def endpoint(edge, end, by_id):
|
||||
"""Absolute (x, y) where ``edge`` meets its source/target vertex.
|
||||
|
||||
Honours exitX/exitY (source) and entryX/entryY (target) if the style pins
|
||||
them; otherwise the vertex centre. Returns None if the vertex is unresolved.
|
||||
"""
|
||||
vid = edge.get(end)
|
||||
if not vid or vid not in by_id:
|
||||
return None
|
||||
box = abs_rect(by_id[vid], by_id)
|
||||
if box is None:
|
||||
return None
|
||||
x, y, w, h = box
|
||||
style = edge.get("style") or ""
|
||||
fx = style_num(style, "exitX" if end == "source" else "entryX")
|
||||
fy = style_num(style, "exitY" if end == "source" else "entryY")
|
||||
return (x + (fx if fx is not None else 0.5) * w,
|
||||
y + (fy if fy is not None else 0.5) * h)
|
||||
|
||||
|
||||
def edge_waypoints(edge):
|
||||
"""Explicit <Array as="points"> waypoints of an edge as [(x, y), ...]."""
|
||||
g = edge.find("mxGeometry")
|
||||
if g is None:
|
||||
return []
|
||||
arr = g.find("Array")
|
||||
if arr is None:
|
||||
return []
|
||||
pts = []
|
||||
for pt in arr.findall("mxPoint"):
|
||||
px, py = pt.get("x"), pt.get("y")
|
||||
if px is not None and py is not None:
|
||||
try:
|
||||
pts.append((float(px), float(py)))
|
||||
except ValueError:
|
||||
pass
|
||||
return pts
|
||||
|
||||
|
||||
def edge_route(edge, by_id):
|
||||
"""Absolute polyline [(x, y), ...] for a waypointed edge, or None.
|
||||
|
||||
Returns None when the edge has no explicit waypoints (auto-routed; path
|
||||
unknown) or an endpoint cannot be resolved.
|
||||
"""
|
||||
waypoints = edge_waypoints(edge)
|
||||
if not waypoints:
|
||||
return None
|
||||
s, t = endpoint(edge, "source", by_id), endpoint(edge, "target", by_id)
|
||||
if s is None or t is None:
|
||||
return None
|
||||
return [s] + waypoints + [t]
|
||||
|
||||
|
||||
def _orient(a, b, c):
|
||||
v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
||||
return 0 if abs(v) < 1e-9 else (1 if v > 0 else -1)
|
||||
|
||||
|
||||
def segments_cross(p1, p2, p3, p4):
|
||||
"""True if segments p1p2 and p3p4 properly cross (interior intersection).
|
||||
|
||||
Proper crossing only: collinear overlap and shared-endpoint touches return
|
||||
False, so edges meeting at a common node or grazing a corner are not flagged.
|
||||
"""
|
||||
o1, o2 = _orient(p1, p2, p3), _orient(p1, p2, p4)
|
||||
o3, o4 = _orient(p3, p4, p1), _orient(p3, p4, p2)
|
||||
return o1 != o2 and o3 != o4 and 0 not in (o1, o2, o3, o4)
|
||||
|
||||
|
||||
def _point_in_rect(p, box, eps=1e-6):
|
||||
x, y, w, h = box
|
||||
return x + eps < p[0] < x + w - eps and y + eps < p[1] < y + h - eps
|
||||
|
||||
|
||||
def route_hits_rect(points, box):
|
||||
"""True if a polyline enters a rectangle's interior or crosses a border."""
|
||||
x, y, w, h = box
|
||||
corners = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
|
||||
borders = list(zip(corners, corners[1:] + corners[:1]))
|
||||
for a, b in zip(points, points[1:]):
|
||||
if _point_in_rect(a, box) or _point_in_rect(b, box):
|
||||
return True
|
||||
if any(segments_cross(a, b, c, d) for c, d in borders):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def routes_cross(pa, pb):
|
||||
"""True if any segment of polyline pa properly crosses any of pb."""
|
||||
for a1, a2 in zip(pa, pa[1:]):
|
||||
for b1, b2 in zip(pb, pb[1:]):
|
||||
if segments_cross(a1, a2, b1, b2):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def geometry_warnings(cells, ids, parents):
|
||||
"""Edge-through-vertex and edge-crossing warnings for waypointed edges."""
|
||||
warns = []
|
||||
routed = [] # (edge_id, polyline, {source, target})
|
||||
for c in cells:
|
||||
if c.get("edge") == "1":
|
||||
pts = edge_route(c, ids)
|
||||
if pts:
|
||||
routed.append((c.get("id"), pts,
|
||||
{c.get("source"), c.get("target")}))
|
||||
# Edge routes through an unrelated leaf vertex (containers wrap children, so
|
||||
# an edge legitimately traverses them — restrict to leaves, as overlap does).
|
||||
leaves = [(c.get("id"), abs_rect(c, ids)) for c in cells
|
||||
if c.get("vertex") == "1" and c.get("id") not in parents
|
||||
and not is_edge_label(c)]
|
||||
leaves = [(vid, box) for vid, box in leaves if box]
|
||||
for eid, pts, ends in routed:
|
||||
for vid, box in leaves:
|
||||
if vid not in ends and route_hits_rect(pts, box):
|
||||
warns.append(f"edge {eid!r} routes through vertex {vid!r}")
|
||||
# Edge-edge crossings (both routes known).
|
||||
for i in range(len(routed)):
|
||||
for j in range(i + 1, len(routed)):
|
||||
(ia, pa, _), (ib, pb, _) = routed[i], routed[j]
|
||||
if routes_cross(pa, pb):
|
||||
warns.append(f"edges {ia!r} and {ib!r} cross")
|
||||
return warns
|
||||
|
||||
|
||||
def check_page(diagram):
|
||||
"""Return (errors, warnings) for one <diagram> page."""
|
||||
name = diagram.get("name", "?")
|
||||
model = diagram.find("mxGraphModel")
|
||||
if model is None:
|
||||
if (diagram.text or "").strip():
|
||||
return [], [f"page {name!r}: compressed, skipped (cannot lint)"]
|
||||
return [f"page {name!r}: no <mxGraphModel>"], []
|
||||
root = model.find("root")
|
||||
# Normalize UserObject/object wrappers (used for links & metadata): the id
|
||||
# lives on the wrapper, geometry/style on the inner mxCell — fold the two
|
||||
# into one cell so edges referencing the wrapper id resolve.
|
||||
cells = []
|
||||
for child in (root if root is not None else []):
|
||||
if child.tag == "mxCell":
|
||||
cells.append(child)
|
||||
elif child.tag in ("UserObject", "object"):
|
||||
inner = child.find("mxCell")
|
||||
if inner is not None:
|
||||
inner.set("id", child.get("id", ""))
|
||||
cells.append(inner)
|
||||
errors, warns = [], []
|
||||
ids = {}
|
||||
for c in cells:
|
||||
cid = c.get("id")
|
||||
if cid in ids:
|
||||
errors.append(f"duplicate id {cid!r}")
|
||||
ids[cid] = c
|
||||
parents = {c.get("parent") for c in cells} # ids that have children
|
||||
for c in cells:
|
||||
cid, parent = c.get("id"), c.get("parent")
|
||||
is_v, is_e = c.get("vertex") == "1", c.get("edge") == "1"
|
||||
if parent is not None and parent not in ids:
|
||||
errors.append(f"cell {cid!r} parent {parent!r} does not exist")
|
||||
for end in ("source", "target"):
|
||||
ref = c.get(end)
|
||||
if ref and ref not in ids:
|
||||
errors.append(f"edge {cid!r} {end} {ref!r} does not exist")
|
||||
if (is_v or is_e) and cid in RESERVED:
|
||||
errors.append(f"cell {cid!r} reuses reserved id 0/1")
|
||||
if is_v and not is_edge_label(c):
|
||||
r = rect(c)
|
||||
if r is None or any(v != v for v in r): # None or NaN
|
||||
errors.append(f"vertex {cid!r} has missing/invalid geometry")
|
||||
else:
|
||||
x, y, w, h = r
|
||||
if w <= 0 or h <= 0:
|
||||
warns.append(f"vertex {cid!r} non-positive size {w:g}x{h:g}")
|
||||
if x < 0 or y < 0:
|
||||
warns.append(f"vertex {cid!r} negative position ({x:g},{y:g})")
|
||||
# Sibling overlap: only leaf vertices (containers legitimately wrap children).
|
||||
boxes = [(c.get("id"), c.get("parent"), rect(c)) for c in cells
|
||||
if c.get("vertex") == "1" and c.get("id") not in parents and rect(c)
|
||||
and not any(v != v for v in rect(c))]
|
||||
for i in range(len(boxes)):
|
||||
for j in range(i + 1, len(boxes)):
|
||||
(ia, pa, ra), (ib, pb, rb) = boxes[i], boxes[j]
|
||||
if pa == pb and overlap(ra, rb):
|
||||
warns.append(f"vertices {ia!r} and {ib!r} overlap")
|
||||
warns += geometry_warnings(cells, ids, parents)
|
||||
return errors, warns
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Lint a .drawio file for structural errors.")
|
||||
ap.add_argument("file")
|
||||
ap.add_argument("--strict", action="store_true", help="treat warnings as failure too")
|
||||
ap.add_argument("--score", action="store_true",
|
||||
help="also print a readability score (lower is better) — "
|
||||
"useful for comparing layout variants of the same graph")
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
tree = ET.parse(args.file)
|
||||
except (ET.ParseError, OSError) as exc:
|
||||
sys.exit(f"error: cannot parse {args.file}: {exc}")
|
||||
pages = tree.getroot().findall("diagram") or [tree.getroot()]
|
||||
errors, warns = [], []
|
||||
for page in pages:
|
||||
e, w = check_page(page)
|
||||
errors += e
|
||||
warns += w
|
||||
for w in warns:
|
||||
print(f"warning: {w}")
|
||||
for e in errors:
|
||||
print(f"error: {e}")
|
||||
print(f"{len(errors)} error(s), {len(warns)} warning(s)")
|
||||
if args.score:
|
||||
# Weighted by how badly each defect hurts readability. Comparable only
|
||||
# across variants of the SAME graph (same nodes/edges).
|
||||
through = sum(1 for w in warns if "routes through" in w)
|
||||
cross = sum(1 for w in warns if " cross" in w)
|
||||
olap = sum(1 for w in warns if " overlap" in w)
|
||||
print(f"score: {20 * through + 10 * cross + 5 * olap} "
|
||||
f"({through} through-vertex, {cross} crossings, {olap} overlaps)")
|
||||
if errors or (args.strict and warns):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user