Ctrl K
ComunidadGuías…

tests/test_install_hook_identity.py

Rama v8 · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Hook install/uninstall must remove only graphify's own hook entries (#4359).

Install and uninstall used to identify graphify's hooks with
``"graphify" in str(group)`` over the whole group dict. That removed:

- a user's own command sharing graphify's ``hooks`` array, along with the group;
- an unrelated group whose command path merely contained the word ``graphify``
  (e.g. a script under a folder named ``graphify-notes``);
- and, on uninstall, wrote the settings file back with no backup while
  ``_strip_graphify_hook`` raised ``AttributeError`` on a non-dict entry.

These tests pin the entry-scoped contract: only entries graphify installed go,
a group is dropped only when nothing is left in it, other tools' commands
survive, and uninstall keeps a rolling backup.
"""
from __future__ import annotations

import json

import pytest

from graphify import install as install_mod

# id -> (install, uninstall, settings file relative to project, section key)
_INSTALLERS = {
    "claude": (
        lambda project: install_mod._install_claude_hook(project),
        lambda project: install_mod._uninstall_claude_hook(project),
        ".claude/settings.json",
        "PreToolUse",
    ),
    "codebuddy": (
        lambda project: install_mod._install_codebuddy_hook(project),
        lambda project: install_mod._uninstall_codebuddy_hook(project),
        ".codebuddy/settings.json",
        "PreToolUse",
    ),
    "codex": (
        lambda project: install_mod._install_codex_hook(project),
        lambda project: install_mod._uninstall_codex_hook(project),
        ".codex/hooks.json",
        "PreToolUse",
    ),
    "gemini": (
        lambda project: install_mod._install_gemini_hook(project),
        lambda project: install_mod._uninstall_gemini_hook(project),
        ".gemini/settings.json",
        "BeforeTool",
    ),
}


def _groups(settings: dict, section: str) -> list:
    return settings["hooks"][section]


def _has_graphify(entry: object) -> bool:
    return install_mod._is_graphify_hook_entry(entry)


@pytest.mark.parametrize("name", sorted(_INSTALLERS))
def test_uninstall_keeps_user_commands_and_unrelated_groups(tmp_path, name):
    install, uninstall, rel, section = _INSTALLERS[name]
    project = tmp_path / "proj"
    project.mkdir()

    install(project)
    settings_path = project / rel
    settings = json.loads(settings_path.read_text(encoding="utf-8"))
    groups = _groups(settings, section)
    assert any(
        _has_graphify(e) for g in groups if isinstance(g, dict) for e in g.get("hooks", [])
    ), f"{name} install registered no graphify hook"

    # 1) the user adds their own command to graphify's own group;
    # 2) an unrelated guard group whose command path contains "graphify".
    for group in groups:
        if isinstance(group, dict) and any(_has_graphify(e) for e in group.get("hooks", [])):
            group["hooks"].append({"type": "command", "command": "python /tools/my-linter.py"})
            break
    groups.append(
        {
            "matcher": "Bash",
            "hooks": [{"type": "command", "command": "python /tools/graphify-notes/check.py"}],
        }
    )
    settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")

    uninstall(project)

    after = settings_path.read_text(encoding="utf-8")
    assert "my-linter" in after, f"{name}: the user's command in graphify's group was removed"
    assert "graphify-notes" in after, f"{name}: the unrelated group was removed"
    assert "hook-guard" not in after and "hook-check" not in after, (
        f"{name}: graphify's own hook commands were not removed"
    )
    backup = settings_path.with_name(settings_path.name + ".graphify-bak")
    assert backup.exists(), f"{name}: uninstall wrote no backup"


@pytest.mark.parametrize("name", sorted(_INSTALLERS))
def test_uninstall_survives_a_non_dict_entry(tmp_path, name):
    """#4359: a plain string in the hook array must not crash uninstall, and must
    be preserved (it is not graphify's)."""
    install, uninstall, rel, section = _INSTALLERS[name]
    project = tmp_path / "proj"
    project.mkdir()

    install(project)
    settings_path = project / rel
    settings = json.loads(settings_path.read_text(encoding="utf-8"))
    _groups(settings, section).append("a-legacy-string-entry")
    settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")

    uninstall(project)  # must not raise

    after = json.loads(settings_path.read_text(encoding="utf-8"))
    assert "a-legacy-string-entry" in _groups(after, section)


def test_install_removes_legacy_bash_hook_and_stays_idempotent(tmp_path):
    """The pre-#522 inline bash guard is still replaced, without duplicating
    graphify's groups on a re-run."""
    project = tmp_path / "proj"
    settings_path = project / ".claude" / "settings.json"
    settings_path.parent.mkdir(parents=True)
    settings_path.write_text(
        json.dumps(
            {
                "hooks": {
                    "PreToolUse": [
                        {
                            "matcher": "Bash",
                            "hooks": [
                                {
                                    "type": "command",
                                    "command": "[ -f graphify-out/graph.json ] && echo x || true",
                                }
                            ],
                        }
                    ]
                }
            }
        ),
        encoding="utf-8",
    )

    install_mod._install_claude_hook(project)
    install_mod._install_claude_hook(project)

    groups = json.loads(settings_path.read_text(encoding="utf-8"))["hooks"]["PreToolUse"]
    assert "graphify-out/graph.json" not in json.dumps(groups), "legacy bash guard survived"
    assert len([g for g in groups if "graphify" in json.dumps(g)]) == 2, "not idempotent"


@pytest.mark.parametrize(
    "command",
    [
        "graphify hook-guard search",
        '"C:/tools/graphify.EXE" hook-guard read --strict',
        "graphify hook-guard gemini",
        "graphify hook-check",
        "[ -f graphify-out/graph.json ] && echo x || true",
    ],
)
def test_graphify_commands_are_recognised(command):
    assert install_mod._is_graphify_hook_command(command)


@pytest.mark.parametrize(
    "command",
    [
        "python /tools/graphify-notes/check.py",
        "python /tools/check.py",
        "eslint .",
        "",
    ],
)
def test_other_commands_are_not_recognised(command):
    assert not install_mod._is_graphify_hook_command(command)


def test_strip_drops_only_emptied_groups_and_passes_malformed_through():
    groups = [
        {
            "matcher": "Bash|Grep",
            "hooks": [
                {"type": "command", "command": "graphify hook-guard search"},
                {"type": "command", "command": "my-own-command"},
            ],
        },
        {"matcher": "Read|Glob", "hooks": [{"type": "command", "command": "graphify hook-guard read"}]},
        "not-a-dict",
        {"matcher": "Bash", "hooks": "not-a-list"},
    ]

    out = install_mod._strip_graphify_hooks(groups)

    assert out == [
        {"matcher": "Bash|Grep", "hooks": [{"type": "command", "command": "my-own-command"}]},
        "not-a-dict",
        {"matcher": "Bash", "hooks": "not-a-list"},
    ]