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:
co-authored by
mysoniw
thang
YinShaokun
0xDarkMatter
Rupam Pal
Yuvraj3905
parent
4b573616cb
commit
b827385a17
Executable
+813
@@ -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())
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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("M0 0L100 0");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
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user