A plugin registry made of file names, kept honest by one test

Ghostwriter · No. 023

Shipped

Ghostwriter generates the visual cards that ship alongside a LinkedIn post, and v0.5.0 added a matrix card: a comparison grid for posts whose point is a few options weighed against each other, joining the existing ramp, flow, terminal, and carousel types. Adding it took one new HTML template, one new CSS block scoped to its own class, and one new paragraph in the skill’s guidance doc. Nothing that already existed had to change.

That’s the part worth teaching. The card library has no registry object anywhere, no CardType class, no list of types a Python module imports and dispatches on. It’s a naming convention across a small set of files, and the thing that keeps the convention honest isn’t a person remembering to check three places. It’s a test. Here’s how to build that pattern for your own plugin-shaped system, and the test that catches a type shipped half-wired.

Why a naming convention instead of a registry object

The obvious way to build an extensible system is a registry: a module that imports every type and exposes something like TYPES = {"ramp": RampCard, "matrix": MatrixCard}. Every new type means editing that module, which means every type change touches a file every other type also touches.

The alternative Ghostwriter uses is to let the file names themselves be the registry. A type is three files that share a name: a template, a style block scoped to that name, and a doc entry that references the same name. The thing that renders a card never enumerates types; it just inlines whatever CSS exists and screenshots the result. Adding a type is adding files, not editing a shared one.

This is convention over configuration, the pattern Ruby on Rails popularized: if you follow a naming rule, the system infers what you meant instead of you having to declare it explicitly (Convention over configuration). The trade a naming convention makes is that nothing enforces it automatically. Rails leans on the framework loader to notice a class name lines up with a table name. A file-based registry like this one needs its own check, because nothing stops you from adding a template and forgetting the CSS block, or adding the CSS and forgetting to document it. That check is what the rest of this post builds.

Build it: declare a type by dropping a file

Take a plugin-shaped system that isn’t cards: notification channels for an app. Instead of a channels.py that imports EmailChannel, SMSChannel, and dispatches on a string, each channel is its own file, discovered by a naming convention: type_<name>.py in a plugins/ directory. Two channels to start:

# plugins/type_ramp.py: stand-in for one plugin type; "ramp" here is arbitrary,
# swap in "email" / "sms" / whatever your own system's types are
"""ramp type: an ordered progression of steps, low to high."""

STYLE = {"accent": "green", "shape": "stair"}


def render(steps: list[str]) -> str:
    return " -> ".join(steps)
# plugins/type_flow.py: a second type, same shape, nothing shared with the first
"""flow type: a directed sequence of named nodes."""

STYLE = {"accent": "blue", "shape": "arrow"}


def render(nodes: list[str]) -> str:
    return " -> ".join(f"[{n}]" for n in nodes)

Every type file defines the same two things: a STYLE dict (this stands in for Ghostwriter’s scoped CSS block) and a render function (this stands in for the HTML template). Nothing imports these directly; something that wants to use a type loads it by file name.

The third file is the doc entry, the part that does the picking. It’s what a person, or an LLM reading the doc as part of a prompt, uses to choose a type in the first place:

<!-- REGISTRY.md -->
## Types

- `plugins/type_ramp.py` - ramp: an ordered progression of steps, low to high.
- `plugins/type_flow.py` - flow: a directed sequence of named nodes.

At this point you have a working system with zero shared code between types. That’s also exactly how it can go wrong quietly: nothing stops a fourth type from showing up with a render function and no doc entry, or a STYLE dict and no render.

Build it: a test that won’t let a type ship half-wired

The check is a small script that treats the directory listing as the source of truth and cross-references it against the doc:

# test_registry.py
import importlib.util
import re
from pathlib import Path

ROOT = Path(__file__).parent
PLUGINS = ROOT / "plugins"
REGISTRY = (ROOT / "REGISTRY.md").read_text()


