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,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 @@
|
|
|
1
|
+
local_codebase_indexer
|