Skip to content

Attempt log — S2-01 — PluginRegistry kernel + Plugin/Adapter/RecipeEngine Protocols

Attempt 1 — 2026-05-18 — GREEN (phase-story-executor)

Outcome: GREEN. All 11 acceptance criteria satisfied by 16 named tests (15 unit + 3 fence — overlap on the Plugin surface freeze). Full make check clean (4236 passed, 33 skipped, 2 xfailed; ruff, lint-imports 4-contracts-kept, mypy --strict on 149 source files, fence 89 passed).

Per-AC evidence

AC Evidence
AC-1 src/codegenie/plugins/protocols.py — three @runtime_checkable Protocols; Plugin has exactly 4 public members (fence verifies); Adapter ships primitive only; RecipeEngine ships kind + applies + apply. Surface annotations stay strings via from __future__ import annotations.
AC-2 tests/unit/plugins/test_registry.py::test_register_method_returns_plugin_unchanged (register(plugin) is plugin); ::test_all_returns_tuple_not_list (tuple, not list); ::test_get_returns_registered_plugin (round-trip); ::test_resolve_stub_names_s2_04 (NotImplementedError with "S2-04" substring).
AC-3 ::test_register_plugin_returns_plugin_unchangedregister_plugin(p, registry=r) is p identity assertion.
AC-4 ::test_collision_raises asserts exc.name == PluginId(...) + both _FakePlugin qualnames in message (count == 2). ::test_get_unknown_raises_plugin_not_registered_with_typed_name asserts .name == PluginId(...). src/codegenie/plugins/errors.py ships PluginExtendsCycle + PluginRejected placeholders for the S2-03/S2-04 raise sites.
AC-5 ::test_all_returns_registration_order — three-plugin identity-tuple (zeta, alpha, mu) with names whose alphabetic ≠ insertion order. Catches return (), set(...), sorted(...) mutants.
AC-6 ::test_fresh_registries_are_isolated — both positive control (reg_a.all() == (p,)) AND negative control (reg_b.all() == ()). ::test_register_plugin_into_explicit_registry_does_not_pollute_default is the function-scoped pollution pin. Session-scoped autouse _default_registry_session_guard re-asserts byte-identical equality at teardown (tests/unit/plugins/conftest.py).
AC-7 tests/fence/test_plugin_protocol_frozen.py — three assertions: exact-set equality on dir(Plugin) ∪ __annotations__ minus dunders; "manifest" in Plugin.__annotations__; inspect.isfunction on the three methods. Explicitly does NOT use __abstractmethods__ (the validator-named landmine — Protocol doesn't populate it like ABCs do, hit during the red→green transition on first run).
AC-8 ::test_runtime_checkable_protocols_match_fakes — both isinstance(fake, Plugin) is True AND isinstance(object(), Plugin) is False. Catches a trivially-passing always-True mutant.
AC-9 src/codegenie/plugins/resolution.py ships class PluginResolution: ... placeholder; module docstring names S2-04 as the expander.
AC-10 ::test_register_plugin_default_singleton_pathregister_plugin(p) without registry= kwarg; asserts p in default_registry.all(); cleanup via autouse restore_default_registry.
AC-11 make check clean — ruff check src/codegenie/plugins/, ruff format --check, mypy --strict src/codegenie/ (149 source files), tests/fence/ (89 passed).

Gate log

Gate Status Detail
ruff check ✅ pass All checks passed!
ruff format --check ✅ pass 532 files already formatted
mypy --strict src/ ✅ pass Success: no issues found in 149 source files
pytest (unit + adv) ✅ pass 4236 passed, 33 skipped, 3 deselected, 2 xfailed
pytest tests/fence/ ✅ pass 89 passed in 0.64s — includes new test_plugin_protocol_frozen.py (3 tests)
make fence ✅ pass
make lint-imports ✅ pass Contracts: 4 kept, 0 broken. (kernel-frozen + LLM-fence rules untouched)

Refactor decisions

  • Module-docstring rule-of-three observation lifted verbatim from depgraph/registry.py:30-38 (Phase-3 Design-Patterns critic F1). Names all four registries; pins the extract trigger ("N=5 OR a new registry needs only the common surface"); cites this story as the audit anchor. Kernel-extract still deferred — Rule 2 (resolve-machinery will dominate LOC once S2-04 ships).
  • Final on default_registry is intentionally tighter than probes/registry.py:238's loose binding; ADR-0002 §Consequences row 2 names the posture. Replacement via DI through register_plugin(..., registry=...) is the only intended path.
  • Origin-string sidecar (_origins: dict[PluginId, str]) mirrors indices/registry.py:101-105 so the collision message can name both call sites without re-introspecting a possibly-mutated existing plugin.
  • register_plugin is a function call, not a class decorator (Notes §3) — module docstring carries the rationale (plugins are instances with composed state, not classes). Three sibling registries use decorators because they register classes; this one registers instances.
  • NO __slots__ on PluginRegistry — precedent probes/registry.py doesn't use it; profile-driven only.
  • NO unregister_for_tests helper — the conftest fixture restores the dict in-place. indices/registry.py:198-208 ships unregister_for_tests because production code registers into its singleton in Phase 2; S2-01's production registration doesn't land until S2-03's loader, so the helper is YAGNI.

Anti-pattern smells walked (none introduced)

  • No Any in src/codegenie/plugins/ (the test_no_any_in_plugin_surface.py fence verifies).
  • No LLM-SDK imports (the test_no_llm_in_transforms.py fence + the codegenie.plugins must not import LLM SDKs import-linter contract verify).
  • No raw str for PluginId in production code; the single boundary lift lives in the test fixture (tests/fixtures/plugins/fake_plugin.py).
  • No module-level mutable singletons — default_registry is a Final instance, not a dict (ADR-0002 §Decision §Option C).
  • No side effects in constructors / at module import — PluginRegistry.__init__ is two assignments; module top-level only constructs the one default_registry instance.
  • No premature kernel-extract across the four registries (Rule 2; the observation IS the deferral — documented in the module docstring).

Lessons learned

  1. @runtime_checkable Protocols don't populate __abstractmethods__. The story's validation explicitly called this out (Test-Quality F10), and the first attempt's fence test would have tripped on it had we followed the validator's call to "use dir(Plugin) - dunders + __annotations__" naively. The actual landmine: dir(Plugin) ALSO omits attribute-only annotations (manifest) on every Python version I tested. The fence test needs the union of dir(Plugin) and __annotations__.keys() to assert the four-member surface.
  2. Module-docstring narrative strings are grep-able. Phase 2's test_zero_strategies_registered_in_phase2 does a literal substring search for @register_dep_graph_strategy across src/codegenie/. Referencing the decorator name with the @ prefix in narrative prose (even inside a multi-line docstring) trips it. Two safe avoidances: drop the @ prefix (the narrative meaning survives), or use a sufficiently distinct phrasing. Took the first path; relevant for any future module docstring that names sibling registries' decorators.
  3. Forward-ref Protocol attributes need a concrete shape under mypy --strict. class PluginManifest: ... with no fields tripped [attr-defined] on plugin.manifest.name. Solution: stub the field on the forward-ref class (name: PluginId); S2-02 replaces the stub with the full Pydantic model. The pattern is local to TYPE_CHECKING-only blocks — runtime is unaffected.