Files
ar9av__obsidian-wiki/obsidian_wiki/code_understanding_builtin.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

151 lines
5.4 KiB
Python

"""Builtin focus-map provider: regex AST extraction + ripgrep references.
Zero-dependency backend for obsidian_wiki.code_understanding. Seeds are the
class/function symbols ast_extractor finds in the seed files; ripgrep adds
cross-file reference items (best-effort — a missing rg only degrades output).
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
from obsidian_wiki.code_understanding import _item, _SUBPROCESS_TIMEOUT
class BuiltinProvider:
def __init__(self, project: Path) -> None:
self.project = project
def build_focus_map(
self,
seed_files: list[str],
origin_files: set[str],
max_symbols: int,
warnings: list[str],
) -> list[dict]:
from obsidian_wiki.ast_extractor import extract
from obsidian_wiki.code_understanding import _finalize
data = extract(self.project)
nodes = data["nodes"]
degree: dict[str, int] = {}
for e in data["edges"]:
degree[e["source"]] = degree.get(e["source"], 0) + 1
degree[e["target"]] = degree.get(e["target"], 0) + 1
seed_set = set(seed_files)
defined = [n for n in nodes if n["kind"] in ("class", "function")]
items: list[dict] = []
seen: set[tuple] = set()
for n in defined:
if n["file"] not in seed_set:
continue
if (n["label"], n["file"]) in seen:
continue
seen.add((n["label"], n["file"]))
changed = n["file"] in origin_files
item = _item(n["label"], n["kind"], n["file"], [n["line"]])
item["evidence"] = "changed-file" if changed else "ast"
item["relation"] = "changed-file" if changed else "defines"
items.append(item)
items = self._rg_references(items, seen, defined, seed_set, warnings)
items.sort(
key=lambda it: (
0 if it["evidence"] == "changed-file" else 1,
0 if it["evidence"] in ("changed-file", "ast") else 1,
-degree.get(it["symbol"], 0),
it["symbol"],
)
)
return _finalize(items, max_symbols)
def _rg_references(
self,
items: list[dict],
seen: set[tuple],
defined: list[dict],
seed_set: set[str],
warnings: list[str],
) -> list[dict]:
"""Add cross-file references via ripgrep (best-effort; never crash)."""
if shutil.which("rg") is None:
warnings.append("rg not available; skipping cross-file reference detection")
return items
seed_files = sorted(f for f in seed_set if (self.project / f).exists())
# References to seed symbols in files other than their defining file.
for n in defined:
if n["file"] not in seed_set:
continue
for f, line in self._rg(n["label"], None):
if f != n["file"]:
item = _item(n["label"], n["kind"], f, [line])
item["evidence"] = "rg-reference"
item["relation"] = "reference"
item["via"] = n["label"]
self._merge(items, seen, item)
break
# External symbols referenced inside seed files.
for n in defined:
if n["file"] in seed_set or not seed_files:
continue
if not any(n["label"] in _file_text(self.project / f) for f in seed_files):
continue
for f, line in self._rg(n["label"], seed_files):
if f not in seed_set:
continue
via = next((m["label"] for m in defined if m["file"] == f), "")
item = _item(n["label"], n["kind"], f, [line])
item["evidence"] = "rg-reference"
item["relation"] = "reference"
item["via"] = via
self._merge(items, seen, item)
break
return items
def _rg(self, name: str, files: list[str] | None) -> list[tuple[str, int]]:
"""rg --json matches of \\bname\\b; returns [(file, line), ...]."""
args = ["rg", "--json", "-n", "--no-messages", "--no-heading", f"\\b{name}\\b"]
if files:
args.extend(files)
try:
proc = subprocess.run(
args,
cwd=str(self.project),
capture_output=True,
text=True,
timeout=_SUBPROCESS_TIMEOUT,
)
except (OSError, subprocess.SubprocessError):
return []
matches: list[tuple[str, int]] = []
for raw in proc.stdout.splitlines():
try:
obj = json.loads(raw)
except ValueError:
continue
if obj.get("type") != "match":
continue
path = obj.get("data", {}).get("path", {}).get("text", "")
line = obj.get("data", {}).get("line_number")
if path and isinstance(line, int):
matches.append((path, line))
return matches
@staticmethod
def _merge(items: list[dict], seen: set[tuple], item: dict) -> None:
key = (item["symbol"], item["file"])
if key in seen:
return
seen.add(key)
items.append(item)
def _file_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""