diff --git a/changelog.d/738.added.md b/changelog.d/738.added.md new file mode 100644 index 00000000..f17c5278 --- /dev/null +++ b/changelog.d/738.added.md @@ -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) diff --git a/src/doberman/cli/doctor.py b/src/doberman/cli/doctor.py index ce5e3c12..577026c4 100644 --- a/src/doberman/cli/doctor.py +++ b/src/doberman/cli/doctor.py @@ -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 @@ -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), ] diff --git a/src/doberman/cli/main.py b/src/doberman/cli/main.py index 848d434f..703494f6 100644 --- a/src/doberman/cli/main.py +++ b/src/doberman/cli/main.py @@ -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") diff --git a/tests/unit/test_cli_doctor.py b/tests/unit/test_cli_doctor.py index a428ec4e..48d3ace7 100644 --- a/tests/unit/test_cli_doctor.py +++ b/tests/unit/test_cli_doctor.py @@ -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