Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/738.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `doberman doctor` warns about an enabled plugin no installed package provides, and `plugins enable` says so too; neither loads plugin code (#738)
32 changes: 32 additions & 0 deletions src/doberman/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,37 @@ def _check_tui_extra() -> CheckResult:
return _check_optional_extra("TUI extra", "textual", "tui")


def installed_plugin_names() -> set[str]:
"""Every entry-point name installed under a Doberman group, without loading any.

Names only, through the unfiltered :func:`~doberman.engine.registry._iter_entry_points`:
diagnosing an enabled-but-missing plugin must never import plugin code.
"""
from doberman.engine.registry import ALL_GROUPS, _iter_entry_points

return {
name
for group in ALL_GROUPS
for entry_point in _iter_entry_points(group)
if isinstance(name := getattr(entry_point, "name", None), str)
}


def _check_plugins() -> CheckResult:
from doberman.engine import plugin_config

enabled = plugin_config.enabled_plugins()
if not enabled:
return CheckResult("Plugins", CheckStatus.OK, "none enabled")
installed = installed_plugin_names()
missing = [name for name in enabled if name not in installed]
if missing:
return CheckResult(
"Plugins", CheckStatus.WARN, f"enabled but not installed: {', '.join(missing)}"
)
return CheckResult("Plugins", CheckStatus.OK, f"{len(enabled)} enabled, all installed")


def _check_config(path: str) -> CheckResult:
from doberman.config import CONFIG_DIR, POLICY_FILE, load_policy

Expand Down Expand Up @@ -598,6 +629,7 @@ def run_checks(path: str = ".") -> list[CheckResult]:
_safe_check("Codex CLI", False, _check_codex_version),
_safe_check("Dash extra", False, _check_dash_extra),
_safe_check("TUI extra", False, _check_tui_extra),
_safe_check("Plugins", False, _check_plugins),
]


Expand Down
8 changes: 8 additions & 0 deletions src/doberman/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1997,6 +1997,14 @@ def plugins_enable(
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
typer.echo(f"Enabled {name!r}. Enabled plugins: {', '.join(names)}")
from doberman.cli.doctor import installed_plugin_names

if name not in installed_plugin_names():
typer.echo(
f"warning: no installed package provides a plugin named {name!r}; "
"it stays enabled but nothing loads until one is installed",
err=True,
)


@plugins_app.command("disable")
Expand Down
84 changes: 84 additions & 0 deletions tests/unit/test_cli_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -788,3 +788,87 @@ def test_doctor_hook_timeout_ok_when_strictly_above_the_ceiling(tmp_path):
_write_claude_hooks(root, timeout=660)
r = _hook_timeout(run_checks(root))
assert r.status is CheckStatus.OK


# ---------------------------------------------------------------------------
# Plugins (#719): an enabled name that nothing installed provides
# ---------------------------------------------------------------------------


@pytest.fixture
def plugins_file(tmp_path, monkeypatch):
from doberman.engine import plugin_config

monkeypatch.setenv(plugin_config.PLUGINS_FILE_ENV, str(tmp_path / "plugins.json"))
return plugin_config


class _FakeEntryPoint:
def __init__(self, name):
self.name = name

def load(self):
raise AssertionError("doctor must never load a plugin to diagnose it")


def _plugins_row(path):
return next(r for r in run_checks(str(path)) if r.name == "Plugins")


def test_doctor_plugins_row_passes_with_nothing_enabled(tmp_path, plugins_file):
row = _plugins_row(tmp_path)
assert row.status is CheckStatus.OK
assert row.critical is False


def test_doctor_warns_when_an_enabled_plugin_is_not_installed(tmp_path, plugins_file):
plugins_file.enable("no_such_plugin")

row = _plugins_row(tmp_path)

assert row.status is CheckStatus.WARN
assert row.detail == "enabled but not installed: no_such_plugin"
assert row.critical is False

result = runner.invoke(app, ["doctor", "--path", str(tmp_path)])
assert "[warn] Plugins: enabled but not installed: no_such_plugin" in result.output


def test_doctor_plugins_row_never_loads_an_installed_plugin(tmp_path, plugins_file, monkeypatch):
from doberman.engine import registry

monkeypatch.setattr(
registry,
"_iter_entry_points",
lambda group: iter([_FakeEntryPoint("real_rule")] if group == registry.RULE_GROUP else []),
)
plugins_file.enable("real_rule")
plugins_file.enable("missing_rule")

row = _plugins_row(tmp_path)

assert row.status is CheckStatus.WARN
assert row.detail == "enabled but not installed: missing_rule"


def test_plugins_enable_warns_when_nothing_installed_provides_the_name(plugins_file):
result = runner.invoke(app, ["plugins", "enable", "no_such_plugin"])

assert result.exit_code == 0, result.output
assert plugins_file.enabled_plugins() == ["no_such_plugin"]
assert "warning: no installed package provides a plugin named 'no_such_plugin'" in result.output


def test_plugins_enable_is_quiet_for_an_installed_name(plugins_file, monkeypatch):
from doberman.engine import registry

monkeypatch.setattr(
registry,
"_iter_entry_points",
lambda group: iter([_FakeEntryPoint("real_rule")] if group == registry.RULE_GROUP else []),
)

result = runner.invoke(app, ["plugins", "enable", "real_rule"])

assert result.exit_code == 0, result.output
assert "warning:" not in result.output
Loading