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,101 @@
1
+ """Project scanning that respects .gitignore (root and nested)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Iterator
8
+
9
+ import pathspec
10
+
11
+ MAX_FILE_BYTES = 5 * 1024 * 1024 # index.md: never slurp files larger than 5MB
12
+
13
+ ALWAYS_IGNORE_DIRS = {
14
+ ".git", ".svn", ".hg", ".ai_context", ".claude", "node_modules", "bin", "obj",
15
+ "__pycache__", ".vs", ".idea", ".vscode", "dist", "build", ".venv", "venv",
16
+ "packages", ".pytest_cache", ".mypy_cache",
17
+ }
18
+
19
+ LANGUAGE_BY_EXT = {
20
+ ".cs": "csharp",
21
+ ".py": "python",
22
+ ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
23
+ ".ts": "typescript", ".tsx": "tsx",
24
+ ".sql": "sql",
25
+ # indexed as plain text windows
26
+ ".vb": "text", ".bas": "text", ".frm": "text", ".cls": "text",
27
+ ".cshtml": "text", ".aspx": "text", ".asmx": "text", ".vue": "text", ".html": "text",
28
+ ".css": "text", ".xml": "text", ".config": "text", ".csproj": "text", ".json": "text",
29
+ ".yml": "text", ".yaml": "text", ".md": "text", ".txt": "text", ".sh": "text",
30
+ ".ps1": "text", ".ahk": "text", ".iss": "text",
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class ScannedFile:
36
+ rel_path: str # posix style, relative to root
37
+ abs_path: str
38
+ mtime_ns: int
39
+ size: int
40
+ language: str
41
+
42
+
43
+ def language_for(path: str) -> str | None:
44
+ return LANGUAGE_BY_EXT.get(os.path.splitext(path)[1].lower())
45
+
46
+
47
+ def _ignored(path: str, is_dir: bool, specs: list) -> bool:
48
+ for base, spec in specs:
49
+ rel = os.path.relpath(path, base).replace(os.sep, "/")
50
+ if is_dir:
51
+ rel += "/"
52
+ if spec.match_file(rel):
53
+ return True
54
+ return False
55
+
56
+
57
+ def iter_files(root: str, max_bytes: int = MAX_FILE_BYTES) -> Iterator[ScannedFile]:
58
+ root = os.path.abspath(root)
59
+ stack: list[tuple[str, list]] = [(root, [])]
60
+ while stack:
61
+ directory, specs = stack.pop()
62
+ gi = os.path.join(directory, ".gitignore")
63
+ if os.path.isfile(gi):
64
+ try:
65
+ with open(gi, encoding="utf-8", errors="ignore") as f:
66
+ specs = specs + [(directory, pathspec.GitIgnoreSpec.from_lines(f.read().splitlines()))]
67
+ except OSError:
68
+ pass
69
+ try:
70
+ entries = sorted(os.scandir(directory), key=lambda e: e.name)
71
+ except OSError:
72
+ continue
73
+ subdirs = []
74
+ for e in entries:
75
+ try:
76
+ is_dir = e.is_dir(follow_symlinks=False)
77
+ is_file = e.is_file(follow_symlinks=False)
78
+ except OSError:
79
+ continue
80
+ if is_dir:
81
+ if e.name in ALWAYS_IGNORE_DIRS or _ignored(e.path, True, specs):
82
+ continue
83
+ subdirs.append((e.path, specs))
84
+ elif is_file:
85
+ lang = language_for(e.name)
86
+ if lang is None or _ignored(e.path, False, specs):
87
+ continue
88
+ try:
89
+ st = e.stat()
90
+ except OSError:
91
+ continue
92
+ if st.st_size > max_bytes:
93
+ continue
94
+ yield ScannedFile(
95
+ rel_path=os.path.relpath(e.path, root).replace(os.sep, "/"),
96
+ abs_path=e.path,
97
+ mtime_ns=st.st_mtime_ns,
98
+ size=st.st_size,
99
+ language=lang,
100
+ )
101
+ stack.extend(reversed(subdirs))
@@ -0,0 +1,301 @@
1
+ """T-SQL data access extraction: which tables a piece of SQL reads / writes and which procedures it executes.
2
+
3
+ This is a lexical analysis (comments and string literals are masked, then statements are matched with
4
+ regular expressions), not a full T-SQL parser. What it does not understand is reported, not guessed:
5
+
6
+ * dynamic SQL (``EXEC(@sql)``, ``sp_executesql``) becomes an ``uncertain`` access to the unknown object ``?``;
7
+ string literals inside it are still analysed but also marked ``uncertain``;
8
+ * temp tables (``#t``) and table variables (``@t``) are ignored, as are CTE names and system objects.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+
16
+ HOLE = "__hole__" # stands in for interpolated / unknown parts of a C# string
17
+
18
+ _NAME = r"[#@]?[\w$]+(?:\s*\.\s*[\w$]*){0,3}"
19
+
20
+ _KEYWORDS = {
21
+ "select", "from", "where", "set", "values", "as", "on", "default", "exec", "execute", "with", "top",
22
+ "output", "cascade", "no", "null", "table", "index", "view", "procedure", "function", "trigger", "into",
23
+ "using", "when", "then", "case", "end", "begin", "inserted", "deleted", "openquery", "openrowset",
24
+ "openjson", "openxml", "string_split", "generate_series", "and", "or", "not", "is", "in", "if", "else",
25
+ "all", "any", "distinct", "union", "go", "declare", "return", "the", "a", "an",
26
+ }
27
+ _ALIAS_STOP = _KEYWORDS | {
28
+ "inner", "left", "right", "full", "cross", "outer", "join", "group", "order", "having", "except",
29
+ "intersect", "option", "for", "natural", "pivot", "unpivot", "tablesample", "update", "delete", "insert",
30
+ "merge", "truncate", "while", "print", "raiserror", "commit", "rollback", "throw", "try", "catch",
31
+ }
32
+ _SYSTEM_QUALIFIERS = {"sys", "information_schema"}
33
+
34
+ _RX_INSERT = re.compile(rf"(?<![@\w$#.])INSERT\s+(?:INTO\s+)?(?P<n>{_NAME})", re.I)
35
+ _RX_UPDATE = re.compile(rf"(?<![@\w$#.])UPDATE\s+(?:TOP\s*\(\s*\d+\s*\)\s+)?(?P<n>{_NAME})\s+SET\b", re.I)
36
+ _RX_DELETE = re.compile(rf"(?<![@\w$#.])DELETE\s+(?:TOP\s*\(\s*\d+\s*\)\s+)?(?:FROM\s+)?(?P<n>{_NAME})", re.I)
37
+ _RX_MERGE = re.compile(rf"(?<![@\w$#.])MERGE\s+(?:TOP\s*\(\s*\d+\s*\)\s+)?(?:INTO\s+)?(?P<n>{_NAME})", re.I)
38
+ _RX_TRUNC = re.compile(rf"(?<![@\w$#.])TRUNCATE\s+TABLE\s+(?P<n>{_NAME})", re.I)
39
+ _RX_INTO = re.compile(rf"(?<![@\w$#.])INTO\s+(?P<n>{_NAME})", re.I)
40
+ _RX_USING = re.compile(rf"(?<![@\w$#.])USING\s+(?P<n>{_NAME})", re.I)
41
+ _RX_FROM = re.compile(r"(?<![@\w$#.])FROM\b", re.I)
42
+ _RX_JOIN = re.compile(r"(?<![@\w$#.])JOIN\b", re.I)
43
+ _RX_EXEC = re.compile(rf"(?<![@\w$#.])EXEC(?:UTE)?\s+(?:@\w+\s*=\s*)?(?P<n>{_NAME})", re.I)
44
+ _RX_EXEC_PAREN = re.compile(r"(?<![@\w$#.])EXEC(?:UTE)?\s*\(", re.I)
45
+ _RX_SPEXEC = re.compile(r"(?<![@\w$#.])sp_executesql\b", re.I)
46
+ _RX_CTE = re.compile(r"(?:\bWITH|,)\s+(?P<n>[\w$]+)\s*(?:\([^)]*\))?\s+AS\s*\(", re.I)
47
+
48
+ _RX_ITEM_NAME = re.compile(rf"\s*(?P<n>{_NAME})")
49
+ _RX_ALIAS = re.compile(r"\s+(?:AS\s+)?(?P<a>[\w$]+)", re.I)
50
+ _RX_HINT = re.compile(r"\s+WITH\s*\([^)]*\)", re.I)
51
+ _RX_COMMA = re.compile(r"\s*,")
52
+
53
+
54
+ @dataclass
55
+ class Access:
56
+ key: str # lower-case last name segment; "?" for dynamic SQL
57
+ name: str # as written (last segment)
58
+ qualifier: str # database / schema / linked server prefix as written
59
+ kind: str # table | procedure | function | unknown
60
+ access: str # read | write | exec | map
61
+ op: str # select | insert | update | delete | merge | truncate | into | exec | dynamic | map
62
+ line: int
63
+ via: str # sql | dynamic_sql | csharp_sql | csharp_sp | ef_mapping
64
+ confidence: str # confirmed | probable | uncertain
65
+ evidence: str = ""
66
+ detail: str = ""
67
+ source_id: str | None = None # symbol that contains the access (set by the caller)
68
+
69
+
70
+ def _blank(chars: list[str], start: int, end: int) -> None:
71
+ for i in range(start, min(end, len(chars))):
72
+ if chars[i] != "\n":
73
+ chars[i] = " "
74
+
75
+
76
+ def mask_sql(text: str) -> tuple[str, list[tuple[int, int, str]]]:
77
+ """Blank out comments and string contents (offsets are preserved). Returns the masked text and the
78
+ string literals as (start, end, content)."""
79
+ out = list(text)
80
+ n = len(text)
81
+ i = 0
82
+ literals: list[tuple[int, int, str]] = []
83
+ while i < n:
84
+ c = text[i]
85
+ if c == "-" and text.startswith("--", i):
86
+ j = text.find("\n", i)
87
+ j = n if j < 0 else j
88
+ _blank(out, i, j)
89
+ i = j
90
+ elif c == "/" and text.startswith("/*", i):
91
+ j = text.find("*/", i + 2)
92
+ j = n if j < 0 else j + 2
93
+ _blank(out, i, j)
94
+ i = j
95
+ elif c == "'":
96
+ j = i + 1
97
+ while j < n:
98
+ if text[j] == "'":
99
+ if j + 1 < n and text[j + 1] == "'":
100
+ j += 2
101
+ continue
102
+ break
103
+ j += 1
104
+ literals.append((i, j, text[i + 1:j].replace("''", "'")))
105
+ _blank(out, i + 1, j)
106
+ i = j + 1
107
+ elif c in '[]"':
108
+ out[i] = " "
109
+ i += 1
110
+ else:
111
+ i += 1
112
+ return "".join(out), literals
113
+
114
+
115
+ def _split_name(raw: str) -> list[str]:
116
+ return [p.strip() for p in raw.split(".")]
117
+
118
+
119
+ def _acceptable(parts: list[str]) -> bool:
120
+ last = parts[-1]
121
+ if not last or last[0] in "#@":
122
+ return False
123
+ if last.lower() in _KEYWORDS:
124
+ return False
125
+ if len(parts) > 1 and parts[-2].lower() in _SYSTEM_QUALIFIERS:
126
+ return False
127
+ return True
128
+
129
+
130
+ def _line_of(text: str, pos: int) -> int:
131
+ return text.count("\n", 0, pos) + 1
132
+
133
+
134
+ def _evidence(original: str, pos: int, limit: int = 160) -> str:
135
+ start = original.rfind("\n", 0, pos) + 1
136
+ end = original.find("\n", pos)
137
+ end = len(original) if end < 0 else end
138
+ return " ".join(original[start:end].split())[:limit]
139
+
140
+
141
+ def _parse_items(masked: str, pos: int, allow_list: bool) -> list[tuple[list[str], str | None, bool, int]]:
142
+ """Table references after FROM / JOIN: [(name parts, alias, is_function, offset)]. With ``allow_list`` a
143
+ comma separated list (FROM a, b c) is followed."""
144
+ items = []
145
+ while True:
146
+ j = pos
147
+ while j < len(masked) and masked[j].isspace():
148
+ j += 1
149
+ if j >= len(masked) or masked[j] == "(":
150
+ break # derived table: its own SELECT is scanned separately
151
+ m = _RX_ITEM_NAME.match(masked, pos)
152
+ if not m:
153
+ break
154
+ parts = _split_name(m.group("n"))
155
+ end = m.end()
156
+ is_func = False
157
+ k = end
158
+ while k < len(masked) and masked[k] in " \t":
159
+ k += 1
160
+ if k < len(masked) and masked[k] == "(":
161
+ is_func = True
162
+ # skip the argument list so a function's arguments are not mistaken for an alias
163
+ depth = 0
164
+ while k < len(masked):
165
+ if masked[k] == "(":
166
+ depth += 1
167
+ elif masked[k] == ")":
168
+ depth -= 1
169
+ if depth == 0:
170
+ k += 1
171
+ break
172
+ k += 1
173
+ end = k
174
+ alias = None
175
+ a = _RX_ALIAS.match(masked, end)
176
+ if a and a.group("a").lower() not in _ALIAS_STOP:
177
+ alias = a.group("a")
178
+ end = a.end()
179
+ h = _RX_HINT.match(masked, end)
180
+ if h:
181
+ end = h.end()
182
+ items.append((parts, alias, is_func, m.start("n")))
183
+ pos = end
184
+ if not allow_list:
185
+ break
186
+ c = _RX_COMMA.match(masked, pos)
187
+ if not c:
188
+ break
189
+ pos = c.end()
190
+ return items
191
+
192
+
193
+ def _resolve_alias(target: list[str], window_items: list) -> list[str]:
194
+ if len(target) != 1:
195
+ return target
196
+ low = target[0].lower()
197
+ for parts, alias, is_func, _ in window_items:
198
+ if alias and alias.lower() == low and not is_func:
199
+ return parts
200
+ return target
201
+
202
+
203
+ def extract_sql_accesses(text: str, first_line: int = 1, _dynamic: bool = False) -> list[Access]:
204
+ """All table / procedure accesses in ``text``; line numbers start at ``first_line``."""
205
+ masked, literals = mask_sql(text)
206
+ out: list[Access] = []
207
+ base_conf = "uncertain" if _dynamic else "confirmed"
208
+ base_via = "dynamic_sql" if _dynamic else "sql"
209
+ ctes = {m.group("n").lower() for m in _RX_CTE.finditer(masked)}
210
+ seen: set[tuple] = set()
211
+
212
+ def add(parts: list[str], pos: int, kind: str, access: str, op: str) -> None:
213
+ if not parts or not _acceptable(parts):
214
+ return
215
+ key = parts[-1].lower()
216
+ if kind == "table" and key in ctes and len(parts) == 1:
217
+ return
218
+ conf = base_conf
219
+ if HOLE in key:
220
+ parts, key, kind, conf = ["(dynamic)"], "?", "unknown", "uncertain"
221
+ line = first_line + _line_of(masked, pos) - 1
222
+ sig = (key, access, op, line)
223
+ if sig in seen:
224
+ return
225
+ seen.add(sig)
226
+ out.append(Access(
227
+ key=key, name=parts[-1], qualifier=".".join(parts[:-1]), kind=kind, access=access, op=op, line=line,
228
+ via=base_via, confidence=conf, evidence=_evidence(text, pos),
229
+ ))
230
+
231
+ def window_items(end: int) -> list:
232
+ stop = masked.find(";", end)
233
+ window = masked[end: (stop if 0 <= stop - end < 3000 else end + 3000)]
234
+ items = []
235
+ for rx in (_RX_FROM, _RX_JOIN):
236
+ for m in rx.finditer(window):
237
+ items.extend(_parse_items(window, m.end(), allow_list=rx is _RX_FROM))
238
+ return items
239
+
240
+ for m in _RX_INSERT.finditer(masked):
241
+ add(_split_name(m.group("n")), m.start("n"), "table", "write", "insert")
242
+ for m in _RX_UPDATE.finditer(masked):
243
+ parts = _resolve_alias(_split_name(m.group("n")), window_items(m.end()))
244
+ add(parts, m.start("n"), "table", "write", "update")
245
+ for m in _RX_DELETE.finditer(masked):
246
+ parts = _resolve_alias(_split_name(m.group("n")), window_items(m.end()))
247
+ add(parts, m.start("n"), "table", "write", "delete")
248
+ for m in _RX_MERGE.finditer(masked):
249
+ add(_split_name(m.group("n")), m.start("n"), "table", "write", "merge")
250
+ u = _RX_USING.search(masked, m.end(), min(len(masked), m.end() + 3000))
251
+ if u:
252
+ add(_split_name(u.group("n")), u.start("n"), "table", "read", "select")
253
+ for m in _RX_TRUNC.finditer(masked):
254
+ add(_split_name(m.group("n")), m.start("n"), "table", "write", "truncate")
255
+ for m in _RX_INTO.finditer(masked):
256
+ if re.search(r"(?:INSERT|MERGE)\s*(?:TOP\s*\(\s*\d+\s*\)\s*)?$", masked[max(0, m.start() - 40): m.start()], re.I):
257
+ continue
258
+ add(_split_name(m.group("n")), m.start("n"), "table", "write", "into")
259
+
260
+ for m in _RX_FROM.finditer(masked):
261
+ if re.search(r"(?<![@\w$#.])DELETE\s+(?:TOP\s*\(\s*\d+\s*\)\s*)?$", masked[max(0, m.start() - 40): m.start()], re.I):
262
+ continue # the FROM of DELETE FROM t is the write target, handled above
263
+ for parts, _alias, is_func, off in _parse_items(masked, m.end(), allow_list=True):
264
+ add(parts, off, "function" if is_func else "table", "read", "select")
265
+ for m in _RX_JOIN.finditer(masked):
266
+ for parts, _alias, is_func, off in _parse_items(masked, m.end(), allow_list=False):
267
+ add(parts, off, "function" if is_func else "table", "read", "select")
268
+
269
+ for m in _RX_EXEC.finditer(masked):
270
+ parts = _split_name(m.group("n"))
271
+ if parts[-1].lower() == "sp_executesql":
272
+ continue
273
+ if parts[0].startswith("@"):
274
+ add([HOLE], m.start("n"), "unknown", "exec", "dynamic")
275
+ else:
276
+ add(parts, m.start("n"), "procedure", "exec", "exec")
277
+
278
+ # dynamic SQL: record the site, and analyse any literal inside the call as uncertain
279
+ if not _dynamic:
280
+ dyn_ranges: list[tuple[int, int]] = []
281
+ for m in list(_RX_EXEC_PAREN.finditer(masked)):
282
+ depth, k = 1, m.end()
283
+ while k < len(masked) and depth:
284
+ depth += (masked[k] == "(") - (masked[k] == ")")
285
+ k += 1
286
+ dyn_ranges.append((m.end(), k))
287
+ add([HOLE], m.start(), "unknown", "exec", "dynamic")
288
+ for m in _RX_SPEXEC.finditer(masked):
289
+ dyn_ranges.append((m.end(), min(len(masked), m.end() + 4000)))
290
+ add([HOLE], m.start(), "unknown", "exec", "dynamic")
291
+ for start, end in dyn_ranges:
292
+ for ls, le, content in literals:
293
+ if start <= ls < end and content.strip():
294
+ line = first_line + _line_of(masked, ls) - 1
295
+ for a in extract_sql_accesses(content, line, _dynamic=True):
296
+ sig = (a.key, a.access, a.op, a.line)
297
+ if sig not in seen:
298
+ seen.add(sig)
299
+ out.append(a)
300
+ out.sort(key=lambda a: (a.line, a.access, a.key))
301
+ return out
@@ -0,0 +1,101 @@
1
+ """Cheap domain-term query expansion.
2
+
3
+ A query that mentions one word of a synonym group also searches the other words of that group, so "SP" finds
4
+ ``usp_*`` / ``proc`` code and "預存程序" finds English identifiers. Groups are plain lists: the first entry is
5
+ the canonical English word. A project can extend or override the built-in groups with
6
+ ``<root>/.ai_context/synonyms.json``: ``{"groups": [["scrap", "報廢", "ng"]], "disable": ["sp"]}``.
7
+
8
+ Expansion only adds terms to the keyword / symbol layers and to the text that is embedded; it never removes terms.
9
+ Single-word (ASCII) triggers must match a whole word, CJK triggers match as substrings.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+
18
+ from .db import index_dir
19
+
20
+ # Generic software + database terms (English <-> zh-TW / zh-CN), then a few common manufacturing words.
21
+ DEFAULT_GROUPS: list[list[str]] = [
22
+ ["procedure", "sp", "proc", "usp", "stored", "預存程序", "預儲程序", "儲存程序", "存储过程", "预存程序"],
23
+ ["table", "資料表", "數據表", "数据表", "表格", "資料庫表"],
24
+ ["view", "檢視表", "視圖", "视图"],
25
+ ["column", "field", "欄位", "字段", "栏位"],
26
+ ["database", "db", "資料庫", "数据库"],
27
+ ["query", "select", "查詢", "查询", "撈資料"],
28
+ ["insert", "add", "create", "新增", "建立", "添加", "创建"],
29
+ ["update", "modify", "change", "更新", "修改", "變更", "变更", "異動"],
30
+ ["delete", "remove", "刪除", "删除", "移除"],
31
+ ["save", "persist", "store", "儲存", "保存", "存檔"],
32
+ ["login", "signin", "authenticate", "auth", "登入", "登录", "驗證", "验证", "認證"],
33
+ ["password", "pwd", "密碼", "密码"],
34
+ ["user", "account", "使用者", "用戶", "用户", "帳號", "账号"],
35
+ ["permission", "role", "權限", "权限", "角色"],
36
+ ["config", "configuration", "settings", "設定", "配置", "组态"],
37
+ ["connection", "connectionstring", "連線", "连接", "连线"],
38
+ ["error", "exception", "failure", "錯誤", "错误", "例外", "異常"],
39
+ ["log", "logging", "紀錄", "記錄", "日誌", "日志"],
40
+ ["cache", "快取", "缓存"],
41
+ ["report", "報表", "报表"],
42
+ ["print", "printer", "列印", "打印", "印表機", "打印机"],
43
+ ["label", "tag", "標籤", "标签"],
44
+ ["export", "匯出", "导出"],
45
+ ["import", "匯入", "导入"],
46
+ ["test", "unittest", "測試", "测试"],
47
+ # manufacturing
48
+ ["lot", "batch", "批號", "批次", "批号"],
49
+ ["scrap", "報廢", "报废"],
50
+ ["inspection", "inspect", "檢驗", "檢查", "检验", "检查"],
51
+ ["defect", "defective", "缺點", "缺陷", "不良"],
52
+ ["yield", "良率"],
53
+ ["workorder", "工單", "工单"],
54
+ ["equipment", "machine", "device", "設備", "机台", "機台", "设备"],
55
+ ["status", "state", "狀態", "状态"],
56
+ ]
57
+
58
+ _WORD = re.compile(r"[A-Za-z0-9_]+")
59
+
60
+
61
+ def _load_project(root: str | None) -> tuple[list[list[str]], set[str]]:
62
+ if not root:
63
+ return [], set()
64
+ path = os.path.join(index_dir(root), "synonyms.json")
65
+ try:
66
+ with open(path, encoding="utf-8") as f:
67
+ data = json.load(f)
68
+ groups = [[str(w) for w in g] for g in data.get("groups", []) if isinstance(g, list)]
69
+ return groups, {str(w).lower() for w in data.get("disable", [])}
70
+ except FileNotFoundError:
71
+ return [], set()
72
+ except (OSError, ValueError, AttributeError) as exc:
73
+ import sys
74
+ print(f"[indexer] ignoring {path}: {exc}", file=sys.stderr)
75
+ return [], set()
76
+
77
+
78
+ class Expander:
79
+ def __init__(self, root: str | None = None):
80
+ extra, disabled = _load_project(root)
81
+ self.groups = [[w.lower() for w in g] for g in DEFAULT_GROUPS + extra]
82
+ self.disabled = disabled
83
+
84
+ def expand(self, query: str) -> list[str]:
85
+ """Extra terms (not in the query yet) for every synonym group the query triggers."""
86
+ low = query.lower()
87
+ words = set(_WORD.findall(low))
88
+ words |= {w[:-1] for w in words if len(w) > 3 and w.endswith("s")} # inserts -> insert
89
+ extra: list[str] = []
90
+ for g in self.groups:
91
+ hit = [w for w in g if w not in self.disabled and
92
+ ((w in words) if w.isascii() else (w in low))]
93
+ if not hit:
94
+ continue
95
+ for w in g:
96
+ if w in self.disabled or w in hit or w in extra:
97
+ continue
98
+ if w.isascii() and w in words:
99
+ continue
100
+ extra.append(w)
101
+ return extra
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: local-codebase-indexer
3
+ Version: 0.2.0
4
+ Summary: Local codebase indexer and context provider for AI coding assistants: MCP server and CLI with SQL data-access and C# call relations. No cloud, no server.
5
+ Author: sfy257000
6
+ Project-URL: Homepage, https://github.com/sfy257000/local-codebase-indexer
7
+ Project-URL: Repository, https://github.com/sfy257000/local-codebase-indexer
8
+ Project-URL: Issues, https://github.com/sfy257000/local-codebase-indexer/issues
9
+ Keywords: mcp,model-context-protocol,claude-code,code-search,semantic-search,codebase-index,tree-sitter,sqlite,stored-procedure,csharp,dapper,t-sql,call-graph,ai-coding-assistant
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: Text Processing :: Indexing
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: tree-sitter>=0.23
24
+ Requires-Dist: tree-sitter-c-sharp
25
+ Requires-Dist: tree-sitter-python
26
+ Requires-Dist: tree-sitter-javascript
27
+ Requires-Dist: tree-sitter-typescript
28
+ Requires-Dist: numpy
29
+ Requires-Dist: pathspec
30
+ Provides-Extra: onnx
31
+ Requires-Dist: onnxruntime; extra == "onnx"
32
+ Requires-Dist: tokenizers; extra == "onnx"
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest; extra == "dev"
35
+
36
+ # Local Codebase Indexer
37
+
38
+ **English** | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
39
+
40
+ A local codebase indexer and context provider for AI coding assistants (Claude Code via MCP, GitHub Copilot / Codex via CLI). It helps them navigate large legacy codebases with far fewer tokens. Everything is stored in `.ai_context/indexer.db` inside your project, with no cloud services involved. The design follows [`index.md`](index.md).
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install local-codebase-indexer # Python 3.10+
46
+ # or, isolated from your other packages:
47
+ pipx install local-codebase-indexer
48
+ # optional real embeddings (see below): pip install "local-codebase-indexer[onnx]"
49
+ ```
50
+
51
+ From a clone: `pip install -e .` (the `bin/indexer` launcher still works in a checkout).
52
+
53
+ ## Usage (CLI)
54
+
55
+ ```bash
56
+ local-codebase-indexer --root <project> index # build / incrementally refresh the index (--force rebuilds all)
57
+ local-codebase-indexer search "database mapping for LotNo" --limit 5
58
+ local-codebase-indexer relations IExistOpenHandleService --direction both # upstream | downstream | both
59
+ local-codebase-indexer context Services/LotService.cs --lines 120 --padding 15 # alias: slice
60
+ local-codebase-indexer data-access MES_Lot # who reads / writes / executes a table or procedure; what a procedure touches
61
+ local-codebase-indexer stats
62
+ local-codebase-indexer status # is the index fresh? failed files, git commit, consistency checks
63
+ ```
64
+
65
+ - Without `--root` the current directory is used; `INDEXER_ROOT` also works.
66
+ - Put `--json` before the subcommand to get JSON output.
67
+ - `search`, `relations` and `context` run an incremental refresh first (mtime check, then md5 only when mtime changed).
68
+ - The tool names from the spec also work as subcommands: `codebase_semantic_search`, `codebase_get_symbol_relations`, `codebase_get_optimized_context` (options `--query`, `--symbol_name --relation_direction`, `--file_path --focus_lines --padding_lines`).
69
+
70
+ ## Usage (Claude Code / MCP)
71
+
72
+ ```bash
73
+ claude mcp add local-indexer -- local-codebase-indexer --root /path/to/project serve
74
+ # without installing: claude mcp add local-indexer -- uvx local-codebase-indexer --root /path/to/project serve
75
+ ```
76
+
77
+ `serve` speaks JSON-RPC (MCP) over stdio and exposes five tools: `codebase_semantic_search`, `codebase_get_symbol_relations`, `codebase_get_optimized_context`, `codebase_get_data_access`, `codebase_index_status`.
78
+
79
+ ## Three-layer index
80
+
81
+ | Layer | Implementation |
82
+ |---|---|
83
+ | Syntactic | tree-sitter for C#, Python, JavaScript, TypeScript/TSX; regex for SQL (PROCEDURE / VIEW / FUNCTION / TABLE); other text files (VB6 `.frm`, `.config`, `.md`, ...) are split into windowed chunks |
84
+ | Topology | `using` / `import` resolved to file dependencies, inheritance (base lists) and type usage (`invocation`), stored in the `dependencies` table |
85
+ | Semantic | 384-dim chunk embeddings in `vss_chunks`; queries fuse FTS5 keyword, symbol-name and vector rankings with Reciprocal Rank Fusion, then expand to the dependency neighbours of the top 3 files |
86
+
87
+ Encodings: UTF-8 first, then Big5, then cp1252. Files over 5MB are skipped. Root and nested `.gitignore` files are respected, and `.git`, `bin`, `obj`, `node_modules` and similar directories are always skipped.
88
+
89
+ ## Differences from the spec
90
+
91
+ - **Vector storage**: `sqlite-vss` needs a native extension and is no longer maintained, so `vss_chunks` stores float32 BLOBs and cosine similarity is computed with numpy. No extension to install.
92
+ - **Embedding model**: the default is a built-in hashing embedder (identifier sub-words, bigrams, CJK character bigrams). It is fast and reproducible but **does not understand synonyms**. For real transformer embeddings, export `all-MiniLM-L6-v2` or `bge-small-en-v1.5` to ONNX, install `onnxruntime` and `tokenizers`, and set `INDEXER_ONNX_MODEL=<folder with model.onnx and tokenizer.json>` (the loading, padding, pooling and re-index logic is covered by a test with a tiny synthetic model; retrieval quality with a real model has not been measured, so run `python benchmarks/run_benchmark.py --compare benchmarks/baseline.json` with the variable set to check it; mean pooling suits MiniLM, bge models recommend CLS pooling). Switching embedders triggers a full re-index.
93
+ - **Call relations** for C# are resolved with limited type inference (using / namespaces, field and constructor-injected types, locals, interface to implementation), and every edge carries a confidence: `confirmed` (the receiver type is known), `probable` (reached through an interface or a single candidate) or `candidate` (name match only). There is no Roslyn: no overload resolution by argument type, no delegates / lambdas / extension methods, and call chains are followed one level. Inheritance edges come from the AST and are reliable.
94
+ - Extra schema additions: `symbols.qualified_name`, `chunk_metadata.symbol_id`, and the `raw_refs`, `chunks_fts` and `meta` tables.
95
+
96
+ ## Data access (SQL)
97
+
98
+ `data-access <table-or-procedure>` answers "what writes / reads / executes X". It is built from T-SQL (INSERT / UPDATE / DELETE / MERGE / TRUNCATE / SELECT INTO / FROM / JOIN / EXEC, with alias resolution; CTEs, temp tables and system objects excluded), from C# SQL strings (literals, `+`, interpolation, `string.Format`, consts, locals) passed to Dapper, `SqlCommand` or EF `FromSql`, from stored-procedure names used with `CommandType.StoredProcedure`, and from EF `ToTable` / `[Table]` mappings. A procedure's callers are indirect writers of the tables it writes (followed up to 4 hops). Each entry has a confidence; **dynamic SQL (`EXEC(@sql)`, `sp_executesql`, SQL built from unknown values) cannot be resolved and is listed separately as `uncertain`**, never mixed into the confirmed lists. Known gaps: `FROM a JOIN b ON ..., c` loses `c`, and a stored-procedure name passed to a non-database method is not seen.
99
+
100
+ Natural-language search routes questions such as "who modifies MES_ScrapRecord" or "哪些程式會寫入 MES_Lot" to this index first.
101
+
102
+ ## Query expansion
103
+
104
+ Queries are expanded with a small synonym dictionary (`SP` / `預存程序` / `stored procedure` / `usp`, table, lot, scrap, ...; `local_codebase_indexer/synonyms.py`). Add or disable groups per project in `.ai_context/synonyms.json`: `{"groups": [["wpnlot", "在製批"]], "disable": ["sp"]}`. This helps abbreviations and Chinese/English terms, not free paraphrase.
105
+
106
+ ## Index health
107
+
108
+ `status` (also the MCP tool `codebase_index_status`) reports whether the index can be trusted without refreshing it: `fresh`, `stale` (files changed or git HEAD moved since the last update), `degraded` (failed files or an inconsistent database) or `not_indexed`. A file that fails to parse is removed from the index, listed with its error, and retried on every update, so search never returns stale lines for it. `index --force` rebuilds everything.
109
+
110
+ ## Benchmark
111
+
112
+ `python benchmarks/run_benchmark.py` measures Recall@5, Recall@10 and MRR on fixed queries. See [`benchmarks/README.md`](benchmarks/README.md).
113
+
114
+ ## Tests
115
+
116
+ ```bash
117
+ python -m pytest -q
118
+ ```
@@ -0,0 +1,21 @@
1
+ local_codebase_indexer/__init__.py,sha256=_fLYwuAkpHTCaqbaTkrI-oQsQ_QBFIxwL-Xs7r4sRN0,97
2
+ local_codebase_indexer/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ local_codebase_indexer/callgraph.py,sha256=oRkBODqNM1gyGYmSkHODpWTM9a97D9SS2RddWbi4eH0,13256
4
+ local_codebase_indexer/cli.py,sha256=bb0rdfZzHuhRCXGOwrJTsnmN1mkGCrrk5MbOt0dJxNc,5494
5
+ local_codebase_indexer/core.py,sha256=OjUPwkGHobXjWbdNSmEUclyPIznmwhzQCZw62PjfsWQ,13685
6
+ local_codebase_indexer/cscalls.py,sha256=7hIM3sGJwWDEzYLm2PPyGTNtSSRHZV1t_cxc0hvkDzk,13829
7
+ local_codebase_indexer/csdata.py,sha256=ySJhl2ijVNialVtBduPxfxzAv7DZ4Y__r1axiQYXmxE,19587
8
+ local_codebase_indexer/dataflow.py,sha256=lwKWFQo7WraS22-x-dmrlQiFCq84l3vgKhKdTjw8am8,11360
9
+ local_codebase_indexer/db.py,sha256=yGyOBUkbwytYYSyR724RRIQmdxni5gazzYbCWKCLYog,6538
10
+ local_codebase_indexer/embedder.py,sha256=B0KfXHSrlUtY5YURORUnILTgsGWkSQN42wr-LEzXtIs,5401
11
+ local_codebase_indexer/langparse.py,sha256=qussP6Mcl6-NxOBUAnfPjADhpu3ElysJtmWhABToR6g,22162
12
+ local_codebase_indexer/mcp_server.py,sha256=yyJ6KRwbk8XtKS-DyCQhv4wUrCiYDPq2tw2d_kZiiH4,6131
13
+ local_codebase_indexer/query.py,sha256=nYDRWXE3Ef7n0gJu0hIhLCKK4jdwYLQmfbp9Z2ycp9Q,40275
14
+ local_codebase_indexer/scanner.py,sha256=pudhVT1VHxt4vnesabtiqIFignKnIuBx5X5Mf6r0UBw,3458
15
+ local_codebase_indexer/sqlaccess.py,sha256=I_gsKQQrCoiyQ48TZFI_FjO7vCZRNXV292zWALLnKKw,12874
16
+ local_codebase_indexer/synonyms.py,sha256=jVl9Y31p31w43nfYTMwJMjusx7qRuu4bP1nDduGRae4,4601
17
+ local_codebase_indexer-0.2.0.dist-info/METADATA,sha256=TNtriHdPCxoLsHe9Cf_RhYtaLgsoDAnDAeHclOKSMKg,8800
18
+ local_codebase_indexer-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
19
+ local_codebase_indexer-0.2.0.dist-info/entry_points.txt,sha256=iqgzZAD66D6YzdgXs1h9XITcIYLbTAqDD3dcGdEKDpU,75
20
+ local_codebase_indexer-0.2.0.dist-info/top_level.txt,sha256=aDUVSeHcu8tffJ5axews940bbBMjYg57yjIrEcBeKq8,23
21
+ local_codebase_indexer-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ local-codebase-indexer = local_codebase_indexer.cli:main
@@ -0,0 +1 @@
1
+ local_codebase_indexer