mirror of
https://github.com/Ar9av/obsidian-wiki.git
synced 2026-09-14 20:36:34 +08:00
fd30720223
* 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>
151 lines
5.4 KiB
Python
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 ""
|