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/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)