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.
- local_codebase_indexer/__init__.py +3 -0
- local_codebase_indexer/__main__.py +5 -0
- local_codebase_indexer/callgraph.py +279 -0
- local_codebase_indexer/cli.py +122 -0
- local_codebase_indexer/core.py +323 -0
- local_codebase_indexer/cscalls.py +304 -0
- local_codebase_indexer/csdata.py +450 -0
- local_codebase_indexer/dataflow.py +250 -0
- local_codebase_indexer/db.py +194 -0
- local_codebase_indexer/embedder.py +137 -0
- local_codebase_indexer/langparse.py +569 -0
- local_codebase_indexer/mcp_server.py +136 -0
- local_codebase_indexer/query.py +756 -0
- local_codebase_indexer/scanner.py +101 -0
- local_codebase_indexer/sqlaccess.py +301 -0
- local_codebase_indexer/synonyms.py +101 -0
- local_codebase_indexer-0.2.0.dist-info/METADATA +118 -0
- local_codebase_indexer-0.2.0.dist-info/RECORD +21 -0
- local_codebase_indexer-0.2.0.dist-info/WHEEL +5 -0
- local_codebase_indexer-0.2.0.dist-info/entry_points.txt +2 -0
- local_codebase_indexer-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|