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,450 @@
1
+ """Data access found in C# code: SQL strings, stored procedure calls (Dapper / ADO.NET) and EF table mappings.
2
+
3
+ Strings are evaluated statically: literals, ``+`` concatenation, interpolation, ``string.Format``, constants and
4
+ local variables (including ``sql += ...``). Parts that cannot be evaluated become holes. A SQL string handed to a
5
+ known database API is ``confirmed`` when it is fully static and ``probable`` otherwise; a SQL-looking string that
6
+ is not provably executed is ``probable``; a table name that is itself a hole, or an execution site whose SQL could
7
+ not be determined at all, is ``uncertain``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+
15
+ from .sqlaccess import HOLE, Access, extract_sql_accesses
16
+
17
+ _STRINGISH = {"string_literal", "verbatim_string_literal", "raw_string_literal", "interpolated_string_expression"}
18
+ _SCOPES = {
19
+ "method_declaration", "constructor_declaration", "destructor_declaration", "operator_declaration",
20
+ "indexer_declaration", "property_declaration",
21
+ }
22
+ _SINK_METHOD = re.compile(r"^(Execute\w*|Query\w*|FromSql\w*|SqlQuery\w*|Fill)$")
23
+ _DAPPER_STRICT = re.compile(r"^(Execute|Query)\w*$")
24
+ _COMMAND_TYPE = re.compile(r"(Command|DataAdapter)$")
25
+ _PROC_NAME = re.compile(r"^[A-Za-z_][\w$]*(?:\.[\w$]+){0,2}$")
26
+ _NOT_PROC = {"select", "update", "delete", "insert", "exec", "execute", "merge", "truncate"}
27
+ _STATEMENT_SHAPES = [
28
+ re.compile(r"^\s*select\b[\s\S]+\bfrom\b\s+\S", re.I),
29
+ re.compile(r"^\s*insert\s+(?:into\s+)?\S+[\s\S]*\b(?:values|select|exec(?:ute)?)\b", re.I),
30
+ re.compile(r"^\s*update\s+\S+\s+set\b", re.I),
31
+ re.compile(r"^\s*delete\s+(?:from\s+)?\S+[\s\S]*(?:\bwhere\b|@\w)", re.I),
32
+ re.compile(r"^\s*merge\b[\s\S]+\busing\b", re.I),
33
+ re.compile(r"^\s*truncate\s+table\s+\S+", re.I),
34
+ re.compile(r"^\s*exec(?:ute)?\s+[\w.\[\]]*[_.@][\w.\[\]]*", re.I),
35
+ re.compile(r"^\s*with\s+\w+\s+as\s*\([\s\S]+\bselect\b", re.I),
36
+ ]
37
+
38
+
39
+ @dataclass
40
+ class _Cand:
41
+ text: str
42
+ complete: bool
43
+ line: int
44
+ sink: bool = False
45
+ proc_ok: bool = False # may be read as a stored procedure name
46
+
47
+
48
+ def looks_like_statement(text: str) -> bool:
49
+ return any(rx.search(text) for rx in _STATEMENT_SHAPES)
50
+
51
+
52
+ def _walk(node, type_name):
53
+ stack = [node]
54
+ while stack:
55
+ n = stack.pop()
56
+ if n.type == type_name:
57
+ yield n
58
+ stack.extend(reversed(n.children))
59
+
60
+
61
+ class _Extractor:
62
+ def __init__(self, src: bytes, symbols: list):
63
+ self.src = src
64
+ self.out: list[Access] = []
65
+ spans = [(s.end - s.start, s) for s in symbols if s.type != "namespace"]
66
+ spans.sort(key=lambda t: (t[0], t[1].start))
67
+ self.spans = [s for _, s in spans]
68
+ self.file_env: dict[str, _Cand] = {}
69
+ self.cands: list[_Cand] = []
70
+
71
+ # ---------------------------------------------------------------- helpers
72
+ def text(self, n) -> str:
73
+ return self.src[n.start_byte:n.end_byte].decode("utf-8", errors="replace")
74
+
75
+ def locate(self, line: int):
76
+ for s in self.spans:
77
+ if s.start <= line <= s.end:
78
+ return s
79
+ return None
80
+
81
+ @staticmethod
82
+ def line(n) -> int:
83
+ return n.start_point[0] + 1
84
+
85
+ def collapsed(self, n) -> str:
86
+ return " ".join(self.text(n).split())[:160]
87
+
88
+ def method_name(self, func) -> str | None:
89
+ if func is None:
90
+ return None
91
+ t = func.type
92
+ if t == "identifier":
93
+ return self.text(func)
94
+ if t == "generic_name":
95
+ for c in func.children:
96
+ if c.type == "identifier":
97
+ return self.text(c)
98
+ if t == "member_access_expression":
99
+ return self.method_name(func.child_by_field_name("name"))
100
+ if t == "conditional_access_expression":
101
+ for c in func.children:
102
+ if c.type == "member_binding_expression":
103
+ for cc in c.children:
104
+ if cc.type in ("identifier", "generic_name"):
105
+ return self.method_name(cc)
106
+ return None
107
+
108
+ @staticmethod
109
+ def arg_exprs(arglist) -> list:
110
+ """[(expression node, is_named_argument)] for an argument_list node."""
111
+ out = []
112
+ if arglist is None:
113
+ return out
114
+ for a in arglist.named_children:
115
+ if a.type != "argument":
116
+ continue
117
+ named = list(a.named_children)
118
+ out.append((named[-1] if named else None, len(named) > 1))
119
+ return out
120
+
121
+ # ---------------------------------------------------------------- string evaluation
122
+ def evaluate(self, n, env) -> tuple[str, bool] | None:
123
+ t = n.type
124
+ if t == "string_literal":
125
+ raw = self.text(n)
126
+ if raw.startswith('"""'):
127
+ return raw.strip('"'), True
128
+ body = raw[1:-1] if len(raw) >= 2 else raw
129
+ body = (body.replace('\\"', '"').replace("\\n", "\n").replace("\\r", " ")
130
+ .replace("\\t", " ").replace("\\\\", "\\"))
131
+ return body, True
132
+ if t == "verbatim_string_literal":
133
+ raw = self.text(n)
134
+ return (raw[2:-1].replace('""', '"') if len(raw) >= 3 else raw), True
135
+ if t == "raw_string_literal":
136
+ return self.text(n).strip('"'), True
137
+ if t == "interpolated_string_expression":
138
+ parts, complete = [], True
139
+ for c in n.children:
140
+ if c.type == "interpolation":
141
+ parts.append(HOLE)
142
+ complete = False
143
+ elif c.type in ("string_content", "string_literal_content", "interpolated_string_text",
144
+ "interpolated_verbatim_string_text"):
145
+ parts.append(self.text(c).replace("{{", "{").replace("}}", "}"))
146
+ return "".join(parts), complete
147
+ if t == "parenthesized_expression":
148
+ inner = list(n.named_children)
149
+ return self.evaluate(inner[0], env) if inner else None
150
+ if t == "binary_expression" and any(c.type == "+" for c in n.children):
151
+ kids = n.named_children
152
+ if len(kids) != 2:
153
+ return None
154
+ left, right = self.evaluate(kids[0], env), self.evaluate(kids[1], env)
155
+ if left is None and right is None:
156
+ return None
157
+ lt, lc = left if left else (HOLE, False)
158
+ rt, rc = right if right else (HOLE, False)
159
+ return lt + rt, lc and rc
160
+ if t == "identifier":
161
+ c = env.get(self.text(n))
162
+ return (c.text, c.complete) if c else None
163
+ if t == "member_access_expression":
164
+ name = n.child_by_field_name("name")
165
+ c = self.file_env.get(self.text(name)) if name is not None else None
166
+ return (c.text, c.complete) if c else None
167
+ if t == "invocation_expression":
168
+ func = n.child_by_field_name("function")
169
+ if self.method_name(func) in ("Format", "Concat") and func is not None and "string" in self.text(func).lower():
170
+ args = self.arg_exprs(n.child_by_field_name("arguments"))
171
+ if args and args[0][0] is not None:
172
+ first = self.evaluate(args[0][0], env)
173
+ if first is not None:
174
+ return re.sub(r"\{\d+[^}]*\}", HOLE, first[0]), False
175
+ return None
176
+
177
+ def _is_stringy(self, n) -> bool:
178
+ if n.type in _STRINGISH:
179
+ return True
180
+ if n.type == "binary_expression" and any(c.type == "+" for c in n.children):
181
+ return any(self._is_stringy(c) for c in n.named_children)
182
+ if n.type == "parenthesized_expression":
183
+ return any(self._is_stringy(c) for c in n.named_children)
184
+ return False
185
+
186
+ # ---------------------------------------------------------------- traversal
187
+ def run(self, root) -> list[Access]:
188
+ self._collect_file_consts(root)
189
+ env: dict[str, _Cand] = dict(self.file_env)
190
+ stack: list = [("node", root)]
191
+ while stack:
192
+ kind, n = stack.pop()
193
+ if kind == "exit":
194
+ mark, scope_node, saved_env = n
195
+ self._flush(mark, scope_node)
196
+ env = saved_env
197
+ continue
198
+ if n.type in _SCOPES:
199
+ stack.append(("exit", (len(self.cands), n, env)))
200
+ env = dict(self.file_env)
201
+ for c in reversed(n.children):
202
+ stack.append(("node", c))
203
+ continue
204
+ if self._visit(n, env, stack):
205
+ continue
206
+ for c in reversed(n.children):
207
+ stack.append(("node", c))
208
+ self._flush(0, root)
209
+ return self.out
210
+
211
+ def _collect_file_consts(self, root) -> None:
212
+ stack = [root]
213
+ while stack:
214
+ n = stack.pop()
215
+ if n.type == "field_declaration":
216
+ for d in _walk(n, "variable_declarator"):
217
+ ident, init = self._declarator(d)
218
+ if ident and init is not None and self._is_stringy(init):
219
+ r = self.evaluate(init, self.file_env)
220
+ if r:
221
+ c = _Cand(r[0], r[1], self.line(init))
222
+ self.file_env[ident] = c
223
+ self.cands.append(c)
224
+ continue
225
+ if n.type in _SCOPES:
226
+ continue
227
+ stack.extend(n.children)
228
+
229
+ def _declarator(self, d):
230
+ ident, init = None, None
231
+ for c in d.named_children:
232
+ if c.type == "identifier" and ident is None:
233
+ ident = self.text(c)
234
+ else:
235
+ init = c
236
+ return ident, init
237
+
238
+ def _visit(self, n, env, stack) -> bool:
239
+ """Handle nodes of interest. Returns True when the node's children must not be traversed."""
240
+ t = n.type
241
+ if t == "variable_declarator":
242
+ ident, init = self._declarator(n)
243
+ if ident and init is not None and self._is_stringy(init):
244
+ r = self.evaluate(init, env)
245
+ if r:
246
+ c = _Cand(r[0], r[1], self.line(init))
247
+ env[ident] = c
248
+ self.cands.append(c)
249
+ return True
250
+ return False
251
+ if t == "assignment_expression":
252
+ return self._assignment(n, env, stack)
253
+ if t == "invocation_expression":
254
+ return self._invocation(n, env, stack)
255
+ if t == "object_creation_expression":
256
+ return self._creation(n, env, stack)
257
+ if t == "attribute":
258
+ self._attribute(n)
259
+ return False
260
+ if self._is_stringy(n):
261
+ r = self.evaluate(n, env)
262
+ if r:
263
+ self.cands.append(_Cand(r[0], r[1], self.line(n)))
264
+ return True
265
+ return False
266
+
267
+ def _assignment(self, n, env, stack) -> bool:
268
+ kids = n.named_children
269
+ if len(kids) != 2:
270
+ return False
271
+ left, right = kids
272
+ op = next((c.type for c in n.children if c.type in ("=", "+=")), "=")
273
+ if left.type == "member_access_expression" and self.text(left.child_by_field_name("name")) == "CommandText":
274
+ if not self._sink_arg(right, env, stack, first=True):
275
+ if not self._is_stringy(right):
276
+ self._dynamic_site(n)
277
+ stack.append(("node", left))
278
+ return True
279
+ if left.type == "identifier" and self._is_stringy(right):
280
+ name = self.text(left)
281
+ r = self.evaluate(right, env)
282
+ if r is None:
283
+ return False
284
+ cur = env.get(name)
285
+ if op == "+=" and cur is not None:
286
+ cur.text += r[0]
287
+ cur.complete = cur.complete and r[1]
288
+ elif cur is not None and re.search(rf"\b{re.escape(name)}\b", self.text(right)):
289
+ cur.text, cur.complete = r # sql = sql + "..."
290
+ else:
291
+ c = _Cand(r[0], r[1], self.line(right))
292
+ env[name] = c
293
+ self.cands.append(c)
294
+ return True
295
+ return False
296
+
297
+ def _sink_arg(self, expr, env, stack, first: bool) -> bool:
298
+ """An argument handed to a database API. True when it was a statically known string."""
299
+ if expr is None:
300
+ return False
301
+ if expr.type == "identifier":
302
+ c = env.get(self.text(expr))
303
+ if c is not None:
304
+ c.sink = True
305
+ c.proc_ok = c.proc_ok or first
306
+ return True
307
+ elif self._is_stringy(expr) or expr.type in ("member_access_expression", "invocation_expression"):
308
+ r = self.evaluate(expr, env)
309
+ if r is not None:
310
+ self.cands.append(_Cand(r[0], r[1], self.line(expr), sink=True, proc_ok=first))
311
+ return True
312
+ stack.append(("node", expr))
313
+ return False
314
+
315
+ def _invocation(self, n, env, stack) -> bool:
316
+ func = n.child_by_field_name("function")
317
+ name = self.method_name(func)
318
+ args = self.arg_exprs(n.child_by_field_name("arguments"))
319
+ if name in ("ToTable", "ToView") and args and args[0][0] is not None and self._is_stringy(args[0][0]):
320
+ r = self.evaluate(args[0][0], env)
321
+ if r and r[1]:
322
+ self._mapping(r[0], n, func)
323
+ if name and _SINK_METHOD.match(name):
324
+ handled_any = False
325
+ for i, (expr, _named) in enumerate(args):
326
+ handled = self._sink_arg(expr, env, stack, first=not handled_any)
327
+ handled_any = handled_any or handled
328
+ if not handled_any and _DAPPER_STRICT.match(name) and args and args[0][0] is not None:
329
+ second = args[1][0] if len(args) > 1 else None
330
+ has_params = second is not None and second.type in (
331
+ "anonymous_object_creation_expression", "object_creation_expression", "identifier")
332
+ if has_params or any(named for _e, named in args):
333
+ self._dynamic_site(n)
334
+ if func is not None:
335
+ stack.append(("node", func))
336
+ return True
337
+ return False
338
+
339
+ def _creation(self, n, env, stack) -> bool:
340
+ tnode = n.child_by_field_name("type")
341
+ tname = self.text(tnode) if tnode is not None else ""
342
+ if not _COMMAND_TYPE.search(tname.split(".")[-1].split("<")[0]):
343
+ return False
344
+ args = self.arg_exprs(n.child_by_field_name("arguments"))
345
+ if args and args[0][0] is not None:
346
+ if not self._sink_arg(args[0][0], env, stack, first=True):
347
+ self._dynamic_site(n)
348
+ for expr, _named in args[1:]:
349
+ if expr is not None:
350
+ stack.append(("node", expr))
351
+ for c in n.children:
352
+ if c.type == "initializer_expression":
353
+ stack.append(("node", c))
354
+ return True
355
+
356
+ def _dynamic_site(self, n) -> None:
357
+ self.out.append(Access(
358
+ key="?", name="(dynamic)", qualifier="", kind="unknown", access="exec", op="dynamic",
359
+ line=self.line(n), via="csharp_sql", confidence="uncertain", evidence=self.collapsed(n),
360
+ ))
361
+
362
+ # ---------------------------------------------------------------- mappings
363
+ def _map_access(self, table: str, line: int, evidence: str, entity: str) -> None:
364
+ parts = [p.strip("[] ") for p in table.split(".")]
365
+ self.out.append(Access(
366
+ key=parts[-1].lower(), name=parts[-1], qualifier=".".join(parts[:-1]), kind="table", access="map",
367
+ op="map", line=line, via="ef_mapping", confidence="confirmed", evidence=evidence, detail=entity,
368
+ ))
369
+
370
+ def _mapping(self, table: str, n, func) -> None:
371
+ entity = ""
372
+ node = func.child_by_field_name("expression") if func is not None and func.type == "member_access_expression" else None
373
+ while node is not None and node.type == "invocation_expression":
374
+ f = node.child_by_field_name("function")
375
+ if f is None or f.type != "member_access_expression":
376
+ break
377
+ nm = f.child_by_field_name("name")
378
+ if nm is not None and nm.type == "generic_name" and self.method_name(nm) == "Entity":
379
+ targs = [c for c in nm.children if c.type == "type_argument_list"]
380
+ if targs and targs[0].named_children:
381
+ entity = self.text(targs[0].named_children[0])
382
+ break
383
+ node = f.child_by_field_name("expression")
384
+ self._map_access(table, self.line(n), self.collapsed(n), entity)
385
+
386
+ def _attribute(self, n) -> None:
387
+ name = next((self.text(c) for c in n.named_children if c.type in ("identifier", "qualified_name")), "")
388
+ if name.split(".")[-1] not in ("Table", "TableAttribute"):
389
+ return
390
+ arglist = next((c for c in n.named_children if c.type == "attribute_argument_list"), None)
391
+ if arglist is None or not arglist.named_children:
392
+ return
393
+ first = list(arglist.named_children[0].named_children)
394
+ if not first or first[0].type not in _STRINGISH:
395
+ return
396
+ r = self.evaluate(first[0], {})
397
+ if not r or not r[0]:
398
+ return
399
+ cls = n.parent.parent if n.parent is not None else None
400
+ entity = ""
401
+ if cls is not None:
402
+ entity = next((self.text(c) for c in cls.named_children if c.type == "identifier"), "")
403
+ self._map_access(r[0], self.line(n), self.collapsed(n), entity)
404
+
405
+ # ---------------------------------------------------------------- emission
406
+ def _flush(self, mark: int, scope_node) -> None:
407
+ cands, self.cands = self.cands[mark:], self.cands[:mark]
408
+ has_sp = (scope_node.type in _SCOPES
409
+ and b"StoredProcedure" in self.src[scope_node.start_byte:scope_node.end_byte])
410
+ for c in cands:
411
+ text = c.text.strip()
412
+ if not text:
413
+ continue
414
+ collapsed = " ".join(text.split())[:160]
415
+ if (c.sink and c.proc_ok and c.complete and "\n" not in text and _PROC_NAME.match(text)
416
+ and text.lower() not in _NOT_PROC):
417
+ parts = text.split(".")
418
+ self.out.append(Access(
419
+ key=parts[-1].lower(), name=parts[-1], qualifier=".".join(parts[:-1]), kind="procedure",
420
+ access="exec", op="exec", line=c.line, via="csharp_sp",
421
+ confidence="confirmed" if has_sp else "probable", evidence=collapsed,
422
+ ))
423
+ continue
424
+ if not (c.sink or looks_like_statement(text)):
425
+ continue
426
+ for a in extract_sql_accesses(text, c.line):
427
+ a.line = c.line
428
+ a.via = "csharp_sql"
429
+ a.evidence = collapsed
430
+ if a.confidence != "uncertain":
431
+ a.confidence = "confirmed" if (c.sink and c.complete) else "probable"
432
+ self.out.append(a)
433
+
434
+
435
+ def extract_cs_accesses(root, src: bytes, symbols: list) -> list[Access]:
436
+ ex = _Extractor(src, symbols)
437
+ try:
438
+ accesses = ex.run(root)
439
+ except RecursionError:
440
+ return []
441
+ seen, out = set(), []
442
+ for a in accesses:
443
+ sym = ex.locate(a.line)
444
+ a.source_id = sym.id if sym is not None else None
445
+ sig = (a.source_id, a.key, a.access, a.op, a.line, a.via)
446
+ if sig not in seen:
447
+ seen.add(sig)
448
+ out.append(a)
449
+ out.sort(key=lambda a: (a.line, a.access, a.key))
450
+ return out