codegraph-network 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +1 -0
- codegraph/bench.py +93 -0
- codegraph/cli.py +128 -0
- codegraph/community.py +77 -0
- codegraph/docs.py +141 -0
- codegraph/embed.py +221 -0
- codegraph/indexer.py +190 -0
- codegraph/install.py +219 -0
- codegraph/keys.py +94 -0
- codegraph/links.py +220 -0
- codegraph/parser.py +459 -0
- codegraph/py.typed +0 -0
- codegraph/queries.py +657 -0
- codegraph/rank.py +121 -0
- codegraph/report.py +199 -0
- codegraph/review.py +145 -0
- codegraph/server.py +81 -0
- codegraph/store.py +638 -0
- codegraph/tools.py +99 -0
- codegraph_network-0.1.0.dist-info/METADATA +220 -0
- codegraph_network-0.1.0.dist-info/RECORD +25 -0
- codegraph_network-0.1.0.dist-info/WHEEL +5 -0
- codegraph_network-0.1.0.dist-info/entry_points.txt +2 -0
- codegraph_network-0.1.0.dist-info/licenses/LICENSE +21 -0
- codegraph_network-0.1.0.dist-info/top_level.txt +1 -0
codegraph/queries.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""Compact, token-budgeted views over the graph. Every method returns plain text."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from collections import defaultdict, deque
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .embed import load_embedder, load_matrix, symbol_text
|
|
13
|
+
from .indexer import db_path
|
|
14
|
+
from .links import is_test_path
|
|
15
|
+
from .rank import personalized_pagerank
|
|
16
|
+
from .store import Store, split_words
|
|
17
|
+
|
|
18
|
+
CHARS_PER_TOKEN = 4
|
|
19
|
+
PPR_MAX_NODES = 20_000
|
|
20
|
+
NEAR_CANDIDATES = 80
|
|
21
|
+
# how much a non-structural link carries a random walk, relative to a call edge (1.0). Lower means
|
|
22
|
+
# the walk prefers structure and only spills over the soft links when structure runs out.
|
|
23
|
+
LINK_WEIGHT = {"similar": 0.3, "tests": 0.6, "key": 0.6}
|
|
24
|
+
COCHANGE_BLEND = 0.25 # share of a co-changed file's score a file inherits in repo_map
|
|
25
|
+
RRF_K = 60 # reciprocal rank fusion constant (the usual value)
|
|
26
|
+
TEST_DAMPING = 0.5 # weight of a semantic hit that lives in a test file
|
|
27
|
+
SEM_MIN = 0.55 # a semantic hit needs at least this cosine ...
|
|
28
|
+
SEM_BAND = 0.12 # ... and must be within this of the best one, so a weak query does not return noise
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def toks(s: str) -> int:
|
|
32
|
+
return max(1, len(s) // CHARS_PER_TOKEN)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def like(s: str) -> str:
|
|
36
|
+
"""Escape a user-supplied string for use inside a LIKE pattern.
|
|
37
|
+
|
|
38
|
+
Without this, a query containing `%` or `_` silently becomes a wildcard and returns
|
|
39
|
+
confident, wrong results.
|
|
40
|
+
"""
|
|
41
|
+
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _suffix(term: str) -> str:
|
|
45
|
+
return "%" + like(term)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def synchronized(fn):
|
|
49
|
+
"""Serialise access to the shared SQLite connection and its caches.
|
|
50
|
+
|
|
51
|
+
The MCP server dispatches tool calls concurrently, and one sqlite3 connection cannot serve
|
|
52
|
+
overlapping statements. Reentrant so that composed tools (context -> _related -> _ppr) work.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@functools.wraps(fn)
|
|
56
|
+
def wrapper(self, *a, **kw):
|
|
57
|
+
with self._lock:
|
|
58
|
+
return fn(self, *a, **kw)
|
|
59
|
+
|
|
60
|
+
return wrapper
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Graph:
|
|
64
|
+
def __init__(self, root: str | Path):
|
|
65
|
+
self.root = Path(root).resolve()
|
|
66
|
+
self.store = Store(db_path(self.root))
|
|
67
|
+
self.db = self.store.db
|
|
68
|
+
# one connection and one cache are shared by concurrent MCP tool calls
|
|
69
|
+
self._lock = threading.RLock()
|
|
70
|
+
self._ftok_cache: dict[str, tuple[float, int]] = {}
|
|
71
|
+
self._sem = None # loaded lazily: see _semantic()
|
|
72
|
+
# outcome of the last symbol lookup: "ok" | "ambiguous" | "missing". The CLI maps it to an exit code.
|
|
73
|
+
self.status = "ok"
|
|
74
|
+
self.reload()
|
|
75
|
+
|
|
76
|
+
def reload(self):
|
|
77
|
+
"""Load adjacency lists into memory (ids only, cheap). Call after the index changes."""
|
|
78
|
+
with self._lock:
|
|
79
|
+
out, inn = defaultdict(list), defaultdict(list)
|
|
80
|
+
for src, dst in self.db.execute("SELECT src, dst FROM edges"):
|
|
81
|
+
out[src].append(dst)
|
|
82
|
+
inn[dst].append(src)
|
|
83
|
+
lnk = defaultdict(list) # soft links, both directions: (other, kind, strength, note, forward)
|
|
84
|
+
for src, dst, kind, w, note in self.db.execute("SELECT src, dst, kind, weight, note FROM links"):
|
|
85
|
+
lnk[src].append((dst, kind, w, note, True))
|
|
86
|
+
lnk[dst].append((src, kind, w, note, False))
|
|
87
|
+
fl = defaultdict(list)
|
|
88
|
+
for a, b, w in self.db.execute("SELECT a, b, weight FROM file_links"):
|
|
89
|
+
fl[a].append((b, w))
|
|
90
|
+
fl[b].append((a, w))
|
|
91
|
+
self._out, self._in, self._ncache = out, inn, {}
|
|
92
|
+
self._lnk, self._fl, self._sem = lnk, fl, None
|
|
93
|
+
self._ftok_cache.clear()
|
|
94
|
+
|
|
95
|
+
def close(self):
|
|
96
|
+
with self._lock:
|
|
97
|
+
self.store.close()
|
|
98
|
+
|
|
99
|
+
@synchronized
|
|
100
|
+
def _nbrs(self, u: int):
|
|
101
|
+
"""Undirected neighbours of u, each weighted down if it is a hub (IDF-style). Soft links
|
|
102
|
+
(similar / tests / key) join the walk at a lower weight than call edges."""
|
|
103
|
+
c = self._ncache.get(u)
|
|
104
|
+
if c is None:
|
|
105
|
+
def hub(v: int) -> float:
|
|
106
|
+
return 1.0 / math.log(2 + len(self._out.get(v, ())) + len(self._in.get(v, ())) + len(self._lnk.get(v, ())))
|
|
107
|
+
|
|
108
|
+
c = [(v, hub(v)) for v in self._out.get(u, []) + self._in.get(u, [])]
|
|
109
|
+
c += [(v, LINK_WEIGHT.get(kind, 0.3) * w * hub(v)) for v, kind, w, _, _ in self._lnk.get(u, ())]
|
|
110
|
+
self._ncache[u] = c
|
|
111
|
+
return c
|
|
112
|
+
|
|
113
|
+
@synchronized
|
|
114
|
+
def _ppr(self, seed_ids) -> dict[int, float]:
|
|
115
|
+
seed_ids = list(dict.fromkeys(seed_ids))
|
|
116
|
+
if not seed_ids:
|
|
117
|
+
return {}
|
|
118
|
+
return personalized_pagerank(self._nbrs, {i: 1.0 / len(seed_ids) for i in seed_ids},
|
|
119
|
+
max_nodes=PPR_MAX_NODES)
|
|
120
|
+
|
|
121
|
+
# ---------- helpers ----------
|
|
122
|
+
def _line(self, r, with_sig=False) -> str:
|
|
123
|
+
s = f"{r['kind']} {r['qualname']} {r['file']}:{r['line_start']}-{r['line_end']}"
|
|
124
|
+
if r["doc"]:
|
|
125
|
+
s += f" # {r['doc'][:80]}"
|
|
126
|
+
if with_sig and r["sig"]:
|
|
127
|
+
s += f"\n {r['sig']}"
|
|
128
|
+
return s
|
|
129
|
+
|
|
130
|
+
# kinds that can never be the target of a user query
|
|
131
|
+
SKIP_KINDS = "('module','impl','namespace')"
|
|
132
|
+
|
|
133
|
+
@synchronized
|
|
134
|
+
def _find(self, q: str):
|
|
135
|
+
"""Resolve a user-supplied symbol to rows: file:name, then qualname, name, then suffix."""
|
|
136
|
+
db = self.db
|
|
137
|
+
q = q.strip()
|
|
138
|
+
if ":" in q and not q.startswith(":"):
|
|
139
|
+
f, _, n = q.rpartition(":")
|
|
140
|
+
rows = db.execute(f"SELECT * FROM symbols WHERE file LIKE ? ESCAPE '\\' "
|
|
141
|
+
f"AND (qualname=? OR name=?) AND kind NOT IN {self.SKIP_KINDS}",
|
|
142
|
+
(_suffix(f), n, n)).fetchall()
|
|
143
|
+
if rows:
|
|
144
|
+
return rows
|
|
145
|
+
for sql, arg in (
|
|
146
|
+
(f"SELECT * FROM symbols WHERE qualname=? AND kind NOT IN {self.SKIP_KINDS}", q),
|
|
147
|
+
(f"SELECT * FROM symbols WHERE name=? AND kind NOT IN {self.SKIP_KINDS}", q),
|
|
148
|
+
(f"SELECT * FROM symbols WHERE qualname LIKE ? ESCAPE '\\' "
|
|
149
|
+
f"AND kind NOT IN {self.SKIP_KINDS}", "%." + like(q)),
|
|
150
|
+
):
|
|
151
|
+
rows = db.execute(sql, (arg,)).fetchall()
|
|
152
|
+
if rows:
|
|
153
|
+
return rows
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
@synchronized
|
|
157
|
+
def _one(self, q: str):
|
|
158
|
+
rows = self._find(q)
|
|
159
|
+
self.status = "missing" if not rows else "ambiguous" if len(rows) > 1 else "ok"
|
|
160
|
+
if not rows:
|
|
161
|
+
return None, f"No symbol matches '{q}'. Try search()."
|
|
162
|
+
if len(rows) > 1:
|
|
163
|
+
lines = "\n".join(self._line(r) for r in rows[:10])
|
|
164
|
+
return None, f"'{q}' is ambiguous ({len(rows)} matches). Re-run with qualname or file:name:\n{lines}"
|
|
165
|
+
return rows[0], ""
|
|
166
|
+
|
|
167
|
+
@synchronized
|
|
168
|
+
def _read(self, r) -> str:
|
|
169
|
+
if r["file"].endswith(".pdf"): # page text lives in the index, line numbers are page numbers
|
|
170
|
+
return r["doc"] or ""
|
|
171
|
+
try:
|
|
172
|
+
lines = (self.root / r["file"]).read_text(errors="replace").splitlines()
|
|
173
|
+
except OSError:
|
|
174
|
+
return "(file unreadable)"
|
|
175
|
+
return "\n".join(lines[r["line_start"] - 1:r["line_end"]])
|
|
176
|
+
|
|
177
|
+
@synchronized
|
|
178
|
+
def _file_tokens(self, files) -> int:
|
|
179
|
+
"""Approximate whole-file token cost, cached per mtime.
|
|
180
|
+
|
|
181
|
+
This is the denominator of the savings estimate, so it is cached rather than re-read on
|
|
182
|
+
every context() call -- reading whole files to describe a snippet is the one thing this
|
|
183
|
+
tool is trying to avoid.
|
|
184
|
+
"""
|
|
185
|
+
total = 0
|
|
186
|
+
with self._lock:
|
|
187
|
+
for f in sorted(set(files)):
|
|
188
|
+
p = self.root / f
|
|
189
|
+
try:
|
|
190
|
+
mtime = p.stat().st_mtime
|
|
191
|
+
except OSError:
|
|
192
|
+
continue
|
|
193
|
+
cached = self._ftok_cache.get(f)
|
|
194
|
+
if cached is not None and cached[0] == mtime:
|
|
195
|
+
total += cached[1]
|
|
196
|
+
continue
|
|
197
|
+
try:
|
|
198
|
+
n = toks(p.read_text(errors="replace"))
|
|
199
|
+
except OSError:
|
|
200
|
+
continue
|
|
201
|
+
self._ftok_cache[f] = (mtime, n)
|
|
202
|
+
total += n
|
|
203
|
+
return total
|
|
204
|
+
|
|
205
|
+
# ---------- tools ----------
|
|
206
|
+
@synchronized
|
|
207
|
+
def _focus_seeds(self, focus: str) -> list[int]:
|
|
208
|
+
seeds: list[int] = []
|
|
209
|
+
for term in [t.strip() for t in focus.split(",") if t.strip()]:
|
|
210
|
+
rows = self.db.execute(
|
|
211
|
+
f"SELECT id FROM symbols WHERE file LIKE ? ESCAPE '\\' AND kind NOT IN "
|
|
212
|
+
f"{self.SKIP_KINDS} ORDER BY id LIMIT 50", (_suffix(term),)).fetchall()
|
|
213
|
+
if rows:
|
|
214
|
+
seeds += [r[0] for r in rows]
|
|
215
|
+
continue
|
|
216
|
+
seeds += [r["id"] for r, _ in self._hybrid_rows(term, 3)]
|
|
217
|
+
return list(dict.fromkeys(seeds))
|
|
218
|
+
|
|
219
|
+
@synchronized
|
|
220
|
+
def repo_map(self, prefix: str = "", max_tokens: int = 1500, focus: str = "") -> str:
|
|
221
|
+
"""Files ranked by PageRank; with `focus` (symbols, words or paths, comma separated) ranked by
|
|
222
|
+
personalized PageRank, i.e. closeness to what you are working on."""
|
|
223
|
+
db = self.db
|
|
224
|
+
note = ""
|
|
225
|
+
scores: dict[int, float] = {}
|
|
226
|
+
if focus:
|
|
227
|
+
seeds = self._focus_seeds(focus)
|
|
228
|
+
if seeds:
|
|
229
|
+
scores = self._ppr(seeds)
|
|
230
|
+
note = f"(personalized to: {focus})\n"
|
|
231
|
+
else:
|
|
232
|
+
note = f"(no symbols matched focus '{focus}'; showing global ranking)\n"
|
|
233
|
+
if not scores:
|
|
234
|
+
scores = {r[0]: r[1] for r in db.execute("SELECT id, rank FROM symbols")}
|
|
235
|
+
syms = db.execute(f"SELECT * FROM symbols WHERE kind NOT IN {self.SKIP_KINDS} "
|
|
236
|
+
f"AND file LIKE ? ESCAPE '\\'", (_suffix(prefix),)).fetchall()
|
|
237
|
+
by_file = defaultdict(list)
|
|
238
|
+
for s_ in syms:
|
|
239
|
+
if scores.get(s_["id"], 0) > 0:
|
|
240
|
+
by_file[s_["file"]].append(s_)
|
|
241
|
+
totals = {f: sum(scores[x["id"]] for x in ss) for f, ss in by_file.items()}
|
|
242
|
+
glob: dict[int, float] = {}
|
|
243
|
+
if self._fl: # files that change together with important files are important too
|
|
244
|
+
boost: dict[str, float] = defaultdict(float)
|
|
245
|
+
for f, t in totals.items():
|
|
246
|
+
for g, w in self._fl.get(f, ()):
|
|
247
|
+
boost[g] += COCHANGE_BLEND * w * t
|
|
248
|
+
for g, b in boost.items():
|
|
249
|
+
if g not in totals: # reachable only through history: show it by its global rank
|
|
250
|
+
if not glob:
|
|
251
|
+
glob = {r[0]: r[1] for r in db.execute("SELECT id, rank FROM symbols")}
|
|
252
|
+
extra = [x for x in syms if x["file"] == g and glob.get(x["id"], 0) > 0]
|
|
253
|
+
if extra:
|
|
254
|
+
by_file[g] = extra
|
|
255
|
+
totals[g] = 0.0
|
|
256
|
+
if g in totals:
|
|
257
|
+
totals[g] += b
|
|
258
|
+
scored = sorted(((t, f) for f, t in totals.items()), key=lambda t: (-t[0], t[1]))
|
|
259
|
+
out, used = [], toks(note)
|
|
260
|
+
for i, (_, f) in enumerate(scored):
|
|
261
|
+
ss = sorted(by_file[f], key=lambda x: -(scores.get(x["id"]) or glob.get(x["id"], 0)))
|
|
262
|
+
seen_qn: set[str] = set() # overloads share a qualname; show each once
|
|
263
|
+
ss = [x for x in ss if not (x["qualname"] in seen_qn or seen_qn.add(x["qualname"]))][:8]
|
|
264
|
+
ss.sort(key=lambda x: x["line_start"])
|
|
265
|
+
block = f + "\n" + "\n".join(
|
|
266
|
+
f" {x['kind']} {x['qualname']}" + (f" ({len(self._in[x['id']])} refs)" if self._in.get(x["id"]) else "")
|
|
267
|
+
for x in ss)
|
|
268
|
+
if used + toks(block) > max_tokens and out:
|
|
269
|
+
out.append(f"... {len(scored) - i} more files (use prefix= or focus= to zoom in)")
|
|
270
|
+
break
|
|
271
|
+
out.append(block)
|
|
272
|
+
used += toks(block)
|
|
273
|
+
return note + "\n".join(out) if out else "Index is empty. Run reindex()."
|
|
274
|
+
|
|
275
|
+
@synchronized
|
|
276
|
+
def _search_rows(self, query: str, limit: int):
|
|
277
|
+
words = [w for w in re.findall(r"[^\W_]+", split_words(query), re.UNICODE) if w]
|
|
278
|
+
if not words:
|
|
279
|
+
return []
|
|
280
|
+
fts = " AND ".join(f'"{w}"*' for w in words)
|
|
281
|
+
try:
|
|
282
|
+
rows = self.db.execute(
|
|
283
|
+
"SELECT s.* FROM sym_fts f JOIN symbols s ON s.id=f.rowid WHERE sym_fts MATCH ? "
|
|
284
|
+
f"AND s.kind NOT IN {self.SKIP_KINDS} "
|
|
285
|
+
"ORDER BY bm25(sym_fts, 10, 5, 3, 1) LIMIT ?", (fts, limit)).fetchall()
|
|
286
|
+
except Exception: # a malformed FTS expression must not take the tool down
|
|
287
|
+
rows = []
|
|
288
|
+
if not rows:
|
|
289
|
+
rows = self.db.execute(
|
|
290
|
+
f"SELECT * FROM symbols WHERE name LIKE ? ESCAPE '\\' AND kind NOT IN "
|
|
291
|
+
f"{self.SKIP_KINDS} LIMIT ?", (_suffix(query), limit)).fetchall()
|
|
292
|
+
return rows
|
|
293
|
+
|
|
294
|
+
@synchronized
|
|
295
|
+
def _semantic(self):
|
|
296
|
+
"""(embedder, ids, matrix, id->row) when this index has vectors made by the configured model,
|
|
297
|
+
else None. Loaded once per index version; the model itself loads on the first query embedding."""
|
|
298
|
+
if self._sem is None:
|
|
299
|
+
self._sem = False
|
|
300
|
+
try:
|
|
301
|
+
emb = load_embedder()
|
|
302
|
+
if emb is not None and self.store.get_meta("embed_model") == emb.name:
|
|
303
|
+
ids, mat = load_matrix(self.db)
|
|
304
|
+
if ids is not None:
|
|
305
|
+
self._sem = (emb, ids, mat, {int(i): n for n, i in enumerate(ids)})
|
|
306
|
+
except Exception as e: # semantic search is a bonus: never take a tool down for it
|
|
307
|
+
print(f"codegraph: semantic search disabled: {e}", file=sys.stderr, flush=True)
|
|
308
|
+
return self._sem or None
|
|
309
|
+
|
|
310
|
+
@synchronized
|
|
311
|
+
def _rows_by_id(self, ids):
|
|
312
|
+
ids = list(ids)
|
|
313
|
+
if not ids:
|
|
314
|
+
return []
|
|
315
|
+
found = {r["id"]: r for r in self.db.execute(
|
|
316
|
+
f"SELECT * FROM symbols WHERE id IN ({','.join('?' * len(ids))})", ids)}
|
|
317
|
+
return [found[i] for i in ids if i in found]
|
|
318
|
+
|
|
319
|
+
@synchronized
|
|
320
|
+
def _vector_hits(self, vec, n: int, exclude: int | None = None) -> list[tuple[int, float]]:
|
|
321
|
+
"""The n symbols closest to `vec` by cosine, best first, as (id, similarity)."""
|
|
322
|
+
import numpy as np
|
|
323
|
+
_, ids, mat, _ = self._semantic()
|
|
324
|
+
sims = mat @ vec
|
|
325
|
+
order = np.argsort(-sims)[:n + 1]
|
|
326
|
+
return [(int(ids[i]), float(sims[i])) for i in order if exclude is None or int(ids[i]) != exclude][:n]
|
|
327
|
+
|
|
328
|
+
@synchronized
|
|
329
|
+
def _hybrid_rows(self, query: str, limit: int):
|
|
330
|
+
"""[(row, signal)] fusing lexical (FTS5) and semantic (vector) rankings with reciprocal rank
|
|
331
|
+
fusion; signal is "lexical", "semantic" or "both". Without vectors this is the plain FTS result."""
|
|
332
|
+
pool = max(limit * 3, 30)
|
|
333
|
+
lex = self._search_rows(query, pool)
|
|
334
|
+
sem = self._semantic()
|
|
335
|
+
if sem is None:
|
|
336
|
+
return [(r, "lexical") for r in lex[:limit]]
|
|
337
|
+
hits = self._vector_hits(sem[0].embed_query(query), pool)
|
|
338
|
+
if hits:
|
|
339
|
+
floor = max(SEM_MIN, hits[0][1] - SEM_BAND)
|
|
340
|
+
hits = [h for h in hits if h[1] >= floor]
|
|
341
|
+
score: dict[int, float] = defaultdict(float)
|
|
342
|
+
seen_lex, seen_sem = set(), set()
|
|
343
|
+
for rank, r in enumerate(lex):
|
|
344
|
+
score[r["id"]] += 1.0 / (RRF_K + rank)
|
|
345
|
+
seen_lex.add(r["id"])
|
|
346
|
+
rows = {r["id"]: r for r in lex}
|
|
347
|
+
rows.update({r["id"]: r for r in self._rows_by_id([i for i, _ in hits if i not in rows])})
|
|
348
|
+
for rank, (i, _) in enumerate(hits):
|
|
349
|
+
# test code resembles everything it exercises; it should not outrank the code itself
|
|
350
|
+
test = i in rows and is_test_path(rows[i]["file"])
|
|
351
|
+
score[i] += (TEST_DAMPING if test else 1.0) / (RRF_K + rank)
|
|
352
|
+
seen_sem.add(i)
|
|
353
|
+
best = sorted(score, key=lambda i: -score[i])[:limit]
|
|
354
|
+
return [(rows[i], "both" if i in seen_lex and i in seen_sem else "semantic" if i in seen_sem else "lexical")
|
|
355
|
+
for i in best if i in rows]
|
|
356
|
+
|
|
357
|
+
@synchronized
|
|
358
|
+
def search(self, query: str, limit: int = 10) -> str:
|
|
359
|
+
limit = max(1, min(limit, 100))
|
|
360
|
+
if self._semantic() is None: # no vectors: the original lexical output, unchanged
|
|
361
|
+
rows = self._search_rows(query, limit)
|
|
362
|
+
return "\n".join(self._line(r, with_sig=True) for r in rows) or "No matches."
|
|
363
|
+
out = []
|
|
364
|
+
for r, signal in self._hybrid_rows(query, limit):
|
|
365
|
+
head, _, sig = self._line(r, with_sig=True).partition("\n")
|
|
366
|
+
out.append(f"{head} [{signal}]" + (f"\n{sig}" if sig else ""))
|
|
367
|
+
return "\n".join(out) or "No matches."
|
|
368
|
+
|
|
369
|
+
@synchronized
|
|
370
|
+
def similar(self, symbol: str, limit: int = 10) -> str:
|
|
371
|
+
"""Symbols whose meaning is closest to this one, whether or not anything links them."""
|
|
372
|
+
sem = self._semantic()
|
|
373
|
+
if sem is None:
|
|
374
|
+
return ("Semantic index not available. Install the extra (pip install 'codegraph[embed]') "
|
|
375
|
+
"and run reindex(), or use related() for structural neighbours.")
|
|
376
|
+
r, err = self._one(symbol)
|
|
377
|
+
if err:
|
|
378
|
+
return err
|
|
379
|
+
limit = max(1, min(limit, 50))
|
|
380
|
+
emb, _, mat, pos = sem
|
|
381
|
+
vec = mat[pos[r["id"]]] if r["id"] in pos else emb.embed_passages([symbol_text(r)])[0]
|
|
382
|
+
direct = set(self._out.get(r["id"], ())) | set(self._in.get(r["id"], ()))
|
|
383
|
+
hits = [h for h in self._vector_hits(vec, limit, exclude=r["id"]) if h[1] >= SEM_MIN]
|
|
384
|
+
rows = {x["id"]: x for x in self._rows_by_id([i for i, _ in hits])}
|
|
385
|
+
return "\n".join(
|
|
386
|
+
f"{sim:.2f} {self._line(rows[i], with_sig=True)}" + (" [linked]" if i in direct else "")
|
|
387
|
+
for i, sim in hits if i in rows) or "(nothing similar)"
|
|
388
|
+
|
|
389
|
+
@synchronized
|
|
390
|
+
def outline(self, file: str) -> str:
|
|
391
|
+
rows = self.db.execute(
|
|
392
|
+
"SELECT * FROM symbols WHERE file LIKE ? ESCAPE '\\' AND kind!='module' "
|
|
393
|
+
"ORDER BY file, line_start", (_suffix(file),)).fetchall()
|
|
394
|
+
self.status = "ok" if rows else "missing"
|
|
395
|
+
if not rows:
|
|
396
|
+
return f"No indexed symbols in '{file}'."
|
|
397
|
+
out, cur = [], None
|
|
398
|
+
depth = {}
|
|
399
|
+
for r in rows:
|
|
400
|
+
if r["file"] != cur:
|
|
401
|
+
cur = r["file"]
|
|
402
|
+
out.append(cur)
|
|
403
|
+
depth[r["id"]] = depth.get(r["parent"], -1) + 1 if r["parent"] in depth else 0
|
|
404
|
+
out.append(" " * (depth[r["id"]] + 1) + f"{r['kind']} {r['name']} L{r['line_start']}-{r['line_end']}"
|
|
405
|
+
+ (f" # {r['doc'][:60]}" if r["doc"] else ""))
|
|
406
|
+
return "\n".join(out)
|
|
407
|
+
|
|
408
|
+
@synchronized
|
|
409
|
+
def source(self, symbol: str) -> str:
|
|
410
|
+
r, err = self._one(symbol)
|
|
411
|
+
if err:
|
|
412
|
+
return err
|
|
413
|
+
return f"{r['file']}:{r['line_start']}-{r['line_end']}\n{self._read(r)}"
|
|
414
|
+
|
|
415
|
+
def _neighbors(self, sid: int, direction: str):
|
|
416
|
+
if direction == "out":
|
|
417
|
+
sql = "SELECT s.*, e.kind AS ek, e.conf AS conf FROM edges e JOIN symbols s ON s.id=e.dst WHERE e.src=?"
|
|
418
|
+
else:
|
|
419
|
+
sql = "SELECT s.*, e.kind AS ek, e.conf AS conf FROM edges e JOIN symbols s ON s.id=e.src WHERE e.dst=?"
|
|
420
|
+
return self.db.execute(sql, (sid,)).fetchall()
|
|
421
|
+
|
|
422
|
+
@synchronized
|
|
423
|
+
def _bfs(self, r, direction: str, depth: int, limit: int):
|
|
424
|
+
seen = {r["id"]}
|
|
425
|
+
q = deque([(r["id"], 0)])
|
|
426
|
+
levels = defaultdict(list)
|
|
427
|
+
while q:
|
|
428
|
+
sid, d = q.popleft()
|
|
429
|
+
if d >= depth:
|
|
430
|
+
continue
|
|
431
|
+
for n in self._neighbors(sid, direction):
|
|
432
|
+
if n["id"] in seen:
|
|
433
|
+
continue
|
|
434
|
+
seen.add(n["id"])
|
|
435
|
+
levels[d + 1].append(n)
|
|
436
|
+
q.append((n["id"], d + 1))
|
|
437
|
+
return levels
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def _conf_tag(n) -> str:
|
|
441
|
+
"""Only edges that are not certain are tagged, so the common case stays free of noise."""
|
|
442
|
+
conf = n["conf"] if "conf" in n.keys() else "extracted" # noqa: SIM118 (sqlite3.Row: `in` tests values)
|
|
443
|
+
return "" if conf == "extracted" else f" [{conf}]"
|
|
444
|
+
|
|
445
|
+
def _render_levels(self, levels, limit: int) -> str:
|
|
446
|
+
out, count = [], 0
|
|
447
|
+
for d in sorted(levels):
|
|
448
|
+
out.append(f"depth {d}:")
|
|
449
|
+
for n in levels[d]:
|
|
450
|
+
if count >= limit:
|
|
451
|
+
out.append(f" ... truncated at {limit}")
|
|
452
|
+
return "\n".join(out)
|
|
453
|
+
out.append(" " + self._line(n) + self._conf_tag(n))
|
|
454
|
+
count += 1
|
|
455
|
+
return "\n".join(out) or "(none)"
|
|
456
|
+
|
|
457
|
+
@synchronized
|
|
458
|
+
def callers(self, symbol: str, depth: int = 1, limit: int = 30) -> str:
|
|
459
|
+
r, err = self._one(symbol)
|
|
460
|
+
if err:
|
|
461
|
+
return err
|
|
462
|
+
depth = max(1, min(depth, 10))
|
|
463
|
+
return f"callers of {r['qualname']}:\n" + self._render_levels(self._bfs(r, "in", depth, limit), limit)
|
|
464
|
+
|
|
465
|
+
@synchronized
|
|
466
|
+
def callees(self, symbol: str, depth: int = 1, limit: int = 30) -> str:
|
|
467
|
+
r, err = self._one(symbol)
|
|
468
|
+
if err:
|
|
469
|
+
return err
|
|
470
|
+
depth = max(1, min(depth, 10))
|
|
471
|
+
return f"callees of {r['qualname']}:\n" + self._render_levels(self._bfs(r, "out", depth, limit), limit)
|
|
472
|
+
|
|
473
|
+
@synchronized
|
|
474
|
+
def impact(self, symbol: str, depth: int = 3) -> str:
|
|
475
|
+
"""Blast radius: everything that transitively depends on the symbol."""
|
|
476
|
+
r, err = self._one(symbol)
|
|
477
|
+
if err:
|
|
478
|
+
return err
|
|
479
|
+
depth = max(1, min(depth, 10))
|
|
480
|
+
levels = self._bfs(r, "in", depth, 10_000)
|
|
481
|
+
files = defaultdict(list)
|
|
482
|
+
for d, ns in levels.items():
|
|
483
|
+
for n in ns:
|
|
484
|
+
files[n["file"]].append((d, n["qualname"] + ("" if n["conf"] == "extracted" else "?")))
|
|
485
|
+
if not files:
|
|
486
|
+
return f"Nothing depends on {r['qualname']} (static analysis)."
|
|
487
|
+
total = sum(len(v) for v in files.values())
|
|
488
|
+
plural = "symbol" if total == 1 else "symbols"
|
|
489
|
+
out = [f"changing {r['qualname']} may affect {total} {plural} in {len(files)} file(s)"
|
|
490
|
+
f"{' (? = reached through an inferred or ambiguous link)' if any(q.endswith('?') for v in files.values() for _, q in v) else ''}:"]
|
|
491
|
+
for f, items in sorted(files.items(), key=lambda kv: min(d for d, _ in kv[1])):
|
|
492
|
+
items.sort()
|
|
493
|
+
out.append(f" {f}: " + ", ".join(f"{q[:-1] if q.endswith('?') else q}(d{d}{'?' if q.endswith('?') else ''})" for d, q in items[:8]) +
|
|
494
|
+
(f", +{len(items) - 8} more" if len(items) > 8 else ""))
|
|
495
|
+
return "\n".join(out)
|
|
496
|
+
|
|
497
|
+
@staticmethod
|
|
498
|
+
def _link_label(kind: str, forward: bool) -> str:
|
|
499
|
+
if kind == "tests":
|
|
500
|
+
return "tests" if forward else "tested by"
|
|
501
|
+
return kind # "similar", "key"
|
|
502
|
+
|
|
503
|
+
@synchronized
|
|
504
|
+
def _related(self, r, budget: int):
|
|
505
|
+
"""Best-first neighbourhood: direct callees/callers first, then soft links (tests, shared keys,
|
|
506
|
+
similar), then the highest personalized-PageRank symbols further out, filled until the token
|
|
507
|
+
budget is spent. Returns [(relation, row, note)]."""
|
|
508
|
+
sid = r["id"]
|
|
509
|
+
scores = self._ppr([sid])
|
|
510
|
+
scores.pop(sid, None)
|
|
511
|
+
direct_out, direct_in = set(self._out.get(sid, ())), set(self._in.get(sid, ()))
|
|
512
|
+
soft: dict[int, tuple[str, str]] = {} # soft links only; a structural edge always wins
|
|
513
|
+
for v, kind, _, note, fwd in sorted(self._lnk.get(sid, ()), key=lambda t: -scores.get(t[0], 0)):
|
|
514
|
+
if v in direct_out or v in direct_in:
|
|
515
|
+
continue
|
|
516
|
+
label, seen_note = soft.get(v, (self._link_label(kind, fwd), ""))
|
|
517
|
+
soft[v] = (label, ", ".join(n for n in dict.fromkeys([*seen_note.split(", "), note]) if n))
|
|
518
|
+
ids = sorted(scores, key=scores.get, reverse=True)[:NEAR_CANDIDATES]
|
|
519
|
+
if not ids and not (direct_out | direct_in | set(soft)):
|
|
520
|
+
return []
|
|
521
|
+
lookups = list(dict.fromkeys(ids + sorted(direct_out | direct_in) + sorted(soft)))
|
|
522
|
+
rows = {x["id"]: x for x in self.db.execute(
|
|
523
|
+
"SELECT * FROM symbols WHERE id IN (" + ",".join("?" * len(lookups)) + ")", lookups)}
|
|
524
|
+
direct = sorted(direct_out | direct_in, key=lambda i: -scores.get(i, 0))
|
|
525
|
+
far = [i for i in ids if i not in direct_out and i not in direct_in and i not in soft]
|
|
526
|
+
picked, used = [], 0
|
|
527
|
+
for i in direct + sorted(soft, key=lambda i: -scores.get(i, 0)) + far:
|
|
528
|
+
x = rows.get(i)
|
|
529
|
+
if x is None or (x["kind"] in ("module", "impl", "namespace") and i not in direct and i not in soft):
|
|
530
|
+
continue
|
|
531
|
+
c = toks(f"{x['file']}:{x['line_start']} {x['sig'] or x['qualname']}\n")
|
|
532
|
+
if used + c > budget:
|
|
533
|
+
continue
|
|
534
|
+
if i in direct_out:
|
|
535
|
+
rel, note = "uses", ""
|
|
536
|
+
elif i in direct_in: # a test that calls it is a test of it, not just a user
|
|
537
|
+
rel, note = ("tested by" if is_test_path(x["file"]) and not is_test_path(r["file"]) else "used by"), ""
|
|
538
|
+
else:
|
|
539
|
+
rel, note = soft.get(i, ("related", ""))
|
|
540
|
+
picked.append((rel, x, note))
|
|
541
|
+
used += c
|
|
542
|
+
return picked
|
|
543
|
+
|
|
544
|
+
@synchronized
|
|
545
|
+
def related(self, symbol: str, max_tokens: int = 800) -> str:
|
|
546
|
+
r, err = self._one(symbol)
|
|
547
|
+
if err:
|
|
548
|
+
return err
|
|
549
|
+
picked = self._related(r, max(max_tokens, 0))
|
|
550
|
+
return "\n".join(f"{rel:8} {x['file']}:{x['line_start']} {x['sig'] or x['qualname']}"
|
|
551
|
+
+ (f" [{note}]" if note else "") for rel, x, note in picked) or "(isolated symbol)"
|
|
552
|
+
|
|
553
|
+
@synchronized
|
|
554
|
+
def context(self, symbol: str, max_tokens: int = 1500) -> str:
|
|
555
|
+
"""One call that replaces reading several files: source + the best-ranked neighbourhood that fits."""
|
|
556
|
+
r, err = self._one(symbol)
|
|
557
|
+
if err:
|
|
558
|
+
return err
|
|
559
|
+
head = f"# {r['kind']} {r['qualname']} {r['file']}:{r['line_start']}-{r['line_end']}"
|
|
560
|
+
src = self._read(r)
|
|
561
|
+
budget_src = max_tokens // 2
|
|
562
|
+
if toks(src) > budget_src:
|
|
563
|
+
src = src[:budget_src * CHARS_PER_TOKEN] + "\n... [source truncated, use source() / Read for the rest]"
|
|
564
|
+
parts = [head, src]
|
|
565
|
+
used_files = [r["file"]]
|
|
566
|
+
if r["parent"]:
|
|
567
|
+
p = self.db.execute("SELECT * FROM symbols WHERE id=?", (r["parent"],)).fetchone()
|
|
568
|
+
if p and p["kind"] not in ("module", "namespace"):
|
|
569
|
+
parts.append(f"## inside\n{p['file']}:{p['line_start']} {p['sig'] or p['qualname']}")
|
|
570
|
+
remaining = max_tokens - toks("\n".join(parts)) - 20
|
|
571
|
+
picked = self._related(r, max(remaining, 0))
|
|
572
|
+
for rel, title in (("uses", "## uses"), ("used by", "## used by"), ("tested by", "## tested by"),
|
|
573
|
+
("tests", "## tests"), ("key", "## shares a string key"),
|
|
574
|
+
("similar", "## similar (by meaning, not linked by calls)"),
|
|
575
|
+
("related", "## related (further out, ranked by closeness)")):
|
|
576
|
+
group = [(x, note) for k, x, note in picked if k == rel]
|
|
577
|
+
if group:
|
|
578
|
+
parts.append(title)
|
|
579
|
+
for x, note in group:
|
|
580
|
+
parts.append(f"{x['file']}:{x['line_start']} {x['sig'] or x['qualname']}"
|
|
581
|
+
+ (f" [{note}]" if note else ""))
|
|
582
|
+
if x["file"] != r["file"]:
|
|
583
|
+
used_files.append(x["file"])
|
|
584
|
+
body = "\n".join(parts)
|
|
585
|
+
naive = self._file_tokens(used_files)
|
|
586
|
+
return (f"{body}\n[~{toks(body)} tokens for this symbol and its neighbours, vs ~{naive} "
|
|
587
|
+
f"to read {len(set(used_files))} whole file(s) - the neighbours listed are "
|
|
588
|
+
f"signatures only, not full bodies]")
|
|
589
|
+
|
|
590
|
+
@synchronized
|
|
591
|
+
def _resolve_file(self, term: str) -> str | None:
|
|
592
|
+
"""Prefer an exact path, then a unique suffix, then the shortest unique suffix match."""
|
|
593
|
+
t = like(term.strip().lstrip("./"))
|
|
594
|
+
exact = self.db.execute("SELECT path FROM files WHERE path=?", (t,)).fetchone()
|
|
595
|
+
if exact:
|
|
596
|
+
return exact[0]
|
|
597
|
+
rows = self.db.execute("SELECT path FROM files WHERE path LIKE ? ESCAPE '\\' "
|
|
598
|
+
"ORDER BY LENGTH(path), path", (_suffix(t),)).fetchall()
|
|
599
|
+
if not rows:
|
|
600
|
+
return None
|
|
601
|
+
if len(rows) == 1:
|
|
602
|
+
return rows[0][0]
|
|
603
|
+
tail = "/" + t.rpartition("/")[2]
|
|
604
|
+
narrowed = [r[0] for r in rows if r[0].endswith(tail)] if "/" in t else []
|
|
605
|
+
if len(narrowed) == 1:
|
|
606
|
+
return narrowed[0]
|
|
607
|
+
return rows[0][0]
|
|
608
|
+
|
|
609
|
+
@synchronized
|
|
610
|
+
def deps(self, file: str) -> str:
|
|
611
|
+
f = self._resolve_file(file)
|
|
612
|
+
self.status = "ok" if f else "missing"
|
|
613
|
+
if f is None:
|
|
614
|
+
return f"No indexed file matches '{file}'."
|
|
615
|
+
imports = [r[0] for r in self.db.execute("SELECT dst FROM imports WHERE file=?", (f,))]
|
|
616
|
+
importers = [r[0] for r in self.db.execute("SELECT file FROM imports WHERE dst=?", (f,))]
|
|
617
|
+
out = (f"{f}\nimports: {', '.join(sorted(imports)) or '-'}\n"
|
|
618
|
+
f"imported by: {', '.join(sorted(importers)) or '-'}")
|
|
619
|
+
partners = sorted(self._fl.get(f, ()), key=lambda t: (-t[1], t[0]))
|
|
620
|
+
if partners: # only when the repo has git history
|
|
621
|
+
out += "\nco-changes with: " + ", ".join(f"{g} ({w:.2f})" for g, w in partners)
|
|
622
|
+
return out
|
|
623
|
+
|
|
624
|
+
@synchronized
|
|
625
|
+
def stats(self) -> str:
|
|
626
|
+
db = self.db
|
|
627
|
+
|
|
628
|
+
def q(sql):
|
|
629
|
+
return db.execute(sql).fetchone()[0]
|
|
630
|
+
|
|
631
|
+
conf = dict(db.execute("SELECT conf, COUNT(*) FROM edges GROUP BY conf").fetchall())
|
|
632
|
+
return (f"{q('SELECT COUNT(*) FROM files')} files, {q('SELECT COUNT(*) FROM symbols')} symbols, "
|
|
633
|
+
f"{q('SELECT COUNT(*) FROM edges')} edges ({conf.get('extracted', 0)} extracted, "
|
|
634
|
+
f"{conf.get('inferred', 0)} inferred, {conf.get('ambiguous', 0)} ambiguous), "
|
|
635
|
+
f"{q('SELECT COUNT(*) FROM imports')} file imports, "
|
|
636
|
+
f"{q('SELECT COUNT(DISTINCT community) FROM symbols WHERE community>=0')} communities")
|
|
637
|
+
|
|
638
|
+
@synchronized
|
|
639
|
+
def report(self, max_tokens: int = 1500) -> str:
|
|
640
|
+
"""Write .codegraph/GRAPH_REPORT.md and graph.html; return the report text."""
|
|
641
|
+
from .report import short, write_all
|
|
642
|
+
|
|
643
|
+
text, paths = write_all(self.db, self.root)
|
|
644
|
+
cap = max_tokens * CHARS_PER_TOKEN
|
|
645
|
+
if len(text) > cap:
|
|
646
|
+
text = text[:cap].rsplit("\n", 1)[0] + "\n... truncated (full report in the file)"
|
|
647
|
+
return text + "\nwritten: " + ", ".join(short(p, self.root) for p in paths)
|
|
648
|
+
|
|
649
|
+
def review(self, base: str = "", max_tokens: int = 1500) -> str:
|
|
650
|
+
"""Impact of the current git diff (against `base`, default: where the branch left main)."""
|
|
651
|
+
from .indexer import index_repo
|
|
652
|
+
from .review import review
|
|
653
|
+
|
|
654
|
+
with self._lock:
|
|
655
|
+
index_repo(self.root) # the diff is the working tree, so the index must be too
|
|
656
|
+
self.reload()
|
|
657
|
+
return review(self, base, max_tokens)
|