def plugin_files():
    return sorted(PLUGINS.glob("type_*.py"))


def load(path: Path):
    spec = importlib.util.spec_from_file_location(path.stem, path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def test_every_type_is_fully_wired():
    for path in plugin_files():
        name = path.stem.removeprefix("type_")
        mod = load(path)
        assert callable(getattr(mod, "render", None)), f"{name}: no render()"
        assert hasattr(mod, "STYLE"), f"{name}: no STYLE block"
        assert re.search(rf"`plugins/type_{re.escape(name)}\.py`", REGISTRY), \
            f"{name}: no REGISTRY.md entry"
    print("test_every_type_is_fully_wired: ok")


def test_no_two_types_share_a_style():
    seen: dict[tuple, str] = {}
    for path in plugin_files():
        name = path.stem
        style_key = tuple(sorted(load(path).STYLE.items()))
        if style_key in seen:
            raise AssertionError(
                f"{name} and {seen[style_key]} share an identical STYLE; did one fork the other?"
            )
        seen[style_key] = name
    print("test_no_two_types_share_a_style: ok")


if __name__ == "__main__":
    test_every_type_is_fully_wired()
    test_no_two_types_share_a_style()

The first check fails the moment a type has a template or a doc entry but not both. The second is a cheap copy-paste detector, not a real structural comparison: it catches a type that was forked from another and barely touched, by noticing they claim an identical style. It won’t catch two types that happen to render the same shape under different names; that call still needs a human.

Use it: watch the test catch what you missed

With type_ramp.py, type_flow.py, and their registry lines in place:

python3 test_registry.py
test_every_type_is_fully_wired: ok
test_no_two_types_share_a_style: ok

Now add a third type the way you actually would under deadline: write the file, get it rendering, and forget the doc line.

# plugins/type_terminal.py
"""terminal type: a single ending state, styled distinctly from a step."""

STYLE = {"accent": "red", "shape": "stop"}


def render(label: str) -> str:
    return f"[[{label}]]"
python3 test_registry.py

It raises AssertionError, and the traceback ends on the line that names the actual problem:

AssertionError: terminal: no REGISTRY.md entry

That’s the failure you want: caught before anyone else discovers the type by accident, not after. Add the line to REGISTRY.md and it passes again. Now fork type_terminal.py into type_stop.py without changing its STYLE, the way a type gets duplicated under time pressure:

python3 test_registry.py

The first check still passes (both types are fully wired), then the second one raises on the traceback’s last line:

test_every_type_is_fully_wired: ok
AssertionError: type_terminal and type_stop share an identical STYLE; did one fork the other?

The second test only fires once the first one is clean, which is deliberate: a half-wired type isn’t ready to be checked for duplication yet.

Gotchas

Watch for a template that renders fine locally but ships with no doc entry. Nothing about running the type is broken, so nothing surfaces the gap until whatever picks a type (a person, or a model reading the doc) simply never finds it. The wiring test is what turns that into a failure at commit time instead of a silent gap in production.

Watch for reaching to unify two types the first time they look similar. The instinct to collapse “we have two of these now” into one shared component with a flag is exactly the trap Sandi Metz named: duplication is cheaper than the wrong abstraction, because the unified version keeps growing a parameter and a conditional for every case that doesn’t quite fit, until nobody can change it safely without breaking another case (The Wrong Abstraction). The rule of three gives a concrete threshold instead of a feeling: let a shape recur about three times before you treat it as a real pattern worth extracting (Rule of three), and resist it on the first or second sighting even when the code looks almost identical (AHA Programming).

Watch for trusting the duplicate-style check as proof rather than a smell. It flags an exact match on the style dict, so a fork that changes one cosmetic value slips past it entirely. It’s a tripwire for the laziest kind of duplication, not a guarantee that no two types overlap.

Sources

Changelog

  • ghostwriter 0.5.0: matrix card type (comparison grid) (03b5c38)