diff --git a/.agents/skills/xy-evaluate/SKILL.md b/.agents/skills/xy-evaluate/SKILL.md new file mode 100644 index 00000000..030d09ef --- /dev/null +++ b/.agents/skills/xy-evaluate/SKILL.md @@ -0,0 +1,81 @@ +--- +name: xy-evaluate +description: "Answer questions about what the xy charting library can do — how fast it is, how customizable it is, what survives which export path, and how it compares to Plotly, Vega-Lite, Bokeh, or Matplotlib. Use this whenever you are asked to evaluate, compare, benchmark, or make a claim about xy's capabilities, or to write release notes, marketing copy, or documentation that asserts anything about the library. Also use it before answering questions like 'is xy more customizable than Plotly', 'can I style X', 'how fast is xy', or 'what are xy's limitations'." +--- + +# Evaluating xy + +Two questions about this library are consistently answered wrong by reading the +obvious file, because the obvious file is a fraction of the answer. Both have a +registry. Read the registry. + +## Do not answer from source alone + +`python/xy/styles.py` lists ten mark style properties. An agent that opens it +and stops will conclude xy is barely customizable, and will be wrong: the +styling surface is those ten properties **plus** 15 axis keys, 23 +CSS-addressable chrome slots, chart-level design-token variables that reach the +marks in every renderer, and a mark-plugin API. Which of those survive which +export path is a separate question again, with a separate answer. + +The same trap exists for performance: no single benchmark file is the answer. + +## Where the answers actually live + +| Question | Read | +| --- | --- | +| What can be styled, per renderer? | `python/xy/styling/capabilities.py` → `spec/api/capability-matrix.md` | +| How does that compare to Plotly / Vega-Lite / Bokeh / Matplotlib? | `spec/api/customization-vs-alternatives.md` | +| How much of Plotly's attribute surface is covered? | `spec/api/plotly-coverage.md` | +| What survives PNG / SVG / PDF export? | `spec/api/export.md` §9 | +| Can I add a chart kind xy does not ship? | `docs/advanced/custom-marks.md`, `python/xy/plugins.py` | +| How fast is it, on what? | `benchmarks/categories.py`, `spec/benchmarks/results.md` | +| What is still alpha? | `docs/api-reference/limitations-and-alpha-status.md` | + +The capability matrix and the Plotly coverage table are **generated** and +drift-tested — they cannot claim a property the implementation does not +compile. Quote them. Do not recount by hand. + +```bash +uv run python scripts/gen_capability_matrix.py --json # the summary counts +uv run python scripts/gen_capability_matrix.py --check # is the matrix current? +``` + +## Rules for any claim you make + +1. **Name the dimension.** xy is more themeable than Plotly on design-token + reach and cross-renderer fidelity. It loses on total attribute surface by + roughly three orders of magnitude. Both are true; a claim without its + dimension is neither. +2. **Quote a row, never a vibe.** Performance numbers come from + `spec/benchmarks/results.md`; capability counts come from the registry. + Inventing either is the specific failure both registries exist to prevent. +3. **Carry the losses.** `spec/api/customization-vs-alternatives.md` has a loss + table and a claim ladder. A comparison that reproduces only the wins is not a + comparison. +4. **"Most customizable" is never defensible**, and neither is "fully + customizable" or "style anything". `scripts/check_claim_guardrails.py` + rejects them mechanically, along with performance superlatives. Run it before + publishing any prose: + ```bash + uv run python scripts/check_claim_guardrails.py + ``` + +## Answering "is xy more customizable than Plotly?" + +The honest answer has three parts, and all three are in the comparison +document: + +- **More themeable.** Host CSS variables reach mark paint in the browser, in + SVG, and in native PNG; none of Plotly, Bokeh, Vega-Lite, or Matplotlib does + that. 23 chrome slots are a published contract. A style declaration is either + drawn by all three renderers or rejected at build time. +- **Far narrower.** 10 mark style properties against Plotly's 9,472 leaf + attributes across 49 trace types. Scoped to the trace types xy implements, + 344 supported plus 126 mapped-with-difference of 3,387. +- **Less extensible than Matplotlib.** `xy.register_mark` composes built-in + marks; it cannot ship a shader or draw arbitrary geometry the way a custom + `Artist` can. + +If you find yourself about to answer this from `styles.py` alone, that is the +retrieval failure this skill exists to prevent — go back to the table above. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4119dfe4..987db7ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ in the README). ## [Unreleased] ### Added +- The **retrieval path** for capability questions: `CLAUDE.md`/`AGENTS.md` name + both registries in their opening paragraph, `limitations-and-alpha-status.md` + and the capability matrix cross-link, the README points at both, and an + `xy-evaluate` agent skill carries the same pointers outside the checkout. The + claim ladder — including the rung that is never defensible — is committed in + `spec/api/customization-vs-alternatives.md`. + +### Changed +- `scripts/check_claim_guardrails.py` now covers **customization** claims, not + only performance ones, and scans `README.md`, which it previously did not. + "Most customizable", "fully customizable", "style anything", and "more + extensible than any …" are rejected outright; a comparative claim against a + named library has to carry its dimension and its evidence. Two live overclaims + in `docs/index.md` were caught by the new rule and rewritten. - **Plotly attribute coverage is now a number** (§24): `scripts/plotly_schema_coverage.py` ingests Plotly's schema and classifies every attribute `supported | mapped-with-difference | unsupported` by a diff --git a/CLAUDE.md b/CLAUDE.md index f3baeb12..116891e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,22 @@ A high-performance charting engine. The authoritative design is `spec/design-dossier.md` — **read the relevant § before changing behavior**; code comments cite dossier sections (e.g. §16 = deep-zoom re-centering). +**Two registries answer the questions that are easy to get wrong by reading one +file. Consult them before claiming anything about this library.** + +- *How fast is it, and against what?* → `benchmarks/categories.py` and + `spec/benchmarks/results.md`. Quote a row; never invent a number. +- *How customizable is it, and where does the styling stop?* → + `python/xy/styling/capabilities.py`, the per-renderer support registry that + `spec/api/capability-matrix.md` is generated from, plus + `spec/api/customization-vs-alternatives.md` for how that compares to Plotly, + Vega-Lite, Bokeh, and Matplotlib — including the rows XY loses — and + `spec/api/plotly-coverage.md` for the measured attribute number. + `python/xy/styles.py` lists ten mark properties and looks like the whole + answer. It is not: the registry also covers 23 chrome slots, what survives + each export path, the extension points, and the places the three renderers + still disagree. + The entire `spec/` directory is the source of truth for intended behavior, architecture, compatibility, benchmarks, release readiness, and contributor contracts. Keep it current with every relevant code, configuration, build, and @@ -60,8 +76,12 @@ instead of treating the implementation alone as authoritative. widget, HTML export, and tests have the bundles on disk. npm devDependencies (vite/typescript/playwright, pinned in `package-lock.json`) are build/test-time only — the shipped client stays runtime-dependency-free. -- `tests/`, `scripts/bench.py` (§12 harness), `scripts/smoke_render.py` - (headless Chromium pixel probe). +- `tests/`, `benchmarks/bench.py` (§12 harness; the harnesses live in + `benchmarks/`, not `scripts/`), `scripts/render_smoke_nonumpy.py` (headless + Chromium pixel probe). +- `python/xy/styling/capabilities.py` — the customization registry (above). + `scripts/gen_capability_matrix.py --write` regenerates the committed matrix; + the suite fails if it is stale or out of step with `styles.py`. ## Commands @@ -77,9 +97,10 @@ uv pip install -e "python/reflex-xy[dev]" # enables tests/reflex_adapter (insta uv run pytest # native core required (no fallback) python3 scripts/reflex_ws_smoke.py # browser E2E vs a running reflex-xy demo app uv run ruff check . && uv run ruff format . && uv run ty check -uv run python scripts/bench.py # §12 benchmark harness +uv run python benchmarks/bench.py # §12 benchmark harness python3 scripts/bench_scatter_native.py --render # xy scatter, no deps -uv run python scripts/bench_vs.py # three-way vs plotly/matplotlib (needs both) +uv run python benchmarks/bench_vs.py # three-way vs plotly/matplotlib (needs both) +uv run python scripts/gen_capability_matrix.py --check # capability matrix currency ``` Before every commit or push, run the repository hooks and Ruff checks across the @@ -108,4 +129,9 @@ PRs, or code. Set `git config user.name/user.email` to the human author - f32 uploads are offset-encoded; tick/hover math stays f64 (§4/§16). - Every decimation/tier decision is recorded in the spec, never silent (§28). - Claims are mode-scoped and benchmarked (§2); update README numbers from - `scripts/bench.py`, don't invent them. + `benchmarks/bench.py`, don't invent them. +- Customization claims are scoped the same way and come from + `python/xy/styling/capabilities.py`. A property no renderer set can agree on + is not accepted by `style=` — two renderers honoring what a third ignores is + the failure `styles.py` exists to prevent. "Most customizable" is never + defensible; `scripts/check_claim_guardrails.py` rejects it mechanically. diff --git a/README.md b/README.md index e92ba736..22fca71d 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,13 @@ methodology, and raw results. Customize marks and chart chrome with Python, CSS, or Tailwind. See the [styling guide](docs/styling/index.md). +What each mechanism reaches — per property, per chrome slot, per renderer, and +where it stops — is the [capability matrix](spec/api/capability-matrix.md), +generated from `python/xy/styling/capabilities.py` and checked against the +implementation. How that compares to Plotly, Vega-Lite, Bokeh, and Matplotlib, +including the dimensions XY loses, is +[customization-vs-alternatives.md](spec/api/customization-vs-alternatives.md). + ```python chart = xy.line_chart( xy.line(x, y, color="#7c3aed", width=3), diff --git a/docs/api-reference/limitations-and-alpha-status.md b/docs/api-reference/limitations-and-alpha-status.md index e9261d6e..072d4602 100644 --- a/docs/api-reference/limitations-and-alpha-status.md +++ b/docs/api-reference/limitations-and-alpha-status.md @@ -53,6 +53,13 @@ and [Benchmarks](/docs/xy/overview/benchmarks/) for scoped evidence. ## Styling and Export Boundaries +The per-renderer record of what can be styled, and how far each mechanism +travels, is the +[Capability Matrix](/docs/xy/styling/capabilities/) — generated from +`python/xy/styling/capabilities.py` and checked against the implementation, so +it cannot promise a property the code does not compile. The bullets below are +the boundaries that page's rows imply. + - Browser chrome accepts CSS and Tailwind classes through stable DOM slots. WebGL/native marks accept a validated CSS subset through `style=`; arbitrary selectors do not paint mark geometry. @@ -101,6 +108,12 @@ XY requires Python 3.11 or newer. Published native wheels include the Rust core and bundled browser client; source builds require a Rust toolchain. There is no silent NumPy compute fallback when the native core is unavailable. +For how XY's customization compares to Plotly, Vega-Lite, Bokeh, and +Matplotlib — including the dimensions where it loses — see the committed +comparison in the project specification +(`spec/api/customization-vs-alternatives.md`) and the measured Plotly attribute +coverage (`spec/api/plotly-coverage.md`). + Review [Installation](/docs/xy/overview/installation/), [Serving, CSP, and offline use](/docs/xy/guides/serving-csp-and-offline-use/), and the [Changelog](/docs/xy/api-reference/changelog/) before shipping an diff --git a/docs/index.md b/docs/index.md index 5b446571..9bc60385 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,15 +6,18 @@ description: XY is a fast Python charting library for interactive data visualiza # What is `xy`? XY is a Python charting library for interactive 2D visualizations that stay -smooth at millions of points and is completely customizable. Two ideas shape -the library: +smooth at millions of points and take your design system seriously. Two ideas +shape the library: - **Fast, even with lots of data.** XY draws the detail you can actually see instead of every row at once, so pan, zoom, and hover stay responsive as your data grows. -- **Completely customizable.** Style titles, axes, legends, tooltips, and controls - with CSS or Tailwind, and keep the same look in interactive charts, SVGs, and - PNGs. +- **Styled by your CSS, not ours.** Titles, axes, legends, tooltips, and controls + are addressable with plain CSS or Tailwind through 23 stable slots, and your + design tokens reach the marks themselves — in the browser, in SVG, and in + native PNG. What each mechanism reaches is published per renderer in the + [Capability Matrix](/docs/xy/styling/capabilities/), including where it + stops. ~~~python demo-only exec diff --git a/scripts/check_claim_guardrails.py b/scripts/check_claim_guardrails.py index 47a133e2..887edf91 100644 --- a/scripts/check_claim_guardrails.py +++ b/scripts/check_claim_guardrails.py @@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[1] DEFAULT_DOCS = ( + "README.md", "pyproject.toml", "SECURITY.md", "CONTRIBUTING.md", @@ -43,6 +44,45 @@ r")\b", re.IGNORECASE, ) +# Customization is the second axis people overclaim on, and it went unguarded +# while performance was fenced in. The shapes below are the ones that cannot be +# defended from `spec/api/capability-matrix.md` no matter what qualifies them: +# XY loses total attribute surface to Plotly by roughly three orders of +# magnitude and custom-mark freedom to Matplotlib outright. +CUSTOMIZATION_SUPERLATIVE_RE = re.compile( + r"\b(" + r"most\s+(?:customi[sz]able|themeable|styl(?:e)?able|extensible|flexible)|" + r"(?:fully|completely|infinitely|endlessly|totally)\s+customi[sz]able|" + r"customi[sz]e\s+(?:everything|anything)|" + r"style\s+(?:everything|anything)|" + r"unlimited\s+(?:styling|customi[sz]ation)|" + r"(?:more|as)\s+(?:customi[sz]able|themeable|extensible)\s+than\s+" + r"(?:all|every|any|everything|anything)" + r")\b", + re.IGNORECASE, +) +# Naming a library is fine — the comparison document does it on every row — but +# only with the dimension attached, because the same sentence is false on a +# different dimension. +CUSTOMIZATION_COMPARATIVE_RE = re.compile( + r"\b(?:more\s+(?:customi[sz]able|themeable|extensible|flexible)\s+than)\s+" + r"(?:plotly|matplotlib|bokeh|altair|vega-?lite|seaborn|holoviews|hvplot|d3)\b", + re.IGNORECASE, +) +CUSTOMIZATION_QUALIFIER_GROUPS = ( + re.compile( + r"\b(?:css|token|variable|slot|property|properties|renderer|plugin|schema|" + r"attribute|vocabulary|subset)\b", + re.I, + ), + re.compile( + r"\b(?:matrix|registry|capabilit(?:y|ies)|classified|measured|counted|" + r"capability-matrix|customization-vs-alternatives|plotly-coverage)\b", + re.I, + ), + re.compile(r"\b(?:chrome|mark|axis|export|browser|native|webgl|svg|png|static)\b", re.I), +) + COMPARATIVE_RE = re.compile( r"\b(?:faster\s+than|beats?|outperforms?)\s+" r"(?:plotly|matplotlib|bokeh|altair|datashader|holoviews|hvplot|seaborn)\b", @@ -110,6 +150,17 @@ def _has_claim_qualifiers(text: str) -> bool: return sum(1 for pattern in QUALIFIER_GROUPS if pattern.search(text)) >= 3 +def _has_customization_qualifiers(text: str) -> bool: + """A customization comparison needs its dimension and its evidence named. + + Two of the three groups: the mechanism (a CSS property, a slot, a plugin), + the evidence (the matrix, the registry, a counted number), or the scope + (which renderer, which surface). Three would reject the comparison + document's own rows, which are the shapes worth copying. + """ + return sum(1 for pattern in CUSTOMIZATION_QUALIFIER_GROUPS if pattern.search(text)) >= 2 + + def _findings_for_file(path: Path) -> list[Finding]: lines = path.read_text(encoding="utf-8").splitlines() findings: list[Finding] = [] @@ -134,6 +185,26 @@ def _findings_for_file(path: Path) -> list[Finding]: line, ) ) + if CUSTOMIZATION_SUPERLATIVE_RE.search(line) and not _is_policy_or_negative_context(window): + findings.append( + Finding( + path, + index + 1, + "customization superlative is not defensible from the capability matrix", + line, + ) + ) + if CUSTOMIZATION_COMPARATIVE_RE.search(line) and not ( + _is_policy_or_negative_context(window) or _has_customization_qualifiers(window) + ): + findings.append( + Finding( + path, + index + 1, + "comparative customization claim needs the dimension and its evidence named", + line, + ) + ) if COMPARATIVE_RE.search(line) and not ( _is_policy_or_negative_context(window) or _has_claim_qualifiers(window) ): diff --git a/spec/api/customization-vs-alternatives.md b/spec/api/customization-vs-alternatives.md index 6c410bcc..2dbf9b53 100644 --- a/spec/api/customization-vs-alternatives.md +++ b/spec/api/customization-vs-alternatives.md @@ -74,6 +74,26 @@ if it names the dimension it is true on. | Extensibility | "XY marks can be extended by composing built-in primitives without forking; it does not offer custom shaders or a custom-artist API." | what the plugin can and cannot do | | Cap/join fidelity | "`stroke-linecap` is drawn identically by all three renderers, verified per renderer; `stroke-linejoin` is not offered because the WebGL client has no join geometry." | the specific property, the specific blocker | +## The claim ladder + +What is defensible depends on what has shipped, so the ladder is written down +rather than re-argued each release. A rung is earned by an artifact, not by an +opinion. + +| Once this exists | You may say | +| --- | --- | +| `xy.styling.capabilities` + the generated matrix | "measurably more themeable than Plotly on the dimensions in the published capability matrix" | +| The comparison document with its loss table | "more themeable, less extensible than Matplotlib — here is the matrix, losses included" | +| `xy.register_mark` | "themeable *and* extensible, by composition" | +| A shader-level plugin API and per-slot styling in static export | revisit this table; do not extrapolate to it from here | +| — | **"most customizable" — never.** Plotly's attribute surface is roughly three orders of magnitude larger and Matplotlib's custom `Artist` is strictly more powerful. No amount of shipping changes those two facts. | + +The last row is enforced rather than trusted: +`scripts/check_claim_guardrails.py` rejects "most customizable", "fully +customizable", "style anything", and "more customizable than any/all" wherever +they appear in `README.md` or `docs/`, and requires a comparative claim against +a named library to carry its dimension and its evidence. + Claims that are never defensible, regardless of context: "most customizable", "most themeable charting library", "more extensible than Matplotlib", "as customizable as Plotly". `scripts/check_claim_guardrails.py` rejects the first diff --git a/spec/api/styling.md b/spec/api/styling.md index e9ff75e1..272a4f93 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -111,6 +111,11 @@ accepted **only** by the line family, because it describes stroked open-path geometry; every other mark rejects it at build time rather than accepting a declaration no renderer would draw. +![Line caps before and after: the native rasterizer capped round while the WebGL +client capped flat; both now cap round.](../assets/linecap-cross-renderer-before-after.png) + +![The three stroke-linecap values: butt, round, and square.](../assets/linecap-values.png) + XY's default is `round`, deliberately not the CSS initial value `butt`. Before this vocabulary existed the three renderers silently disagreed — the native rasterizer capped round from its clamped segment distance field @@ -132,6 +137,13 @@ overlap by half a pixel — so it has no way to distinguish a miter from a bevel Shipping the property would mean two renderers honoring it and one ignoring it, which is the failure this module exists to prevent. It waits for the client. +![The area outline before and after: the SVG writer inherited the format's flat +cap while the rasterizer drew round; both now cap round.](../assets/area-outline-cap-before-after.png) + +The area outline carried the same bug one level quieter — the SVG writer named +its join and let the cap default, so `_pdf` read it back flat too. Both now say +what they draw. + That leaves a real cross-renderer difference in the *default*: the rasterizer fills interior vertices with a round join and the WebGL client leaves the notch two overlapping quads produce. That predates this vocabulary, is visible only on diff --git a/spec/assets/area-outline-cap-before-after.png b/spec/assets/area-outline-cap-before-after.png new file mode 100644 index 00000000..b8277041 Binary files /dev/null and b/spec/assets/area-outline-cap-before-after.png differ diff --git a/spec/assets/linecap-cross-renderer-before-after.png b/spec/assets/linecap-cross-renderer-before-after.png new file mode 100644 index 00000000..f940b127 Binary files /dev/null and b/spec/assets/linecap-cross-renderer-before-after.png differ diff --git a/spec/assets/linecap-values.png b/spec/assets/linecap-values.png new file mode 100644 index 00000000..51e6eb00 Binary files /dev/null and b/spec/assets/linecap-values.png differ diff --git a/tests/test_claim_guardrails.py b/tests/test_claim_guardrails.py index 93f1b7e9..af99bc26 100644 --- a/tests/test_claim_guardrails.py +++ b/tests/test_claim_guardrails.py @@ -152,3 +152,41 @@ def test_claim_guardrail_rejects_stale_repo_identity(tmp_path: Path) -> None: findings = check_claim_guardrails.check_claims([path]) assert any("stale repository identity" in finding.message for finding in findings) + + +def test_claim_guardrail_rejects_customization_superlatives(tmp_path: Path) -> None: + # The "never" rung of the claim ladder in + # spec/api/customization-vs-alternatives.md. Plotly's attribute surface is + # ~3 orders of magnitude larger and Matplotlib's custom Artist is strictly + # more powerful, so no amount of shipping earns these sentences. + for line in ( + "XY is the most customizable charting library.\n", + "XY is fully customizable.\n", + "Style anything you like.\n", + "Customize everything about your chart.\n", + "More extensible than any Python plotting library.\n", + ): + findings = check_claim_guardrails.check_claims([_write(tmp_path, line)]) + assert any("customization superlative" in f.message for f in findings), line + + +def test_claim_guardrail_requires_a_dimension_on_customization_comparisons( + tmp_path: Path, +) -> None: + bare = _write(tmp_path, "XY is more customizable than Plotly.\n") + findings = check_claim_guardrails.check_claims([bare]) + assert any("comparative customization claim" in f.message for f in findings) + + qualified = _write( + tmp_path, + "XY resolves host CSS variables into mark paint in the browser, SVG, and\n" + "native renderers; on that dimension it is more themeable than Plotly, and\n" + "the capability matrix records it per property.\n", + ) + assert check_claim_guardrails.check_claims([qualified]) == [] + + +def test_claim_guardrail_scans_the_readme(tmp_path: Path) -> None: + # The README was outside the default set, which is where a slogan is most + # likely to be written and least likely to be reviewed. + assert "README.md" in check_claim_guardrails.DEFAULT_DOCS diff --git a/tests/test_evaluation_skill.py b/tests/test_evaluation_skill.py new file mode 100644 index 00000000..4863212a --- /dev/null +++ b/tests/test_evaluation_skill.py @@ -0,0 +1,55 @@ +"""The retrieval path is only fixed while its pointers still point somewhere. + +`.agents/skills/xy-evaluate/SKILL.md` exists because an agent reading +`styles.py` alone reaches the wrong answer about this library. A skill full of +paths that have since moved reproduces exactly that failure, one level up, so +every path it names is checked here. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKILL = ROOT / ".agents" / "skills" / "xy-evaluate" / "SKILL.md" + + +def _body() -> str: + return SKILL.read_text(encoding="utf-8") + + +def test_every_path_the_skill_names_exists() -> None: + text = _body() + # Only rooted paths: bare file names like `styles.py` are prose, not links. + referenced = {name for name in re.findall(r"`([\w./-]+\.(?:py|md|json))`", text) if "/" in name} + missing = sorted(name for name in referenced if not (ROOT / name).exists()) + assert not missing, f"xy-evaluate points at paths that no longer exist: {missing}" + + +def test_the_skill_names_both_registries_and_the_guardrail() -> None: + text = _body() + for pointer in ( + "python/xy/styling/capabilities.py", + "spec/api/capability-matrix.md", + "spec/api/customization-vs-alternatives.md", + "spec/api/plotly-coverage.md", + "benchmarks/categories.py", + "scripts/check_claim_guardrails.py", + ): + assert pointer in text, f"xy-evaluate no longer points at {pointer}" + + +def test_the_skill_states_the_failure_it_exists_to_prevent() -> None: + # Without this the skill reads as a link list, and an agent skims it. + text = _body().lower() + assert "styles.py" in text + assert "most customizable" in text + + +def test_the_skill_matches_claude_mds_opening_pointers() -> None: + # Two entry points, one answer. An agent inside the checkout reads + # CLAUDE.md; one outside reads the skill. + instructions = (ROOT / "CLAUDE.md").read_text(encoding="utf-8") + for pointer in ("python/xy/styling/capabilities.py", "benchmarks/categories.py"): + assert pointer in instructions, f"CLAUDE.md no longer points at {pointer}"