Files
ar9av__obsidian-wiki/tests/test_doctor.py
Mike Tsai fd30720223 feat: improve project ingest with optional code-graph-assisted understanding (#167) (#169)
* test: add code-understanding fixtures (fake codegraph CLI, repo builder)

* feat: add code_understanding module with builtin + codegraph providers

Provider abstraction behind code_understand(): builtin uses the regex
ast_extractor plus rg cross-file references (dependency-free fallback);
codegraph shells out to the codegraph CLI with defensive JSON parsing.
Focus-map output is ranked symbols with file:line citations, capped by
max_symbols, and never contains source bodies.

* feat(cli): wire code-understand command and doctor code-understanding checks

New 'obsidian-wiki code-understand --project' subcommand emits the focus
map JSON; doctor --project gains 5 code-understanding capability checks
(builtin, rg, codegraph, codegraph-index, codegraph-fresh). Missing
optional CodeGraph reports warn (exit 0); explicit CODE_UNDERSTANDING_
BACKEND=codegraph with a broken backend fails (exit 1).

* feat(skills): add optional code-understanding focus-map step to wiki-update

Step 3b runs 'obsidian-wiki code-understand --since <last_commit_synced>'
between deciding what to distill and distilling. GUARD-soft-fails like
wiki-ingest Step 1c; mandates file:line citations for architectural
claims and a pruning rule that removes relationships no longer
supported by the codebase. Registers CODE_UNDERSTANDING_BACKEND and
CODE_UNDERSTANDING_CODEGRAPH_BIN in the llm-wiki env reference.

* docs: document CODE_UNDERSTANDING_BACKEND and code-understand command

* docs: add CodeGraph install steps; teach wiki-update to offer install

configuration.md gains a Setup subsection under Code understanding
(npm install -g @colbymchenry/codegraph + verify + CODE_UNDERSTANDING_
CODEGRAPH_BIN fallback). wiki-update Step 3b gains an optional offer:
when codegraph is missing and the user wants the enhanced backend, the
agent may install it (with consent) and re-run the step.

* docs(readme): note code-graph-aware /wiki-update (EN + zh-TW parity)

* docs(readme): clarify zh-TW /wiki-update codegraph note

* fix(codegraph): cap impact queries at 25 seeds on full-project scans

Full scans (no --changed/--since) seed from every tracked file, so an
unbounded impact loop fired one subprocess per symbol — hundreds of
codegraph CLI calls on real repos (observed 120s+ timeout on a 4.3k-node
project). Cap impact at 25 seeds, matching the existing callers/callees
cap (seeds[:5]), keeping full scans bounded while --changed/--since
delta scans stay exhaustive for their small seed sets.

Regression test: test_codegraph_caps_impact_queries_on_full_scan pins the
25-call ceiling (RED: 30 calls -> GREEN: <= 25).

* feat(skills): add standalone code-understand skill

Exposes the code-graph-assisted focus map directly to any coding
agent session, not just as wiki-update Step 3b — so it can be invoked
on demand ("help me understand this codebase", "what breaks if I
change X") independent of a wiki sync.

---------

Co-authored-by: ar9av <ar9avg@gmail.com>
2026-08-15 12:48:11 -07:00

254 lines
8.1 KiB
Python

"""Tests for the doctor CLI command."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from obsidian_wiki.cli import list_skills
from conftest import build_index_state, make_fake_codegraph_bin, make_project
def _run(home: Path, *args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["HOME"] = str(home)
return subprocess.run(
[sys.executable, "-m", "obsidian_wiki.cli", *args],
capture_output=True,
text=True,
env=env,
)
def _run_env(
home: Path, env_overrides: dict[str, str], *args: str
) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["HOME"] = str(home)
env.update(env_overrides)
return subprocess.run(
[sys.executable, "-m", "obsidian_wiki.cli", *args],
capture_output=True,
text=True,
env=env,
)
def _write_config(home: Path, vault: Path, *, version: str | None = None) -> None:
config_dir = home / ".obsidian-wiki"
config_dir.mkdir(parents=True, exist_ok=True)
lines = [f'OBSIDIAN_VAULT_PATH="{vault}"']
if version is not None:
lines.append(f'OBSIDIAN_WIKI_VERSION="{version}"')
(config_dir / "config").write_text("\n".join(lines) + "\n", encoding="utf-8")
def _make_vault(vault: Path, *, manifest: str = '{"sources": {}}') -> None:
vault.mkdir(parents=True, exist_ok=True)
for name in ("index.md", "log.md", "hot.md"):
(vault / name).write_text(f"# {name}\n", encoding="utf-8")
(vault / ".manifest.json").write_text(manifest, encoding="utf-8")
def _install_all_skills(home: Path) -> None:
target = home / ".claude" / "skills"
target.mkdir(parents=True, exist_ok=True)
for name in list_skills():
skill_dir = target / name
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8")
def test_doctor_json_clean_install(tmp_path: Path) -> None:
home = tmp_path / "home"
vault = tmp_path / "vault"
_make_vault(vault)
_write_config(home, vault)
_install_all_skills(home)
proc = _run(home, "doctor", "--json")
assert proc.returncode == 0
data = json.loads(proc.stdout)
assert data["status"] == "pass"
assert any(check["name"] == "manifest-json" and check["status"] == "pass" for check in data["checks"])
def test_doctor_warns_without_agent_installs_but_exits_zero(tmp_path: Path) -> None:
home = tmp_path / "home"
vault = tmp_path / "vault"
_make_vault(vault)
_write_config(home, vault)
proc = _run(home, "doctor", "--json")
assert proc.returncode == 0
data = json.loads(proc.stdout)
assert data["status"] == "warn"
assert any(check["name"] == "agent-installs" and check["status"] == "warn" for check in data["checks"])
def test_doctor_fails_on_invalid_manifest(tmp_path: Path) -> None:
home = tmp_path / "home"
vault = tmp_path / "vault"
_make_vault(vault, manifest="{not json")
_write_config(home, vault)
_install_all_skills(home)
proc = _run(home, "doctor", "--json")
assert proc.returncode == 1
data = json.loads(proc.stdout)
assert data["status"] == "fail"
assert any(check["name"] == "manifest-json" and check["status"] == "fail" for check in data["checks"])
def test_doctor_strict_turns_warnings_into_nonzero_exit(tmp_path: Path) -> None:
home = tmp_path / "home"
vault = tmp_path / "vault"
_make_vault(vault)
_write_config(home, vault, version="0.0.0")
proc = _run(home, "doctor", "--json", "--strict")
assert proc.returncode == 1
data = json.loads(proc.stdout)
assert any(check["name"] == "setup-version" and check["status"] == "warn" for check in data["checks"])
def test_doctor_without_project_has_no_code_understanding_checks(tmp_path: Path) -> None:
home = tmp_path / "home"
vault = tmp_path / "vault"
_make_vault(vault)
_write_config(home, vault)
_install_all_skills(home)
proc = _run(home, "doctor", "--json")
assert proc.returncode == 0
data = json.loads(proc.stdout)
assert not any(check["name"].startswith("code-understanding") for check in data["checks"])
def test_doctor_project_shows_builtin_checks(tmp_path: Path) -> None:
home = tmp_path / "home"
vault = tmp_path / "vault"
_make_vault(vault)
_write_config(home, vault)
_install_all_skills(home)
project = make_project(tmp_path, {"src/foo.py": "def foo():\n return 1\n"}, git=False)
proc = _run(home, "doctor", "--json", "--project", str(project))
assert proc.returncode == 0
data = json.loads(proc.stdout)
names = {check["name"] for check in data["checks"]}
assert "code-understanding.builtin" in names
assert "code-understanding.rg" in names
assert data["status"] != "fail"
def test_doctor_project_auto_missing_codegraph_is_warn_not_fail(tmp_path: Path) -> None:
home = tmp_path / "home"
vault = tmp_path / "vault"
_make_vault(vault)
_write_config(home, vault)
_install_all_skills(home)
project = make_project(tmp_path, {"src/foo.py": "def foo():\n return 1\n"}, git=False)
no_bin = tmp_path / "no-bin"
no_bin.mkdir()
proc = _run_env(home, {"PATH": str(no_bin), "CODE_UNDERSTANDING_CODEGRAPH_BIN": ""}, "doctor", "--json", "--project", str(project))
assert proc.returncode == 0
data = json.loads(proc.stdout)
assert data["status"] != "fail"
check = next(c for c in data["checks"] if c["name"] == "code-understanding.codegraph")
assert check["status"] == "warn"
def test_doctor_project_explicit_codegraph_broken_fails(tmp_path: Path) -> None:
home = tmp_path / "home"
vault = tmp_path / "vault"
_make_vault(vault)
_write_config(home, vault)
_install_all_skills(home)
project = make_project(tmp_path, {"src/foo.py": "def foo():\n return 1\n"}, git=False)
no_bin = tmp_path / "no-bin"
no_bin.mkdir()
proc = _run_env(
home,
{"PATH": str(no_bin), "CODE_UNDERSTANDING_BACKEND": "codegraph", "CODE_UNDERSTANDING_CODEGRAPH_BIN": ""},
"doctor",
"--json",
"--project",
str(project),
)
assert proc.returncode == 1
data = json.loads(proc.stdout)
assert data["status"] == "fail"
check = next(c for c in data["checks"] if c["name"] == "code-understanding.codegraph")
assert check["status"] == "fail"
def test_doctor_project_codegraph_enhanced_checks_pass(tmp_path: Path) -> None:
home = tmp_path / "home"
vault = tmp_path / "vault"
_make_vault(vault)
_write_config(home, vault)
_install_all_skills(home)
project = make_project(tmp_path, {"src/foo.py": "def foo():\n return 1\n"}, git=False)
codegraph_bin = make_fake_codegraph_bin(tmp_path)
build_index_state(project)
no_bin = tmp_path / "no-bin"
no_bin.mkdir()
proc = _run_env(
home,
{"PATH": str(no_bin), "CODE_UNDERSTANDING_CODEGRAPH_BIN": str(codegraph_bin)},
"doctor",
"--json",
"--project",
str(project),
)
assert proc.returncode == 0
data = json.loads(proc.stdout)
statuses = {check["name"]: check["status"] for check in data["checks"]}
assert statuses["code-understanding.codegraph"] == "pass"
assert statuses["code-understanding.codegraph-index"] == "pass"
assert statuses["code-understanding.codegraph-fresh"] == "pass"
def test_doctor_project_index_stale_warns(tmp_path: Path) -> None:
home = tmp_path / "home"
vault = tmp_path / "vault"
_make_vault(vault)
_write_config(home, vault)
_install_all_skills(home)
project = make_project(tmp_path, {"src/foo.py": "def foo():\n return 1\n"}, git=False)
codegraph_bin = make_fake_codegraph_bin(tmp_path)
build_index_state(project, fresh=False)
no_bin = tmp_path / "no-bin"
no_bin.mkdir()
proc = _run_env(
home,
{"PATH": str(no_bin), "CODE_UNDERSTANDING_CODEGRAPH_BIN": str(codegraph_bin)},
"doctor",
"--json",
"--project",
str(project),
)
assert proc.returncode == 0
data = json.loads(proc.stdout)
check = next(c for c in data["checks"] if c["name"] == "code-understanding.codegraph-fresh")
assert check["status"] == "warn"