feat: consolidate reviewed Diagram Design 2.6.0 release (#134)

* fix(commands): stop hardcoding the visual-type count

Integrates the final reviewed diff from PR #112.

* docs(high-level): correct effective width checklist

Integrates the final reviewed diff from PR #105.

* fix(commands): quote profile argument hint

Integrates the final reviewed diff from PR #126.

* test(sankey): gate conservation and ribbon geometry

Integrates the final reviewed diff from PR #103.

* feat(tooling): add doctor diagnostics gate

Integrates the final reviewed diff from PR #132.

* feat(scripts): lint diagrams as rendered

Integrates the final reviewed diff from PR #18.

* feat(types): add quantitative polar chart

Integrates the final reviewed diff from PR #101.

* chore(release): consolidate reviewed changes in 2.6.0

Documents every combined gate and synchronizes Claude, Codex, and Factory metadata for the 39-type release.

* fix(doctor): support installed-skill projects

---------

Co-authored-by: mysoniw <soniw1@gmail.com>
Co-authored-by: thang <76550342+thangldw@users.noreply.github.com>
Co-authored-by: YinShaokun <yinshaokun@didiglobal.com>
Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com>
Co-authored-by: Rupam Pal <rupam.pal@euromonitor.com>
Co-authored-by: Yuvraj3905 <yuvraj202001@gmail.com>
This commit is contained in:
Cathryn Lavery
2026-08-20 12:21:43 -07:00
committed by GitHub
co-authored by mysoniw thang YinShaokun 0xDarkMatter Rupam Pal Yuvraj3905
parent 4b573616cb
commit b827385a17
42 changed files with 7090 additions and 130 deletions
+813
View File
@@ -0,0 +1,813 @@
#!/usr/bin/env python3
"""Lint diagram examples as *rendered* — catches breakage the source can't show.
``lint-skin.py`` reads the HTML. This one renders it in headless Chromium and
asks the browser what actually got painted, so it catches content cut off by the
SVG viewport, collapsed SVGs, sideways page overflow and runtime errors.
How the clipping check works
---------------------------
Chromium's ``getBoundingClientRect()`` on an SVG child reports *geometry*, not
paint: it excludes stroke width, markers and filter bleed (so a 40px stroke
spilling past the viewport measures as inside), and it ignores ``clip-path``,
``opacity: 0`` ancestors and ``overflow: visible`` (so safe content measures as
outside). Both directions were reproduced in Chromium, so no geometry model is
used here.
Instead the browser is the oracle: screenshot the viewport as authored,
screenshot it again with ``overflow`` released, and diff the two. New ink means
paint was being cut off — ink is ink, so strokes, markers and filter bleed all
count, while clip-path, invisible and already-visible content produce no new ink.
Releases are **staged**, because a diagram can be clipped at more than one level
and each release repaints its own box:
- stage 0 releases the SVG alone, and looks for ink outside the SVG's box;
- stage *k* also releases the *k* nearest clipping ancestors, and looks for ink
outside the outermost released box.
Without staging, releasing an ancestor masks spill just outside the SVG (its box
becomes part of the ignored area), and an SVG authored ``overflow: visible``
inside a clipping wrapper looks clean while the wrapper cuts it off.
What the diff ignores, and why:
- The outermost released box for that stage. A released element repaints itself —
an ``overflow: hidden`` box with a ``border-radius`` loses its rounded corners —
which is not spill.
- An ``EDGE_GUARD``-px band around it, and anything under ``MIN_DIFF_PIXELS``
pixels past ``CHANNEL_THRESHOLD``. Releasing ``overflow`` re-antialiases
boundary pixels by a channel step or two, which is not a clipped diagram.
Every stage runs at each of ``DIFF_SCALES``: 1x resolves spill of a few px, 0.25x
pulls spill up to four viewports wide back into frame. All geometry stays in one
coordinate system — viewport pixels, straight from ``getBoundingClientRect()``,
matching ``page.screenshot()`` — so an SVG below the fold measures the same as
one above it.
Known ceilings: spill of ~2px or less, spill past 0.25x framing, and SVGs inside
a *scrolling* ancestor, which report ``unmeasurable`` rather than passing quietly.
Every property this script mutates is snapshotted and restored verbatim in a
``finally``, and ``--self-test`` asserts the DOM is byte-identical afterwards.
Trust boundary
--------------
This renders contributor HTML in a real browser with JavaScript enabled, so treat
it like opening the file yourself. Network is cut at the resolver
(``--host-resolver-rules``), which also stops WebSockets and anything else that
does not go through Playwright's request routing, with request routing as a second
layer. ``--fonts`` excludes exactly the two Google Fonts hostnames from the
resolver block and allows them only over HTTPS on an exact hostname match.
``--self-test`` proves the isolation against a local listener.
Because the oracle is pixels, CI must pin Playwright and its bundled Chromium
rather than taking whatever is newest; see ``.github/workflows/ci.yml``.
python3 scripts/lint-render.py --all
python3 scripts/lint-render.py skills/diagram-design/assets/example-venn.html
python3 scripts/lint-render.py --self-test # proves every check still fires
Requires Playwright (same dev dependency the PNG export uses):
pip install playwright && playwright install chromium
"""
import argparse
import base64
import os
import socket
import sys
import tempfile
import threading
from pathlib import Path
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills/diagram-design/assets"
VIEWPORT = {"width": 1600, "height": 1000}
TOLERANCE = 1.0 # px of slop before page overflow counts, absorbs subpixel layout
CHANNEL_THRESHOLD = 16 # per-channel delta that counts as new ink, not antialiasing
MIN_DIFF_PIXELS = 8 # new-ink pixels needed before a clip is reported
# ponytail: 2px guard band around a released box absorbs boundary re-antialiasing;
# the cost is that a spill of 2px or less goes unreported.
EDGE_GUARD = 2
# Two passes: 1x sees spills of a few px, 0.25x pulls a spill four viewports wide
# back into frame. ponytail: anything past that stays unseen, and says so above.
DIFF_SCALES = (1, 0.25)
# Deepest ancestor chain to stage. Diagram wrappers are shallow; this only bounds
# pathological nesting. ponytail: raise it if a real example needs more.
MAX_ANCESTOR_STAGES = 4
FONT_HOSTS = ("fonts.googleapis.com", "fonts.gstatic.com")
# Kill name resolution outright. Unlike request routing this also covers
# WebSockets, EventSource and anything else that opens its own connection.
RESOLVER_RULE = "MAP * ~NOTFOUND"
# Findings in these categories are reported but do not fail the run: they say
# "not checked", which is worth printing and wrong to treat as a defect.
NOTE_CATEGORIES = {"unmeasurable"}
# Per-svg facts that don't need paint: its box, whether the author already set
# overflow visible, and the chain of ancestors that clip it. A clipping ancestor
# that is currently scrolling cannot be released (its scrollbars would move and
# change the pixels), so the chain stops there and the rest is reported unchecked.
SURVEY_JS = """
() => {
const label = (el) => {
const id = el.id ? '#' + el.id : '';
const cls = el.getAttribute('class') ? '.' + el.getAttribute('class').split(/\\s+/)[0] : '';
return el.tagName.toLowerCase() + id + cls;
};
return [...document.querySelectorAll('svg')].map((svg, index) => {
const box = svg.getBoundingClientRect();
const chain = [];
let blockedBy = null;
for (let el = svg.parentElement; el; el = el.parentElement) {
const overflow = getComputedStyle(el).overflow;
if (overflow === 'visible') continue;
const scrolls = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
if (scrolls) { blockedBy = `${label(el)} (overflow: ${overflow})`; break; }
chain.push(`${label(el)} (overflow: ${overflow})`);
}
return {
index,
label: label(svg),
width: box.width,
height: box.height,
ownOverflowVisible: getComputedStyle(svg).overflow === 'visible',
chain,
blockedBy,
};
});
}
"""
# Mutations record the exact `style` attribute they replace, so RESTORE_JS can put
# it back verbatim — including an authored inline transform, which an earlier
# version clobbered by resetting the property to ''.
REMEMBER_JS = """
window.__lintSnapshots = window.__lintSnapshots || [];
const remember = (el) => window.__lintSnapshots.push([el, el.getAttribute('style')]);
"""
RESTORE_JS = """
() => {
const snapshots = window.__lintSnapshots || [];
// Reverse order, so an element mutated twice ends on its earliest snapshot.
for (const [el, style] of snapshots.slice().reverse()) {
if (style === null) el.removeAttribute('style');
else el.setAttribute('style', style);
}
delete window.__lintSnapshots;
}
"""
# A paint-only scale on the svg. Transforms don't reflow, so the rest of the page
# stays put and both screenshots of a pass are directly comparable. Shrinking the
# svg pulls spill that would land off-screen back into the viewport.
SET_SCALE_JS = (
"""
([index, scale]) => {
"""
+ REMEMBER_JS
+ """
const svg = document.querySelectorAll('svg')[index];
remember(svg);
svg.style.setProperty('transform-origin', 'top left', 'important');
svg.style.setProperty('transform', `scale(${scale})`, 'important');
}
"""
)
# Release overflow on the svg plus the `depth` nearest clipping ancestors, and
# return every released box in viewport coordinates, innermost first. `important`
# so a class rule can't win; the exact prior style attribute is snapshotted.
RELEASE_JS = (
"""
([index, depth]) => {
"""
+ REMEMBER_JS
+ """
const svg = document.querySelectorAll('svg')[index];
const targets = [svg];
let taken = 0;
for (let el = svg.parentElement; el && taken < depth; el = el.parentElement) {
if (getComputedStyle(el).overflow === 'visible') continue;
targets.push(el);
taken++;
}
const rects = [];
for (const el of targets) {
remember(el);
const r = el.getBoundingClientRect();
rects.push([r.left, r.top, r.right, r.bottom]);
el.style.setProperty('overflow', 'visible', 'important');
}
return rects;
}
"""
)
DOM_STATE_JS = "() => document.documentElement.outerHTML"
PAGE_OVERFLOW_JS = """
() => {
const doc = document.documentElement;
return [doc.scrollWidth - doc.clientWidth, doc.clientWidth];
}
"""
# Diff two screenshots of the same viewport inside the browser: no image library
# on the Python side. Only ink OUTSIDE the released box counts, so an element
# repainting its own area can't masquerade as clipping.
DIFF_JS = """
async ({a, b, interior, threshold, minPixels, guard}) => {
const load = async (src) => {
const bitmap = await createImageBitmap(await (await fetch(src)).blob());
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
canvas.getContext('2d').drawImage(bitmap, 0, 0);
return canvas.getContext('2d').getImageData(0, 0, bitmap.width, bitmap.height);
};
const [before, after] = await Promise.all([load(a), load(b)]);
if (before.width !== after.width || before.height !== after.height) {
return {count: 0, resized: true, sides: {}};
}
const sides = {left: 0, top: 0, right: 0, bottom: 0};
let count = 0;
const g = guard;
for (let y = 0; y < before.height; y++) {
for (let x = 0; x < before.width; x++) {
if (x >= interior.left - g && x < interior.right + g &&
y >= interior.top - g && y < interior.bottom + g) continue;
const i = (y * before.width + x) * 4;
const delta = Math.max(
Math.abs(before.data[i] - after.data[i]),
Math.abs(before.data[i + 1] - after.data[i + 1]),
Math.abs(before.data[i + 2] - after.data[i + 2]),
Math.abs(before.data[i + 3] - after.data[i + 3]),
);
if (delta <= threshold) continue;
count++;
if (x < interior.left) sides.left = Math.max(sides.left, interior.left - x);
if (x >= interior.right) sides.right = Math.max(sides.right, x - interior.right + 1);
if (y < interior.top) sides.top = Math.max(sides.top, interior.top - y);
if (y >= interior.bottom) sides.bottom = Math.max(sides.bottom, y - interior.bottom + 1);
}
}
return {count, resized: false, sides, reported: count >= minPixels};
}
"""
def parse_args():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("files", nargs="*", type=Path, help="HTML files to render")
parser.add_argument("--all", action="store_true", help="render every example")
parser.add_argument("--quiet", action="store_true", help="summary only")
parser.add_argument(
"--fonts",
action="store_true",
help="allow exactly the Google Fonts hosts, over HTTPS, so text is measured "
"with the real typefaces",
)
parser.add_argument(
"--self-test",
action="store_true",
dest="self_test",
help="check the checks: detection, false flags, network isolation, DOM restore",
)
return parser.parse_args()
def shipped_assets():
"""Every asset that ships and renders: examples *and* the templates they are
scaffolded from. A broken template ships broken diagrams, so leaving templates
out of --all was a hole (thanks @greptile). index.html and icons.html are
galleries of the others, so they add no coverage.
"""
patterns = ("example-*.html", "template*.html")
return sorted({path for pattern in patterns for path in ASSET_DIR.glob(pattern)})
def display_path(path):
try:
return str(path.resolve().relative_to(ROOT))
except ValueError:
return str(path)
def resolver_rule(allow_fonts):
"""Block every hostname, minus the font hosts when they are opted in."""
if not allow_fonts:
return RESOLVER_RULE
return ",".join([RESOLVER_RULE] + [f"EXCLUDE {host}" for host in FONT_HOSTS])
def launch(playwright, allow_fonts):
"""Bundled Chromium, or the channel named by DIAGRAM_LINT_BROWSER_CHANNEL.
No silent fallback: in CI a missing bundled Chromium is a broken environment,
and quietly using whatever browser is lying around hides that.
"""
args = [f"--host-resolver-rules={resolver_rule(allow_fonts)}"]
channel = os.environ.get("DIAGRAM_LINT_BROWSER_CHANNEL")
if channel:
print(f"Using browser channel {channel!r} (DIAGRAM_LINT_BROWSER_CHANNEL).", file=sys.stderr)
return playwright.chromium.launch(channel=channel, args=args)
return playwright.chromium.launch(args=args)
def block_network(context, allow_fonts):
"""Second layer behind the resolver block: local documents only.
The font exception requires HTTPS and an exact hostname, so a URL merely
containing "fonts.googleapis.com" (a path, a query, a lookalike host) is not
enough to get through.
"""
def handler(route):
url = route.request.url
if url.startswith(("file://", "data:", "blob:")):
route.continue_()
return
parsed = urlparse(url)
if allow_fonts and parsed.scheme == "https" and parsed.hostname in FONT_HOSTS:
route.continue_()
return
route.abort()
context.route("**/*", handler)
def watch(page, findings):
"""Runtime failures. Local assets are reported precisely, via requestfailed."""
page.on("pageerror", lambda error: findings.append(("page-error", str(error))))
page.on(
"console",
lambda message: message.type == "error"
# Resource failures arrive on requestfailed with a URL, which is where they
# get judged. This drops the duplicate, not the signal.
and not message.text.startswith("Failed to load resource")
and findings.append(("console-error", message.text)),
)
def on_request_failed(request):
if request.url.startswith("file://"):
findings.append(("missing-asset", f"{request.url} failed to load"))
# Remote requests are blocked by policy (resolver + routing); that is this
# linter's doing, not the diagram's.
page.on("requestfailed", on_request_failed)
def shoot(page):
"""The viewport, not the page: fixed dimensions, so releasing overflow can't
resize the image, and the whole visible area is available to spill into.
Viewport pixels are also the coordinate system every rect here uses."""
return page.screenshot(animations="disabled")
def data_url(png_bytes):
return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")
def compare_release(page, index, depth, scale):
"""One authored-vs-released paint comparison. Returns (diff, released_label_depth).
Restores every mutated property before returning, whatever happens.
"""
try:
if scale != 1:
page.evaluate(SET_SCALE_JS, [index, scale])
as_authored = shoot(page)
rects = page.evaluate(RELEASE_JS, [index, depth])
released = shoot(page)
finally:
page.evaluate(RESTORE_JS)
if not rects:
return None
# Outermost released box: the only area whose own repaint is expected.
left, top, right, bottom = rects[-1]
interior = {
"left": round(left),
"top": round(top),
"right": round(right),
"bottom": round(bottom),
}
return page.evaluate(
DIFF_JS,
{
"a": data_url(as_authored),
"b": data_url(released),
"interior": interior,
"threshold": CHANNEL_THRESHOLD,
"minPixels": MIN_DIFF_PIXELS,
"guard": EDGE_GUARD,
},
)
def clipping_findings(page, survey):
"""Staged paint comparisons per svg: the svg alone, then each clipping ancestor."""
findings = []
handles = page.locator("svg")
for entry in survey:
if not entry["width"] or not entry["height"]:
findings.append(
("svg-collapsed", f"{entry['label']} renders {entry['width']}x{entry['height']}")
)
continue
if entry["blockedBy"]:
findings.append(
(
"unmeasurable",
f"{entry['label']} sits inside scrolling {entry['blockedBy']}; "
"clipping cannot be measured through it",
)
)
chain = entry["chain"][:MAX_ANCESTOR_STAGES]
# Stage 0 is the svg's own overflow — skipped only when the author already
# set it visible. Ancestor stages run regardless, since a wrapper can clip
# an svg that does not clip itself.
stages = [] if entry["ownOverflowVisible"] else [0]
stages += list(range(1, len(chain) + 1))
if not stages:
continue
handles.nth(entry["index"]).scroll_into_view_if_needed()
for depth in stages:
where = entry["label"] if depth == 0 else f"{entry['label']} inside {chain[depth - 1]}"
worst = None
unmeasurable = False
for scale in DIFF_SCALES:
diff = compare_release(page, entry["index"], depth, scale)
if diff is None:
continue
if diff.get("resized"):
findings.append(
("unmeasurable", f"{where} changed size when overflow was released")
)
unmeasurable = True
break
if diff.get("reported") and (worst is None or diff["count"] > worst[0]["count"]):
worst = (diff, scale)
if unmeasurable or not worst:
continue
diff, scale = worst
spills = ", ".join(
f"{side} by {int(distance / scale)}px"
for side, distance in sorted(diff["sides"].items())
if distance
)
findings.append(
(
"clipped",
f"{where} paints outside its box: {spills} "
f"({diff['count']} px of ink cut off at {scale:g}x)",
)
)
# One report per svg is enough; deeper stages describe the same spill.
break
return findings
def measure(page):
findings = []
# Playwright awaits a returned promise; no timeout kwarg exists on evaluate.
page.evaluate("() => document.fonts.ready")
survey = page.evaluate(SURVEY_JS)
findings.extend(clipping_findings(page, survey))
spill, client_width = page.evaluate(PAGE_OVERFLOW_JS)
if spill > TOLERANCE:
findings.append(
("page-overflow", f"page scrolls {spill:.0f}px horizontally at {client_width}px wide")
)
return findings
def check(page, path):
findings = []
watch(page, findings)
page.goto(path.resolve().as_uri(), wait_until="load")
return measure(page) + findings
# --- self-test ---------------------------------------------------------------
SVG_CASES = [
# (name, svg markup, svg inline style, wrapper html or None, should_be_flagged)
("plain-overflow", '<rect x="150" y="60" width="400" height="10" fill="#000"/>', "", None, True),
(
"thick-stroke-spill",
'<rect x="150" y="20" width="45" height="40" fill="none" stroke="#000" stroke-width="40"/>',
"",
None,
True,
),
(
"marker-spill",
'<defs><marker id="a" markerWidth="30" markerHeight="30" refX="0" refY="5" overflow="visible">'
'<path d="M0,0 L30,5 L0,10 Z" fill="#000"/></marker></defs>'
'<line x1="100" y1="50" x2="199" y2="50" stroke="#000" marker-end="url(#a)"/>',
"",
None,
True,
),
(
"filter-bleed",
'<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="20"/></filter>'
'<rect x="165" y="10" width="30" height="20" fill="#000" filter="url(#b)"/>',
"",
None,
True,
),
(
"clip-path-keeps-it-safe",
'<clipPath id="c"><rect x="0" y="0" width="200" height="100"/></clipPath>'
'<rect x="150" y="10" width="400" height="30" fill="#000" clip-path="url(#c)"/>',
"",
None,
False,
),
(
"opacity-0-ancestor",
'<g opacity="0"><rect x="150" y="10" width="400" height="10" fill="#000"/></g>',
"",
None,
False,
),
(
"display-none-ancestor",
'<g style="display:none"><rect x="150" y="10" width="400" height="10" fill="#000"/></g>',
"",
None,
False,
),
("all-inside", '<rect x="10" y="10" width="100" height="40" fill="#000"/>', "", None, False),
# The author set overflow visible: the svg itself clips nothing.
(
"overflow-visible-svg",
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
"overflow:visible",
None,
False,
),
# ... but a wrapper still clips it, and staging must catch that.
(
"overflow-visible-svg-in-clipping-wrapper",
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
"overflow:visible",
'<div style="overflow:hidden;width:200px;height:100px">',
True,
),
# Wrapper clipping declared inline, and via a class — both must be released.
(
"inline-wrapper",
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
"",
'<div style="overflow:hidden;width:200px;height:100px">',
True,
),
(
"class-wrapper",
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
"",
'<style>.wrap{overflow:hidden;width:200px;height:100px}</style><div class="wrap">',
True,
),
# A rounded clipping wrapper repaints its corners when released; not spill.
(
"rounded-wrapper",
'<rect x="10" y="10" width="100" height="40" fill="#000"/>',
"background:#eee",
'<div style="overflow:hidden;border-radius:12px;padding:40px;background:#222;width:280px">',
False,
),
# An authored inline transform must survive the scale pass untouched.
(
"authored-transform",
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
"transform:rotate(2deg)",
None,
True,
),
]
FIXTURE_PAGE = """
<!DOCTYPE html><html><body style="margin:40px">%(filler)s%(open)s
<svg width="200" height="100" viewBox="0 0 200 100" style="display:block;%(svg_style)s">%(markup)s</svg>
%(close)s</body></html>
"""
# Enough filler to push the svg below a 1000px-tall viewport, so the paired
# above/below-fold cases exercise scrolled measurement.
BELOW_FOLD_FILLER = '<div style="height:1800px;background:#eee"></div>'
def fixture_html(markup, svg_style="", wrapper=None, filler=""):
return FIXTURE_PAGE % {
"filler": filler,
"open": wrapper or "",
"close": "</div>" if wrapper else "",
"svg_style": svg_style,
"markup": markup,
}
def network_isolation_failures(context):
"""Point a page at a local listener four different ways; nothing may connect.
Request routing alone does not stop a WebSocket — verified — which is why the
resolver block exists. This asserts the boundary rather than documenting it.
"""
connections = []
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 0))
port = server.getsockname()[1]
server.listen(8)
def accept_loop():
while True:
try:
connection, address = server.accept()
except OSError:
return
connections.append(address)
connection.close()
thread = threading.Thread(target=accept_loop, daemon=True)
thread.start()
page = context.new_page()
try:
with tempfile.TemporaryDirectory() as directory:
fixture = Path(directory) / "isolation.html"
fixture.write_text(
"<!DOCTYPE html><html><body><script>\n"
f" const target = '127.0.0.1:{port}';\n"
" try { new WebSocket('ws://' + target + '/ws'); } catch (e) {}\n"
" try { fetch('http://' + target + '/fetch').catch(() => {}); } catch (e) {}\n"
" try { new EventSource('http://' + target + '/sse'); } catch (e) {}\n"
" const img = new Image(); img.src = 'http://' + target + '/img.png';\n"
"</script></body></html>",
encoding="utf-8",
)
page.goto(fixture.as_uri(), wait_until="load")
page.wait_for_timeout(1200)
finally:
page.close()
server.close()
if connections:
return [f"network-isolation: {len(connections)} connection(s) reached a local listener"]
return []
def self_test(context):
page = context.new_page()
failures = []
checks = 0
def flagged(html):
page.set_content(html)
return any(category == "clipped" for category, _ in measure(page))
for name, markup, svg_style, wrapper, expected in SVG_CASES:
checks += 1
if flagged(fixture_html(markup, svg_style, wrapper)) != expected:
failures.append(f"{name}: flagged={not expected}, expected={expected}")
# Paired above/below-fold: identical spill, so a scrolled measurement that
# mixed document and viewport coordinates would disagree with its twin.
for label, filler in (("above-fold", ""), ("below-fold", BELOW_FOLD_FILLER)):
checks += 1
if not flagged(fixture_html(SVG_CASES[0][1], filler=filler)):
failures.append(f"{label}-spill: not caught")
checks += 1
if flagged(fixture_html(SVG_CASES[7][1], filler=BELOW_FOLD_FILLER)):
failures.append("below-fold-clean: false clipped finding")
# The DOM must come back exactly as authored, including inline transforms.
checks += 1
page.set_content(fixture_html(SVG_CASES[0][1], svg_style="transform:rotate(2deg)"))
before = page.evaluate(DOM_STATE_JS)
measure(page)
if page.evaluate(DOM_STATE_JS) != before:
failures.append("dom-restore: markup changed after measuring")
# A *scrolling* ancestor must be reported unmeasurable, not clean.
checks += 1
page.set_content(
fixture_html(SVG_CASES[0][1], wrapper='<div style="overflow:auto;width:120px">')
)
if not any(category == "unmeasurable" for category, _ in measure(page)):
failures.append("scrolling-ancestor: no unmeasurable finding")
# Collapsed svg, and a page that scrolls sideways.
checks += 2
page.set_content(
'<!DOCTYPE html><html><body style="margin:0"><div style="width:3000px">wide</div>'
'<svg width="0" height="0" viewBox="0 0 200 100"><rect width="10" height="10"/></svg></body></html>'
)
categories = {category for category, _ in measure(page)}
for expected_category in ("svg-collapsed", "page-overflow"):
if expected_category not in categories:
failures.append(f"{expected_category}: check did not fire")
page.close()
# A missing local asset must surface, not be swallowed with the remote noise.
checks += 1
asset_page = context.new_page()
asset_findings = []
watch(asset_page, asset_findings)
with tempfile.TemporaryDirectory() as directory:
fixture = Path(directory) / "missing-asset.html"
fixture.write_text('<img src="does-not-exist-12345.png">', encoding="utf-8")
asset_page.goto(fixture.as_uri(), wait_until="load")
asset_page.wait_for_timeout(300)
asset_page.close()
if not any(category == "missing-asset" for category, _ in asset_findings):
failures.append("missing-asset: check did not fire")
checks += 1
failures += network_isolation_failures(context)
if failures:
print("self-test FAILED:")
for failure in failures:
print(f" {failure}")
return 1
print(f"self-test OK: {checks} cases, no false flags.")
return 0
def main():
args = parse_args()
try:
from playwright.sync_api import sync_playwright
except ImportError:
print(
"Playwright is not installed. Install it with:\n"
" pip install playwright && playwright install chromium",
file=sys.stderr,
)
return 2
paths = shipped_assets() if args.all else args.files
if not paths and not args.self_test:
print("Nothing to check. Pass files, --all or --self-test.", file=sys.stderr)
return 2
total_findings = 0
total_notes = 0
with sync_playwright() as playwright:
try:
browser = launch(playwright, args.fonts)
except Exception as error:
print(
f"Could not launch a browser: {error}\n"
" playwright install chromium\n"
" (or set DIAGRAM_LINT_BROWSER_CHANNEL=chrome to use a system Chrome)",
file=sys.stderr,
)
return 2
# The oracle is pixels, so record which browser produced them.
print(f"Chromium {browser.version}, network: {resolver_rule(args.fonts)}", file=sys.stderr)
context = browser.new_context(viewport=VIEWPORT)
block_network(context, args.fonts)
if args.self_test:
status = self_test(context)
if status or not paths:
browser.close()
return status
for path in paths:
page = context.new_page()
try:
findings = check(page, path)
except Exception as error:
findings = [("render-error", str(error).splitlines()[0])]
finally:
page.close()
findings.sort()
total_findings += sum(1 for category, _ in findings if category not in NOTE_CATEGORIES)
total_notes += sum(1 for category, _ in findings if category in NOTE_CATEGORIES)
if not args.quiet:
shown_path = display_path(path)
for category, message in findings:
print(f"{shown_path}: {category}: {message}")
browser.close()
print(
f"Summary: {len(paths)} file(s) rendered, {total_findings} finding(s), "
f"{total_notes} note(s) (not checked, not failed)."
)
return 1 if total_findings else 0
if __name__ == "__main__":
sys.exit(main())
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Render the 38 canonical minimal-light examples and record their digests."""
"""Render the 39 canonical minimal-light examples and record their digests."""
from __future__ import annotations
@@ -21,8 +21,8 @@ from screenshot_catalog import (
def main() -> int:
slugs = canonical_slugs()
if len(slugs) != 38 or len(slugs) != len(set(slugs)):
raise SystemExit(f"expected 38 unique canonical types; found {len(slugs)}")
if len(slugs) != 39 or len(slugs) != len(set(slugs)):
raise SystemExit(f"expected 39 unique canonical types; found {len(slugs)}")
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
entries: list[dict[str, object]] = []
@@ -56,7 +56,7 @@ def main() -> int:
"height": height,
}
)
print(f"[{index:02d}/38] {slug}: {width}x{height}")
print(f"[{index:02d}/39] {slug}: {width}x{height}")
browser.close()
payload = {
+175 -18
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Regression tests for docs links and profile-surface verification."""
"""Regression tests for docs links and routing-surface verification."""
from __future__ import annotations
@@ -13,6 +13,37 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERIFY = ROOT / "scripts" / "verify-docs-sync.py"
HIGH_LEVEL_REFERENCE = """\
## 2. Layout formulas — deterministic geometry
### 2.1 Canvas
```
has_vertical = any(c.vertical for c in chevrons)
right_strip_w = 28 if has_vertical else 0
strip_margin = 8 if has_vertical else 0
effective_w = 1000 - right_strip_w - strip_margin
```
## 7. Reproducibility checklist (the taste gate)
1. Check one.
2. Check two.
3. If a vertical chevron exists, `effective_w = 964`; otherwise, `effective_w = 1000`.
4. Check four.
5. Check five.
6. Check six.
7. Check seven.
8. Check eight.
9. Check nine.
10. Check ten.
11. Check eleven.
12. Check twelve.
13. Check thirteen.
## 8. Anti-patterns
"""
def load_verify_module():
sys.dont_write_bytecode = True
@@ -144,34 +175,71 @@ From a repository checkout, run `python3 <repo-root>/scripts/verify-geometry.py
root = Path(temp_dir) / "repo"
profile_reference = root / "skills/diagram-design/references/profiles.md"
command = root / "commands/profile.md"
prompt = root / "prompts/profile.md"
for path in (profile_reference, command, prompt):
doctor_reference = root / "skills/diagram-design/references/doctor.md"
export_reference = root / "skills/diagram-design/references/export.md"
drawio_reference = root / "skills/diagram-design/references/import-drawio.md"
mermaid_reference = root / "skills/diagram-design/references/import-mermaid.md"
export_command = root / "commands/export-diagram.md"
drawio_command = root / "commands/import-drawio.md"
mermaid_command = root / "commands/import-mermaid.md"
profile_command = root / "commands/profile.md"
doctor_command = root / "commands/doctor.md"
export_prompt = root / "prompts/export-diagram.md"
mermaid_prompt = root / "prompts/import-mermaid.md"
profile_prompt = root / "prompts/profile.md"
doctor_prompt = root / "prompts/doctor.md"
for path in (
profile_reference,
doctor_reference,
export_reference,
drawio_reference,
mermaid_reference,
export_command,
drawio_command,
mermaid_command,
profile_command,
doctor_command,
export_prompt,
mermaid_prompt,
profile_prompt,
doctor_prompt,
):
path.parent.mkdir(parents=True, exist_ok=True)
profile_reference.write_text("# Profiles\n", encoding="utf-8")
command.write_text("Follow references/profiles.md.\n", encoding="utf-8")
prompt.write_text("Follow references/profiles.md.\n", encoding="utf-8")
doctor_reference.write_text("# Doctor\n", encoding="utf-8")
export_reference.write_text("# Export\n", encoding="utf-8")
drawio_reference.write_text("# Draw.io\n", encoding="utf-8")
mermaid_reference.write_text("# Mermaid\n", encoding="utf-8")
export_command.write_text("Follow references/export.md.\n", encoding="utf-8")
drawio_command.write_text("Follow references/import-drawio.md.\n", encoding="utf-8")
mermaid_command.write_text("Follow references/import-mermaid.md.\n", encoding="utf-8")
profile_command.write_text("Follow references/profiles.md.\n", encoding="utf-8")
doctor_command.write_text("Follow references/doctor.md.\n", encoding="utf-8")
export_prompt.write_text("Follow references/export.md.\n", encoding="utf-8")
mermaid_prompt.write_text("Follow references/import-mermaid.md.\n", encoding="utf-8")
profile_prompt.write_text("Follow references/profiles.md.\n", encoding="utf-8")
doctor_prompt.write_text("Follow references/doctor.md.\n", encoding="utf-8")
errors = []
verify.check_profile_surfaces(errors, root)
verify.check_routing_surfaces(errors, root)
if errors:
raise AssertionError(f"valid profile surfaces failed: {errors}")
raise AssertionError(f"valid routing surfaces failed: {errors}")
prompt.unlink()
doctor_prompt.unlink()
errors = []
verify.check_profile_surfaces(errors, root)
expected = "profile surface is missing: prompts/profile.md"
verify.check_routing_surfaces(errors, root)
expected = "routing surface is missing: prompts/doctor.md"
if errors != [expected]:
raise AssertionError(f"missing Pi prompt was not reported: {errors}")
raise AssertionError(f"missing routing prompt was not reported: {errors}")
prompt.write_text("Stale standalone instructions.\n", encoding="utf-8")
doctor_prompt.write_text("Stale standalone instructions.\n", encoding="utf-8")
errors = []
verify.check_profile_surfaces(errors, root)
verify.check_routing_surfaces(errors, root)
expected = (
"profile surface does not route to references/profiles.md: prompts/profile.md"
"routing surface does not route to references/doctor.md: prompts/doctor.md"
)
if errors != [expected]:
raise AssertionError(f"stale Pi prompt was not reported: {errors}")
raise AssertionError(f"stale routing prompt was not reported: {errors}")
factory_manifest = root / ".factory-plugin/plugin.json"
factory_marketplace = root / ".factory-plugin/marketplace.json"
@@ -262,9 +330,98 @@ diagram-design/
if errors != [expected]:
raise AssertionError(f"missing Factory native path was not reported: {errors}")
counted = root / "commands"
counted.mkdir(parents=True, exist_ok=True)
drawio = counted / "import-drawio.md"
mermaid = counted / "import-mermaid.md"
routed = "`--type` forces one of the visual types in SKILL.md \u00a73.\n"
for path in (drawio, mermaid):
path.write_text(routed, encoding="utf-8")
errors = []
verify.check_type_counts(errors, root)
if errors:
raise AssertionError(f"a command pointing at SKILL.md failed: {errors}")
# The stale wording the gate was written for, the same wording wrapped
# across the line the real commands wrap on, and the rewrites a later
# edit would reach for. Each is the only defect in the tree, so the gate
# has to report exactly one error.
for stale in (
"`--type` forces one of the 27.\n",
"`--type` forces one of the\n27 visual types.\n",
"`--type` forces one of 28.\n",
"The skill draws 28 visual types.\n",
"The skill draws 28 supported visual diagram types.\n",
"The skill supports 28 types of visual diagrams.\n",
):
mermaid.write_text(stale, encoding="utf-8")
errors = []
verify.check_type_counts(errors, root)
if len(errors) != 1 or "hardcodes the visual-type count" not in errors[0]:
raise AssertionError(
f"a hardcoded count was not reported for {stale!r}: {errors}"
)
# Counts that are not the visual-type count must pass. A gate that
# rejects "accepts 2 file types" is one contributors route around, and
# both commands already carry unrelated numbers in their flag docs.
for benign in (
"`--type` accepts 2 file types.\n",
"Produces 3 output types.\n",
"`--detail=faithful` allows 24 nodes.\n",
"Reads 2 types of visual file.\n",
):
mermaid.write_text(routed + benign, encoding="utf-8")
errors = []
verify.check_type_counts(errors, root)
if errors:
raise AssertionError(
f"a count unrelated to the taxonomy was rejected for {benign!r}: {errors}"
)
# Restore the routed wording first: leaving a stale count behind lets
# this case pass on the wrong error and never names the missing surface.
mermaid.write_text(routed, encoding="utf-8")
drawio.unlink()
errors = []
verify.check_type_counts(errors, root)
expected = "type-count surface is missing: commands/import-drawio.md"
if errors != [expected]:
raise AssertionError(f"a missing command surface was not reported: {errors}")
errors = []
verify.check_high_level_reference(errors, HIGH_LEVEL_REFERENCE)
if errors:
raise AssertionError(f"valid High-Level reference failed: {errors}")
errors = []
verify.check_high_level_reference(
errors,
HIGH_LEVEL_REFERENCE.replace("effective_w = 964", "effective_w = 972", 1),
)
expected = (
"High-Level checklist item 3 has effective_w=972; "
"expected 1000 - 28 - 8 = 964"
)
if errors != [expected]:
raise AssertionError(f"stale effective width was not reported: {errors}")
errors = []
verify.check_high_level_reference(
errors,
HIGH_LEVEL_REFERENCE.replace("13. Check thirteen.", "11. Check thirteen."),
)
expected = (
"High-Level reproducibility checklist numbering is not sequential: "
"expected 1..13, found 1,2,3,4,5,6,7,8,9,10,11,12,11"
)
if errors != [expected]:
raise AssertionError(f"duplicate checklist number was not reported: {errors}")
print(
"PASS: docs sync checks references, strict-bundler packaging, profile surfaces, "
"and Factory install contract"
"PASS: docs sync checks references, strict-bundler packaging, routing surfaces, "
"Factory install contract, type-count routing, and High-Level invariants"
)
return 0
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Adversarial tests for verify-doctor.py."""
from __future__ import annotations
import contextlib
import importlib.util
import io
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERIFY = ROOT / "scripts" / "verify-doctor.py"
def load_verify_module():
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("diagram_design_verify_doctor", VERIFY)
if spec is None or spec.loader is None:
raise AssertionError("could not load verify-doctor.py")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def touch(path: Path, content: str = "placeholder\n") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def seed_repo(module, root: Path) -> None:
touch(root / "skills/diagram-design/SKILL.md", "# Skill\n")
for relative in module.MAINTAINER_MARKERS:
touch(root / relative)
for relative in module.EXPECTED_SCRIPTS:
touch(root / relative)
for relative, reference in module.ROUTING_SURFACES.items():
touch(root / relative, f"Routes to {reference}.\n")
def expect_status(check, status: str, needle: str) -> None:
if check.status != status or needle not in check.message:
raise AssertionError(
f"expected ({status}, contains {needle!r}); got ({check.status}, {check.message!r})"
)
def main() -> int:
verify = load_verify_module()
with tempfile.TemporaryDirectory(prefix="verify-doctor-") as temp_dir:
root = Path(temp_dir)
seed_repo(verify, root)
check = verify.check_expected_scripts(root)
expect_status(check, verify.PASS, "All required scripts are present")
print("OK: expected scripts pass when all are present")
missing = root / verify.EXPECTED_SCRIPTS[0]
missing.unlink()
check = verify.check_expected_scripts(root)
expect_status(check, verify.FAIL, "Missing required script")
print("OK: missing expected script fails")
touch(missing)
check = verify.check_routing_surfaces(root)
expect_status(check, verify.PASS, "routing surfaces")
print("OK: routing surfaces pass when all are wired")
broken_surface = root / next(iter(verify.ROUTING_SURFACES.keys()))
broken_surface.write_text("stale standalone instructions\n", encoding="utf-8")
check = verify.check_routing_surfaces(root)
expect_status(check, verify.FAIL, "reference mismatches")
print("OK: routing mismatch fails")
check = verify.check_common_path_mistakes(root, root)
if check.status not in (verify.PASS, verify.WARN):
raise AssertionError(f"unexpected common path status: {check.status}")
print("OK: common path check returns non-fail in normal repository roots")
installed_root = root / "installed-skill"
touch(installed_root / "SKILL.md", "# Installed skill\n")
unrelated_project = root / "user-project"
unrelated_project.mkdir()
if verify.is_maintainer_checkout(installed_root):
raise AssertionError("standalone skill was misidentified as a maintainer checkout")
for installed_check in (
verify.check_expected_scripts(installed_root),
verify.check_routing_surfaces(installed_root),
verify.check_common_path_mistakes(installed_root, unrelated_project),
):
if installed_check.status != verify.PASS:
raise AssertionError(
"healthy installed skill outside the maintainer repository did not pass: "
f"{installed_check}"
)
print("OK: installed skill in an arbitrary user project does not require maintainer files")
summary_checks = [
verify.CheckResult("a", verify.PASS, "ok"),
verify.CheckResult("b", verify.WARN, "warn", "fix warn"),
]
status, counts, exit_code = verify.summarize(summary_checks, strict=False)
if (status, counts[verify.WARN], exit_code) != ("WARN", 1, 0):
raise AssertionError("non-strict summarize did not preserve WARN semantics")
status, counts, exit_code = verify.summarize(summary_checks, strict=True)
if (status, counts[verify.WARN], exit_code) != ("WARN", 1, 1):
raise AssertionError("strict summarize did not treat WARN as failure")
print("OK: strict mode elevates WARN to failing exit code")
report_out = io.StringIO()
with contextlib.redirect_stdout(report_out):
code = verify.print_report(summary_checks, strict=True, emit_json=True)
report_text = report_out.getvalue()
if code != 1:
raise AssertionError(f"expected strict WARN report to exit 1, got {code}")
for needle in (
"Doctor summary: WARN",
"Next actions",
'"status": "WARN"',
'"checks"',
):
if needle not in report_text:
raise AssertionError(f"report missing {needle!r}")
print("OK: report prints summary, next actions, and JSON payload")
print("All doctor verifier tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2 -2
View File
@@ -402,7 +402,7 @@ def main() -> int:
missing_guide = directory / "missing-guide.md"
missing_guide.write_text(
original_skill.read_text(encoding="utf-8").replace(
"### Visual-type guide (38)", "### Visual guide"
"### Visual-type guide (39)", "### Visual guide"
),
encoding="utf-8",
)
@@ -410,7 +410,7 @@ def main() -> int:
markdown_errors = semantic_module.verify_markdown()
finally:
semantic_module.SKILL = original_skill
if not any("must contain the 38-row visual-type guide" in error for error in markdown_errors):
if not any("must contain the 39-row visual-type guide" in error for error in markdown_errors):
raise AssertionError(f"missing visual-guide anchor was accepted: {markdown_errors}")
print("OK: missing visual-type guide anchor is rejected")
+558
View File
@@ -0,0 +1,558 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import io
import sys
import tempfile
from contextlib import redirect_stdout
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERIFIER = ROOT / "scripts/verify-polar.py"
def load_verifier():
spec = importlib.util.spec_from_file_location("verify_polar", VERIFIER)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def category(index: int, value: int, endpoint: tuple[float, float] | None) -> str:
body = '<text data-polar-value-label="">{}</text>'.format(value)
if endpoint is not None:
x, y = endpoint
body = (
f'<line data-polar-ray="" x1="100" y1="100" x2="{x}" y2="{y}" '
'stroke-width="2"/>'
f'<circle data-polar-marker="" cx="{x}" cy="{y}" r="4" '
'stroke-width="1.2"/>' + body
)
return (
f'<g data-polar-category="c{index}" data-polar-index="{index}" '
f'data-polar-value="{value}">{body}</g>'
)
def document(categories: str, extra_svg: str = "") -> str:
return (
'<!DOCTYPE html><html><body>'
'<svg data-polar-chart="" data-polar-cx="100" data-polar-cy="100" '
'data-polar-radius="100" data-polar-min="0" data-polar-max="100" '
'data-polar-start-angle="-90" data-polar-clockwise="true" '
'data-polar-radius-encoding="linear" data-polar-inner-radius="0">'
'<rect data-polar-background="" width="100%" height="100%" fill="white"/>'
'<circle data-polar-ring="" cx="100" cy="100" r="20" fill="none" '
'stroke-width="0.8"/>'
'<circle data-polar-ring="" cx="100" cy="100" r="40" fill="none" '
'stroke-width="0.8"/>'
'<circle data-polar-ring="" cx="100" cy="100" r="60" fill="none" '
'stroke-width="0.8"/>'
'<circle data-polar-ring="" cx="100" cy="100" r="80" fill="none" '
'stroke-width="0.8"/>'
'<circle data-polar-ring="" cx="100" cy="100" r="100" fill="none" '
'stroke-width="0.8"/>'
'<line data-polar-spoke="" data-polar-index="0" x1="100" y1="100" '
'x2="100" y2="0" stroke-width="0.8"/>'
'<line data-polar-spoke="" data-polar-index="1" x1="100" y1="100" '
'x2="200" y2="100" stroke-width="0.8"/>'
'<line data-polar-spoke="" data-polar-index="2" x1="100" y1="100" '
'x2="100" y2="200" stroke-width="0.8"/>'
'<line data-polar-spoke="" data-polar-index="3" x1="100" y1="100" '
'x2="0" y2="100" stroke-width="0.8"/>'
f'{extra_svg}{categories}</svg></body></html>'
)
VALID = document(
category(0, 0, None)
+ category(1, 25, (125, 100))
+ category(2, 50, (100, 150))
+ category(3, 100, (0, 100))
)
def main() -> int:
module = load_verifier()
cases = [
(
"25 percent hub",
VALID.replace('data-polar-inner-radius="0"', 'data-polar-inner-radius="25"'),
"inner radius",
),
(
"sqrt radius",
VALID.replace('x2="125"', 'x2="150"').replace('cx="125"', 'cx="150"'),
"linear endpoint",
),
(
"visible zero ray",
VALID.replace(
'<text data-polar-value-label="">0</text>',
'<line data-polar-ray="" x1="100" y1="100" x2="100" y2="100"/>'
'<text data-polar-value-label="">0</text>',
),
"zero category",
),
(
"out of range",
VALID.replace('data-polar-value="100"', 'data-polar-value="101"'),
"outside 0..100",
),
(
"duplicate index",
VALID.replace('data-polar-index="3"', 'data-polar-index="2"'),
"indices",
),
(
"quantitative wedge",
VALID.replace('</svg>', '<path data-polar-wedge="" d="M0 0"/></svg>'),
"wedge",
),
(
"unmarked quantitative wedge",
VALID.replace(
'</svg>',
'<path d="M100 100 L100 20 A80 80 0 0 1 180 100 Z"/></svg>',
),
"wedge",
),
(
"polygon area encoding",
VALID.replace(
'</svg>',
'<polygon points="100,100 100,20 180,100" fill="red"/></svg>',
),
"unsupported SVG element <polygon>",
),
(
"external referenced shape",
VALID.replace('</svg>', '<use href="#polar-wedge"/></svg>'),
"unsupported SVG element <use>",
),
(
"URL-valued ray marker",
VALID.replace(
'data-polar-ray=""',
'data-polar-ray="" marker-end="url(https://example.com/marker.svg#tip)"',
1,
),
"URL-valued SVG attribute marker-end is forbidden",
),
(
"external chart stylesheet",
VALID.replace(
'<body>',
'<head><link rel="stylesheet" href="https://example.com/chart.css"></head><body>',
),
"external stylesheets are forbidden in polar documents",
),
(
"CSS geometry override",
VALID.replace(
'<body>',
'<head><style>[data-polar-ray]{stroke-width:40!important}</style></head><body>',
),
"CSS geometry properties are forbidden in polar documents",
),
(
"CSS imported stylesheet",
VALID.replace(
'<body>',
'<head><style>@import url(https://attacker.invalid/polar.css);</style></head><body>',
),
"CSS stylesheet loading is forbidden in polar documents",
),
(
"CSS marker radius override",
VALID.replace(
'<body>',
'<head><style>circle[data-polar-marker]{r:40px}</style></head><body>',
),
"CSS geometry properties are forbidden in polar documents",
),
(
"CSS marker position override",
VALID.replace(
'<body>',
'<head><style>circle[data-polar-marker]{cx:1000px;cy:1000px}</style></head><body>',
),
"CSS geometry properties are forbidden in polar documents",
),
(
"CSS all shorthand override",
VALID.replace(
'<body>',
'<head><style>[data-polar-ray]{all:initial!important}</style></head><body>',
),
"CSS selectors must not target data-polar-chart",
),
(
"CSS individual scale override",
VALID.replace(
'<body>',
'<head><style>[data-polar-ray]{scale:10}</style></head><body>',
),
"CSS selectors must not target data-polar-chart",
),
(
"inline CSS marker radius override",
VALID.replace(
'data-polar-marker="" cx="125"',
'data-polar-marker="" style="r:40px" cx="125"',
1,
),
"inline CSS geometry properties are forbidden inside data-polar-chart",
),
(
"inline CSS marker position override",
VALID.replace(
'data-polar-marker="" cx="125"',
'data-polar-marker="" style="cx:1000px;cy:1000px" cx="125"',
1,
),
"inline CSS geometry properties are forbidden inside data-polar-chart",
),
(
"inline CSS ray translation",
VALID.replace(
'data-polar-ray=""',
'data-polar-ray="" style="translate:100px 0"',
1,
),
"inline styles are forbidden inside data-polar-chart",
),
(
"inline CSS marker offset path",
VALID.replace(
'data-polar-marker=""',
'data-polar-marker="" style="offset-path:path(&quot;M0 0L100 0&quot;);offset-distance:100%"',
1,
),
"inline styles are forbidden inside data-polar-chart",
),
(
"classed chart primitive",
VALID.replace('data-polar-marker=""', 'data-polar-marker="" class="marker"', 1),
"class attributes are forbidden inside data-polar-chart",
),
(
"duplicate ray stroke-width",
VALID.replace(
'stroke-width="2"', 'stroke-width="40" stroke-width="2"', 1
),
"duplicate attribute stroke-width on <line>",
),
(
"duplicate marker radius",
VALID.replace('r="4" stroke-width="1.2"',
'r="40" r="4" stroke-width="1.2"', 1),
"duplicate attribute r on <circle>",
),
(
"duplicate ring fill",
VALID.replace('r="20" fill="none"',
'r="20" fill="red" fill="none"', 1),
"duplicate attribute fill on <circle>",
),
(
"mismatched chart end tag",
VALID.replace(
'</svg>',
'</bogus><polygon points="100,100 100,20 180,100"/></svg>',
),
"mismatched closing tag </bogus>",
),
(
"nested SVG",
VALID.replace('</svg>', '<svg></svg></svg>'),
"unsupported SVG element <svg>",
),
(
"unmarked filled circle",
VALID.replace(
'</svg>', '<circle cx="100" cy="100" r="80" fill="red"/></svg>'
),
"circle must be a ring or category marker",
),
(
"unmarked rectangle",
VALID.replace(
'</svg>', '<rect width="50%" height="50%" fill="red"/></svg>'
),
"rect must be the chart background",
),
(
"partial chart background",
VALID.replace('data-polar-background="" width="100%"',
'data-polar-background="" width="50%"'),
"background must cover the full chart",
),
(
"filled grid ring",
VALID.replace(
'</svg>',
'<circle data-polar-ring="" cx="100" cy="100" r="80" fill="red"/></svg>',
),
"ring must use fill none",
),
(
"off-grid ring",
VALID.replace(
'</svg>',
'<circle data-polar-ring="" cx="100" cy="100" r="70" fill="none" stroke-width="0.8"/></svg>',
),
"ring radius 70 is not a 20% scale interval",
),
(
"thick grid ring",
VALID.replace(
'</svg>',
'<circle data-polar-ring="" cx="100" cy="100" r="80" fill="none" stroke-width="40"/></svg>',
),
"ring stroke-width 40 does not match expected 0.8",
),
(
"inline-style thick grid ring",
VALID.replace(
'r="80" fill="none" stroke-width="0.8"',
'r="80" fill="none" stroke-width="0.8" style="stroke-width: 40"',
),
"ring stroke-width 40 does not match expected 0.8",
),
(
"missing grid ring",
VALID.replace(
'<circle data-polar-ring="" cx="100" cy="100" r="20" fill="none" '
'stroke-width="0.8"/>',
'',
),
"grid-ring count is 4, expected five",
),
(
"duplicate grid radius",
VALID.replace('r="20" fill="none"', 'r="40" fill="none"'),
"ring radii must cover 20% through 100% intervals",
),
(
"small-radius duplicate grid rings",
VALID.replace('data-polar-radius="100"', 'data-polar-radius="1"')
.replace('r="20" fill="none"', 'r="0.5" fill="none"')
.replace('r="40" fill="none"', 'r="0.5" fill="none"')
.replace('r="60" fill="none"', 'r="0.5" fill="none"')
.replace('r="80" fill="none"', 'r="0.5" fill="none"')
.replace('r="100" fill="none"', 'r="0.5" fill="none"'),
"ring radii must cover 20% through 100% intervals",
),
(
"oversized endpoint marker",
VALID.replace('data-polar-marker="" cx="125" cy="100" r="4"',
'data-polar-marker="" cx="125" cy="100" r="40"'),
"marker radius 40 does not match expected 4",
),
(
"thick endpoint marker",
VALID.replace('cx="125" cy="100" r="4" stroke-width="1.2"',
'cx="125" cy="100" r="4" stroke-width="40"'),
"marker stroke-width 40 does not match expected 1.2",
),
(
"unmarked thick line",
VALID.replace(
'</svg>',
'<line x1="100" y1="100" x2="200" y2="100" stroke-width="80"/></svg>',
),
"line must be a spoke, category ray, or rule",
),
(
"thick category ray",
VALID.replace('data-polar-ray="" x1="100" y1="100" x2="125" y2="100" '
'stroke-width="2"',
'data-polar-ray="" x1="100" y1="100" x2="125" y2="100" '
'stroke-width="40"'),
"ray stroke-width 40 does not match expected 2",
),
(
"missing category spoke",
VALID.replace(
'<line data-polar-spoke="" data-polar-index="3" x1="100" y1="100" '
'x2="0" y2="100" stroke-width="0.8"/>',
'',
),
"spoke indices must be contiguous 0..3",
),
(
"thick chart rule",
VALID.replace(
'</svg>',
'<line data-polar-rule="" x1="0" y1="210" x2="200" y2="210" '
'stroke-width="40"/></svg>',
),
"rule stroke-width 40 does not match expected 0.8",
),
(
"duplicate chart rules",
VALID.replace(
'</svg>',
'<line data-polar-rule="" x1="0" y1="210" x2="200" y2="210" '
'stroke-width="0.8"/>'
'<line data-polar-rule="" x1="0" y1="220" x2="200" y2="220" '
'stroke-width="0.8"/></svg>',
),
"chart rule count is 2, expected at most one",
),
(
"transformed chart geometry",
VALID.replace('<g data-polar-category="c1"',
'<g transform="scale(10)" data-polar-category="c1"'),
"transforms are forbidden inside data-polar-chart",
),
(
"non-SVG chart root",
VALID.replace('<svg data-polar-chart=""', '<div data-polar-chart=""')
.replace('</svg>', '</div>'),
"data-polar-chart must be an SVG element",
),
(
"blank value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label=""> </text>',
),
"finite numeric",
),
(
"mismatched value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="">26</text>',
),
"does not match",
),
(
"hidden value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" hidden>25</text>',
),
"explicitly hidden",
),
(
"aria-hidden value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" aria-hidden="true">25</text>',
),
"explicitly hidden",
),
(
"display-none value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" style="display: none">25</text>',
),
"explicitly hidden",
),
(
"visibility-hidden value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" style="visibility: hidden">25</text>',
),
"explicitly hidden",
),
(
"opacity-zero value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" style="opacity: 0">25</text>',
),
"explicitly hidden",
),
(
"direct display-none value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" display=" NONE ">25</text>',
),
"explicitly hidden",
),
(
"direct visibility-hidden value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" visibility=" HiDdEn ">25</text>',
),
"explicitly hidden",
),
(
"direct opacity-zero value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" opacity=" 0.0 ">25</text>',
),
"explicitly hidden",
),
]
failures: list[str] = []
with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
valid_path = root / "valid.html"
valid_path.write_text(VALID, encoding="utf-8")
findings = module.check(valid_path)
if findings != []:
failures.append(f"valid fixture: expected no findings, got {findings}")
for label, source, expected in cases:
path = root / f"{label.replace(' ', '-')}.html"
path.write_text(source, encoding="utf-8")
findings = module.check(path)
if not findings:
failures.append(f"{label}: expected findings, got none")
elif not any(expected in finding for finding in findings):
failures.append(
f"{label}: expected a finding containing {expected!r}, got {findings}"
)
light = root / "light.html"
dark = root / "dark.html"
full = root / "full.html"
light.write_text(VALID, encoding="utf-8")
dark.write_text(VALID.replace('data-polar-value="25"', 'data-polar-value="26"'), encoding="utf-8")
full.write_text(VALID, encoding="utf-8")
findings = module.check_variants((light, dark, full))
if not any("variant data drift" in finding for finding in findings):
failures.append(f"variant data drift: expected finding, got {findings}")
first = root / "first.html"
second = root / "second.html"
first.write_text(VALID, encoding="utf-8")
second.write_text(
VALID.replace('data-polar-inner-radius="0"', 'data-polar-inner-radius="25"'),
encoding="utf-8",
)
output = io.StringIO()
with redirect_stdout(output):
exit_code = module.main([str(first), str(second)])
rendered = output.getvalue()
expected = f"FAIL {second}: inner radius must be zero"
if exit_code != 1 or expected not in rendered:
failures.append(
f"CLI path attribution: expected {expected!r}, got exit={exit_code}, output={rendered!r}"
)
if failures:
print("FAILURES:")
for failure in failures:
print(f"- {failure}")
return 1
print("All polar verifier tests passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+132 -19
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Verify that routing and browsing surfaces stay in sync with the skill.
Eight drift classes, each of which has shipped before:
Ten drift classes, each of which has shipped before:
1. The SKILL.md frontmatter description is the only text an agent sees before
deciding to load the skill — every visual type in the selection table must
@@ -10,7 +10,7 @@ Eight drift classes, each of which has shipped before:
gallery tab must point at a file that exists.
3. Every concrete file named in README.md's architecture tree must exist.
4. Every relative references/*.md link in SKILL.md must resolve.
5. Claude and Pi profile surfaces must both route to the profile reference.
5. Claude and Pi command/prompt surfaces must route to the matching reference.
6. The plugin manifests repeat the SKILL.md description verbatim. They are the
text a user reads *before installing*, so by ADR 0004's own argument they
need every type's lexical hook too - and nothing else notices when they
@@ -19,6 +19,10 @@ Eight drift classes, each of which has shipped before:
with the package metadata instead of becoming a second hand-maintained API.
8. Every support path a strict skill bundler can extract from SKILL.md must be
a literal file shipped inside the skill package.
9. Import command surfaces must route to the visual-type taxonomy instead of
hardcoding a count that becomes stale when a type is added.
10. The High-Level reproducibility checklist must agree with its canvas formula
and retain sequential numbering.
"""
from __future__ import annotations
@@ -34,17 +38,26 @@ SKILL = ROOT / "skills/diagram-design/SKILL.md"
GALLERY = ROOT / "skills/diagram-design/assets/index.html"
ASSET_DIR = ROOT / "skills/diagram-design/assets"
README = ROOT / "README.md"
HIGH_LEVEL_REFERENCE = ROOT / "skills/diagram-design/references/type-high-level.md"
VARIANTS = ("", "-dark", "-full")
VISUAL_TYPE_COUNT = 39
# Types whose selection-table name differs from its description vocabulary.
DESCRIPTION_ALIASES = {
"bar chart": "bar",
"line chart": "line",
"scatter plot": "scatter",
}
PROFILE_SURFACES = (
Path("commands/profile.md"),
Path("prompts/profile.md"),
)
ROUTING_SURFACES = {
Path("commands/export-diagram.md"): "references/export.md",
Path("commands/import-drawio.md"): "references/import-drawio.md",
Path("commands/import-mermaid.md"): "references/import-mermaid.md",
Path("commands/profile.md"): "references/profiles.md",
Path("commands/doctor.md"): "references/doctor.md",
Path("prompts/export-diagram.md"): "references/export.md",
Path("prompts/import-mermaid.md"): "references/import-mermaid.md",
Path("prompts/profile.md"): "references/profiles.md",
Path("prompts/doctor.md"): "references/doctor.md",
}
FACTORY_MANIFEST = Path(".factory-plugin/plugin.json")
FACTORY_MARKETPLACE = Path(".factory-plugin/marketplace.json")
SUPPORT_DIRECTORIES = frozenset(
@@ -102,8 +115,10 @@ def check_description(errors: list[str]) -> None:
errors.append("SKILL.md frontmatter description is missing")
return
types = selection_table_types(markdown)
if len(types) != 38:
errors.append(f"expected 38 visual types in the selection table; found {len(types)}")
if len(types) != VISUAL_TYPE_COUNT:
errors.append(
f"expected {VISUAL_TYPE_COUNT} visual types in the selection table; found {len(types)}"
)
for name in types:
key = normalized(name)
key = DESCRIPTION_ALIASES.get(key, key)
@@ -210,18 +225,18 @@ def check_packaged_support_references(
)
def check_profile_surfaces(errors: list[str], root: Path) -> None:
reference = root / "skills/diagram-design/references/profiles.md"
if not reference.is_file():
errors.append("profile source of truth is missing: skills/diagram-design/references/profiles.md")
for relative in PROFILE_SURFACES:
def check_routing_surfaces(errors: list[str], root: Path) -> None:
for relative, reference_link in ROUTING_SURFACES.items():
reference = root / "skills/diagram-design" / reference_link
if not reference.is_file():
errors.append(f"routing source of truth is missing: skills/diagram-design/{reference_link}")
path = root / relative
if not path.is_file():
errors.append(f"profile surface is missing: {relative.as_posix()}")
errors.append(f"routing surface is missing: {relative.as_posix()}")
continue
if "references/profiles.md" not in path.read_text(encoding="utf-8"):
if reference_link not in path.read_text(encoding="utf-8"):
errors.append(
f"profile surface does not route to references/profiles.md: {relative.as_posix()}"
f"routing surface does not route to {reference_link}: {relative.as_posix()}"
)
@@ -270,6 +285,102 @@ def check_factory_install_surface(errors: list[str], root: Path) -> None:
)
# A command that spells the type count out has to be edited by every PR that
# adds a type, and is the one file such a PR has no reason to open. Both import
# commands were left at 27 while the selection table moved on.
# The phrasing varies, so match the count rather than the one sentence it went
# stale in. Two forms carry it: the bare count standing in for the table
# (`one of the 27`), and a count attached to the taxonomy noun with room for
# adjectives between, in either order (`28 visual types`, `28 supported visual
# diagram types`, `28 types of visual diagrams`). Those clauses insist on that
# noun so an unrelated quantity — `accepts 2 file types` — is not rejected by a
# gate about the visual taxonomy.
#
# Every gap is whitespace-tolerant because both commands already wrap the
# sentence that carried the stale count, so a count can land just after the
# wrap. That is why the whole file is searched at once and the line is derived
# from the match offset rather than iterating lines.
#
# Word-form numerals (`Twenty-eight visual types`) are out of scope; README and
# the docstring say "numeral" so the gate does not claim more than it checks.
HARDCODED_COUNT_RE = re.compile(
r"one\s+of\s+(?:the\s+)?\d+\b"
r"|\b\d+\s+(?:[\w-]+\s+){0,2}?(?:visual|diagram)[\s-]+types?\b"
r"|\b\d+\s+types?\s+of\s+(?:[\w-]+\s+){0,2}?diagrams?\b",
re.IGNORECASE,
)
COUNT_SURFACES = (
Path("commands/import-drawio.md"),
Path("commands/import-mermaid.md"),
)
def check_type_counts(errors: list[str], root: Path) -> None:
"""No routing surface may write the visual-type count as a numeral."""
for relative in COUNT_SURFACES:
path = root / relative
if not path.is_file():
errors.append(f"type-count surface is missing: {relative.as_posix()}")
continue
text = path.read_text(encoding="utf-8")
for match in HARDCODED_COUNT_RE.finditer(text):
number = text.count("\n", 0, match.start()) + 1
phrase = " ".join(match.group(0).split())
errors.append(
f"{relative.as_posix()}:{number} hardcodes the visual-type count "
f"({phrase!r}); point at SKILL.md \u00a73 instead so adding a type "
f"cannot leave it stale"
)
def check_high_level_reference(errors: list[str], markdown: str) -> None:
width_formula = re.search(
r"^effective_w\s*=\s*(\d+)\s*-\s*right_strip_w\s*-\s*strip_margin",
markdown,
re.MULTILINE,
)
right_strip = re.search(r"^right_strip_w\s*=\s*(\d+)\s+if", markdown, re.MULTILINE)
strip_margin = re.search(r"^strip_margin\s*=\s*(\d+)\s+if", markdown, re.MULTILINE)
if not all((width_formula, right_strip, strip_margin)):
errors.append("High-Level canvas is missing the effective-width formula")
return
checklist = re.search(
r"^## 7\. Reproducibility checklist[^\n]*\n(.*?)(?=^## |\Z)",
markdown,
re.MULTILINE | re.DOTALL,
)
if checklist is None:
errors.append("High-Level reproducibility checklist is missing")
return
items = re.findall(r"^(\d+)\.\s+(.+)$", checklist.group(1), re.MULTILINE)
numbers = [int(number) for number, _ in items]
expected_numbers = list(range(1, len(numbers) + 1))
if numbers != expected_numbers:
rendered = ",".join(str(number) for number in numbers)
errors.append(
"High-Level reproducibility checklist numbering is not sequential: "
f"expected 1..{len(numbers)}, found {rendered}"
)
item_three = next((text for number, text in items if number == "3"), "")
checklist_width = re.search(r"effective_w\s*=\s*(\d+)", item_three)
expected_width = (
int(width_formula.group(1))
- int(right_strip.group(1))
- int(strip_margin.group(1))
)
if checklist_width is None:
errors.append("High-Level checklist item 3 is missing effective_w")
elif int(checklist_width.group(1)) != expected_width:
errors.append(
f"High-Level checklist item 3 has effective_w={checklist_width.group(1)}; "
f"expected {width_formula.group(1)} - {right_strip.group(1)} - "
f"{strip_margin.group(1)} = {expected_width}"
)
MANIFEST_DESCRIPTIONS = (
(Path(".claude-plugin/plugin.json"), ("description",)),
(Path(".claude-plugin/marketplace.json"), ("description",)),
@@ -340,7 +451,9 @@ def main() -> int:
SKILL.read_text(encoding="utf-8"),
SKILL.parent,
)
check_profile_surfaces(errors, ROOT)
check_type_counts(errors, ROOT)
check_high_level_reference(errors, HIGH_LEVEL_REFERENCE.read_text(encoding="utf-8"))
check_routing_surfaces(errors, ROOT)
if errors:
print("FAIL docs sync")
for error in errors:
@@ -348,8 +461,8 @@ def main() -> int:
return 1
print(
"OK docs sync: description hooks, gallery reachability, README tree, "
"reference links, packaged support files, profile surfaces, manifest descriptions, "
"Factory install contract"
"reference links, packaged support files, routing surfaces, manifest descriptions, "
"Factory install contract, type-count routing, High-Level invariants"
)
return 0
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""One-shot environment diagnostics verifier for Diagram Design.
Runs read-only checks for local readiness and exits non-zero only on hard failures
(or warnings when --strict is supplied).
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import shutil
import subprocess
import sys
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SKILL_RELATIVE = Path("skills/diagram-design/SKILL.md")
MAINTAINER_MARKERS = (
Path("CONTRIBUTING.md"),
Path(".github/workflows/ci.yml"),
Path("scripts/verify-plugin-package.py"),
)
EXPECTED_SCRIPTS = (
Path("scripts/verify-drawio-import.py"),
Path("scripts/verify-mermaid-import.py"),
Path("scripts/verify-motion.py"),
Path("scripts/lint-skin.py"),
Path("scripts/verify-docs-sync.py"),
)
ROUTING_SURFACES = {
Path("commands/export-diagram.md"): "references/export.md",
Path("commands/import-drawio.md"): "references/import-drawio.md",
Path("commands/import-mermaid.md"): "references/import-mermaid.md",
Path("commands/profile.md"): "references/profiles.md",
Path("commands/doctor.md"): "references/doctor.md",
Path("prompts/export-diagram.md"): "references/export.md",
Path("prompts/import-mermaid.md"): "references/import-mermaid.md",
Path("prompts/profile.md"): "references/profiles.md",
Path("prompts/doctor.md"): "references/doctor.md",
}
PASS = "pass"
WARN = "warn"
FAIL = "fail"
@dataclass
class CheckResult:
name: str
status: str
message: str
fix: str | None = None
def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
def probe_python_command() -> tuple[str | None, str | None]:
"""Resolve python3 first, then python."""
for candidate in ("python3", "python"):
executable = shutil.which(candidate)
if executable:
return candidate, executable
return None, None
def check_python_runtime() -> tuple[CheckResult, str | None]:
command_name, executable = probe_python_command()
if command_name is None or executable is None:
return (
CheckResult(
name="Python runtime",
status=FAIL,
message="No python3 or python command was found on PATH.",
fix="Install Python 3.10+ and ensure python3 or python is available on PATH.",
),
None,
)
version_probe = run_command(
[command_name, "-c", "import sys; print('.'.join(str(p) for p in sys.version_info[:3]))"]
)
if version_probe.returncode != 0:
detail = version_probe.stderr.strip() or "version probe failed"
return (
CheckResult(
name="Python runtime",
status=FAIL,
message=f"Could not query version via {command_name}: {detail}",
fix="Ensure the selected Python command is executable and not blocked by shell aliases.",
),
command_name,
)
version_text = version_probe.stdout.strip()
try:
major, minor, patch = (int(part) for part in version_text.split(".", 2))
except ValueError:
return (
CheckResult(
name="Python runtime",
status=FAIL,
message=f"Unexpected Python version format from {command_name}: {version_text!r}",
fix="Use a standard CPython installation with semantic version output.",
),
command_name,
)
if (major, minor) < (3, 10):
return (
CheckResult(
name="Python runtime",
status=FAIL,
message=(
f"Python {major}.{minor}.{patch} found via {command_name} at {executable}; "
"Diagram Design requires Python >= 3.10."
),
fix="Upgrade Python to 3.10+ and re-run /diagram-design:doctor.",
),
command_name,
)
return (
CheckResult(
name="Python runtime",
status=PASS,
message=f"Python {major}.{minor}.{patch} found via {command_name} at {executable}.",
),
command_name,
)
def check_playwright(python_cmd: str | None) -> CheckResult:
if python_cmd is None:
return CheckResult(
name="Playwright PNG export readiness",
status=FAIL,
message="Playwright check skipped because no Python command was available.",
fix="Resolve Python first; then install with: pip install playwright && playwright install chromium",
)
import_probe = run_command([python_cmd, "-c", "import playwright; print(playwright.__version__)"])
if import_probe.returncode != 0:
return CheckResult(
name="Playwright PNG export readiness",
status=WARN,
message="Playwright package is not available in the active Python interpreter.",
fix="pip install playwright && playwright install chromium",
)
chromium_probe = run_command(
[
python_cmd,
"-c",
(
"from pathlib import Path\n"
"from playwright.sync_api import sync_playwright\n"
"with sync_playwright() as p:\n"
" path = Path(p.chromium.executable_path)\n"
" print(path)\n"
" raise SystemExit(0 if path.is_file() else 3)\n"
),
]
)
if chromium_probe.returncode == 0:
return CheckResult(
name="Playwright PNG export readiness",
status=PASS,
message=f"Playwright is installed and Chromium is present at {chromium_probe.stdout.strip()}.",
)
detail = chromium_probe.stderr.strip() or "Chromium executable was not detected"
return CheckResult(
name="Playwright PNG export readiness",
status=WARN,
message=f"Playwright is installed but Chromium is not ready: {detail}",
fix="pip install playwright && playwright install chromium",
)
def is_maintainer_checkout(root: Path) -> bool:
"""Return whether root is a source checkout with maintainer-only surfaces."""
return all((root / marker).is_file() for marker in MAINTAINER_MARKERS)
def resolve_skill_file(root: Path) -> Path | None:
"""Find SKILL.md in either a repository/plugin root or standalone skill root."""
repository_skill = root / SKILL_RELATIVE
if repository_skill.is_file():
return repository_skill
standalone_skill = root / "SKILL.md"
if standalone_skill.is_file():
return standalone_skill
return None
def check_expected_scripts(root: Path) -> CheckResult:
if not is_maintainer_checkout(root):
return CheckResult(
name="Expected script presence",
status=PASS,
message="Installed-skill mode detected; maintainer-only repository scripts are not required.",
)
missing = [path.as_posix() for path in EXPECTED_SCRIPTS if not (root / path).is_file()]
if missing:
return CheckResult(
name="Expected script presence",
status=FAIL,
message="Missing required script(s): " + ", ".join(missing),
fix="Restore missing scripts from the repository root and re-run diagnostics.",
)
return CheckResult(
name="Expected script presence",
status=PASS,
message=f"All required scripts are present ({len(EXPECTED_SCRIPTS)} checked).",
)
def check_routing_surfaces(root: Path) -> CheckResult:
if not is_maintainer_checkout(root):
return CheckResult(
name="Plugin wiring surfaces",
status=PASS,
message="Installed-skill mode detected; maintainer command/prompt wiring is not required.",
)
missing_files: list[str] = []
mismatched: list[str] = []
for relative_path, reference in ROUTING_SURFACES.items():
path = root / relative_path
if not path.is_file():
missing_files.append(relative_path.as_posix())
continue
source = path.read_text(encoding="utf-8")
if reference not in source:
mismatched.append(f"{relative_path.as_posix()} -> {reference}")
if missing_files or mismatched:
issues: list[str] = []
if missing_files:
issues.append("missing files: " + ", ".join(missing_files))
if mismatched:
issues.append("reference mismatches: " + ", ".join(mismatched))
return CheckResult(
name="Plugin wiring surfaces",
status=FAIL,
message="; ".join(issues),
fix="Ensure each command/prompt file exists and routes to the matching references/*.md file.",
)
return CheckResult(
name="Plugin wiring surfaces",
status=PASS,
message=f"All command/prompt routing surfaces are present and wired ({len(ROUTING_SURFACES)} checked).",
)
def check_common_path_mistakes(root: Path, cwd: Path) -> CheckResult:
warnings: list[str] = []
fixes: list[str] = []
if resolve_skill_file(root) is None:
warnings.append(
"Diagram Design SKILL.md was not found under the resolved installation root."
)
fixes.append("Reinstall or update Diagram Design, then run the doctor again.")
if platform.system().lower().startswith("win") and " " in str(cwd):
warnings.append(
"Path contains spaces on Windows; unquoted shell examples can fail for import/export commands."
)
fixes.append('Use quoted paths, e.g. "C:/path with spaces/diagram.html".')
if not warnings:
return CheckResult(
name="Common path mistakes",
status=PASS,
message="No common path mistakes were detected.",
)
return CheckResult(
name="Common path mistakes",
status=WARN,
message=" ".join(warnings),
fix=" ".join(fixes),
)
def summarize(checks: list[CheckResult], strict: bool) -> tuple[str, dict[str, int], int]:
counts = {PASS: 0, WARN: 0, FAIL: 0}
for check in checks:
counts[check.status] += 1
if counts[FAIL] > 0:
status = "FAIL"
elif counts[WARN] > 0:
status = "WARN"
else:
status = "PASS"
exit_code = 0
if counts[FAIL] > 0:
exit_code = 1
elif strict and counts[WARN] > 0:
exit_code = 1
return status, counts, exit_code
def print_report(checks: list[CheckResult], strict: bool, emit_json: bool) -> int:
status, counts, exit_code = summarize(checks, strict)
print(
f"Doctor summary: {status} ({counts[PASS]} pass, {counts[WARN]} warn, {counts[FAIL]} fail)"
)
markers = {PASS: "PASS", WARN: "WARN", FAIL: "FAIL"}
for check in checks:
print(f"[{markers[check.status]}] {check.name}: {check.message}")
needs_next_actions = counts[WARN] > 0 or counts[FAIL] > 0
if needs_next_actions:
print("\nNext actions")
for check in checks:
if check.status in (WARN, FAIL) and check.fix:
print(f"- {check.fix}")
if emit_json:
payload = {
"status": status,
"counts": counts,
"checks": [asdict(check) for check in checks],
"timestamp": datetime.now(timezone.utc).isoformat(),
"strict": strict,
"platform": {
"system": platform.system(),
"release": platform.release(),
"python": sys.version.split()[0],
"cwd": str(Path.cwd()),
"root": str(ROOT),
},
}
print(json.dumps(payload, indent=2))
return exit_code
def run_doctor(root: Path, cwd: Path, strict: bool, emit_json: bool) -> int:
checks: list[CheckResult] = []
python_check, python_cmd = check_python_runtime()
checks.append(python_check)
checks.append(check_playwright(python_cmd))
checks.append(check_expected_scripts(root))
checks.append(check_routing_surfaces(root))
checks.append(check_common_path_mistakes(root, cwd))
return print_report(checks, strict, emit_json)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run one-shot Diagram Design environment diagnostics."
)
parser.add_argument(
"--strict",
action="store_true",
help="Treat warnings as failures.",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON report after the human summary.",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
root = ROOT
cwd = Path.cwd()
return run_doctor(root, cwd, args.strict, args.json)
if __name__ == "__main__":
raise SystemExit(main())
+802
View File
@@ -0,0 +1,802 @@
#!/usr/bin/env python3
"""Verify the machine-readable contract for quantitative polar diagrams."""
from __future__ import annotations
from dataclasses import dataclass
from html.parser import HTMLParser
from math import cos, hypot, isfinite, pi, radians, sin
from pathlib import Path
import re
import sys
from typing import Sequence
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills" / "diagram-design" / "assets"
DEFAULT_PATHS = (
ASSET_DIR / "example-polar.html",
ASSET_DIR / "example-polar-dark.html",
ASSET_DIR / "example-polar-full.html",
)
TOLERANCE = 0.75
ALLOWED_CHART_ELEMENTS = frozenset({"svg", "g", "line", "circle", "rect", "text", "title", "desc"})
VOID_ELEMENTS = frozenset({
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
"param", "source", "track", "wbr",
})
CSS_GEOMETRY_PROPERTY = re.compile(
r"(?:^|[;{])\s*(?:fill|stroke-width|transform|filter|clip-path|mask|"
r"marker-(?:start|mid|end)|r|cx|cy|x|y|x1|y1|x2|y2|rx|ry|d|points)\s*:",
re.IGNORECASE,
)
CSS_STYLESHEET_LOADING = re.compile(r"@import\b|url\s*\(", re.IGNORECASE)
REFERENCE_ATTRIBUTES = frozenset({
"href", "xlink:href", "filter", "clip-path", "mask",
"marker-start", "marker-mid", "marker-end",
})
SAFE_HTML_STYLE_TARGETS = frozenset({
"html", "body", "div", "main", "header", "footer", "section", "article",
"p", "h1", "h2", "h3", "h4", "h5", "h6",
})
CSS_RULE = re.compile(r"([^{}]+)\{([^{}]*)\}")
CSS_COMBINATOR = re.compile(r"\s+|[>+~]")
@dataclass
class ValueLabel:
attrs: dict[str, str]
text: str
@dataclass
class Category:
name: str
index: int
value: float
focal: bool
rays: list[dict[str, str]]
markers: list[dict[str, str]]
value_labels: list[ValueLabel]
@dataclass
class Chart:
attrs: dict[str, str]
categories: list[Category]
tag: str
backgrounds: list[dict[str, str]]
rings: list[dict[str, str]]
spokes: list[dict[str, str]]
rules: list[dict[str, str]]
unsupported_elements: list[str]
duplicate_attributes: list[tuple[str, str]]
url_attributes: list[str]
unmarked_circle_count: int
unmarked_line_count: int
unmarked_rect_count: int
transformed_element_count: int
css_geometry_count: int
inline_style_count: int
class_attribute_count: int
def _float(value: str | None) -> float:
try:
parsed = float(value) if value is not None else float("nan")
except (TypeError, ValueError):
return float("nan")
return parsed if isfinite(parsed) else float("nan")
def _int(value: str | None) -> int:
try:
return int(value) if value is not None else -1
except (TypeError, ValueError):
return -1
def _geometry_tolerance(radius: float | None) -> float:
if radius is None or not isfinite(radius) or radius <= 0:
return TOLERANCE
return min(TOLERANCE, radius / 20)
def _contains_geometry_css(css: str) -> bool:
without_comments = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
return "\\" in without_comments or CSS_GEOMETRY_PROPERTY.search(without_comments) is not None
def _contains_stylesheet_loading(css: str) -> bool:
without_comments = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
return CSS_STYLESHEET_LOADING.search(without_comments) is not None
def _canonical_svg_layout(declarations: str) -> bool:
parsed: dict[str, str] = {}
for declaration in declarations.split(";"):
if not declaration.strip():
continue
if ":" not in declaration:
return False
name, value = declaration.split(":", 1)
key = name.strip().casefold()
if key in parsed:
return False
parsed[key] = value.strip().casefold()
return parsed == {"width": "100%", "min-width": "900px", "display": "block"}
def _selector_can_target_chart(selector: str) -> bool:
selector = selector.strip()
if not selector or "[" in selector or "#" in selector or "\\" in selector:
return True
if selector == ":root":
return False
target = CSS_COMBINATOR.split(selector)[-1]
target = target.split(":", 1)[0]
if target.startswith("."):
return False
return target.casefold() not in SAFE_HTML_STYLE_TARGETS
def _stylesheet_can_target_chart(css: str) -> bool:
without_comments = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
if "\\" in without_comments:
return True
for prelude, declarations in CSS_RULE.findall(without_comments):
prelude = prelude.strip()
if prelude.startswith("@"):
continue
for selector in prelude.split(","):
if selector.strip() == "svg" and _canonical_svg_layout(declarations):
continue
if _selector_can_target_chart(selector):
return True
return False
def _allowed_stylesheet(href: str) -> bool:
parsed = urlsplit(href.strip())
return (
parsed.scheme == "https"
and parsed.hostname == "fonts.googleapis.com"
and parsed.path == "/css2"
)
def _style_property(attrs: dict[str, str], property_name: str) -> str | None:
result: str | None = None
for declaration in attrs.get("style", "").split(";"):
if ":" not in declaration:
continue
name, raw_value = declaration.split(":", 1)
if name.strip().casefold() == property_name:
result = raw_value.split("!", 1)[0].strip().casefold()
return result
def _presentation_property(attrs: dict[str, str], property_name: str) -> str:
style_value = _style_property(attrs, property_name)
if style_value is not None:
return style_value
return attrs.get(property_name, "").strip().casefold()
class PolarParser(HTMLParser):
"""Capture charts and the geometry-bearing descendants of each category."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.charts: list[Chart] = []
self.parse_findings: list[str] = []
self.style_blocks: list[str] = []
self.stylesheet_urls: list[str] = []
self._stack: list[str] = []
self._style_stack: list[tuple[int, int]] = []
self._chart_depth: int | None = None
self._chart: Chart | None = None
self._category_stack: list[tuple[int, Category]] = []
self._value_label_stack: list[tuple[int, ValueLabel]] = []
def _in_chart(self) -> bool:
return self._chart is not None and self._chart_depth is not None
def _current_category(self) -> Category | None:
if not self._category_stack:
return None
return self._category_stack[-1][1]
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.casefold()
data: dict[str, str] = {}
duplicate_attributes: list[str] = []
for raw_key, raw_value in attrs:
key = raw_key.casefold()
if key in data:
duplicate_attributes.append(key)
else:
data[key] = raw_value or ""
depth = len(self._stack)
if tag == "style":
self.style_blocks.append("")
self._style_stack.append((depth, len(self.style_blocks) - 1))
if tag == "link" and "stylesheet" in data.get("rel", "").casefold().split():
self.stylesheet_urls.append(data.get("href", ""))
if "data-polar-chart" in data:
chart = Chart(
attrs=dict(data),
categories=[],
tag=tag,
backgrounds=[],
rings=[],
spokes=[],
rules=[],
unsupported_elements=[],
duplicate_attributes=[],
url_attributes=[],
unmarked_circle_count=0,
unmarked_line_count=0,
unmarked_rect_count=0,
transformed_element_count=0,
css_geometry_count=0,
inline_style_count=0,
class_attribute_count=0,
)
chart.duplicate_attributes.extend((tag, key) for key in duplicate_attributes)
self.charts.append(chart)
if self._chart is None:
self._chart = chart
self._chart_depth = depth
if self._in_chart():
assert self._chart is not None
assert self._chart_depth is not None
is_chart_root = depth == self._chart_depth
if not is_chart_root:
self._chart.duplicate_attributes.extend(
(tag, key) for key in duplicate_attributes
)
if tag not in ALLOWED_CHART_ELEMENTS or (tag == "svg" and not is_chart_root):
self._chart.unsupported_elements.append(tag)
if "transform" in data or _style_property(data, "transform") is not None:
self._chart.transformed_element_count += 1
if _contains_geometry_css(data.get("style", "")):
self._chart.css_geometry_count += 1
if "style" in data:
self._chart.inline_style_count += 1
if "class" in data:
self._chart.class_attribute_count += 1
for key, value in data.items():
normalized_value = value.strip().casefold()
if key in REFERENCE_ATTRIBUTES and normalized_value not in {"", "none"}:
self._chart.url_attributes.append(key)
elif key in {"fill", "stroke"} and "url(" in normalized_value:
self._chart.url_attributes.append(key)
if tag == "g" and "data-polar-category" in data:
category = Category(
name=data.get("data-polar-category", ""),
index=_int(data.get("data-polar-index")),
value=_float(data.get("data-polar-value")),
focal=data.get("data-polar-focal", "").casefold() == "true",
rays=[],
markers=[],
value_labels=[],
)
self._chart.categories.append(category)
self._category_stack.append((depth, category))
current = self._current_category()
if current is not None:
if tag == "text" and "data-polar-value-label" in data:
label = ValueLabel(attrs=data, text="")
current.value_labels.append(label)
self._value_label_stack.append((depth, label))
if tag == "line":
is_ray = "data-polar-ray" in data
is_spoke = "data-polar-spoke" in data
is_rule = "data-polar-rule" in data
if is_ray and not is_spoke and not is_rule and current is not None:
current.rays.append(data)
elif is_spoke and not is_ray and not is_rule:
self._chart.spokes.append(data)
elif is_rule and not is_ray and not is_spoke:
self._chart.rules.append(data)
else:
self._chart.unmarked_line_count += 1
elif tag == "circle":
is_marker = "data-polar-marker" in data
is_ring = "data-polar-ring" in data
if is_marker and not is_ring and current is not None:
current.markers.append(data)
elif is_ring and not is_marker:
self._chart.rings.append(data)
else:
self._chart.unmarked_circle_count += 1
elif tag == "rect":
if "data-polar-background" in data:
self._chart.backgrounds.append(data)
else:
self._chart.unmarked_rect_count += 1
if tag not in VOID_ELEMENTS:
self._stack.append(tag)
def handle_data(self, data: str) -> None:
if self._style_stack:
style_index = self._style_stack[-1][1]
self.style_blocks[style_index] += data
if self._value_label_stack:
self._value_label_stack[-1][1].text += data
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self.handle_starttag(tag, attrs)
self.handle_endtag(tag)
def handle_endtag(self, tag: str) -> None:
tag = tag.casefold()
if tag in VOID_ELEMENTS:
return
if not self._stack or self._stack[-1] != tag:
if self._in_chart():
self.parse_findings.append(f"mismatched closing tag </{tag}>")
return
self._stack.pop()
depth = len(self._stack)
while self._style_stack and self._style_stack[-1][0] >= depth:
self._style_stack.pop()
while self._value_label_stack and self._value_label_stack[-1][0] >= depth:
self._value_label_stack.pop()
while self._category_stack and self._category_stack[-1][0] >= depth:
self._category_stack.pop()
if self._chart_depth is not None and depth <= self._chart_depth:
self._chart = None
self._chart_depth = None
self._category_stack.clear()
self._value_label_stack.clear()
def close(self) -> None:
super().close()
if self._chart is not None:
self.parse_findings.append("unterminated data-polar-chart element")
def _parse(path: Path) -> PolarParser:
parser = PolarParser()
parser.feed(path.read_text(encoding="utf-8"))
parser.close()
return parser
def _point(attrs: dict[str, str], x_key: str, y_key: str) -> tuple[float, float] | None:
x = _float(attrs.get(x_key))
y = _float(attrs.get(y_key))
if not isfinite(x) or not isfinite(y):
return None
return x, y
def _explicitly_hidden(attrs: dict[str, str]) -> bool:
if "hidden" in attrs or attrs.get("aria-hidden", "").strip().casefold() == "true":
return True
if attrs.get("display", "").strip().casefold() == "none":
return True
if attrs.get("visibility", "").strip().casefold() == "hidden":
return True
if "opacity" in attrs and _float(attrs["opacity"].strip()) == 0:
return True
for declaration in attrs.get("style", "").split(";"):
if ":" not in declaration:
continue
name, raw_value = declaration.split(":", 1)
name = name.strip().casefold()
value = raw_value.split("!", 1)[0].strip().casefold()
if (name == "display" and value == "none") or (
name == "visibility" and value == "hidden"
):
return True
if name == "opacity" and _float(value) == 0:
return True
return False
def check(path: Path) -> list[str]:
"""Return all contract violations found in one HTML document."""
parser = _parse(path)
findings: list[str] = []
if not parser.charts:
return ["missing data-polar-chart"]
if len(parser.charts) != 1:
findings.append(f"duplicate data-polar-chart elements ({len(parser.charts)})")
chart = parser.charts[0]
attrs = chart.attrs
findings.extend(parser.parse_findings)
if any(_contains_stylesheet_loading(css) for css in parser.style_blocks):
findings.append("CSS stylesheet loading is forbidden in polar documents")
if any(_contains_geometry_css(css) for css in parser.style_blocks):
findings.append("CSS geometry properties are forbidden in polar documents")
if any(_stylesheet_can_target_chart(css) for css in parser.style_blocks):
findings.append("CSS selectors must not target data-polar-chart")
if any(not _allowed_stylesheet(href) for href in parser.stylesheet_urls):
findings.append("external stylesheets are forbidden in polar documents")
for tag, key in chart.duplicate_attributes:
findings.append(f"duplicate attribute {key} on <{tag}>")
for key in sorted(set(chart.url_attributes)):
findings.append(f"URL-valued SVG attribute {key} is forbidden")
if chart.css_geometry_count:
findings.append(
"inline CSS geometry properties are forbidden inside data-polar-chart "
f"({chart.css_geometry_count})"
)
if chart.inline_style_count:
findings.append(
"inline styles are forbidden inside data-polar-chart "
f"({chart.inline_style_count})"
)
if chart.class_attribute_count:
findings.append(
"class attributes are forbidden inside data-polar-chart "
f"({chart.class_attribute_count})"
)
if chart.tag != "svg":
findings.append("data-polar-chart must be an SVG element")
for tag in sorted(set(chart.unsupported_elements)):
count = chart.unsupported_elements.count(tag)
findings.append(
f"unsupported SVG element <{tag}> can create forbidden wedge or area encoding ({count})"
)
if chart.unmarked_circle_count:
findings.append(
"circle must be a ring or category marker "
f"({chart.unmarked_circle_count} unmarked)"
)
if chart.unmarked_line_count:
findings.append(
"line must be a spoke, category ray, or rule "
f"({chart.unmarked_line_count} unmarked)"
)
if chart.unmarked_rect_count:
findings.append(
f"rect must be the chart background ({chart.unmarked_rect_count} unmarked)"
)
if chart.transformed_element_count:
findings.append(
"transforms are forbidden inside data-polar-chart "
f"({chart.transformed_element_count})"
)
if len(chart.backgrounds) > 1:
findings.append(f"chart background count is {len(chart.backgrounds)}, expected at most one")
for background in chart.backgrounds:
x = _float(background.get("x", "0"))
y = _float(background.get("y", "0"))
if (
background.get("width", "").strip() != "100%"
or background.get("height", "").strip() != "100%"
or x != 0
or y != 0
):
findings.append("background must cover the full chart")
if len(chart.rules) > 1:
findings.append(
f"chart rule count is {len(chart.rules)}, expected at most one"
)
for rule in chart.rules:
stroke_width = _float(_presentation_property(rule, "stroke-width"))
if stroke_width != 0.8:
findings.append(
f"rule stroke-width {stroke_width:g} does not match expected 0.8"
)
required = (
"data-polar-cx",
"data-polar-cy",
"data-polar-radius",
"data-polar-min",
"data-polar-max",
"data-polar-start-angle",
"data-polar-clockwise",
"data-polar-radius-encoding",
"data-polar-inner-radius",
)
numeric_keys = {
"data-polar-cx",
"data-polar-cy",
"data-polar-radius",
"data-polar-min",
"data-polar-max",
"data-polar-start-angle",
"data-polar-inner-radius",
}
metadata: dict[str, float | bool] = {}
for key in required:
raw = attrs.get(key)
if raw is None or raw == "":
findings.append(f"missing metadata {key}")
continue
if key in numeric_keys:
parsed = _float(raw)
if not isfinite(parsed):
findings.append(f"non-numeric metadata {key}")
else:
metadata[key] = parsed
elif key == "data-polar-clockwise":
value = raw.casefold()
if value not in {"true", "false"}:
findings.append(f"non-numeric metadata {key}")
else:
metadata[key] = value == "true"
minimum = metadata.get("data-polar-min")
maximum = metadata.get("data-polar-max")
radius = metadata.get("data-polar-radius")
inner_radius = metadata.get("data-polar-inner-radius")
cx = metadata.get("data-polar-cx")
cy = metadata.get("data-polar-cy")
start_angle = metadata.get("data-polar-start-angle")
clockwise = metadata.get("data-polar-clockwise")
geometry_tolerance = _geometry_tolerance(radius if isinstance(radius, float) else None)
if isinstance(minimum, float) and minimum != 0:
findings.append(f"minimum must be zero (got {minimum:g})")
if isinstance(maximum, float) and maximum <= 0:
findings.append(f"maximum must be positive (got {maximum:g})")
if isinstance(radius, float) and radius <= 0:
findings.append(f"radius must be positive (got {radius:g})")
if attrs.get("data-polar-radius-encoding") not in {None, "linear"}:
findings.append("non-linear radius encoding")
if isinstance(inner_radius, float) and inner_radius != 0:
findings.append(f"inner radius must be zero (got {inner_radius:g})")
if len(chart.rings) != 5:
findings.append(f"grid-ring count is {len(chart.rings)}, expected five")
for ring in chart.rings:
ring_radius = _float(ring.get("r"))
if _presentation_property(ring, "fill") != "none":
findings.append("ring must use fill none")
ring_center = _point(ring, "cx", "cy")
if (
ring_center is None
or not isinstance(cx, float)
or not isinstance(cy, float)
or hypot(ring_center[0] - cx, ring_center[1] - cy) > geometry_tolerance
):
findings.append("ring center must match the polar chart center")
if isinstance(radius, float) and radius > 0 and isfinite(ring_radius):
expected_radii = [radius * step / 5 for step in range(1, 6)]
if min(abs(ring_radius - expected) for expected in expected_radii) > geometry_tolerance:
findings.append(
f"ring radius {ring_radius:g} is not a 20% scale interval"
)
elif not isfinite(ring_radius):
findings.append("ring radius must be finite numeric")
stroke_width = _float(_presentation_property(ring, "stroke-width"))
if stroke_width != 0.8:
findings.append(
f"ring stroke-width {stroke_width:g} does not match expected 0.8"
)
if isinstance(radius, float) and radius > 0 and len(chart.rings) == 5:
observed_radii = sorted(_float(ring.get("r")) for ring in chart.rings)
expected_radii = [radius * step / 5 for step in range(1, 6)]
if any(
not isfinite(observed) or abs(observed - expected) > geometry_tolerance
for observed, expected in zip(observed_radii, expected_radii)
):
findings.append("ring radii must cover 20% through 100% intervals")
count = len(chart.categories)
if not 4 <= count <= 8:
findings.append(f"category count {count} outside 4..8")
indices = [category.index for category in chart.categories]
if sorted(indices) != list(range(count)):
findings.append(f"indices must be contiguous 0..{count - 1}")
names = [category.name.strip() for category in chart.categories]
if len(names) != len(set(names)):
findings.append("duplicate trimmed category names")
if sum(category.focal for category in chart.categories) > 1:
findings.append("more than one focal category")
geometry_ready = (
isinstance(cx, float)
and isinstance(cy, float)
and isinstance(radius, float)
and isinstance(maximum, float)
and isinstance(start_angle, float)
and isinstance(clockwise, bool)
and radius > 0
and maximum > 0
and count > 0
)
spoke_indices = [_int(spoke.get("data-polar-index")) for spoke in chart.spokes]
if sorted(spoke_indices) != list(range(count)):
findings.append(f"spoke indices must be contiguous 0..{count - 1}")
for spoke, spoke_index in zip(chart.spokes, spoke_indices):
stroke_width = _float(_presentation_property(spoke, "stroke-width"))
if stroke_width != 0.8:
findings.append(
f"spoke {spoke_index} stroke-width {stroke_width:g} "
"does not match expected 0.8"
)
if not geometry_ready or not 0 <= spoke_index < count:
continue
assert isinstance(cx, float)
assert isinstance(cy, float)
assert isinstance(radius, float)
assert isinstance(start_angle, float)
assert isinstance(clockwise, bool)
expected_angle = radians(start_angle) + (1 if clockwise else -1) * 2 * pi * spoke_index / count
expected_x = cx + radius * cos(expected_angle)
expected_y = cy + radius * sin(expected_angle)
spoke_start = _point(spoke, "x1", "y1")
spoke_end = _point(spoke, "x2", "y2")
if spoke_start is None or hypot(spoke_start[0] - cx, spoke_start[1] - cy) > geometry_tolerance:
findings.append(f"spoke {spoke_index} must start at chart center")
if spoke_end is None or hypot(spoke_end[0] - expected_x, spoke_end[1] - expected_y) > geometry_tolerance:
findings.append(f"spoke {spoke_index} endpoint outside tolerance")
for category in chart.categories:
value = category.value
ray = category.rays[0] if len(category.rays) == 1 else None
if ray is not None:
ray_stroke_width = _float(_presentation_property(ray, "stroke-width"))
expected_ray_stroke_width = 2.4 if category.focal else 2.0
if ray_stroke_width != expected_ray_stroke_width:
findings.append(
f"category {category.name!r} ray stroke-width {ray_stroke_width:g} "
f"does not match expected {expected_ray_stroke_width:g}"
)
marker = category.markers[0] if len(category.markers) == 1 else None
if marker is not None:
marker_radius = _float(marker.get("r"))
expected_marker_radius = 5.0 if category.focal else 4.0
if marker_radius != expected_marker_radius:
findings.append(
f"category {category.name!r} marker radius {marker_radius:g} "
f"does not match expected {expected_marker_radius:g}"
)
marker_stroke_width = _float(_presentation_property(marker, "stroke-width"))
if marker_stroke_width != 1.2:
findings.append(
f"category {category.name!r} marker stroke-width {marker_stroke_width:g} "
"does not match expected 1.2"
)
if len(category.value_labels) != 1:
findings.append(
f"category {category.name!r} value-label count is {len(category.value_labels)}, expected one"
)
else:
label = category.value_labels[0]
label_value = _float(label.text.strip())
if _explicitly_hidden(label.attrs):
findings.append(f"category {category.name!r} value label is explicitly hidden")
if not isfinite(label_value):
findings.append(f"category {category.name!r} value label must be finite numeric")
elif isfinite(value) and label_value != value:
findings.append(
f"category {category.name!r} value label {label_value:g} "
f"does not match data-polar-value {value:g}"
)
if not isfinite(value):
findings.append(f"non-numeric category value for {category.name!r}")
continue
if isinstance(minimum, float) and isinstance(maximum, float) and not minimum <= value <= maximum:
findings.append(f"value {value:g} outside {minimum:g}..{maximum:g}")
if value == 0:
if category.rays or category.markers:
findings.append(f"zero category {category.name!r} must not carry a ray or marker")
elif value > 0:
if len(category.rays) != 1 or len(category.markers) != 1:
findings.append(
f"positive category {category.name!r} must carry exactly one ray and marker"
)
if not geometry_ready or value <= 0 or len(category.rays) != 1:
continue
assert isinstance(cx, float)
assert isinstance(cy, float)
assert isinstance(radius, float)
assert isinstance(maximum, float)
assert isinstance(start_angle, float)
assert isinstance(clockwise, bool)
expected_angle = radians(start_angle) + (1 if clockwise else -1) * 2 * pi * category.index / count
expected_radius = radius * value / maximum
expected_x = cx + expected_radius * cos(expected_angle)
expected_y = cy + expected_radius * sin(expected_angle)
assert ray is not None
ray_start = _point(ray, "x1", "y1")
ray_end = _point(ray, "x2", "y2")
if ray_start is None:
findings.append(f"ray geometry non-numeric for {category.name!r}")
elif hypot(ray_start[0] - cx, ray_start[1] - cy) > geometry_tolerance:
findings.append(f"ray start not at center for {category.name!r}")
if ray_end is None:
findings.append(f"ray endpoint non-numeric for {category.name!r}")
elif hypot(ray_end[0] - expected_x, ray_end[1] - expected_y) > geometry_tolerance:
findings.append(
f"linear endpoint outside tolerance for {category.name!r}: "
f"expected ({expected_x:g},{expected_y:g}), got ({ray_end[0]:g},{ray_end[1]:g})"
)
marker_end = _point(marker, "cx", "cy") if marker is not None else None
if marker_end is None:
if marker is not None:
findings.append(f"marker endpoint non-numeric for {category.name!r}")
elif hypot(marker_end[0] - expected_x, marker_end[1] - expected_y) > geometry_tolerance:
findings.append(f"marker endpoint outside tolerance for {category.name!r}")
return findings
def _signature(path: Path) -> tuple[object, ...] | None:
parser = _parse(path)
if len(parser.charts) != 1:
return None
chart = parser.charts[0]
attrs = chart.attrs
values = [_float(attrs.get(key)) for key in (
"data-polar-cx",
"data-polar-cy",
"data-polar-radius",
"data-polar-min",
"data-polar-max",
"data-polar-start-angle",
)]
clockwise = attrs.get("data-polar-clockwise", "").casefold() == "true"
if any(not isfinite(value) for value in values):
return None
cx, cy, radius, minimum, maximum, start_angle = values
return (
cx,
cy,
radius,
minimum,
maximum,
start_angle,
clockwise,
tuple(
(category.index, category.name, category.value, category.focal)
for category in sorted(chart.categories, key=lambda category: category.index)
),
)
def _check_variants_with_paths(paths: Sequence[Path]) -> list[tuple[Path, str]]:
"""Return variant findings while retaining the file that owns each finding."""
findings: list[tuple[Path, str]] = []
signatures: list[tuple[object, ...] | None] = []
for path in paths:
if not path.exists():
findings.append((path, f"file not found: {path}"))
signatures.append(None)
continue
findings.extend((path, finding) for finding in check(path))
signatures.append(_signature(path))
present = [signature for signature in signatures if signature is not None]
if len(present) >= 2 and any(signature != present[0] for signature in present[1:]):
findings.append((paths[0], "variant data drift"))
return findings
def check_variants(paths: Sequence[Path]) -> list[str]:
"""Check each variant and ensure all variants carry one shared dataset."""
return [finding for _path, finding in _check_variants_with_paths(paths)]
def main(argv: Sequence[str] | None = None) -> int:
paths = [Path(argument) for argument in (argv if argv is not None else sys.argv[1:])]
if not paths:
paths = list(DEFAULT_PATHS)
findings = _check_variants_with_paths(paths)
if findings:
for path, finding in findings:
print(f"FAIL {path}: {finding}")
return 1
for path in paths:
print(f"OK {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -20,8 +20,8 @@ from screenshot_catalog import (
def main() -> int:
errors: list[str] = []
slugs = canonical_slugs()
if len(slugs) != 38 or len(slugs) != len(set(slugs)):
errors.append(f"expected 38 unique canonical types; found {len(slugs)}")
if len(slugs) != 39 or len(slugs) != len(set(slugs)):
errors.append(f"expected 39 unique canonical types; found {len(slugs)}")
if not MANIFEST.is_file():
errors.append("docs/screenshots/manifest.json is missing")
@@ -96,7 +96,7 @@ def main() -> int:
for error in errors:
print(f" - {error}")
return 1
print("OK screenshot freshness: 38 canonical sources and PNG digests match")
print("OK screenshot freshness: 39 canonical sources and PNG digests match")
return 0
+15 -6
View File
@@ -21,6 +21,7 @@ PATTERNS = ROOT / "skills/diagram-design/references/semantic-patterns.md"
ANIMATION = ROOT / "skills/diagram-design/references/animation.md"
EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html"
MAX_SKILL_BYTES = 40_000
VISUAL_TYPE_COUNT = 39
PATTERN_NAMES = (
"Fan-in queue / bottleneck",
@@ -191,18 +192,23 @@ def verify_markdown() -> list[str]:
if "Selection: semantic pattern, then visual type" not in skill:
errors.append("SKILL.md must choose semantic pattern before visual type")
router_position = skill.find("semantic-patterns.md")
guide_position = skill.find("### Visual-type guide (38)")
guide_heading = f"### Visual-type guide ({VISUAL_TYPE_COUNT})"
guide_position = skill.find(guide_heading)
if router_position < 0:
errors.append("SKILL.md must link to semantic-patterns.md")
if guide_position < 0:
errors.append("SKILL.md must contain the 38-row visual-type guide")
errors.append(
f"SKILL.md must contain the {VISUAL_TYPE_COUNT}-row visual-type guide"
)
if router_position >= 0 and guide_position >= 0 and router_position > guide_position:
errors.append("semantic-pattern router must precede the visual-type guide")
visual_guide = section(skill, "### Visual-type guide (38)", "Rules of thumb:")
visual_guide = section(skill, guide_heading, "Rules of thumb:")
visual_rows = re.findall(r"^\|.*\[type-[^)]+\.md\]", visual_guide, re.MULTILINE)
if len(visual_rows) != 38:
errors.append(f"visual-type guide must preserve 38 rows; found {len(visual_rows)}")
if len(visual_rows) != VISUAL_TYPE_COUNT:
errors.append(
f"visual-type guide must preserve {VISUAL_TYPE_COUNT} rows; found {len(visual_rows)}"
)
for index, name in enumerate(PATTERN_NAMES, 1):
if name not in skill:
@@ -419,7 +425,10 @@ def main() -> int:
print(f"- {error}")
return 1
if not args.example_only:
print("OK: 7 semantic patterns route independently to the preserved 38 visual types")
print(
f"OK: 7 semantic patterns route independently to the preserved "
f"{VISUAL_TYPE_COUNT} visual types"
)
print("OK: animation modes, primitives, static fallback, and accessibility contract")
if not args.markdown_only:
print("OK: policy-trace example controls, structure, reduced motion, and unique IDs")