blob: 2d46463b43a61af2128f30105c6dce14f98d5960 [file]
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# https://www.apache.org/licenses/LICENSE-2.0
"""Validate the one recording and the authored screenshots the docs embed.
`docs/quick-start.md` used to carry one install screenshot per *harness* and
each `docs/<family>/README.md` one per *family* — fourteen stills, with a
design on file to grow them to twenty-five. Every one of them showed the same
thing, a plugin list with a plugin in it: proof that an install succeeded, and
nothing about the framework doing anything.
They were replaced by recordings. That fixed the wrong half. The quick start's
`/magpie-setup` recording earned its place, but the nine per-family recordings
all showed *setup's* arc — a pre-flight stopping on an unadopted repo — which
is the quick start's subject, not the family's. Nine copies of it, filed under
ten families, taught a reader nothing about what the families do. They are
gone.
What each family shows now is that family working, and those are **authored**:
a committed `.txt` transcript rendered to a static `.svg` by
`render-screenshot.sh`. A capture needs a terminal, a scratch project and a
human, and needs all three again whenever any output moves — which is exactly
why nine placeholders sat in the tree for months. A transcript is a text file:
it reviews as a diff and a contributor can fix a line without a recording
session.
What authoring gives up is the guarantee that the picture matches the program,
and this file buys back the half it can. Rendering is deterministic, so every
committed `.svg` can be proven to still match its `.txt`. The other half —
that the `.txt` matches what the skill prints today — is a human's judgement,
and `assets/quickstart/README.md` says so rather than letting a reader assume
this check is stronger than it is.
Checks:
- every family in the live `family:` frontmatter has a screenshot directory
with at least one transcript in it, and every directory on disk belongs to a
family;
- every screenshot names a skill that family actually ships, so a renamed
skill cannot leave a picture of a command nobody can run;
- every `.txt` has an `.svg` beside it, every `.svg` has a `.txt`, and every
`.svg` still regenerates byte-identically from its source;
- every screenshot is embedded by its family install page
(`docs/<family>/install.md`), and every family install page embeds at least one;
- `magpie-setup.svg` — the whole first run, including the secure-agent setup
that follows it — regenerates from `render-wizard.py`, is embedded by the
quick start, and the setup family's install page embeds it rather than a copy.
It used to be the repository's one *recording*, and it was wrong: it opened
with the marketplace install, which became a prerequisite with its own page,
and re-cutting it needed a terminal, a scratch project and a human. Nothing
here is captured any more;
- the per-family wizard animations regenerate from the frontmatter they are
derived from, and each is embedded by its family install page. These have no
transcript to pair with -- their source is `requires_config:` -- so the
pairing rule does not apply to them and the staleness rule does;
- the first-run walkthrough's screenshots are paired and embedded by the
chapter they exist for, in the order its steps run. A walkthrough whose
pictures are a step out of order teaches the wrong sequence, and no link
check can see it;
- every SVG parses as XML with an `<svg>` root, carries the Apache licence
header, and is under the size cap. These are *text*: one that fails to parse
still "exists", a hand-edited file loses the licence header its generator
writes, and one that grows unnoticed ships in every source release. None of it shows up in review, because the diff of a generated SVG
is unreadable by design;
- nothing still points at anything retired — the fourteen PNG stills, the nine
`*-first-run.svg` recordings, or the `assets/examples/` set the authored
screenshots replaced.
There is no placeholder machinery left. It existed because ten recordings
needed ten capture sessions and blocking commits until someone sat down with
asciinema would simply have got the hook disabled. One recording is a thing a
person does once; a screenshot is a file you write. Neither needs an interim
state, so a missing file is now an error rather than a note.
"""
from __future__ import annotations
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
QUICKSTART = Path("assets/quickstart")
FAMILY_DIR = QUICKSTART / "families"
WALKTHROUGH_DIR = QUICKSTART / "walkthrough"
WIZARD_DIR = QUICKSTART / "wizard"
WIZARD_SCRIPT = Path("tools/dev/render-wizard.py")
FIRST_RUN_DOC = Path("docs/quick-start/first-run.md")
PLUGINS = Path("plugins")
SETUP_RECORDING = QUICKSTART / "magpie-setup.svg"
HERO = QUICKSTART / "install.svg"
QUICK_START_DOC = Path("docs/quick-start.md")
# Every top-level generated animation, and the page that must show it.
TOP_LEVEL = {
HERO: QUICK_START_DOC,
QUICKSTART / "step-install.svg": QUICK_START_DOC,
QUICKSTART / "step-isolation.svg": QUICK_START_DOC,
QUICKSTART / "step-use.svg": QUICK_START_DOC,
QUICKSTART / "step-adopt.svg": QUICK_START_DOC,
SETUP_RECORDING: QUICK_START_DOC,
}
RENDER_SCRIPT = Path("tools/dev/render-screenshot.sh")
SKILLS = Path("skills")
# Generous next to a terminal GIF and still small enough to not be felt in a
# source release. Nothing generated here comes close; the cap is the guard
# against a transcript that grew without anyone noticing.
MAX_BYTES = 1536 * 1024
LICENCE_MARKER = "Licensed to the Apache Software Foundation"
SVG_ROOT = "{http://www.w3.org/2000/svg}svg"
# One family's docs directory is not named after the family.
DOCS_DIR_OVERRIDES = {"issue": "issue-management"}
# Everything this set replaced. A live reference to any of it renders a broken
# image, and the offline link check does not cover every path that could
# reintroduce one.
RETIRED = re.compile(
r"assets/quickstart/(?:families/)?[A-Za-z0-9._-]+\.png"
r"|assets/quickstart/families/[A-Za-z0-9._-]+-first-run\.svg"
r"|assets/examples/[A-Za-z0-9._-]+\.svg"
)
def families() -> list[str]:
"""The live family list, from the frontmatter the plugins are built from."""
found = set()
for skill in SKILLS.glob("*/SKILL.md"):
for line in skill.read_text().splitlines():
if line.startswith("family:"):
found.add(line.split("family:", 1)[1].strip())
break
return sorted(found)
def docs_readme(family: str) -> Path:
return Path("docs") / DOCS_DIR_OVERRIDES.get(family, family) / "install.md"
def family_skills(family: str) -> set[str]:
"""The skills a family plugin ships, under the aliases it advertises them by."""
skills_dir = PLUGINS / f"magpie-{family}" / "skills"
if not skills_dir.is_dir():
return set()
return {p.name for p in skills_dir.iterdir()}
def check_svg(path: Path) -> list[str]:
"""Parse, licence header and size cap — the checks that apply to any SVG
the docs embed, authored or recorded."""
if not path.is_file():
return [f"{path}: missing"]
errors: list[str] = []
raw = path.read_bytes()
if (n := len(raw)) > MAX_BYTES:
errors.append(
f"{path}: {n // 1024} KB exceeds the {MAX_BYTES // 1024} KB cap — "
f"shorten the transcript it renders from"
)
text = raw.decode("utf-8", errors="replace")
try:
root = ET.fromstring(text)
except ET.ParseError as exc:
errors.append(f"{path}: not parsable as XML ({exc}) — it will render as nothing")
return errors
if root.tag != SVG_ROOT:
errors.append(
f"{path}: root element is {root.tag!r}, expected an SVG root — the page embeds this as an image"
)
if LICENCE_MARKER not in text:
errors.append(f"{path}: no Apache licence header. Regenerate it — every generator here prepends one")
return errors
def check_screenshots(known: list[str]) -> list[str]:
"""The authored set: coverage, pairing, naming, and that the docs show it."""
errors: list[str] = []
if not FAMILY_DIR.is_dir():
return [f"{FAMILY_DIR}: missing — every family's screenshots live here"]
on_disk = {d.name for d in FAMILY_DIR.iterdir() if d.is_dir()}
for stray in sorted(on_disk - set(known)):
errors.append(
f"{FAMILY_DIR / stray}: no family declares {stray!r} in its "
f"frontmatter — drop the directory, or add the family"
)
# A loose file directly under families/ is the shape the retired recordings
# had. Name it, rather than letting it pass as "not a directory".
for loose in sorted(p for p in FAMILY_DIR.iterdir() if p.is_file()):
errors.append(
f"{loose}: screenshots live in a per-family directory — "
f"{FAMILY_DIR}/<family>/<skill>.txt and its rendered .svg"
)
for family in known:
fdir = FAMILY_DIR / family
readme = docs_readme(family)
transcripts = sorted(fdir.glob("*.txt")) if fdir.is_dir() else []
if not transcripts:
errors.append(
f"{fdir}: no screenshots for the {family!r} family — write at "
f"least one transcript and render it with {RENDER_SCRIPT}"
)
continue
body = readme.read_text() if readme.is_file() else ""
if not readme.is_file():
errors.append(f"{readme}: missing")
ships = family_skills(family)
for txt in transcripts:
svg = txt.with_suffix(".svg")
if not svg.is_file():
errors.append(f"{svg}: missing — render it with {RENDER_SCRIPT} {txt}")
continue
errors += check_svg(svg)
if ships and txt.stem not in ships:
errors.append(
f"{txt}: magpie-{family} ships no skill {txt.stem!r}, so this "
f"pictures a command nobody can run"
)
if body and svg.as_posix() not in body:
errors.append(f"{readme}: does not embed {svg}")
for svg in sorted(fdir.glob("*.svg")):
if not svg.with_suffix(".txt").is_file():
errors.append(
f"{svg}: no transcript beside it — an SVG here is generated, never hand-written"
)
return errors
def check_walkthrough() -> list[str]:
"""The first-run chapter: every step pictured, and pictured in order.
Separate from the family screenshots because the constraint is different.
A family screenshot has to name a skill that family ships; a walkthrough
screenshot has to be step N of a sequence the reader follows top to
bottom, so the check that matters is that the chapter embeds them all, in
the order their filenames number them.
"""
if not WALKTHROUGH_DIR.is_dir():
return [f"{WALKTHROUGH_DIR}: missing — the first-run chapter has no screenshots"]
errors: list[str] = []
steps = sorted(WALKTHROUGH_DIR.glob("*.txt"))
if not steps:
return [f"{WALKTHROUGH_DIR}: no transcripts"]
if not FIRST_RUN_DOC.is_file():
return [f"{FIRST_RUN_DOC}: missing — the walkthrough screenshots are shown by nothing"]
body = FIRST_RUN_DOC.read_text(encoding="utf-8")
seen_at: list[tuple[int, str]] = []
for txt in steps:
svg = txt.with_suffix(".svg")
if not svg.is_file():
errors.append(f"{svg}: missing — render it with {RENDER_SCRIPT} {txt}")
continue
errors += check_svg(svg)
at = body.find(svg.as_posix())
if at == -1:
errors.append(f"{FIRST_RUN_DOC}: does not embed {svg}")
else:
seen_at.append((at, txt.stem))
for svg in sorted(WALKTHROUGH_DIR.glob("*.svg")):
if not svg.with_suffix(".txt").is_file():
errors.append(f"{svg}: no transcript beside it — generated, never hand-written")
if [name for _, name in sorted(seen_at)] != [name for _, name in seen_at]:
errors.append(
f"{FIRST_RUN_DOC}: embeds the walkthrough screenshots out of order — "
f"they are numbered steps and the page is read top to bottom"
)
return errors
def check_wizards() -> list[str]:
"""The animated per-family config runs: regenerate, and are shown.
Derived from `requires_config:` rather than from a transcript, so the
check is the same in spirit and different in mechanism: re-derive and
compare, then confirm the family page embeds the result.
"""
if not WIZARD_SCRIPT.is_file():
return [f"{WIZARD_SCRIPT}: missing — nothing can verify the wizard animations"]
errors: list[str] = []
proc = subprocess.run(["python3", str(WIZARD_SCRIPT), "--check"], capture_output=True, text=True)
if proc.returncode != 0:
errors += [line for line in proc.stderr.splitlines() if line.strip()]
if not WIZARD_DIR.is_dir():
return errors
for svg in sorted(WIZARD_DIR.glob("*.svg")):
errors += check_svg(svg)
readme = docs_readme(svg.stem)
if not readme.is_file():
errors.append(f"{readme}: missing — {svg} is shown by nothing")
elif svg.as_posix() not in readme.read_text(encoding="utf-8"):
errors.append(f"{readme}: does not embed {svg}")
return errors
def check_regenerates() -> list[str]:
"""Every committed .svg still matches its .txt.
This is the guard a hand-authored image cannot have, and the whole reason
the renderer is byte-deterministic. Delegated to the renderer so the
comparison lives in one place.
"""
if not RENDER_SCRIPT.is_file():
return [f"{RENDER_SCRIPT}: missing — nothing can verify the screenshots"]
proc = subprocess.run(
["bash", str(RENDER_SCRIPT), "--check"],
capture_output=True,
text=True,
)
if proc.returncode == 0:
return []
return [line for line in proc.stderr.splitlines() if line.strip()]
def check_embedded(doc: Path, recording: Path) -> list[str]:
"""A recording and the page that exists to show it must not drift apart."""
if not doc.is_file():
return [f"{doc}: missing"]
if recording.name in doc.read_text():
return []
return [f"{doc}: does not embed {recording} — the page this recording exists for"]
def check_no_retired_references() -> list[str]:
"""Three sets were retired. Nothing may point at any of them again."""
errors: list[str] = []
for png in sorted(QUICKSTART.rglob("*.png")):
errors.append(
f"{png}: the quick-start screenshot set was retired in favour of the "
f"recording and the authored screenshots — a new still here is shown by nothing"
)
for stale in sorted(FAMILY_DIR.glob("*-first-run.svg")) if FAMILY_DIR.is_dir() else []:
errors.append(
f"{stale}: the per-family first-run recordings were retired — they "
f"showed setup's arc, which the quick start already shows"
)
if Path("assets/examples").is_dir():
errors.append(
"assets/examples/: retired — the authored screenshots under "
f"{FAMILY_DIR} do this job for every family, not one"
)
for doc in [*sorted(Path("docs").rglob("*.md")), Path("README.md")]:
if not doc.is_file():
continue
# A design records what was replaced, in prose that explains the
# replacement. Only live references matter here.
if doc.parts[:2] == ("docs", "designs"):
continue
for match in sorted(set(RETIRED.findall(doc.read_text()))):
errors.append(f"{doc}: references retired asset {match}")
return errors
def main() -> int:
known = families()
errors: list[str] = []
# The first-run animation. The setup family's first run *is* that run, so
# its install page embeds it rather than a copy.
for svg, doc in TOP_LEVEL.items():
errors += check_svg(svg)
errors += check_embedded(doc, svg)
if "setup" in known:
errors += check_embedded(docs_readme("setup"), SETUP_RECORDING)
errors += check_screenshots(known)
errors += check_walkthrough()
errors += check_wizards()
errors += check_regenerates()
errors += check_no_retired_references()
if errors:
for err in errors:
print(f"error: {err}", file=sys.stderr)
return 1
shots = len(list(FAMILY_DIR.rglob("*.svg"))) if FAMILY_DIR.is_dir() else 0
steps = len(list(WALKTHROUGH_DIR.glob("*.svg"))) if WALKTHROUGH_DIR.is_dir() else 0
wizards = len(list(WIZARD_DIR.glob("*.svg"))) if WIZARD_DIR.is_dir() else 0
print(
f"Screenshots OK ({shots} family screenshots, {steps} walkthrough steps, "
f"{wizards + len(TOP_LEVEL)} generated animations). Nothing is captured."
)
return 0
if __name__ == "__main__":
sys.exit(main())