test(plugins): pin the plugin_context contract before carving server.py (R3b) (#898)

tests/test_plugin_context_contract.py (3 tests). No production code changes.

server.py is about to be carved apart around startup_events(), and `plugin_context` — the
20-key dict handed to every plugin's setup() — is built inline inside it. Issue #48 flagged
this while planning the split and asked for exactly this guard:

    "Plugin context[...] are passed as live references into already-loaded plugins.
     Refactoring must preserve the exact callables — moving them to a new module is fine,
     but renaming or wrapping them breaks third-party plugins. We'd want a
     'plugin context unchanged' assertion in CI."

It never got written. Writing it FIRST, because a key silently dropped or renamed by a move
is invisible to every other test in the suite — nothing in-tree reads most of these — and
would break plugins at runtime, in the field.

This is the backend's version of the window contract, and the frontend carve just taught me
what that costs: 43 of library.js's exports were referenced ONLY from app.js's top-level
window block, invisible to any call-graph scan, and trusting the scan would have shipped a
dead A-Z rail with CI fully green. A contract only external code reads has to be pinned BY
NAME, before the move, not after.

THE SURFACE IS BIGGER THAN server.py's DICT. Shipped plugins read `log` and `load_sibling`,
and neither is in it — plugins/__init__.py layers them on per-plugin. A test pinning only
server.py's 18 keys would have missed both.

━━━ CODEX CAUGHT ME WRITING A VACUOUS ASSERTION ━━━

My first identity test built a dict locally and called setup() on it — asserting
`dict(x)['k'] is x['k']`, which is trivially true and blind to everything the loader does.
[P2], and correct. It now drives the REAL plugins.load_plugins() with a probe plugin, which
matters: the loader DOES deliberately wrap one key (register_library_provider is scoped
per-plugin so a plugin cannot forge owner attribution and impersonate another). The test
pins that single intentional exception so it cannot quietly become two.

Codex then caught [P2] number two: my hand-rolled teardown restored only PLUGINS_DIR and
LOADED_PLUGINS, while load_plugins() also mutates sys.path, sys.modules and
PENDING_PLUGINS — order- and environment-dependent. tests/test_plugins.py already had a
fixture that does this properly, so `reset_plugin_state` moved to tests/conftest.py: ONE
copy, shared, rather than a second that will drift.

BITE-TESTED IN FIVE DIRECTIONS — drop a key, rename a key, drop a per-plugin key, wrap
extract_meta in the loader (all key names intact, identity broken), and remove the
register_library_provider scoping (the impersonation guard). Each fails.

pytest 2399, Codex 0.

Refs #48

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-12 00:29:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 756588678b
commit 1cef01d02c
3 changed files with 265 additions and 50 deletions
-50
View File
@@ -46,56 +46,6 @@ def capture_logger(caplog, logger_name, level=logging.WARNING):
logger.propagate = orig_propagate
# Bare module names that this test module pre-populates into
# sys.modules to simulate the bare-import path. Saved/restored by
# the reset_plugin_state fixture so they don't leak to other test
# files. Codex / Copilot review on PR for feedBack#33.
_BARE_NAMES_USED = ("util", "extractor")
@pytest.fixture()
def reset_plugin_state(monkeypatch):
"""Clear loader module-level state and restore on teardown.
Saves and restores:
* `plugins.LOADED_PLUGINS`
* any `plugin_*` keys we add to `sys.modules`
* the bare names this module simulates (`util`, `extractor`)
* `sys.path` — `plugins.load_plugins()` mutates it
Also unsets `FEEDBACK_PLUGINS_DIR` for the test's duration
(via monkeypatch) so a CI env that pre-sets it can't leak
real user plugins into a tmp_path-driven test. Per-module
locks are owned by the standard import system
(`importlib._bootstrap._module_locks`) and are not our
responsibility to reset.
"""
monkeypatch.delenv("FEEDBACK_PLUGINS_DIR", raising=False)
plugins = importlib.import_module("plugins")
saved_loaded = list(plugins.LOADED_PLUGINS)
saved_pending = dict(plugins.PENDING_PLUGINS)
saved_modules = {k: v for k, v in sys.modules.items() if k.startswith("plugin_")}
saved_bare = {k: sys.modules[k] for k in _BARE_NAMES_USED if k in sys.modules}
saved_path = list(sys.path)
plugins.LOADED_PLUGINS.clear()
plugins.PENDING_PLUGINS.clear()
for k in list(sys.modules):
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
del sys.modules[k]
try:
yield plugins
finally:
plugins.LOADED_PLUGINS.clear()
plugins.LOADED_PLUGINS.extend(saved_loaded)
plugins.PENDING_PLUGINS.clear()
plugins.PENDING_PLUGINS.update(saved_pending)
for k in list(sys.modules):
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
del sys.modules[k]
sys.modules.update(saved_modules)
sys.modules.update(saved_bare)
sys.path[:] = saved_path
def _make_plugin(plugin_root, plugin_id, *, sibling_files=None, routes_body=None):
"""Create a minimal plugin directory under `plugin_root`.