From 83c2f4245cc730c748dd76b10efa7953dd800a08 Mon Sep 17 00:00:00 2001 From: Safi Date: Mon, 6 Apr 2026 01:23:34 +0100 Subject: [PATCH] fix obsidian always generating, uninstall early return bug, add missing tests --- graphify/__main__.py | 3 +- graphify/skill.md | 28 +++++-------- tests/test_claude_md.py | 39 ++++++++++++++++++ tests/test_hooks.py | 34 +++++++++++++++- tests/test_rationale.py | 89 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 20 deletions(-) create mode 100644 tests/test_rationale.py diff --git a/graphify/__main__.py b/graphify/__main__.py index ba1dfeec..fbd4ec93 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -171,12 +171,11 @@ def claude_uninstall(project_dir: Path | None = None) -> None: ).rstrip() if cleaned: target.write_text(cleaned + "\n") + print(f"graphify section removed from {target.resolve()}") else: target.unlink() print(f"CLAUDE.md was empty after removal - deleted {target.resolve()}") - return - print(f"graphify section removed from {target.resolve()}") _uninstall_claude_hook(project_dir or Path(".")) diff --git a/graphify/skill.md b/graphify/skill.md index 60e5ee99..2b1331d7 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -411,9 +411,11 @@ print('Report updated with community labels') Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`). Replace INPUT_PATH with the actual path. -### Step 6 - Generate Obsidian vault (default) + optional HTML +### Step 6 - Generate Obsidian vault (opt-in) + HTML -**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was given** — it generates one file per node which creates thousands of files in large repos. Skip it by default. +**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node. + +If `--obsidian` was given: ```bash python3 -c " @@ -444,7 +446,7 @@ print(' _COMMUNITY_* - overview notes with cohesion scores and dataview queries " ``` -Also generate the HTML graph (always, unless `--no-viz`): +Generate the HTML graph (always, unless `--no-viz`): ```bash python3 -c " @@ -631,22 +633,14 @@ rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_ rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Tell the user: +Tell the user (omit the obsidian line unless --obsidian was given): ``` -Graph complete. Outputs are in a hidden folder called graphify-out/ inside the directory you ran this on. +Graph complete. Outputs in PATH_TO_DIR/graphify-out/ -The folder is hidden (dot prefix) so it won't show in Finder or a normal ls. -To see it: - Mac/Linux: ls -la graphify-out/ - VS Code: the Explorer panel shows hidden files by default - Finder: Cmd+Shift+. to toggle hidden files - -What's inside: - graphify-out/obsidian/ - open this folder as a vault in Obsidian (File > Open Vault) - graphify-out/GRAPH_REPORT.md - full audit report, also readable here in Claude - graphify-out/graph.json - persistent graph, query it later with /graphify query "..." - -Full path: PATH_TO_DIR/graphify-out/ + graph.html - interactive graph, open in browser + GRAPH_REPORT.md - audit report + graph.json - raw graph data + obsidian/ - Obsidian vault (only if --obsidian was given) ``` Replace PATH_TO_DIR with the actual absolute path of the directory that was processed. diff --git a/tests/test_claude_md.py b/tests/test_claude_md.py index 8ec6d3d8..d7d6c968 100644 --- a/tests/test_claude_md.py +++ b/tests/test_claude_md.py @@ -95,3 +95,42 @@ def test_uninstall_no_op_when_no_file(tmp_path, capsys): claude_uninstall(tmp_path) out = capsys.readouterr().out assert "No CLAUDE.md" in out or "nothing to do" in out + + +# --------------------------------------------------------------------------- +# settings.json PreToolUse hook +# --------------------------------------------------------------------------- + +def test_install_creates_settings_json(tmp_path): + """claude_install also writes .claude/settings.json with PreToolUse hook.""" + import json + claude_install(tmp_path) + settings_path = tmp_path / ".claude" / "settings.json" + assert settings_path.exists() + settings = json.loads(settings_path.read_text()) + hooks = settings.get("hooks", {}).get("PreToolUse", []) + assert any("Glob|Grep" in h.get("matcher", "") for h in hooks) + + +def test_install_settings_json_idempotent(tmp_path): + """Running claude_install twice does not duplicate the PreToolUse hook.""" + import json + claude_install(tmp_path) + claude_install(tmp_path) + settings_path = tmp_path / ".claude" / "settings.json" + settings = json.loads(settings_path.read_text()) + hooks = settings.get("hooks", {}).get("PreToolUse", []) + glob_grep_hooks = [h for h in hooks if "Glob|Grep" in h.get("matcher", "")] + assert len(glob_grep_hooks) == 1 + + +def test_uninstall_removes_settings_hook(tmp_path): + """claude_uninstall removes the PreToolUse hook from settings.json.""" + import json + claude_install(tmp_path) + claude_uninstall(tmp_path) + settings_path = tmp_path / ".claude" / "settings.json" + if settings_path.exists(): + settings = json.loads(settings_path.read_text()) + hooks = settings.get("hooks", {}).get("PreToolUse", []) + assert not any("Glob|Grep" in h.get("matcher", "") for h in hooks) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 07b5c0a1..0bf59b62 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -2,7 +2,7 @@ import subprocess from pathlib import Path import pytest -from graphify.hooks import install, uninstall, status, _HOOK_MARKER +from graphify.hooks import install, uninstall, status, _HOOK_MARKER, _CHECKOUT_MARKER def _make_git_repo(tmp_path: Path) -> Path: @@ -78,3 +78,35 @@ def test_status_not_installed(tmp_path): def test_no_git_repo_raises(tmp_path): with pytest.raises(RuntimeError, match="No git repository"): install(tmp_path / "not_a_repo") + + +def test_install_creates_post_checkout_hook(tmp_path): + repo = _make_git_repo(tmp_path) + install(repo) + hook = repo / ".git" / "hooks" / "post-checkout" + assert hook.exists() + assert _CHECKOUT_MARKER in hook.read_text() + + +def test_install_post_checkout_is_executable(tmp_path): + repo = _make_git_repo(tmp_path) + install(repo) + hook = repo / ".git" / "hooks" / "post-checkout" + assert hook.stat().st_mode & 0o111 + + +def test_uninstall_removes_post_checkout_hook(tmp_path): + repo = _make_git_repo(tmp_path) + install(repo) + uninstall(repo) + hook = repo / ".git" / "hooks" / "post-checkout" + assert not hook.exists() + + +def test_status_shows_both_hooks(tmp_path): + repo = _make_git_repo(tmp_path) + install(repo) + result = status(repo) + assert "post-commit" in result + assert "post-checkout" in result + assert result.count("installed") >= 2 diff --git a/tests/test_rationale.py b/tests/test_rationale.py new file mode 100644 index 00000000..010493e3 --- /dev/null +++ b/tests/test_rationale.py @@ -0,0 +1,89 @@ +"""Tests for rationale/docstring extraction in extract.py.""" +import textwrap +from pathlib import Path +import pytest +from graphify.extract import extract_python + + +def _write_py(tmp_path: Path, code: str) -> Path: + p = tmp_path / "sample.py" + p.write_text(textwrap.dedent(code)) + return p + + +def test_module_docstring_extracted(tmp_path): + path = _write_py(tmp_path, ''' + """This module handles authentication because legacy sessions were insecure.""" + def login(): pass + ''') + result = extract_python(path) + rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"] + assert len(rationale) >= 1 + assert any("authentication" in n["label"] for n in rationale) + + +def test_function_docstring_extracted(tmp_path): + path = _write_py(tmp_path, ''' + def process(): + """We use chunked processing here because the full dataset exceeds RAM.""" + pass + ''') + result = extract_python(path) + rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"] + assert any("chunked" in n["label"] for n in rationale) + + +def test_class_docstring_extracted(tmp_path): + path = _write_py(tmp_path, ''' + class Cache: + """Chosen over Redis because we need zero external dependencies in the test env.""" + pass + ''') + result = extract_python(path) + rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"] + assert any("Redis" in n["label"] for n in rationale) + + +def test_rationale_comment_extracted(tmp_path): + path = _write_py(tmp_path, ''' + def build(): + # NOTE: must run before compile() or linker will fail + pass + ''') + result = extract_python(path) + rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"] + assert any("NOTE" in n["label"] for n in rationale) + + +def test_rationale_for_edges_present(tmp_path): + path = _write_py(tmp_path, ''' + """Module docstring explaining the why.""" + def foo(): + """Function docstring with rationale.""" + pass + ''') + result = extract_python(path) + rationale_edges = [e for e in result["edges"] if e.get("relation") == "rationale_for"] + assert len(rationale_edges) >= 1 + + +def test_short_docstring_ignored(tmp_path): + """Trivial docstrings under 20 chars should not become rationale nodes.""" + path = _write_py(tmp_path, ''' + def foo(): + """Constructor.""" + pass + ''') + result = extract_python(path) + rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"] + assert len(rationale) == 0 + + +def test_rationale_confidence_is_extracted(tmp_path): + path = _write_py(tmp_path, ''' + """This module exists because we needed a standalone parser.""" + def parse(): pass + ''') + result = extract_python(path) + rationale_edges = [e for e in result["edges"] if e.get("relation") == "rationale_for"] + assert all(e.get("confidence") == "EXTRACTED" for e in rationale_edges)