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,3 @@
1
+ """Universal Local Codebase Indexer & Context Provider (see index.md)."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,279 @@
1
+ """Symbol-level call resolution for C#: match every recorded call site to method definitions.
2
+
3
+ Confidence levels
4
+ confirmed the receiver's type is known and exactly one method matches name and argument count
5
+ probable the receiver's type is known but several overloads match, or the call goes through an interface
6
+ that has a single implementation, or the receiver is unknown but the method name is unique
7
+ candidate the receiver's type is unknown and several methods share the name (or an interface has several
8
+ implementations)
9
+
10
+ A receiver whose type is known but is not defined in the index (``List<T>``, ``IDbConnection``, ...) produces no
11
+ edge at all. That is what removes the false matches a name-only search produces. What this cannot do: overload
12
+ resolution by argument type, calls through delegates or lambdas, extension methods, and member access chains
13
+ longer than one call.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sqlite3
19
+ from collections import defaultdict
20
+
21
+ CONF_ORDER = {"confirmed": 0, "probable": 1, "candidate": 2}
22
+ _CLASS_KINDS = {"class", "interface", "struct", "record", "enum"}
23
+ _CALLABLE = {"method", "operator"}
24
+ _MAX_UNKNOWN_CANDIDATES = 8
25
+
26
+
27
+ class _Graph:
28
+ def __init__(self, conn: sqlite3.Connection):
29
+ self.sym: dict[str, sqlite3.Row] = {}
30
+ self.types_by_name: dict[str, list] = defaultdict(list)
31
+ self.members: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list))
32
+ self.methods_by_name: dict[str, list] = defaultdict(list)
33
+ self.file_ns: dict[str, set[str]] = defaultdict(set)
34
+ for r in conn.execute(
35
+ "SELECT s.id, s.file_path, s.symbol_name, s.symbol_type, s.parent_id, s.qualified_name, s.type_name, "
36
+ "s.min_args, s.max_args FROM symbols s JOIN files f ON f.file_path = s.file_path "
37
+ "WHERE f.language='csharp'"
38
+ ):
39
+ self.sym[r["id"]] = r
40
+ t = r["symbol_type"]
41
+ if t in _CLASS_KINDS:
42
+ self.types_by_name[r["symbol_name"]].append(r)
43
+ elif t == "namespace":
44
+ self.file_ns[r["file_path"]].add(r["qualified_name"] or r["symbol_name"])
45
+ if r["parent_id"]:
46
+ self.members[r["parent_id"]][r["symbol_name"]].append(r)
47
+ if t in _CALLABLE:
48
+ self.methods_by_name[r["symbol_name"]].append(r)
49
+ for r in conn.execute(
50
+ "SELECT r.file_path, r.name FROM raw_refs r JOIN files f ON f.file_path = r.file_path "
51
+ "WHERE r.kind='import' AND f.language='csharp'"
52
+ ):
53
+ self.file_ns[r["file_path"]].add(r["name"])
54
+ self.bases: dict[str, list[str]] = defaultdict(list)
55
+ for r in conn.execute(
56
+ "SELECT r.owner, r.name FROM raw_refs r WHERE r.kind='base' AND r.owner != ''"
57
+ ):
58
+ if r["owner"] in self.sym:
59
+ self.bases[r["owner"]].append(r["name"])
60
+ self._anc: dict[str, list[str]] = {}
61
+ self._desc: dict[str, list[str]] | None = None
62
+
63
+ # ------------------------------------------------------------------ type helpers
64
+ def ns_of(self, row) -> str:
65
+ while row["parent_id"] and row["parent_id"] in self.sym:
66
+ row = self.sym[row["parent_id"]]
67
+ q = row["qualified_name"] or ""
68
+ return q.rsplit(".", 1)[0] if "." in q else ""
69
+
70
+ def pick_types(self, name: str, file_path: str) -> list:
71
+ cands = self.types_by_name.get(name, [])
72
+ if len(cands) > 1:
73
+ vis = self.file_ns.get(file_path, set())
74
+ visible = [c for c in cands if self.ns_of(c) in vis]
75
+ if visible:
76
+ return visible
77
+ return cands
78
+
79
+ def ancestors(self, cid: str) -> list[str]:
80
+ if cid in self._anc:
81
+ return self._anc[cid]
82
+ self._anc[cid] = [] # cycle guard
83
+ out, queue, seen = [], list(self.bases.get(cid, [])), {cid}
84
+ file_path = self.sym[cid]["file_path"]
85
+ depth = 0
86
+ while queue and depth < 12:
87
+ nxt = []
88
+ for name in queue:
89
+ for t in self.pick_types(name, file_path):
90
+ if t["id"] not in seen:
91
+ seen.add(t["id"])
92
+ out.append(t["id"])
93
+ nxt.extend(self.bases.get(t["id"], []))
94
+ queue, depth = nxt, depth + 1
95
+ self._anc[cid] = out
96
+ return out
97
+
98
+ def implementers(self, iface_id: str) -> list[str]:
99
+ if self._desc is None:
100
+ self._desc = defaultdict(list)
101
+ for cid, row in self.sym.items():
102
+ if row["symbol_type"] in _CLASS_KINDS:
103
+ for a in self.ancestors(cid):
104
+ self._desc[a].append(cid)
105
+ return [c for c in self._desc.get(iface_id, []) if self.sym[c]["symbol_type"] != "interface"]
106
+
107
+ def enclosing_class(self, row):
108
+ while row is not None:
109
+ if row["symbol_type"] in _CLASS_KINDS:
110
+ return row
111
+ row = self.sym.get(row["parent_id"]) if row["parent_id"] else None
112
+ return None
113
+
114
+ def member_type(self, cls_id: str, name: str) -> str:
115
+ for c in [cls_id] + self.ancestors(cls_id):
116
+ types = {m["type_name"] for m in self.members[c].get(name, ())
117
+ if m["symbol_type"] in ("field", "property") and m["type_name"]}
118
+ if len(types) == 1:
119
+ return next(iter(types))
120
+ return ""
121
+
122
+ # ------------------------------------------------------------------ method lookup
123
+ @staticmethod
124
+ def _fits(m, argc: int) -> bool:
125
+ return m["min_args"] is None or m["min_args"] < 0 or m["min_args"] <= argc <= m["max_args"]
126
+
127
+ def find_methods(self, class_ids: list[str], name: str, argc: int, ctor: bool = False):
128
+ """(methods, arity_ok). Searches each class then its ancestors, stopping at the first class that has the name."""
129
+ found: dict[str, object] = {}
130
+ for cid in class_ids:
131
+ chain = [cid] if ctor else [cid] + self.ancestors(cid)
132
+ kinds = ("constructor",) if ctor else tuple(_CALLABLE)
133
+ for c in chain:
134
+ ms = [m for m in self.members[c].get(name, ()) if m["symbol_type"] in kinds]
135
+ if ms:
136
+ for m in ms:
137
+ found[m["id"]] = m
138
+ break
139
+ allm = list(found.values())
140
+ fitting = [m for m in allm if self._fits(m, argc)]
141
+ return (fitting, True) if fitting else (allm, False)
142
+
143
+
144
+ def _decode_expr(expr: str):
145
+ kind, name, method, argc, rtype = (expr.split("|") + [""] * 5)[:5]
146
+ return kind, name, method, int(argc or 0), rtype
147
+
148
+
149
+ def resolve_calls(conn: sqlite3.Connection) -> None:
150
+ """Rebuild the ``calls`` table from ``call_sites`` and ``symbols``."""
151
+ conn.execute("DELETE FROM calls")
152
+ g = _Graph(conn)
153
+ edges: dict[tuple, tuple] = {}
154
+
155
+ def add(caller, callee, file_path, line, conf, reason):
156
+ key = (caller, callee["id"], line)
157
+ old = edges.get(key)
158
+ if old is None or CONF_ORDER[conf] < CONF_ORDER[old[0]]:
159
+ edges[key] = (conf, reason, file_path)
160
+
161
+ def receiver_classes(site, caller_row, ctx_class, depth: int = 0):
162
+ """(class rows, how, known). ``known`` is False when the receiver's type could not be determined;
163
+ an empty class list with known=True means the type is external to the index."""
164
+ kind = site["recv_kind"]
165
+ file_path = site["file_path"]
166
+ if kind in ("none", "this"):
167
+ return ([ctx_class], "this" if kind == "this" else "same class", True) if ctx_class is not None else ([], "", False)
168
+ if kind == "base":
169
+ if ctx_class is None:
170
+ return [], "", False
171
+ anc = [g.sym[a] for a in g.ancestors(ctx_class["id"])]
172
+ return anc, "base class", True
173
+ typ = site["recv_type"]
174
+ how = ""
175
+ if kind in ("new", "cast"):
176
+ how = "new/cast type"
177
+ elif kind == "type":
178
+ how = "constructor"
179
+ elif kind in ("name", "member"):
180
+ if typ:
181
+ how = f"local '{site['recv_name']}' is {typ}"
182
+ else:
183
+ if site["recv_expr"] and depth == 0:
184
+ inner = _decode_expr(site["recv_expr"])
185
+ rt = resolve_expr_type(inner, site, ctx_class)
186
+ if rt:
187
+ typ, how = rt, "return type of earlier call"
188
+ if not typ and ctx_class is not None and site["recv_name"]:
189
+ typ = g.member_type(ctx_class["id"], site["recv_name"])
190
+ how = f"'{site['recv_name']}' is declared as {typ}" if typ else ""
191
+ if not typ and kind == "name" and site["recv_name"] in g.types_by_name:
192
+ return g.pick_types(site["recv_name"], file_path), "static call", True
193
+ elif kind == "expr" and site["recv_expr"] and depth == 0:
194
+ rt = resolve_expr_type(_decode_expr(site["recv_expr"]), site, ctx_class)
195
+ if rt:
196
+ typ, how = rt, "return type of chained call"
197
+ if not typ:
198
+ return [], "", False
199
+ classes = g.pick_types(typ, file_path)
200
+ return classes, how, True
201
+
202
+ def resolve_expr_type(inner, site, ctx_class) -> str:
203
+ kind, name, method, argc, rtype = inner
204
+ fake = {"recv_kind": kind, "recv_name": name, "recv_type": rtype, "recv_expr": "", "file_path": site["file_path"]}
205
+ classes, _how, known = receiver_classes(fake, None, ctx_class, depth=1)
206
+ if not known or not classes:
207
+ return ""
208
+ methods, _ok = g.find_methods([c["id"] for c in classes], method, argc)
209
+ types = {m["type_name"] for m in methods if m["type_name"]}
210
+ return next(iter(types)) if len(types) == 1 and len(methods) == 1 else ""
211
+
212
+ sites = conn.execute(
213
+ "SELECT c.* FROM call_sites c JOIN files f ON f.file_path = c.file_path WHERE f.language='csharp'"
214
+ ).fetchall()
215
+ for site in sites:
216
+ caller_id = site["caller_id"]
217
+ if not caller_id or caller_id not in g.sym:
218
+ continue
219
+ caller = g.sym[caller_id]
220
+ ctx = g.enclosing_class(caller)
221
+ file_path, line, name, argc = site["file_path"], site["line"], site["name"], site["argc"]
222
+
223
+ if site["is_ctor"]:
224
+ for cls in g.pick_types(name, file_path):
225
+ ctors, ok = g.find_methods([cls["id"]], cls["symbol_name"], argc, ctor=True)
226
+ if len(ctors) == 1:
227
+ add(caller_id, ctors[0], file_path, line, "confirmed" if ok else "probable", "constructor call")
228
+ elif ctors:
229
+ for m in ctors:
230
+ add(caller_id, m, file_path, line, "probable", "constructor overload")
231
+ else:
232
+ add(caller_id, cls, file_path, line, "confirmed", "instantiates class")
233
+ continue
234
+
235
+ classes, how, known = receiver_classes(site, caller, ctx)
236
+ if known:
237
+ if not classes:
238
+ continue # type outside the index
239
+ methods, arity_ok = g.find_methods([c["id"] for c in classes], name, argc)
240
+ if not methods:
241
+ continue
242
+ if len(methods) == 1 and arity_ok and len(classes) == 1:
243
+ add(caller_id, methods[0], file_path, line, "confirmed", how)
244
+ else:
245
+ for m in methods:
246
+ add(caller_id, m, file_path, line, "probable",
247
+ f"{how}; {len(methods)} overloads or types match" if how else "several matches")
248
+ for m in methods:
249
+ owner = g.sym.get(m["parent_id"]) if m["parent_id"] else None
250
+ if owner is None or owner["symbol_type"] != "interface":
251
+ continue
252
+ impls = []
253
+ for cid in g.implementers(owner["id"]):
254
+ ims, _ = g.find_methods([cid], name, argc)
255
+ impls.extend(ims)
256
+ if len(impls) == 1:
257
+ add(caller_id, impls[0], file_path, line, "probable",
258
+ f"only implementation of {owner['symbol_name']}.{name}")
259
+ else:
260
+ for im in impls:
261
+ add(caller_id, im, file_path, line, "candidate",
262
+ f"one of {len(impls)} implementations of {owner['symbol_name']}.{name}")
263
+ continue
264
+
265
+ # receiver type unknown: fall back to the method name, labelled as such
266
+ named = [m for m in g.methods_by_name.get(name, ()) if g._fits(m, argc)]
267
+ if not named or len(named) > _MAX_UNKNOWN_CANDIDATES:
268
+ continue
269
+ if len(named) == 1:
270
+ add(caller_id, named[0], file_path, line, "probable", "receiver type unknown; method name is unique")
271
+ else:
272
+ for m in named:
273
+ add(caller_id, m, file_path, line, "candidate",
274
+ f"receiver type unknown; {len(named)} methods share the name")
275
+
276
+ conn.executemany(
277
+ "INSERT INTO calls(caller_id, callee_id, file_path, line, confidence, reason) VALUES(?,?,?,?,?,?)",
278
+ [(k[0], k[1], v[2], k[2], v[0], v[1]) for k, v in sorted(edges.items(), key=lambda kv: (kv[0][0], kv[0][2], kv[0][1]))],
279
+ )
@@ -0,0 +1,122 @@
1
+ """Command line interface: ``bin/indexer <tool_name> --params`` (index.md section 4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ from . import __version__
11
+
12
+
13
+ def _tools(root: str):
14
+ from .query import CodebaseTools
15
+ return CodebaseTools(root)
16
+
17
+
18
+ def _emit(data, text: str, as_json: bool) -> None:
19
+ print(json.dumps(data, ensure_ascii=False, indent=2) if as_json else text)
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ p = argparse.ArgumentParser(prog="local-codebase-indexer", description="Local codebase indexer & context provider")
24
+ p.add_argument("--root", default=os.environ.get("INDEXER_ROOT", os.getcwd()), help="project root (default: cwd)")
25
+ p.add_argument("--json", action="store_true", help="print JSON instead of compact text")
26
+ p.add_argument("--version", action="version", version=__version__)
27
+ sub = p.add_subparsers(dest="cmd", required=True)
28
+
29
+ s = sub.add_parser("index", help="build / incrementally refresh the index")
30
+ s.add_argument("--force", action="store_true", help="re-index every file")
31
+
32
+ s = sub.add_parser("search", aliases=["codebase_semantic_search"], help="hybrid semantic search")
33
+ s.add_argument("query", nargs="?")
34
+ s.add_argument("--query", dest="query_opt")
35
+ s.add_argument("--limit", type=int, default=5)
36
+
37
+ s = sub.add_parser("relations", aliases=["codebase_get_symbol_relations"], help="symbol dependency graph")
38
+ s.add_argument("symbol", nargs="?")
39
+ s.add_argument("--symbol_name", dest="symbol_opt")
40
+ s.add_argument("--direction", "--relation_direction", dest="direction", default="both",
41
+ choices=["upstream", "downstream", "both"])
42
+
43
+ s = sub.add_parser("context", aliases=["slice", "codebase_get_optimized_context"],
44
+ help="extract only the needed lines of a file")
45
+ s.add_argument("file", nargs="?")
46
+ s.add_argument("--file_path", dest="file_opt")
47
+ s.add_argument("--lines", "--focus_lines", dest="lines", nargs="+", type=int, required=True)
48
+ s.add_argument("--padding", "--padding_lines", dest="padding", type=int, default=15)
49
+
50
+ s = sub.add_parser("data-access", aliases=["dataflow", "codebase_get_data_access"],
51
+ help="who reads / writes / executes a table or stored procedure, and what a procedure touches")
52
+ s.add_argument("object", nargs="?")
53
+ s.add_argument("--object_name", dest="object_opt")
54
+
55
+ sub.add_parser("stats", help="show index statistics")
56
+ sub.add_parser("status", aliases=["codebase_index_status"], help="index health: freshness, failures, consistency")
57
+ sub.add_parser("serve", help="run as MCP server over stdio (JSON-RPC)")
58
+ return p
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ args = build_parser().parse_args(argv)
63
+ root = os.path.abspath(args.root)
64
+
65
+ if args.cmd == "serve":
66
+ from .mcp_server import serve
67
+ return serve(root)
68
+
69
+ from .query import render_context, render_data_access, render_relations, render_search, render_status
70
+
71
+ try:
72
+ t = _tools(root)
73
+ if args.cmd == "index":
74
+ st = t.refresh(force=args.force).as_dict()
75
+ text = (f'indexed {st["scanned"]} files: +{st["added"]} ~{st["updated"]} ={st["unchanged"]} '
76
+ f'-{st["deleted"]} (skipped {st["skipped"]}, failed {st["failed"]}); {st["symbols"]} symbols, {st["chunks"]} chunks')
77
+ if st["warnings"]:
78
+ text += "\nwarnings:\n " + "\n ".join(st["warnings"][:20])
79
+ _emit(st, text, args.json)
80
+ elif args.cmd in ("search", "codebase_semantic_search"):
81
+ q = args.query_opt or args.query
82
+ if not q:
83
+ print("error: a query is required", file=sys.stderr)
84
+ return 2
85
+ res = t.semantic_search(q, args.limit)
86
+ _emit(res, render_search(res), args.json)
87
+ elif args.cmd in ("relations", "codebase_get_symbol_relations"):
88
+ sym = args.symbol_opt or args.symbol
89
+ if not sym:
90
+ print("error: a symbol name is required", file=sys.stderr)
91
+ return 2
92
+ res = t.symbol_relations(sym, args.direction)
93
+ _emit(res, render_relations(res), args.json)
94
+ elif args.cmd in ("context", "slice", "codebase_get_optimized_context"):
95
+ f = args.file_opt or args.file
96
+ if not f:
97
+ print("error: a file path is required", file=sys.stderr)
98
+ return 2
99
+ res = t.optimized_context(f, args.lines, args.padding)
100
+ _emit(res, render_context(res), args.json)
101
+ elif args.cmd in ("data-access", "dataflow", "codebase_get_data_access"):
102
+ name = args.object_opt or args.object
103
+ if not name:
104
+ print("error: an object name is required", file=sys.stderr)
105
+ return 2
106
+ res = t.data_access(name)
107
+ _emit(res, render_data_access(res), args.json)
108
+ elif args.cmd in ("status", "codebase_index_status"):
109
+ res = t.index_status()
110
+ _emit(res, render_status(res), args.json)
111
+ return 0 if res["state"] in ("fresh", "stale") else 3
112
+ elif args.cmd == "stats":
113
+ st = t.stats()
114
+ _emit(st, "\n".join(f"{k}: {v}" for k, v in st.items()), args.json)
115
+ except (ValueError, FileNotFoundError) as exc:
116
+ print(f"error: {exc}", file=sys.stderr)
117
+ return 1
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ sys.exit(main())