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,250 @@
|
|
|
1
|
+
"""Data access queries: who reads / writes / executes a table or stored procedure, and what a procedure touches.
|
|
2
|
+
|
|
3
|
+
Direct accesses come from the ``data_access`` table. Indirect ones follow SQL objects: if procedure P writes
|
|
4
|
+
table T, then everything that executes P (C# code or other procedures) is an indirect writer of T, found up to
|
|
5
|
+
``MAX_DEPTH`` hops. Accesses with ``uncertain`` confidence (dynamic SQL) are never mixed into the confirmed lists;
|
|
6
|
+
they are reported as ``possible``. Call chains between C# methods are not followed here, use ``relations``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
from collections import deque
|
|
14
|
+
|
|
15
|
+
MAX_DEPTH = 4
|
|
16
|
+
LIST_CAP = 200
|
|
17
|
+
SQL_OBJECT_KINDS = ("table", "procedure", "view", "function")
|
|
18
|
+
_CONF = {"confirmed": 0, "probable": 1, "uncertain": 2}
|
|
19
|
+
|
|
20
|
+
WRITE_WORDS = {
|
|
21
|
+
"write", "writes", "written", "writing", "update", "updates", "modify", "modifies", "modifying", "insert",
|
|
22
|
+
"inserts", "delete", "deletes", "change", "changes", "save", "saves", "persist", "persists", "populate",
|
|
23
|
+
"populates", "寫入", "寫", "更新", "修改", "新增", "刪除", "異動", "變更", "儲存",
|
|
24
|
+
"写入", "写", "删除", "变更", "储存",
|
|
25
|
+
}
|
|
26
|
+
READ_WORDS = {
|
|
27
|
+
"read", "reads", "select", "selects", "query", "queries", "fetch", "fetches", "load", "loads", "retrieve",
|
|
28
|
+
"retrieves", "讀取", "讀", "查詢", "取得", "撈", "读取", "读", "查询", "取得",
|
|
29
|
+
}
|
|
30
|
+
TOUCH_WORDS = {
|
|
31
|
+
"touch", "touches", "access", "accesses", "reference", "references", "use", "uses", "used", "using",
|
|
32
|
+
"depend", "depends", "involving", "存取", "使用", "用到", "相關", "涉及", "相关",
|
|
33
|
+
}
|
|
34
|
+
CALL_WORDS = {
|
|
35
|
+
"call", "calls", "called", "execute", "executes", "executed", "invoke", "invokes", "invoked", "exec", "run",
|
|
36
|
+
"runs", "呼叫", "執行", "被誰", "誰用", "调用", "执行", "被谁",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def object_key(name: str) -> str:
|
|
41
|
+
"""Lower-case last segment of a possibly schema / database qualified, bracketed name."""
|
|
42
|
+
parts = [p.strip("[]\"` ") for p in name.strip().split(".")]
|
|
43
|
+
return (parts[-1] if parts else "").lower()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detect_intents(query: str) -> set[str]:
|
|
47
|
+
words = set(re.findall(r"[A-Za-z]+", query.lower()))
|
|
48
|
+
out = set()
|
|
49
|
+
for intent, vocab in (("write", WRITE_WORDS), ("read", READ_WORDS), ("touch", TOUCH_WORDS), ("call", CALL_WORDS)):
|
|
50
|
+
if words & vocab or any((not v.isascii()) and v in query for v in vocab):
|
|
51
|
+
out.add(intent)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def known_object_keys(conn: sqlite3.Connection) -> set[str]:
|
|
56
|
+
keys = {r[0] for r in conn.execute("SELECT DISTINCT target_key FROM data_access WHERE target_key != '?'")}
|
|
57
|
+
keys |= {r[0].lower() for r in conn.execute(
|
|
58
|
+
f"SELECT symbol_name FROM symbols WHERE symbol_type IN ({','.join('?' * len(SQL_OBJECT_KINDS))})",
|
|
59
|
+
SQL_OBJECT_KINDS)}
|
|
60
|
+
return keys
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Lookup:
|
|
64
|
+
def __init__(self, conn: sqlite3.Connection):
|
|
65
|
+
self.conn = conn
|
|
66
|
+
self._sym: dict[str, sqlite3.Row | None] = {}
|
|
67
|
+
|
|
68
|
+
def symbol(self, sid: str | None):
|
|
69
|
+
if not sid:
|
|
70
|
+
return None
|
|
71
|
+
if sid not in self._sym:
|
|
72
|
+
self._sym[sid] = self.conn.execute(
|
|
73
|
+
"SELECT id, symbol_name, symbol_type, qualified_name, file_path, start_line FROM symbols WHERE id=?",
|
|
74
|
+
(sid,)).fetchone()
|
|
75
|
+
return self._sym[sid]
|
|
76
|
+
|
|
77
|
+
def entry(self, row: sqlite3.Row, depth: int = 0, chain: list[str] | None = None) -> dict:
|
|
78
|
+
s = self.symbol(row["source_id"])
|
|
79
|
+
return {
|
|
80
|
+
"source": (s["qualified_name"] or s["symbol_name"]) if s else row["file_path"],
|
|
81
|
+
"source_type": s["symbol_type"] if s else "file",
|
|
82
|
+
"source_id": row["source_id"],
|
|
83
|
+
"file_path": row["file_path"],
|
|
84
|
+
"line": row["line"],
|
|
85
|
+
"target": row["target_name"],
|
|
86
|
+
"op": row["op"],
|
|
87
|
+
"access": row["access"],
|
|
88
|
+
"via": row["via"],
|
|
89
|
+
"confidence": row["confidence"],
|
|
90
|
+
"evidence": row["evidence"],
|
|
91
|
+
"detail": row["detail"],
|
|
92
|
+
"depth": depth,
|
|
93
|
+
"via_chain": chain or [],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _sort(entries: list[dict]) -> list[dict]:
|
|
98
|
+
return sorted(entries, key=lambda e: (e["depth"], _CONF[e["confidence"]], e["file_path"], e["line"]))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _definitions(conn: sqlite3.Connection, key: str) -> list[dict]:
|
|
102
|
+
return [
|
|
103
|
+
{"file_path": r["file_path"], "line": r["start_line"], "type": r["symbol_type"],
|
|
104
|
+
"qualified_name": r["qualified_name"], "id": r["id"]}
|
|
105
|
+
for r in conn.execute(
|
|
106
|
+
f"SELECT id, file_path, start_line, symbol_type, qualified_name FROM symbols "
|
|
107
|
+
f"WHERE lower(symbol_name)=? AND symbol_type IN ({','.join('?' * len(SQL_OBJECT_KINDS))}) "
|
|
108
|
+
f"ORDER BY file_path, start_line", (key, *SQL_OBJECT_KINDS))
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _expand_indirect(conn: sqlite3.Connection, lk: _Lookup, direct: list[dict], max_depth: int) -> list[dict]:
|
|
113
|
+
"""Accessors of the SQL objects that themselves access the target (procedures executing procedures, views
|
|
114
|
+
read by code, ...). Each result keeps the effective access of the direct entry it descends from."""
|
|
115
|
+
out: list[dict] = []
|
|
116
|
+
seen = {(e["source_id"], e["line"]) for e in direct}
|
|
117
|
+
queue = deque()
|
|
118
|
+
for e in direct:
|
|
119
|
+
if e["source_type"] in ("procedure", "view", "function"):
|
|
120
|
+
queue.append((e, e["via_chain"] + [lk.symbol(e["source_id"])["symbol_name"]]))
|
|
121
|
+
while queue:
|
|
122
|
+
entry, chain = queue.popleft()
|
|
123
|
+
if len(chain) > max_depth:
|
|
124
|
+
continue
|
|
125
|
+
key = chain[-1].lower()
|
|
126
|
+
for r in conn.execute(
|
|
127
|
+
"SELECT * FROM data_access WHERE target_key=? AND access IN ('exec','read') AND confidence != 'uncertain'",
|
|
128
|
+
(key,),
|
|
129
|
+
):
|
|
130
|
+
sig = (r["source_id"], r["line"])
|
|
131
|
+
if sig in seen:
|
|
132
|
+
continue
|
|
133
|
+
seen.add(sig)
|
|
134
|
+
e = lk.entry(r, depth=len(chain), chain=list(chain))
|
|
135
|
+
e["access"], e["op"] = entry["access"], entry["op"] # effective access on the original target
|
|
136
|
+
out.append(e)
|
|
137
|
+
if e["source_type"] in ("procedure", "view", "function"):
|
|
138
|
+
queue.append((e, chain + [lk.symbol(e["source_id"])["symbol_name"]]))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _touches(conn: sqlite3.Connection, lk: _Lookup, definitions: list[dict], max_depth: int) -> dict:
|
|
143
|
+
"""What a procedure / view / function reads, writes and executes, following nested procedure calls."""
|
|
144
|
+
reads, writes, execs, dynamic = [], [], [], []
|
|
145
|
+
seen_obj = set()
|
|
146
|
+
queue = deque((d["id"], [], 0) for d in definitions)
|
|
147
|
+
while queue:
|
|
148
|
+
sid, chain, depth = queue.popleft()
|
|
149
|
+
if sid in seen_obj:
|
|
150
|
+
continue
|
|
151
|
+
seen_obj.add(sid)
|
|
152
|
+
for r in conn.execute("SELECT * FROM data_access WHERE source_id=? ORDER BY line", (sid,)):
|
|
153
|
+
e = lk.entry(r, depth=depth, chain=chain)
|
|
154
|
+
if r["op"] == "dynamic" or r["confidence"] == "uncertain":
|
|
155
|
+
dynamic.append(e)
|
|
156
|
+
elif r["access"] == "write":
|
|
157
|
+
writes.append(e)
|
|
158
|
+
elif r["access"] == "read":
|
|
159
|
+
reads.append(e)
|
|
160
|
+
elif r["access"] == "exec":
|
|
161
|
+
execs.append(e)
|
|
162
|
+
if depth < max_depth:
|
|
163
|
+
for d in _definitions(conn, r["target_key"]):
|
|
164
|
+
if d["type"] in ("procedure", "function", "view"):
|
|
165
|
+
queue.append((d["id"], chain + [r["target_name"]], depth + 1))
|
|
166
|
+
return {"reads": _sort(reads)[:LIST_CAP], "writes": _sort(writes)[:LIST_CAP],
|
|
167
|
+
"executes": _sort(execs)[:LIST_CAP], "dynamic_sql": _sort(dynamic)[:LIST_CAP]}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def data_access(conn: sqlite3.Connection, object_name: str, max_depth: int = MAX_DEPTH) -> dict:
|
|
171
|
+
key = object_key(object_name)
|
|
172
|
+
lk = _Lookup(conn)
|
|
173
|
+
defs = _definitions(conn, key)
|
|
174
|
+
rows = conn.execute("SELECT * FROM data_access WHERE target_key=? ORDER BY file_path, line", (key,)).fetchall()
|
|
175
|
+
if not defs and not rows:
|
|
176
|
+
like = [r[0] for r in conn.execute(
|
|
177
|
+
"SELECT DISTINCT target_name FROM data_access WHERE target_key LIKE ? AND target_key != '?' LIMIT 10",
|
|
178
|
+
(f"%{key}%",))]
|
|
179
|
+
like += [r[0] for r in conn.execute(
|
|
180
|
+
f"SELECT DISTINCT symbol_name FROM symbols WHERE symbol_type IN ({','.join('?' * len(SQL_OBJECT_KINDS))}) "
|
|
181
|
+
f"AND lower(symbol_name) LIKE ? LIMIT 10", (*SQL_OBJECT_KINDS, f"%{key}%"))]
|
|
182
|
+
return {"object": object_name, "key": key, "found": False, "suggestions": sorted(set(like))[:10]}
|
|
183
|
+
|
|
184
|
+
if defs:
|
|
185
|
+
kind = defs[0]["type"]
|
|
186
|
+
else:
|
|
187
|
+
kinds = [r["target_kind"] for r in rows]
|
|
188
|
+
kind = max(set(kinds), key=kinds.count) if kinds else "unknown"
|
|
189
|
+
|
|
190
|
+
direct = [lk.entry(r) for r in rows]
|
|
191
|
+
confirmed = [e for e in direct if e["confidence"] != "uncertain"]
|
|
192
|
+
possible = [e for e in direct if e["confidence"] == "uncertain"]
|
|
193
|
+
indirect = _expand_indirect(conn, lk, [e for e in confirmed if e["access"] in ("write", "read", "exec")], max_depth)
|
|
194
|
+
|
|
195
|
+
def pick(access: str) -> list[dict]:
|
|
196
|
+
return _sort([e for e in confirmed + indirect if e["access"] == access])
|
|
197
|
+
|
|
198
|
+
result = {
|
|
199
|
+
"object": object_name, "key": key, "found": True, "kind": kind, "definitions": defs,
|
|
200
|
+
"readers": pick("read")[:LIST_CAP],
|
|
201
|
+
"writers": pick("write")[:LIST_CAP],
|
|
202
|
+
"executed_by": pick("exec")[:LIST_CAP],
|
|
203
|
+
"mapped_by": _sort([e for e in confirmed if e["access"] == "map"]),
|
|
204
|
+
"possible": _sort(possible)[:LIST_CAP],
|
|
205
|
+
"notes": [],
|
|
206
|
+
}
|
|
207
|
+
if kind in ("procedure", "function", "view") and defs:
|
|
208
|
+
result["touches"] = _touches(conn, lk, [d for d in defs if d["type"] == kind], max_depth)
|
|
209
|
+
# dynamic SQL elsewhere whose text names this object
|
|
210
|
+
word = re.compile(rf"(?<![\w$]){re.escape(key)}(?![\w$])", re.I)
|
|
211
|
+
for r in conn.execute("SELECT * FROM data_access WHERE target_key='?' AND op='dynamic'"):
|
|
212
|
+
if word.search(r["evidence"] or ""):
|
|
213
|
+
result["possible"].append(lk.entry(r))
|
|
214
|
+
result["notes"].append(
|
|
215
|
+
"Static analysis: dynamic SQL is listed under 'possible' and cannot be resolved. C# call chains are not "
|
|
216
|
+
"followed; use the relations tool for who calls a C# method.")
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def accessors_for_search(conn: sqlite3.Connection, key: str, intents: set[str]) -> list[dict]:
|
|
221
|
+
"""Ordered accessors of ``key`` that match the intents of a natural language query (empty when the intent
|
|
222
|
+
does not apply to the object)."""
|
|
223
|
+
res = data_access(conn, key, max_depth=2)
|
|
224
|
+
if not res["found"]:
|
|
225
|
+
return []
|
|
226
|
+
kind = res["kind"]
|
|
227
|
+
picked: list[dict] = []
|
|
228
|
+
if kind in ("procedure", "function"):
|
|
229
|
+
if intents & {"call", "touch"}:
|
|
230
|
+
picked = res["executed_by"]
|
|
231
|
+
else:
|
|
232
|
+
want = set()
|
|
233
|
+
if "write" in intents:
|
|
234
|
+
want.add("write")
|
|
235
|
+
if "read" in intents:
|
|
236
|
+
want.add("read")
|
|
237
|
+
if "touch" in intents or "call" in intents:
|
|
238
|
+
want |= {"read", "write"}
|
|
239
|
+
for e in res["writers"] + res["readers"]:
|
|
240
|
+
if e["access"] in want:
|
|
241
|
+
picked.append(e)
|
|
242
|
+
if "touch" in intents or "call" in intents:
|
|
243
|
+
picked += res["mapped_by"]
|
|
244
|
+
seen, out = set(), []
|
|
245
|
+
for e in _sort(picked):
|
|
246
|
+
sig = (e["source_id"], e["file_path"], e["line"])
|
|
247
|
+
if sig not in seen:
|
|
248
|
+
seen.add(sig)
|
|
249
|
+
out.append(e)
|
|
250
|
+
return out
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""SQLite storage layer: schema from index.md section 2 plus a few helper tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
|
|
8
|
+
INDEX_DIR_NAME = ".ai_context"
|
|
9
|
+
DB_NAME = "indexer.db"
|
|
10
|
+
|
|
11
|
+
SCHEMA = """
|
|
12
|
+
-- 1. File metadata cache
|
|
13
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
14
|
+
file_path TEXT PRIMARY KEY,
|
|
15
|
+
last_modified INTEGER NOT NULL,
|
|
16
|
+
md5_hash TEXT NOT NULL,
|
|
17
|
+
language TEXT NOT NULL
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
-- 2. Syntactic symbol table (AST extraction)
|
|
21
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
file_path TEXT NOT NULL,
|
|
24
|
+
symbol_name TEXT NOT NULL,
|
|
25
|
+
symbol_type TEXT NOT NULL,
|
|
26
|
+
parent_id TEXT,
|
|
27
|
+
start_line INTEGER NOT NULL,
|
|
28
|
+
end_line INTEGER NOT NULL,
|
|
29
|
+
signature TEXT,
|
|
30
|
+
qualified_name TEXT,
|
|
31
|
+
type_name TEXT,
|
|
32
|
+
min_args INTEGER,
|
|
33
|
+
max_args INTEGER,
|
|
34
|
+
FOREIGN KEY(file_path) REFERENCES files(file_path)
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(symbol_name);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path);
|
|
38
|
+
|
|
39
|
+
-- 3. Dependency topology (file level)
|
|
40
|
+
CREATE TABLE IF NOT EXISTS dependencies (
|
|
41
|
+
source_path TEXT NOT NULL,
|
|
42
|
+
target_path TEXT NOT NULL,
|
|
43
|
+
dependency_type TEXT NOT NULL,
|
|
44
|
+
PRIMARY KEY (source_path, target_path, dependency_type)
|
|
45
|
+
);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_dep_target ON dependencies(target_path);
|
|
47
|
+
|
|
48
|
+
-- Raw, unresolved references captured from the AST (import / base / ident).
|
|
49
|
+
-- Dependencies and symbol relations are resolved from this table.
|
|
50
|
+
CREATE TABLE IF NOT EXISTS raw_refs (
|
|
51
|
+
file_path TEXT NOT NULL,
|
|
52
|
+
kind TEXT NOT NULL,
|
|
53
|
+
name TEXT NOT NULL,
|
|
54
|
+
line INTEGER NOT NULL,
|
|
55
|
+
owner TEXT NOT NULL DEFAULT '',
|
|
56
|
+
PRIMARY KEY (file_path, kind, name, owner)
|
|
57
|
+
);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_refs_name ON raw_refs(kind, name);
|
|
59
|
+
|
|
60
|
+
-- 5. Chunk metadata mapping
|
|
61
|
+
CREATE TABLE IF NOT EXISTS chunk_metadata (
|
|
62
|
+
rowid INTEGER PRIMARY KEY,
|
|
63
|
+
file_path TEXT NOT NULL,
|
|
64
|
+
start_line INTEGER NOT NULL,
|
|
65
|
+
end_line INTEGER NOT NULL,
|
|
66
|
+
raw_text TEXT NOT NULL,
|
|
67
|
+
symbol_id TEXT
|
|
68
|
+
);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_chunk_file ON chunk_metadata(file_path);
|
|
70
|
+
|
|
71
|
+
-- 4. Vector storage. index.md names this vss_chunks (sqlite-vss); this
|
|
72
|
+
-- implementation stores float32 BLOBs and does cosine search with numpy so no
|
|
73
|
+
-- native SQLite extension is required.
|
|
74
|
+
CREATE TABLE IF NOT EXISTS vss_chunks (
|
|
75
|
+
rowid INTEGER PRIMARY KEY,
|
|
76
|
+
chunk_embedding BLOB NOT NULL
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
-- Keyword layer (FTS5) over chunk text; rowid == chunk_metadata.rowid
|
|
80
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(search_text, tokenize='unicode61');
|
|
81
|
+
|
|
82
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
83
|
+
key TEXT PRIMARY KEY,
|
|
84
|
+
value TEXT NOT NULL
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
-- Data access: which table / procedure a SQL object or C# method reads, writes, executes or maps.
|
|
88
|
+
-- target_key is the lower-case last name segment; "?" marks dynamic SQL whose target is unknown.
|
|
89
|
+
CREATE TABLE IF NOT EXISTS data_access (
|
|
90
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
91
|
+
file_path TEXT NOT NULL,
|
|
92
|
+
source_id TEXT,
|
|
93
|
+
line INTEGER NOT NULL,
|
|
94
|
+
target_key TEXT NOT NULL,
|
|
95
|
+
target_name TEXT NOT NULL,
|
|
96
|
+
qualifier TEXT NOT NULL DEFAULT '',
|
|
97
|
+
target_kind TEXT NOT NULL,
|
|
98
|
+
access TEXT NOT NULL,
|
|
99
|
+
op TEXT NOT NULL,
|
|
100
|
+
via TEXT NOT NULL,
|
|
101
|
+
confidence TEXT NOT NULL,
|
|
102
|
+
evidence TEXT NOT NULL DEFAULT '',
|
|
103
|
+
detail TEXT NOT NULL DEFAULT ''
|
|
104
|
+
);
|
|
105
|
+
CREATE INDEX IF NOT EXISTS idx_da_target ON data_access(target_key);
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_da_file ON data_access(file_path);
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_da_source ON data_access(source_id);
|
|
108
|
+
|
|
109
|
+
-- C# call sites as seen in the syntax tree, and the resolved symbol-level call graph derived from them.
|
|
110
|
+
CREATE TABLE IF NOT EXISTS call_sites (
|
|
111
|
+
file_path TEXT NOT NULL,
|
|
112
|
+
caller_id TEXT,
|
|
113
|
+
line INTEGER NOT NULL,
|
|
114
|
+
name TEXT NOT NULL,
|
|
115
|
+
argc INTEGER NOT NULL,
|
|
116
|
+
recv_kind TEXT NOT NULL,
|
|
117
|
+
recv_name TEXT NOT NULL DEFAULT '',
|
|
118
|
+
recv_type TEXT NOT NULL DEFAULT '',
|
|
119
|
+
recv_expr TEXT NOT NULL DEFAULT '',
|
|
120
|
+
is_ctor INTEGER NOT NULL DEFAULT 0
|
|
121
|
+
);
|
|
122
|
+
CREATE INDEX IF NOT EXISTS idx_cs_file ON call_sites(file_path);
|
|
123
|
+
CREATE TABLE IF NOT EXISTS calls (
|
|
124
|
+
caller_id TEXT NOT NULL,
|
|
125
|
+
callee_id TEXT NOT NULL,
|
|
126
|
+
file_path TEXT NOT NULL,
|
|
127
|
+
line INTEGER NOT NULL,
|
|
128
|
+
confidence TEXT NOT NULL,
|
|
129
|
+
reason TEXT NOT NULL
|
|
130
|
+
);
|
|
131
|
+
CREATE INDEX IF NOT EXISTS idx_calls_callee ON calls(callee_id);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS idx_calls_caller ON calls(caller_id);
|
|
133
|
+
|
|
134
|
+
-- Files that could not be indexed on the last attempt. They are retried on every update.
|
|
135
|
+
CREATE TABLE IF NOT EXISTS index_failures (
|
|
136
|
+
file_path TEXT PRIMARY KEY,
|
|
137
|
+
error TEXT NOT NULL,
|
|
138
|
+
failed_at INTEGER NOT NULL
|
|
139
|
+
);
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
# Bump when the stored data layout or parsing output changes; a mismatch triggers a full rebuild.
|
|
143
|
+
INDEX_VERSION = "4"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def index_dir(root: str) -> str:
|
|
147
|
+
return os.path.join(root, INDEX_DIR_NAME)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def default_db_path(root: str) -> str:
|
|
151
|
+
return os.path.join(index_dir(root), DB_NAME)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def connect(db_path: str) -> sqlite3.Connection:
|
|
155
|
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
156
|
+
# keep the index out of version control
|
|
157
|
+
gi = os.path.join(os.path.dirname(db_path), ".gitignore")
|
|
158
|
+
if not os.path.exists(gi):
|
|
159
|
+
with open(gi, "w", encoding="utf-8") as f:
|
|
160
|
+
f.write("*\n")
|
|
161
|
+
conn = sqlite3.connect(db_path)
|
|
162
|
+
conn.row_factory = sqlite3.Row
|
|
163
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
164
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
165
|
+
conn.executescript(SCHEMA)
|
|
166
|
+
_migrate(conn)
|
|
167
|
+
return conn
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _migrate(conn: sqlite3.Connection) -> None:
|
|
171
|
+
"""Add columns that older index files lack (the index version check then rebuilds their content)."""
|
|
172
|
+
have = {r[1] for r in conn.execute("PRAGMA table_info(symbols)")}
|
|
173
|
+
for col, decl in (("type_name", "TEXT"), ("min_args", "INTEGER"), ("max_args", "INTEGER")):
|
|
174
|
+
if col not in have:
|
|
175
|
+
conn.execute(f"ALTER TABLE symbols ADD COLUMN {col} {decl}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_meta(conn: sqlite3.Connection, key: str) -> str | None:
|
|
179
|
+
row = conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
|
|
180
|
+
return row["value"] if row else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def set_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
|
|
184
|
+
conn.execute(
|
|
185
|
+
"INSERT INTO meta(key, value) VALUES(?, ?) "
|
|
186
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
187
|
+
(key, value),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def clear_all(conn: sqlite3.Connection) -> None:
|
|
192
|
+
for t in ("files", "symbols", "dependencies", "raw_refs", "chunk_metadata", "vss_chunks", "chunks_fts",
|
|
193
|
+
"index_failures", "data_access", "call_sites", "calls"):
|
|
194
|
+
conn.execute(f"DELETE FROM {t}")
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Semantic layer: local embedding backends (384 dimensions, no cloud calls).
|
|
2
|
+
|
|
3
|
+
Two backends:
|
|
4
|
+
|
|
5
|
+
* ``HashingEmbedder`` (default): dependency-light signed feature hashing over
|
|
6
|
+
identifier sub-words, whole identifiers, bigrams and CJK character bigrams.
|
|
7
|
+
It is a lexical-semantic approximation, deterministic and fast, but it does
|
|
8
|
+
not understand synonyms.
|
|
9
|
+
* ``OnnxEmbedder``: real transformer embeddings (all-MiniLM-L6-v2 or
|
|
10
|
+
bge-small-en-v1.5 exported to ONNX). Enabled by setting ``INDEXER_ONNX_MODEL``
|
|
11
|
+
to a directory containing ``model.onnx`` and ``tokenizer.json``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import zlib
|
|
20
|
+
from typing import Sequence
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
DIM = 384
|
|
25
|
+
|
|
26
|
+
_WORD_RE = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+|[一-鿿]+")
|
|
27
|
+
_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
28
|
+
_STOP = {
|
|
29
|
+
"the", "a", "an", "of", "to", "in", "is", "for", "and", "or", "if", "else", "return",
|
|
30
|
+
"var", "new", "this", "public", "private", "protected", "static", "void", "int", "string",
|
|
31
|
+
"bool", "true", "false", "null", "self", "def", "class", "import", "from", "using", "get", "set",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def split_identifier(token: str) -> list[str]:
|
|
36
|
+
return [p.lower() for p in _WORD_RE.findall(token.replace("_", " "))]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def tokenize(text: str) -> list[str]:
|
|
40
|
+
"""Words for lexical features: whole identifiers plus their camelCase/snake_case parts."""
|
|
41
|
+
out: list[str] = []
|
|
42
|
+
for ident in _IDENT_RE.findall(text):
|
|
43
|
+
parts = split_identifier(ident)
|
|
44
|
+
if len(parts) > 1:
|
|
45
|
+
out.append(ident.lower())
|
|
46
|
+
out.extend(parts)
|
|
47
|
+
for run in re.findall(r"[一-鿿]+", text):
|
|
48
|
+
out.extend(run[i:i + 2] for i in range(max(1, len(run) - 1)))
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HashingEmbedder:
|
|
53
|
+
name = "hashing-384-v1"
|
|
54
|
+
dim = DIM
|
|
55
|
+
|
|
56
|
+
def _vec(self, text: str) -> np.ndarray:
|
|
57
|
+
v = np.zeros(DIM, dtype=np.float32)
|
|
58
|
+
toks = [t for t in tokenize(text) if t not in _STOP and len(t) > 1]
|
|
59
|
+
if not toks:
|
|
60
|
+
return v
|
|
61
|
+
counts: dict[str, int] = {}
|
|
62
|
+
for t in toks:
|
|
63
|
+
counts[t] = counts.get(t, 0) + 1
|
|
64
|
+
for i in range(len(toks) - 1):
|
|
65
|
+
bg = toks[i] + " " + toks[i + 1]
|
|
66
|
+
counts[bg] = counts.get(bg, 0) + 1
|
|
67
|
+
for feat, c in counts.items():
|
|
68
|
+
h = zlib.crc32(feat.encode("utf-8"))
|
|
69
|
+
idx = h % DIM
|
|
70
|
+
sign = 1.0 if (h >> 16) & 1 else -1.0
|
|
71
|
+
v[idx] += sign * (1.0 + math.log(c))
|
|
72
|
+
n = float(np.linalg.norm(v))
|
|
73
|
+
return v / n if n else v
|
|
74
|
+
|
|
75
|
+
def embed(self, texts: Sequence[str]) -> np.ndarray:
|
|
76
|
+
return np.stack([self._vec(t) for t in texts]) if texts else np.zeros((0, DIM), np.float32)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class OnnxEmbedder:
|
|
80
|
+
dim = DIM
|
|
81
|
+
|
|
82
|
+
def __init__(self, model_dir: str):
|
|
83
|
+
import onnxruntime as ort
|
|
84
|
+
from tokenizers import Tokenizer
|
|
85
|
+
|
|
86
|
+
self.name = "onnx:" + os.path.basename(os.path.abspath(model_dir))
|
|
87
|
+
self.tok = Tokenizer.from_file(os.path.join(model_dir, "tokenizer.json"))
|
|
88
|
+
self.tok.enable_truncation(max_length=256)
|
|
89
|
+
self.tok.enable_padding()
|
|
90
|
+
self.session = ort.InferenceSession(
|
|
91
|
+
os.path.join(model_dir, "model.onnx"), providers=["CPUExecutionProvider"]
|
|
92
|
+
)
|
|
93
|
+
self.input_names = {i.name for i in self.session.get_inputs()}
|
|
94
|
+
# bge models pool the [CLS] token and expect an instruction prefix on queries; MiniLM-style models mean-pool
|
|
95
|
+
self.pooling = os.environ.get("INDEXER_ONNX_POOLING", "mean").lower()
|
|
96
|
+
if self.pooling not in ("mean", "cls"):
|
|
97
|
+
raise ValueError("INDEXER_ONNX_POOLING must be 'mean' or 'cls'")
|
|
98
|
+
self.query_prefix = os.environ.get("INDEXER_ONNX_QUERY_PREFIX", "")
|
|
99
|
+
self.name += f":{self.pooling}"
|
|
100
|
+
out_dim = self.session.get_outputs()[0].shape[-1]
|
|
101
|
+
if isinstance(out_dim, int):
|
|
102
|
+
self.dim = out_dim
|
|
103
|
+
|
|
104
|
+
def embed_query(self, text: str) -> np.ndarray:
|
|
105
|
+
return self.embed([self.query_prefix + text])[0]
|
|
106
|
+
|
|
107
|
+
def embed(self, texts: Sequence[str]) -> np.ndarray:
|
|
108
|
+
if not texts:
|
|
109
|
+
return np.zeros((0, self.dim), np.float32)
|
|
110
|
+
out = []
|
|
111
|
+
for i in range(0, len(texts), 16):
|
|
112
|
+
enc = self.tok.encode_batch(list(texts[i:i + 16]))
|
|
113
|
+
ids = np.array([e.ids for e in enc], dtype=np.int64)
|
|
114
|
+
mask = np.array([e.attention_mask for e in enc], dtype=np.int64)
|
|
115
|
+
feeds = {"input_ids": ids, "attention_mask": mask}
|
|
116
|
+
if "token_type_ids" in self.input_names:
|
|
117
|
+
feeds["token_type_ids"] = np.zeros_like(ids)
|
|
118
|
+
hidden = self.session.run(None, {k: v for k, v in feeds.items() if k in self.input_names})[0]
|
|
119
|
+
if self.pooling == "cls":
|
|
120
|
+
pooled = hidden[:, 0, :]
|
|
121
|
+
else:
|
|
122
|
+
m = mask[..., None].astype(np.float32)
|
|
123
|
+
pooled = (hidden * m).sum(1) / np.clip(m.sum(1), 1e-9, None)
|
|
124
|
+
pooled /= np.clip(np.linalg.norm(pooled, axis=1, keepdims=True), 1e-9, None)
|
|
125
|
+
out.append(pooled.astype(np.float32))
|
|
126
|
+
return np.vstack(out)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_embedder():
|
|
130
|
+
model_dir = os.environ.get("INDEXER_ONNX_MODEL")
|
|
131
|
+
if model_dir:
|
|
132
|
+
try:
|
|
133
|
+
return OnnxEmbedder(model_dir)
|
|
134
|
+
except Exception as exc: # fall back, but say so
|
|
135
|
+
import sys
|
|
136
|
+
print(f"[indexer] ONNX embedder unavailable ({exc}); using hashing embedder", file=sys.stderr)
|
|
137
|
+
return HashingEmbedder()
|