local-codebase-indexer 0.2.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.
@@ -0,0 +1,756 @@
1
+ """Hybrid query pipeline and the three tools from index.md section 4."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import sqlite3
8
+ import textwrap
9
+ from collections import defaultdict
10
+
11
+ import numpy as np
12
+
13
+ from . import dataflow
14
+ from . import db as dbmod
15
+ from .callgraph import CONF_ORDER
16
+ from .core import Indexer, cjk_bigrams, git_info
17
+ from .scanner import iter_files
18
+ from .embedder import split_identifier
19
+ from .synonyms import Expander
20
+
21
+ RRF_K = 60
22
+ # the symbol-name layer is a weak signal (substring match), so it counts for less
23
+ LAYER_WEIGHTS = {"keyword": 1.0, "vector": 1.0, "symbol": 0.5}
24
+ MAX_CONTEXT_CHARS = 24000
25
+ _TYPE_KINDS = ("class", "interface", "struct", "record", "enum", "type")
26
+ _STOPWORDS = {
27
+ "the", "a", "an", "of", "to", "in", "is", "are", "for", "and", "or", "where", "do", "we", "how",
28
+ "what", "which", "that", "this", "with", "on", "it", "be", "does", "can", "find", "show", "me",
29
+ "by", "at", "as", "from", "into", "i", "my", "our", "there", "when", "who", "so", "up",
30
+ }
31
+
32
+
33
+ def _query_terms(query: str) -> list[str]:
34
+ terms: list[str] = []
35
+ for ident in re.findall(r"[A-Za-z_][A-Za-z0-9_]*|[一-鿿]+|\d+", query):
36
+ if not re.match(r"[A-Za-z_0-9]", ident):
37
+ terms.extend(cjk_bigrams(ident)) # matches the bigrams indexed in chunks_fts
38
+ continue
39
+ parts = split_identifier(ident) if re.match(r"[A-Za-z_]", ident) else [ident.lower()]
40
+ if len(parts) > 1:
41
+ terms.append(ident.lower())
42
+ terms.extend(parts)
43
+ out = []
44
+ for t in terms:
45
+ if t not in _STOPWORDS and t not in out and len(t) > 1:
46
+ out.append(t)
47
+ return out
48
+
49
+
50
+ def _has_word(term: str, text: str) -> bool:
51
+ """True if ``term`` equals the text or one of its camelCase / snake_case / path words."""
52
+ low = text.lower()
53
+ if term == low:
54
+ return True
55
+ if not term.isascii(): # Chinese: no word boundaries, use substring
56
+ return term in text
57
+ words: set[str] = set()
58
+ for piece in re.split(r"[^A-Za-z0-9\u4e00-\u9fff]+", text):
59
+ words.update(split_identifier(piece))
60
+ words.add(piece.lower())
61
+ return term in words
62
+
63
+
64
+ class CodebaseTools:
65
+ """Facade exposing codebase_semantic_search / get_symbol_relations / get_optimized_context."""
66
+
67
+ def __init__(self, root: str, auto_refresh: bool = True, embedder=None):
68
+ self.indexer = Indexer(root, embedder=embedder)
69
+ self.root = self.indexer.root
70
+ self.conn: sqlite3.Connection = self.indexer.conn
71
+ self.embedder = self.indexer.embedder
72
+ self.auto_refresh = auto_refresh
73
+ self.expander = Expander(self.root)
74
+ self._fresh = False
75
+
76
+ def refresh(self, force: bool = False):
77
+ stats = self.indexer.update(force=force)
78
+ self._fresh = True
79
+ return stats
80
+
81
+ def _ensure(self) -> None:
82
+ if self.auto_refresh and not self._fresh:
83
+ self.refresh()
84
+
85
+ # ------------------------------------------------------------------ Tool 1
86
+ def semantic_search(self, query: str, limit: int = 5) -> dict:
87
+ self._ensure()
88
+ limit = max(1, min(int(limit or 5), 50))
89
+ pool = max(limit * 6, 30)
90
+ extra = self.expander.expand(query)
91
+ terms = _query_terms(query)
92
+ for t in _query_terms(" ".join(extra)):
93
+ if t not in terms:
94
+ terms.append(t)
95
+ ranked: dict[str, list[int]] = {"keyword": [], "symbol": [], "vector": []}
96
+ ranks_override: dict[str, dict[int, int]] = {}
97
+
98
+ # keyword layer: FTS5 / bm25 over chunk text
99
+ if terms:
100
+ match = " OR ".join('"' + t.replace('"', "") + '"' for t in terms)
101
+ try:
102
+ rows = self.conn.execute(
103
+ "SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY bm25(chunks_fts) LIMIT ?",
104
+ (match, pool),
105
+ ).fetchall()
106
+ ranked["keyword"] = [r[0] for r in rows]
107
+ except sqlite3.OperationalError:
108
+ pass
109
+
110
+ # symbol-name layer: explicit tokens against symbol / file names (whole sub-word match)
111
+ if terms:
112
+ scores: dict[int, int] = defaultdict(int)
113
+ for t in terms:
114
+ like = f"%{t}%"
115
+ for r in self.conn.execute(
116
+ "SELECT c.rowid, s.symbol_name, s.qualified_name FROM chunk_metadata c "
117
+ "JOIN symbols s ON s.id = c.symbol_id "
118
+ "WHERE lower(s.symbol_name) LIKE ? OR lower(s.qualified_name) LIKE ? LIMIT 500",
119
+ (like, like),
120
+ ):
121
+ if _has_word(t, r["symbol_name"]) or _has_word(t, r["qualified_name"] or ""):
122
+ scores[r[0]] += 1
123
+ for r in self.conn.execute(
124
+ "SELECT rowid, file_path FROM chunk_metadata WHERE lower(file_path) LIKE ? LIMIT 300", (like,)
125
+ ):
126
+ if _has_word(t, r["file_path"]):
127
+ scores[r[0]] += 1
128
+ ordered = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))[:pool]
129
+ ranked["symbol"] = [rid for rid, _ in ordered]
130
+ # equal match counts must share a rank, otherwise arbitrary row order decides
131
+ tie_rank: dict[int, int] = {}
132
+ for pos, (rid, sc) in enumerate(ordered, start=1):
133
+ tie_rank[rid] = next(i for i, (_, s2) in enumerate(ordered, start=1) if s2 == sc)
134
+ ranks_override["symbol"] = tie_rank
135
+
136
+ # vector layer: cosine similarity over local embeddings
137
+ rows = self.conn.execute("SELECT rowid, chunk_embedding FROM vss_chunks").fetchall()
138
+ if rows:
139
+ ids = np.fromiter((r[0] for r in rows), dtype=np.int64, count=len(rows))
140
+ mat = np.frombuffer(b"".join(r[1] for r in rows), dtype=np.float32).reshape(len(rows), -1)
141
+ qtext = query + " " + " ".join(extra) if extra else query
142
+ qv = (self.embedder.embed_query(qtext) if hasattr(self.embedder, "embed_query")
143
+ else self.embedder.embed([qtext])[0])
144
+ if float(np.linalg.norm(qv)) > 0:
145
+ sims = mat @ qv
146
+ top = np.argsort(-sims)[:pool]
147
+ ranked["vector"] = [int(ids[i]) for i in top if sims[i] > 0]
148
+
149
+ fused: dict[int, float] = defaultdict(float)
150
+ via: dict[int, list[str]] = defaultdict(list)
151
+ for name, lst in ranked.items():
152
+ for rank, rid in enumerate(lst, start=1):
153
+ rank = ranks_override.get(name, {}).get(rid, rank)
154
+ fused[rid] += LAYER_WEIGHTS[name] / (RRF_K + rank)
155
+ via[rid].append(name)
156
+ top_ids = [rid for rid, _ in sorted(fused.items(), key=lambda kv: -kv[1])]
157
+
158
+ results, seen_spans = [], set()
159
+ for acc in self._route_data_access(query)[:limit]:
160
+ hit = self._chunk_result(acc)
161
+ if hit is not None and (hit["file_path"], hit["start_line"], hit["end_line"]) not in seen_spans:
162
+ seen_spans.add((hit["file_path"], hit["start_line"], hit["end_line"]))
163
+ results.append(hit)
164
+ for rid in top_ids:
165
+ if len(results) >= limit:
166
+ break
167
+ row = self.conn.execute(
168
+ "SELECT c.file_path, c.start_line, c.end_line, c.raw_text, c.symbol_id, "
169
+ "s.symbol_type, s.qualified_name, s.signature "
170
+ "FROM chunk_metadata c LEFT JOIN symbols s ON s.id = c.symbol_id WHERE c.rowid=?",
171
+ (rid,),
172
+ ).fetchone()
173
+ if row is None:
174
+ continue
175
+ span = (row["file_path"], row["start_line"], row["end_line"])
176
+ if span in seen_spans:
177
+ continue
178
+ seen_spans.add(span)
179
+ snippet_lines = textwrap.dedent(row["raw_text"].rstrip()).split("\n")
180
+ results.append({
181
+ "file_path": row["file_path"],
182
+ "start_line": row["start_line"],
183
+ "end_line": row["end_line"],
184
+ "symbol": row["qualified_name"],
185
+ "symbol_type": row["symbol_type"],
186
+ "signature": row["signature"],
187
+ "score": round(fused[rid], 5),
188
+ "matched_by": via[rid],
189
+ "snippet": "\n".join(snippet_lines[:12]) + ("\n..." if len(snippet_lines) > 12 else ""),
190
+ })
191
+ if len(results) >= limit:
192
+ break
193
+
194
+ return {"query": query, "results": results, "related_files": self._related_files(results[:3])}
195
+
196
+ # ------------------------------------------------------------------ data access routing
197
+ _OBJECT_HINT = re.compile(r"\b(table|tables|procedure|procedures|proc|stored|view|views|sp)\b|資料表|預存程序|存储过程|数据表", re.I)
198
+
199
+ def _route_data_access(self, query: str) -> list[dict]:
200
+ """Queries such as "what writes to MES_Lot" name a known table / procedure and an intent. A ranked chunk
201
+ list cannot enumerate every writer, so those queries get the accessors from the data access index first."""
202
+ intents = dataflow.detect_intents(query)
203
+ if not intents:
204
+ return []
205
+ keys = dataflow.known_object_keys(self.conn)
206
+ hinted = bool(self._OBJECT_HINT.search(query))
207
+ found: list[str] = []
208
+ for ident in re.findall(r"[A-Za-z_][\w$]*(?:\.[\w$]+){0,3}", query):
209
+ key = dataflow.object_key(ident)
210
+ looks_like_object = bool(re.search(r"[_.\d]", ident) or (re.search(r"[a-z]", ident) and re.search(r"[A-Z]", ident[1:])))
211
+ if len(key) >= 3 and key in keys and (looks_like_object or hinted) and key not in found:
212
+ found.append(key)
213
+ out: list[dict] = []
214
+ seen = set()
215
+ for key in found[:2]:
216
+ for acc in dataflow.accessors_for_search(self.conn, key, intents):
217
+ sig = (acc["source_id"], acc["file_path"], acc["line"])
218
+ if sig not in seen:
219
+ seen.add(sig)
220
+ out.append(acc)
221
+ return out
222
+
223
+ def _chunk_result(self, acc: dict) -> dict | None:
224
+ conn = self.conn
225
+ if acc["source_id"]:
226
+ row = conn.execute(
227
+ "SELECT c.file_path, c.start_line, c.end_line, c.raw_text, s.symbol_type, s.qualified_name, s.signature "
228
+ "FROM chunk_metadata c LEFT JOIN symbols s ON s.id = c.symbol_id WHERE c.symbol_id=? "
229
+ "ORDER BY (c.start_line<=? AND c.end_line>=?) DESC, c.start_line LIMIT 1",
230
+ (acc["source_id"], acc["line"], acc["line"])).fetchone()
231
+ else:
232
+ row = conn.execute(
233
+ "SELECT c.file_path, c.start_line, c.end_line, c.raw_text, NULL AS symbol_type, NULL AS qualified_name, "
234
+ "NULL AS signature FROM chunk_metadata c WHERE c.file_path=? AND c.start_line<=? AND c.end_line>=? LIMIT 1",
235
+ (acc["file_path"], acc["line"], acc["line"])).fetchone()
236
+ if row is None:
237
+ return None
238
+ snippet_lines = textwrap.dedent(row["raw_text"].rstrip()).split("\n")
239
+ return {
240
+ "file_path": row["file_path"], "start_line": row["start_line"], "end_line": row["end_line"],
241
+ "symbol": row["qualified_name"], "symbol_type": row["symbol_type"], "signature": row["signature"],
242
+ "score": 1.0, "matched_by": ["data_access"],
243
+ "data_access": {k: acc[k] for k in ("target", "access", "op", "via", "confidence", "line", "depth", "via_chain")},
244
+ "snippet": "\n".join(snippet_lines[:12]) + ("\n..." if len(snippet_lines) > 12 else ""),
245
+ }
246
+
247
+ def data_access(self, object_name: str) -> dict:
248
+ self._ensure()
249
+ return dataflow.data_access(self.conn, object_name)
250
+
251
+ def _related_files(self, seeds: list[dict]) -> list[dict]:
252
+ """Graph traversal: dependencies of / on the top seed files."""
253
+ seed_files = {s["file_path"] for s in seeds}
254
+ related: dict[str, set[str]] = defaultdict(set)
255
+ for f in seed_files:
256
+ for r in self.conn.execute(
257
+ "SELECT target_path AS p, dependency_type AS t FROM dependencies WHERE source_path=?", (f,)
258
+ ):
259
+ related[r["p"]].add("uses:" + r["t"])
260
+ for r in self.conn.execute(
261
+ "SELECT source_path AS p, dependency_type AS t FROM dependencies WHERE target_path=?", (f,)
262
+ ):
263
+ related[r["p"]].add("used-by:" + r["t"])
264
+ out = []
265
+ for p, rels in related.items():
266
+ if p in seed_files:
267
+ continue
268
+ types = [r["symbol_name"] for r in self.conn.execute(
269
+ f"SELECT symbol_name FROM symbols WHERE file_path=? AND symbol_type IN ({','.join('?' * len(_TYPE_KINDS))}) LIMIT 4",
270
+ (p, *_TYPE_KINDS))]
271
+ out.append({"file_path": p, "relations": sorted(rels), "types": types})
272
+ out.sort(key=lambda d: (-len(d["relations"]), d["file_path"]))
273
+ return out[:8]
274
+
275
+ # ------------------------------------------------------------------ Tool 2
276
+ def symbol_relations(self, symbol_name: str, relation_direction: str = "both") -> dict:
277
+ self._ensure()
278
+ if relation_direction not in ("upstream", "downstream", "both"):
279
+ raise ValueError("relation_direction must be 'upstream', 'downstream' or 'both'")
280
+ conn = self.conn
281
+ defs = conn.execute(
282
+ "SELECT * FROM symbols WHERE symbol_name=? AND symbol_type != 'namespace' ORDER BY file_path, start_line LIMIT 8",
283
+ (symbol_name,),
284
+ ).fetchall()
285
+ if not defs:
286
+ like = conn.execute(
287
+ "SELECT DISTINCT symbol_name FROM symbols WHERE symbol_name LIKE ? LIMIT 10", (f"%{symbol_name}%",)
288
+ ).fetchall()
289
+ return {"symbol": symbol_name, "definitions": [], "suggestions": [r[0] for r in like]}
290
+
291
+ type_defs: dict[str, list[sqlite3.Row]] = defaultdict(list)
292
+ for r in conn.execute(
293
+ f"SELECT * FROM symbols WHERE symbol_type IN ({','.join('?' * len(_TYPE_KINDS))})", _TYPE_KINDS
294
+ ):
295
+ type_defs[r["symbol_name"]].append(r)
296
+
297
+ out_defs = []
298
+ for d in defs:
299
+ entry = {
300
+ "id": d["id"], "file_path": d["file_path"], "type": d["symbol_type"],
301
+ "qualified_name": d["qualified_name"], "start_line": d["start_line"],
302
+ "end_line": d["end_line"], "signature": d["signature"],
303
+ }
304
+ if relation_direction in ("upstream", "both"):
305
+ entry["upstream"] = self._upstream(d)
306
+ if relation_direction in ("downstream", "both"):
307
+ entry["downstream"] = self._downstream(d, type_defs)
308
+ out_defs.append(entry)
309
+ return {
310
+ "symbol": symbol_name, "definitions": out_defs,
311
+ "note": ("C# method calls are resolved by receiver type (confirmed / probable / candidate); type references and "
312
+ "fields remain identifier-name matches; inheritance edges come from the AST"),
313
+ }
314
+
315
+ def _type_upstream(self, d: sqlite3.Row) -> dict:
316
+ name = d["symbol_name"]
317
+ conn = self.conn
318
+ inheritors = [
319
+ {"symbol": r["qualified_name"], "type": r["symbol_type"], "file_path": r["file_path"], "line": r["start_line"]}
320
+ for r in conn.execute(
321
+ "SELECT s.* FROM raw_refs r JOIN symbols s ON s.id = r.owner WHERE r.kind='base' AND r.name=? "
322
+ "ORDER BY s.file_path, s.start_line LIMIT 40", (name,))
323
+ ]
324
+ refs = []
325
+ for r in conn.execute(
326
+ "SELECT r.file_path, r.line, r.owner, s.qualified_name AS user_symbol FROM raw_refs r "
327
+ "LEFT JOIN symbols s ON s.id = r.owner WHERE r.kind='ident' AND r.name=? "
328
+ "ORDER BY r.file_path, r.line LIMIT 200", (name,)):
329
+ if r["file_path"] == d["file_path"] and d["start_line"] <= r["line"] <= d["end_line"]:
330
+ continue # inside the definition itself
331
+ refs.append({"file_path": r["file_path"], "line": r["line"], "in_symbol": r["user_symbol"]})
332
+ up = {"inheritors_or_implementers": inheritors, "referenced_by": refs[:60], "referenced_by_total": len(refs)}
333
+ if d["symbol_type"] in _TYPE_KINDS:
334
+ up["instantiated_by"] = self._callers(
335
+ "c.callee_id IN (SELECT id FROM symbols WHERE id=? OR (parent_id=? AND symbol_type='constructor'))",
336
+ (d["id"], d["id"]))[0]
337
+ return up
338
+
339
+ _METHOD_KINDS = ("method", "constructor", "operator")
340
+ _SQL_KINDS = ("procedure", "view", "function", "table")
341
+
342
+ def _callers(self, where: str, params: tuple, limit: int = 200) -> tuple[list[dict], int]:
343
+ order = "CASE c.confidence WHEN 'confirmed' THEN 0 WHEN 'probable' THEN 1 ELSE 2 END, c.file_path, c.line"
344
+ base = f"FROM calls c JOIN symbols s ON s.id = c.caller_id WHERE {where}"
345
+ total = self.conn.execute(f"SELECT COUNT(*) {base}", params).fetchone()[0]
346
+ rows = self.conn.execute(
347
+ f"SELECT s.qualified_name, s.symbol_type, c.file_path, c.line, c.confidence, c.reason {base} "
348
+ f"ORDER BY {order} LIMIT ?", (*params, limit)).fetchall()
349
+ return ([{"symbol": r["qualified_name"], "type": r["symbol_type"], "file_path": r["file_path"],
350
+ "line": r["line"], "confidence": r["confidence"], "reason": r["reason"]} for r in rows], total)
351
+
352
+ def _upstream(self, d: sqlite3.Row) -> dict:
353
+ kind = d["symbol_type"]
354
+ if kind in self._METHOD_KINDS:
355
+ callers, total = self._callers("c.callee_id=?", (d["id"],))
356
+ impls = []
357
+ parent = self.conn.execute("SELECT * FROM symbols WHERE id=?", (d["parent_id"],)).fetchone() if d["parent_id"] else None
358
+ if parent is not None and parent["symbol_type"] == "interface":
359
+ impls = [{"symbol": r["qualified_name"], "file_path": r["file_path"], "line": r["start_line"]}
360
+ for r in self.conn.execute(
361
+ "SELECT m.* FROM raw_refs r JOIN symbols m ON m.parent_id = r.owner "
362
+ "WHERE r.kind='base' AND r.name=? AND m.symbol_name=? AND m.symbol_type='method' "
363
+ "ORDER BY m.file_path, m.start_line", (parent["symbol_name"], d["symbol_name"]))]
364
+ return {"callers": callers, "callers_total": total, "implementations": impls,
365
+ "inheritors_or_implementers": [], "referenced_by": [], "referenced_by_total": 0}
366
+ if kind in self._SQL_KINDS:
367
+ res = dataflow.data_access(self.conn, d["symbol_name"])
368
+ callers = []
369
+ for label, key in (("writes", "writers"), ("reads", "readers"), ("executes", "executed_by"), ("maps", "mapped_by")):
370
+ for e in res.get(key, []):
371
+ callers.append({"symbol": e["source"], "type": e["source_type"], "file_path": e["file_path"],
372
+ "line": e["line"], "confidence": e["confidence"],
373
+ "reason": f'{label} ({e["op"]}, {e["via"]})' + (
374
+ f' via {" > ".join(e["via_chain"])}' if e["via_chain"] else "")})
375
+ return {"callers": callers[:200], "callers_total": len(callers), "implementations": [],
376
+ "inheritors_or_implementers": [], "referenced_by": [], "referenced_by_total": 0}
377
+ return self._type_upstream(d)
378
+
379
+ def _downstream(self, d: sqlite3.Row, type_defs: dict) -> dict:
380
+ kind = d["symbol_type"]
381
+ conn = self.conn
382
+ if kind in self._METHOD_KINDS:
383
+ order = "CASE c.confidence WHEN 'confirmed' THEN 0 WHEN 'probable' THEN 1 ELSE 2 END"
384
+ calls = [{"symbol": r["qualified_name"], "type": r["symbol_type"], "file_path": r["file_path"],
385
+ "line": r["line"], "callee_line": r["start_line"], "confidence": r["confidence"],
386
+ "reason": r["reason"]}
387
+ for r in conn.execute(
388
+ "SELECT s.qualified_name, s.symbol_type, s.file_path, s.start_line, c.line, c.confidence, c.reason "
389
+ f"FROM calls c JOIN symbols s ON s.id = c.callee_id WHERE c.caller_id=? ORDER BY c.line, {order}",
390
+ (d["id"],))]
391
+ down = self._type_downstream(d, type_defs)
392
+ down["calls"] = calls
393
+ down["data_access"] = self._own_data_access(d["id"])
394
+ return down
395
+ if kind in ("procedure", "view", "function"):
396
+ res = dataflow.data_access(conn, d["symbol_name"])
397
+ t = res.get("touches", {})
398
+ down = {"base_types": [], "uses_types": [], "calls": [], "data_access": []}
399
+ for key in ("writes", "reads", "executes", "dynamic_sql"):
400
+ for e in t.get(key, []):
401
+ down["data_access"].append({
402
+ "target": e["target"], "access": e["access"], "op": e["op"], "via": e["via"],
403
+ "confidence": e["confidence"], "line": e["line"], "file_path": e["file_path"],
404
+ "via_chain": e["via_chain"]})
405
+ return down
406
+ return self._type_downstream(d, type_defs)
407
+
408
+ def _own_data_access(self, symbol_id: str) -> list[dict]:
409
+ return [{"target": r["target_name"], "access": r["access"], "op": r["op"], "via": r["via"],
410
+ "confidence": r["confidence"], "line": r["line"], "file_path": r["file_path"], "via_chain": []}
411
+ for r in self.conn.execute(
412
+ "SELECT * FROM data_access WHERE source_id=? ORDER BY line", (symbol_id,))]
413
+
414
+ def _type_downstream(self, d: sqlite3.Row, type_defs: dict) -> dict:
415
+ conn = self.conn
416
+ bases = []
417
+ for r in conn.execute("SELECT name FROM raw_refs WHERE kind='base' AND owner=?", (d["id"],)):
418
+ targets = [{"file_path": t["file_path"], "line": t["start_line"], "type": t["symbol_type"]}
419
+ for t in type_defs.get(r["name"], [])]
420
+ bases.append({"name": r["name"], "defined_in": targets})
421
+ used: dict[str, dict] = {}
422
+ for r in conn.execute(
423
+ "SELECT name, MIN(line) AS line FROM raw_refs WHERE kind='ident' AND file_path=? "
424
+ "AND line BETWEEN ? AND ? GROUP BY name", (d["file_path"], d["start_line"], d["end_line"])):
425
+ nm = r["name"]
426
+ if nm == d["symbol_name"] or nm not in type_defs:
427
+ continue
428
+ used[nm] = {
429
+ "name": nm, "first_line": r["line"],
430
+ "defined_in": [{"file_path": t["file_path"], "line": t["start_line"], "type": t["symbol_type"]}
431
+ for t in type_defs[nm][:5]],
432
+ }
433
+ return {"base_types": bases, "uses_types": sorted(used.values(), key=lambda u: u["first_line"])[:60]}
434
+
435
+ # ------------------------------------------------------------------ Tool 3
436
+ def optimized_context(self, file_path: str, focus_lines: list[int], padding_lines: int = 15) -> dict:
437
+ abs_path = os.path.abspath(os.path.join(self.root, file_path))
438
+ if os.path.commonpath([abs_path, self.root]) != self.root:
439
+ raise ValueError("file_path must stay inside the project root")
440
+ if not os.path.isfile(abs_path):
441
+ raise FileNotFoundError(file_path)
442
+ if not focus_lines:
443
+ raise ValueError("focus_lines must contain at least one line number")
444
+ pad = max(0, int(padding_lines if padding_lines is not None else 15))
445
+
446
+ from .core import decode_bytes # local import to avoid cycles at module load
447
+
448
+ pad_ranges = sorted((max(1, int(l) - pad), int(l) + pad) for l in focus_lines if int(l) >= 1)
449
+ wanted: dict[int, str] = {}
450
+ total = 0
451
+ # stream line by line: never loads a whole (possibly huge) file into memory
452
+ with open(abs_path, "rb") as f:
453
+ for total, raw in enumerate(f, start=1):
454
+ if any(a <= total <= b for a, b in pad_ranges):
455
+ wanted[total] = decode_bytes(raw.rstrip(b"\r\n"))
456
+ lines = wanted
457
+
458
+ ranges = sorted((a, min(total, b)) for a, b in pad_ranges)
459
+ merged: list[list[int]] = []
460
+ for s, e in ranges:
461
+ if merged and s <= merged[-1][1] + 1:
462
+ merged[-1][1] = max(merged[-1][1], e)
463
+ else:
464
+ merged.append([s, e])
465
+
466
+ width = len(str(total))
467
+ parts, size, truncated = [], 0, False
468
+ for s, e in merged:
469
+ block = "\n".join(f"{n:>{width}} | {lines.get(n, '')}" for n in range(s, e + 1))
470
+ if size + len(block) > MAX_CONTEXT_CHARS:
471
+ truncated = True
472
+ break
473
+ parts.append(block)
474
+ size += len(block)
475
+
476
+ symbols = [
477
+ {"symbol": r["qualified_name"], "type": r["symbol_type"], "start_line": r["start_line"], "end_line": r["end_line"]}
478
+ for r in self.conn.execute(
479
+ "SELECT * FROM symbols WHERE file_path=? AND symbol_type != 'namespace' AND start_line<=? AND end_line>=? "
480
+ "ORDER BY (end_line-start_line) LIMIT 5",
481
+ (file_path.replace("\\", "/"), max(int(l) for l in focus_lines), min(int(l) for l in focus_lines)))
482
+ ]
483
+ return {
484
+ "file_path": file_path, "total_lines": total, "ranges": merged,
485
+ "enclosing_symbols": symbols, "truncated": truncated,
486
+ "context": "\n ...\n".join(parts),
487
+ }
488
+
489
+ # ------------------------------------------------------------------ Tool 4
490
+ def index_status(self) -> dict:
491
+ """Report how trustworthy the index is. Never refreshes the index itself."""
492
+ conn = self.conn
493
+ meta = lambda k: dbmod.get_meta(conn, k) # noqa: E731
494
+ indexed = {r["file_path"]: r["last_modified"] for r in conn.execute("SELECT file_path, last_modified FROM files")}
495
+
496
+ on_disk = {sf.rel_path: sf.mtime_ns for sf in iter_files(self.root)}
497
+ new = sorted(set(on_disk) - set(indexed))
498
+ gone = sorted(set(indexed) - set(on_disk))
499
+ touched = sorted(p for p in set(on_disk) & set(indexed) if on_disk[p] != indexed[p])
500
+ failed = [dict(file_path=r["file_path"], error=r["error"], failed_at=r["failed_at"])
501
+ for r in conn.execute("SELECT * FROM index_failures ORDER BY file_path")]
502
+ failed_paths = {f["file_path"] for f in failed}
503
+ new = [p for p in new if p not in failed_paths] # already reported as failures
504
+
505
+ git_now = git_info(self.root)
506
+ git_then = {"commit": meta("git_commit"), "branch": meta("git_branch"),
507
+ "dirty_files": int(meta("git_dirty_files") or 0)} if meta("git_commit") else None
508
+ head_moved = bool(git_now and git_then and git_now["commit"] != git_then["commit"])
509
+
510
+ problems = self._consistency_problems()
511
+ last = meta("last_indexed_at")
512
+
513
+ outdated_format = bool(indexed) and meta("index_version") != dbmod.INDEX_VERSION
514
+ if not indexed and not failed:
515
+ state = "not_indexed"
516
+ elif problems or failed:
517
+ state = "degraded"
518
+ elif new or gone or touched or head_moved or outdated_format:
519
+ state = "stale"
520
+ else:
521
+ state = "fresh"
522
+
523
+ def cap(items):
524
+ return {"count": len(items), "examples": items[:10]}
525
+
526
+ return {
527
+ "state": state,
528
+ "root": self.root,
529
+ "embedder": meta("embedder"),
530
+ "index_version": meta("index_version"),
531
+ "index_format_outdated": outdated_format,
532
+ "last_indexed_at": int(last) if last else None,
533
+ "indexed_files": len(indexed),
534
+ "symbols": conn.execute("SELECT COUNT(*) FROM symbols").fetchone()[0],
535
+ "chunks": conn.execute("SELECT COUNT(*) FROM chunk_metadata").fetchone()[0],
536
+ "git": {"indexed": git_then, "current": git_now, "head_moved": head_moved},
537
+ "pending_changes": {
538
+ "new_files": cap(new), "deleted_files": cap(gone),
539
+ "possibly_modified_files": cap(touched),
540
+ "note": "modified is judged by mtime only; a touched file with identical content is re-checked by md5 on the next update",
541
+ },
542
+ "failed_files": failed,
543
+ "consistency": {"ok": not problems, "problems": problems},
544
+ }
545
+
546
+ def _consistency_problems(self) -> list[str]:
547
+ c = self.conn
548
+ one = lambda sql: c.execute(sql).fetchone()[0] # noqa: E731
549
+ checks = [
550
+ ("chunk_metadata rows without a vector",
551
+ "SELECT COUNT(*) FROM chunk_metadata WHERE rowid NOT IN (SELECT rowid FROM vss_chunks)"),
552
+ ("vectors without chunk_metadata",
553
+ "SELECT COUNT(*) FROM vss_chunks WHERE rowid NOT IN (SELECT rowid FROM chunk_metadata)"),
554
+ ("chunk_metadata rows without a full-text entry",
555
+ "SELECT COUNT(*) FROM chunk_metadata WHERE rowid NOT IN (SELECT rowid FROM chunks_fts)"),
556
+ ("full-text entries without chunk_metadata",
557
+ "SELECT COUNT(*) FROM chunks_fts WHERE rowid NOT IN (SELECT rowid FROM chunk_metadata)"),
558
+ ("chunks of files that are not indexed",
559
+ "SELECT COUNT(*) FROM chunk_metadata WHERE file_path NOT IN (SELECT file_path FROM files)"),
560
+ ("symbols of files that are not indexed",
561
+ "SELECT COUNT(*) FROM symbols WHERE file_path NOT IN (SELECT file_path FROM files)"),
562
+ ("references of files that are not indexed",
563
+ "SELECT COUNT(*) FROM raw_refs WHERE file_path NOT IN (SELECT file_path FROM files)"),
564
+ ("dependencies pointing at files that are not indexed",
565
+ "SELECT COUNT(*) FROM dependencies WHERE source_path NOT IN (SELECT file_path FROM files)"
566
+ " OR target_path NOT IN (SELECT file_path FROM files)"),
567
+ ("data access rows of files that are not indexed",
568
+ "SELECT COUNT(*) FROM data_access WHERE file_path NOT IN (SELECT file_path FROM files)"),
569
+ ("call sites of files that are not indexed",
570
+ "SELECT COUNT(*) FROM call_sites WHERE file_path NOT IN (SELECT file_path FROM files)"),
571
+ ("calls whose caller or callee symbol is missing",
572
+ "SELECT COUNT(*) FROM calls WHERE caller_id NOT IN (SELECT id FROM symbols)"
573
+ " OR callee_id NOT IN (SELECT id FROM symbols)"),
574
+ ]
575
+ return [f"{n} {label}" for label, sql in checks if (n := one(sql))]
576
+
577
+ # ------------------------------------------------------------------ misc
578
+ def stats(self) -> dict:
579
+ c = self.conn
580
+ langs = {r[0]: r[1] for r in c.execute("SELECT language, COUNT(*) FROM files GROUP BY language")}
581
+ return {
582
+ "root": self.root, "db": self.indexer.db_path, "embedder": self.embedder.name,
583
+ "files": c.execute("SELECT COUNT(*) FROM files").fetchone()[0],
584
+ "symbols": c.execute("SELECT COUNT(*) FROM symbols").fetchone()[0],
585
+ "chunks": c.execute("SELECT COUNT(*) FROM chunk_metadata").fetchone()[0],
586
+ "dependencies": c.execute("SELECT COUNT(*) FROM dependencies").fetchone()[0],
587
+ "calls": c.execute("SELECT COUNT(*) FROM calls").fetchone()[0],
588
+ "data_access": c.execute("SELECT COUNT(*) FROM data_access").fetchone()[0],
589
+ "languages": langs,
590
+ }
591
+
592
+
593
+ # ---------------------------------------------------------------------- text rendering
594
+
595
+ def render_search(res: dict) -> str:
596
+ out = [f'Search: "{res["query"]}" ({len(res["results"])} results)']
597
+ if not res["results"]:
598
+ out.append("No matches.")
599
+ for i, r in enumerate(res["results"], 1):
600
+ head = f'[{i}] {r["file_path"]}:{r["start_line"]}-{r["end_line"]}'
601
+ if r["symbol"]:
602
+ head += f' {r["symbol_type"]} {r["symbol"]}'
603
+ out.append(f'{head} (score {r["score"]}, via {"+".join(r["matched_by"])})')
604
+ if r["signature"]:
605
+ out.append(f' sig: {r["signature"]}')
606
+ da = r.get("data_access")
607
+ if da:
608
+ chain = f' via {" > ".join(da["via_chain"])}' if da["via_chain"] else ""
609
+ out.append(f' data access: {da["access"]}/{da["op"]} {da["target"]} '
610
+ f'[{da["confidence"]}, {da["via"]}] line {da["line"]}{chain}')
611
+ out.extend(" " + ln for ln in r["snippet"].split("\n"))
612
+ if res["related_files"]:
613
+ out.append("Related files (dependency graph):")
614
+ for rf in res["related_files"]:
615
+ t = f' [{", ".join(rf["types"])}]' if rf["types"] else ""
616
+ out.append(f' {rf["file_path"]}{t} <- {", ".join(rf["relations"])}')
617
+ return "\n".join(out)
618
+
619
+
620
+ def render_relations(res: dict) -> str:
621
+ if not res["definitions"]:
622
+ msg = f'Symbol "{res["symbol"]}" not found.'
623
+ if res.get("suggestions"):
624
+ msg += " Similar: " + ", ".join(res["suggestions"])
625
+ return msg
626
+ out = []
627
+ for d in res["definitions"]:
628
+ out.append(f'{d["type"]} {d["qualified_name"]} {d["file_path"]}:{d["start_line"]}-{d["end_line"]}')
629
+ if d.get("signature"):
630
+ out.append(f' sig: {d["signature"]}')
631
+ up = d.get("upstream")
632
+ if up is not None:
633
+ out.append(" UPSTREAM (who depends on it)")
634
+ for x in up.get("callers", [])[:30]:
635
+ out.append(f' [{x["confidence"]}] {x["type"]} {x["symbol"]} {x["file_path"]}:{x["line"]} ({x["reason"]})')
636
+ if up.get("callers_total", 0) > 30:
637
+ out.append(f' ... {up["callers_total"] - 30} more')
638
+ for x in up.get("implementations", []):
639
+ out.append(f' implemented by {x["symbol"]} {x["file_path"]}:{x["line"]}')
640
+ for x in up.get("instantiated_by", [])[:15]:
641
+ out.append(f' [{x["confidence"]}] instantiated in {x["symbol"]} {x["file_path"]}:{x["line"]}')
642
+ for x in up["inheritors_or_implementers"]:
643
+ out.append(f' inherited/implemented by {x["type"]} {x["symbol"]} {x["file_path"]}:{x["line"]}')
644
+ for x in up["referenced_by"][:25]:
645
+ out.append(f' referenced at {x["file_path"]}:{x["line"]}' + (f' in {x["in_symbol"]}' if x["in_symbol"] else ""))
646
+ if up["referenced_by_total"] > 25:
647
+ out.append(f' ... {up["referenced_by_total"] - 25} more references')
648
+ down = d.get("downstream")
649
+ if down is not None:
650
+ out.append(" DOWNSTREAM (what it depends on)")
651
+ for x in down.get("calls", [])[:40]:
652
+ out.append(f' [{x["confidence"]}] calls {x["type"]} {x["symbol"]} {x["file_path"]}:{x["callee_line"]}'
653
+ f' (line {x["line"]}; {x["reason"]})')
654
+ for x in down.get("data_access", [])[:40]:
655
+ chain = f' via {" > ".join(x["via_chain"])}' if x.get("via_chain") else ""
656
+ out.append(f' [{x["confidence"]}] {x["access"]}/{x["op"]} {x["target"]} (line {x["line"]}; {x["via"]}){chain}')
657
+ for b in down["base_types"]:
658
+ where = ", ".join(f'{t["file_path"]}:{t["line"]}' for t in b["defined_in"]) or "external/unresolved"
659
+ out.append(f' base/interface {b["name"]} -> {where}')
660
+ for u in down["uses_types"][:30]:
661
+ where = ", ".join(f'{t["file_path"]}:{t["line"]}' for t in u["defined_in"])
662
+ out.append(f' uses {u["name"]} (line {u["first_line"]}) -> {where}')
663
+ if res.get("note"):
664
+ out.append(f'note: {res["note"]}')
665
+ return "\n".join(out)
666
+
667
+
668
+ def _da_line(e: dict) -> str:
669
+ chain = f' via {" > ".join(e["via_chain"])}' if e["via_chain"] else ""
670
+ return (f' [{e["confidence"]}] {e["source_type"]} {e["source"]} {e["file_path"]}:{e["line"]} '
671
+ f'{e["op"]} ({e["via"]}){chain}')
672
+
673
+
674
+ def render_data_access(res: dict) -> str:
675
+ if not res["found"]:
676
+ msg = f'No table or procedure named "{res["object"]}" found.'
677
+ if res.get("suggestions"):
678
+ msg += " Similar: " + ", ".join(res["suggestions"])
679
+ return msg
680
+ out = [f'{res["kind"]} {res["object"]}']
681
+ for d in res["definitions"]:
682
+ out.append(f' defined in {d["file_path"]}:{d["line"]}')
683
+ sections = [("WRITTEN BY", "writers"), ("READ BY", "readers"), ("EXECUTED BY", "executed_by"),
684
+ ("MAPPED BY (EF)", "mapped_by")]
685
+ for title, key in sections:
686
+ items = res[key]
687
+ if items:
688
+ out.append(f" {title} ({len(items)})")
689
+ out.extend(_da_line(e) for e in items[:40])
690
+ if len(items) > 40:
691
+ out.append(f" ... {len(items) - 40} more")
692
+ t = res.get("touches")
693
+ if t:
694
+ for title, key in (("WRITES", "writes"), ("READS", "reads"), ("EXECUTES", "executes")):
695
+ if t[key]:
696
+ out.append(f" {title} ({len(t[key])})")
697
+ for e in t[key][:40]:
698
+ chain = f' via {" > ".join(e["via_chain"])}' if e["via_chain"] else ""
699
+ out.append(f' [{e["confidence"]}] {e["target"]} {e["file_path"]}:{e["line"]} {e["op"]}{chain}')
700
+ if t["dynamic_sql"]:
701
+ out.append(f' DYNAMIC SQL inside ({len(t["dynamic_sql"])}) - targets cannot be determined')
702
+ for e in t["dynamic_sql"][:10]:
703
+ out.append(f' {e["file_path"]}:{e["line"]} {e["evidence"][:100]}')
704
+ if res["possible"]:
705
+ out.append(f' POSSIBLE (uncertain, dynamic SQL) ({len(res["possible"])})')
706
+ out.extend(_da_line(e) for e in res["possible"][:20])
707
+ if not any(res[k] for _t, k in sections) and not (t and any(t[k] for k in ("reads", "writes", "executes"))):
708
+ out.append(" no recorded access")
709
+ for n in res.get("notes", []):
710
+ out.append(f"note: {n}")
711
+ return "\n".join(out)
712
+
713
+
714
+ def render_status(st: dict) -> str:
715
+ import datetime as _dt
716
+
717
+ when = _dt.datetime.fromtimestamp(st["last_indexed_at"]).strftime("%Y-%m-%d %H:%M:%S") if st["last_indexed_at"] else "never"
718
+ out = [f'index state: {st["state"].upper()}',
719
+ f'last indexed: {when} files: {st["indexed_files"]} symbols: {st["symbols"]} chunks: {st["chunks"]}',
720
+ f'embedder: {st["embedder"]} index version: {st["index_version"]}']
721
+ g = st["git"]
722
+ if g["current"] is None and g["indexed"] is None:
723
+ out.append("git: not a git work tree")
724
+ else:
725
+ for label, info in (("indexed at", g["indexed"]), ("current ", g["current"])):
726
+ if info:
727
+ out.append(f'git {label}: {info["commit"][:10]} ({info["branch"] or "?"}), {info["dirty_files"]} uncommitted paths')
728
+ if g["head_moved"]:
729
+ out.append("git: HEAD has moved since the last index update")
730
+ pc = st["pending_changes"]
731
+ for key, label in (("new_files", "new"), ("deleted_files", "deleted"), ("possibly_modified_files", "possibly modified")):
732
+ if pc[key]["count"]:
733
+ more = " ..." if pc[key]["count"] > len(pc[key]["examples"]) else ""
734
+ out.append(f'{label} since last update ({pc[key]["count"]}): ' + ", ".join(pc[key]["examples"]) + more)
735
+ if st["failed_files"]:
736
+ out.append(f'failed files ({len(st["failed_files"])}), not searchable until fixed:')
737
+ out.extend(f' {f["file_path"]}: {f["error"]}' for f in st["failed_files"][:20])
738
+ if st["consistency"]["ok"]:
739
+ out.append("consistency: ok")
740
+ else:
741
+ out.append("consistency problems (run `index --force` to rebuild):")
742
+ out.extend(" " + p for p in st["consistency"]["problems"])
743
+ if st.get("index_format_outdated"):
744
+ out.append("index was built by an older version and will be rebuilt on the next update")
745
+ if st["state"] in ("stale", "not_indexed"):
746
+ out.append("hint: run `bin/indexer index` (search/relations/context also refresh automatically)")
747
+ return "\n".join(out)
748
+
749
+
750
+ def render_context(res: dict) -> str:
751
+ head = f'{res["file_path"]} ({res["total_lines"]} lines), ranges: ' + ", ".join(f"{a}-{b}" for a, b in res["ranges"])
752
+ if res["enclosing_symbols"]:
753
+ head += "\nenclosing: " + "; ".join(
754
+ f'{s["type"]} {s["symbol"]} ({s["start_line"]}-{s["end_line"]})' for s in res["enclosing_symbols"])
755
+ tail = "\n[truncated: output limit reached; request fewer or narrower ranges]" if res["truncated"] else ""
756
+ return head + "\n" + res["context"] + tail