leyline-code 0.8.1__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.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/channels.py ADDED
@@ -0,0 +1,1782 @@
1
+ """Channels that are not calls, beyond events, launches, HTTP and files: dependency injection, message queues
2
+ and buses, databases, and RPC.
3
+
4
+ Each adapter hands its syntax tree to `extract`, which records the ends of these channels as `Endpoint`s on
5
+ the file's result, placed in the function (or type) whose span holds them. `resolve` then links the ends
6
+ across the workspace as `communicates` edges. The two halves meet by name: a topic, a table, a message type,
7
+ a service and method, an interface. A link is "heuristic" when the names matched as written, and "guess"
8
+ when one side was inferred (a factory's `new X`, a table matched only after singular and plural are
9
+ folded together)."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections import Counter, defaultdict
15
+ from typing import Optional
16
+
17
+ from .model import CallSite, Edge, Endpoint, FileResult
18
+
19
+ CHANNELS = ("di", "queue", "db", "rpc")
20
+
21
+
22
+ def _t(node) -> str:
23
+ return node.text.decode("utf8", "replace") if node is not None else ""
24
+
25
+
26
+ # -- shared shapes -----------------------------------------------------------------------------------------
27
+
28
+ # Names a topic or queue goes by in keyword arguments, most specific first: a RabbitMQ publish names an
29
+ # exchange and a routing key, and the key is the one a consumer binds to.
30
+ TOPIC_KW = ("topic", "topics", "subject", "routing_key", "routingKey", "queue", "queue_name", "queueName",
31
+ "topicName", "channel", "stream", "exchange", "event", "pattern")
32
+ HANDLER_KW = ("callback", "cb", "handler", "listener", "on_message_callback", "on_message", "func", "fn", "f")
33
+ # A receiver that is a broker of some kind, for verbs too common to trust on anything (send, put).
34
+ BROKER = re.compile(r"(produc|publish|kafka|bus|broker|queue|channel|nats|sns|sqs|pubsub|redis|rabbit|amqp|emitter|"
35
+ r"events?$|hub|topic|mq|stream|sio|socket|client|proxy)", re.I)
36
+ # Event names that streams, sockets, processes and the DOM raise on their own. Code here that subscribes to one
37
+ # is listening to a library, and code that emits one rarely means the same subscriber.
38
+ COMMON_EVENTS = frozenset("""error data end close finish open message connect connection connected disconnect
39
+ disconnected exit ready change click load unload drain pipe unpipe readable response request timeout abort
40
+ listening upgrade resize scroll input submit keydown keyup mousedown mouseup focus blur online offline
41
+ beforeunload uncaughtException unhandledRejection SIGINT SIGTERM warning start stop done progress success fail
42
+ destroy init update""".split())
43
+ HANDLER_IFACES = {"IRequestHandler", "INotificationHandler", "IConsumer", "IHandleMessages", "ICommandHandler",
44
+ "IEventHandler", "IIntegrationEventHandler", "IQueryHandler", "IHandle", "IHandler",
45
+ "IStreamRequestHandler", "IMessageHandler", "IDomainEventHandler"}
46
+ HANDLE_METHODS = ("Handle", "HandleAsync", "Consume", "ConsumeAsync", "Execute", "ExecuteAsync", "Process", "Run")
47
+
48
+
49
+ def _sql_name(raw: str) -> Optional[str]:
50
+ """A table name as written in SQL, without quotes, brackets or schema: [dbo].[Users] -> Users."""
51
+ name = re.sub(r"[`\"\[\]]", "", raw).rsplit(".", 1)[-1]
52
+ if not re.fullmatch(r"[A-Za-z_][\w$]*", name) or name.lower() in SQL_WORDS:
53
+ return None
54
+ return name
55
+
56
+
57
+ SQL_WORDS = frozenset("""select from where join on as set values into table only lateral unnest dual information_schema
58
+ pg_catalog sqlite_master generate_series json_each json_table openjson the a an this that it""".split())
59
+ _T = r"((?:[`\"\[]?[A-Za-z_][\w$]*[`\"\]]?\.){0,2}[`\"\[]?[A-Za-z_][\w$]*[`\"\]]?)"
60
+ _SQL_VERB = re.compile(r"^[\s(]*(select|insert|update|delete|with|merge|replace|upsert|truncate)\b", re.I)
61
+ _SQL_WRITES = [re.compile(p, re.I) for p in (
62
+ rf"\binsert\s+(?:or\s+\w+\s+|ignore\s+)?into\s+{_T}", rf"\bupdate\s+(?:or\s+\w+\s+|only\s+)?{_T}\s+set\b",
63
+ rf"\bdelete\s+from\s+(?:only\s+)?{_T}", rf"\bmerge\s+into\s+{_T}", rf"\breplace\s+into\s+{_T}",
64
+ rf"\btruncate\s+(?:table\s+)?{_T}", rf"\bupsert\s+into\s+{_T}")]
65
+ _SQL_READS = [re.compile(p, re.I) for p in (rf"\bfrom\s+(?:only\s+)?{_T}", rf"\bjoin\s+{_T}")]
66
+ _SQL_NOT_TABLE = re.compile(r"\b(extract|substring|trim|position|overlay)\s*\([^()]*\)", re.I)
67
+
68
+
69
+ def sql_tables(text: str) -> list[tuple[str, str]]:
70
+ """(role, table) for each table a SQL statement reads or writes. Only a string that starts like SQL, with
71
+ its keywords in one case (SELECT ... FROM or select ... from), is read: prose starts "Select a file"."""
72
+ m = _SQL_VERB.match(text)
73
+ if not m or len(text) > 20000:
74
+ return []
75
+ verb = m.group(1)
76
+ if not (verb.isupper() or verb.islower()):
77
+ return []
78
+ upper = verb.isupper()
79
+ body = _SQL_NOT_TABLE.sub(" ", text)
80
+ ctes = {c.lower() for c in re.findall(r"(\w+)\s+as\s*\(", body, re.I)}
81
+ out = []
82
+ for p in _SQL_WRITES:
83
+ for w in p.finditer(body):
84
+ kw = w.group(0).split()[0]
85
+ if kw.isupper() != upper:
86
+ continue
87
+ name = _sql_name(w.group(1))
88
+ if name and name.lower() not in ctes:
89
+ out.append(("write", name))
90
+ # The written table is not also read: blank out the clause before looking for FROM and JOIN.
91
+ body = body[:w.start()] + " " * (w.end() - w.start()) + body[w.end():]
92
+ for p in _SQL_READS:
93
+ for r in p.finditer(body):
94
+ kw = r.group(0).split()[0]
95
+ if kw.isupper() != upper:
96
+ continue
97
+ name = _sql_name(r.group(1))
98
+ if name and name.lower() not in ctes:
99
+ out.append(("read", name))
100
+ return list(dict.fromkeys(out))
101
+
102
+
103
+ # Database operations by name, for ORMs and document stores. A name is compared with underscores dropped and
104
+ # in lower case, so insert_one and insertOne are one operation.
105
+ DB_WRITE = frozenset("""create createmany insert insertone insertmany update updateone updatemany updatemany upsert delete
106
+ deleteone deletemany destroy remove save bulkcreate bulkupdate bulkwrite getorcreate updateorcreate replaceone
107
+ findoneandupdate findoneandreplace findoneanddelete findbyidandupdate findbyidanddelete findbyidandremove
108
+ findoneandremove softdelete restore increment decrement insertmany add addrange addasync addrangeasync
109
+ removerange updaterange attach executedelete executedeleteasync executeupdate executeupdateasync merge
110
+ deletebyid insertmany replace del truncate""".split())
111
+ DB_READ = frozenset("""find findall findone findmany findunique findfirst finduniqueorthrow findfirstorthrow findbyid
112
+ findbypk findby findoneby findandcount count countdocuments estimateddocumentcount aggregate groupby distinct
113
+ select get getornone getbyid filter all exclude first last exists where query watch values valueslist""".split())
114
+ PRISMA_OPS = frozenset("""findMany findUnique findFirst findUniqueOrThrow findFirstOrThrow count aggregate groupBy create
115
+ createMany createManyAndReturn update updateMany upsert delete deleteMany""".split())
116
+ MONGO_OPS = frozenset("""find findone insertone insertmany updateone updatemany replaceone deleteone deletemany aggregate
117
+ countdocuments estimateddocumentcount distinct findoneandupdate findoneandreplace findoneanddelete bulkwrite
118
+ watch drop createindex""".split())
119
+
120
+
121
+ def _op(name: str) -> str:
122
+ return name.replace("_", "").lower()
123
+
124
+
125
+ def _db_role(name: str) -> Optional[str]:
126
+ k = _op(name)
127
+ return "write" if k in DB_WRITE else "read" if k in DB_READ else None
128
+
129
+
130
+ class _Where:
131
+ """The innermost function, and type, a line sits in, from the spans of what the adapter already found."""
132
+
133
+ def __init__(self, res: FileResult, file_id: str):
134
+ self.file_id = file_id
135
+ self.nodes = {n.id: n for n in res.nodes}
136
+ self.fn_at: dict[int, str] = {}
137
+ self.type_at: dict[int, str] = {}
138
+ self.starts: list[tuple[int, int, str]] = []
139
+ for kinds, at in ((("callable", "test"), self.fn_at), (("type",), self.type_at)):
140
+ spans = sorted(((n.span_start, n.span_end, n.id) for n in res.nodes if n.kind in kinds and n.span_start),
141
+ key=lambda s: s[0] - s[1]) # outermost first, so an inner span paints over it
142
+ for a, b, i in spans:
143
+ for line in range(a, (b or a) + 1):
144
+ at[line] = i
145
+ if at is self.fn_at:
146
+ self.starts = sorted(spans)
147
+
148
+ def fn(self, line: int) -> Optional[str]:
149
+ return self.fn_at.get(line)
150
+
151
+ def type(self, line: int) -> Optional[str]:
152
+ return self.type_at.get(line)
153
+
154
+ def next_fn(self, line: int, within: Optional[str] = None) -> Optional[str]:
155
+ """The first function declared at or after a line: a TypeScript decorator sits outside the method it marks."""
156
+ for a, _b, i in self.starts:
157
+ if a >= line and (within is None or self.nodes[i].parent_id == within):
158
+ return i
159
+ return None
160
+
161
+
162
+ class _Out:
163
+ """Collects endpoints for one file, once each."""
164
+
165
+ def __init__(self, res: FileResult):
166
+ self.res = res
167
+ self.seen: set = set()
168
+
169
+ def add(self, channel, role, src, address, line, method=None, literals=None, handler=None) -> None:
170
+ if not src or not (address or role == "table"): # a mapped class need not name its table
171
+ return
172
+ key = (channel, role, src, address, method, handler, tuple(literals or ()))
173
+ if key in self.seen:
174
+ return
175
+ self.seen.add(key)
176
+ self.res.endpoints.append(Endpoint(channel, role, src, address, line, method, list(literals or []), handler))
177
+
178
+
179
+ def extract(lang: str, tree, res: FileResult, file_id: str, consts: Optional[dict] = None) -> None:
180
+ """Record the channel ends in one parsed file. A failure here loses this file's channels, not its parse."""
181
+ where = _Where(res, file_id)
182
+ try:
183
+ out = _Out(res)
184
+ {"python": _Python, "csharp": _CSharp, "typescript": _TypeScript}[lang](tree, where, out, consts or {}).run()
185
+ except RecursionError:
186
+ pass
187
+ from . import formats # keys built in one place and taken apart in another
188
+ formats.extract(lang, tree, where, _Out(res), consts)
189
+
190
+
191
+ # -- Python ------------------------------------------------------------------------------------------------
192
+
193
+ def _py_str(node) -> Optional[str]:
194
+ if node is None or node.type != "string":
195
+ return None
196
+ if any(c.type == "interpolation" for c in node.children):
197
+ return None
198
+ return "".join(_t(c) for c in node.children if c.type == "string_content")
199
+
200
+
201
+ def _py_name(node) -> Optional[str]:
202
+ """A dotted name (a.b.C), or None for anything else."""
203
+ if node is not None and node.type in ("identifier", "attribute") and re.fullmatch(r"[\w.]+", _t(node)):
204
+ return _t(node)
205
+ return None
206
+
207
+
208
+ class _Python:
209
+ DI_REGISTER = {"register", "bind", "add_singleton", "add_scoped", "add_transient", "register_singleton",
210
+ "register_transient", "register_scoped", "register_type"}
211
+ PUB = {"publish", "basic_publish", "publish_message", "sendMessage", "send_message", "produce", "xadd",
212
+ "lpush", "rpush", "send", "send_json", "put_message", "enqueue_message"}
213
+ PUB_LOOSE = {"send", "send_json", "send_message", "put_message"}
214
+ SUB = {"subscribe", "psubscribe", "basic_consume", "consume", "queue_bind", "blpop", "brpop", "xread",
215
+ "xreadgroup", "KafkaConsumer", "AIOKafkaConsumer"}
216
+ SESSION = re.compile(r"(session|db|tx|uow|conn|^s)$", re.I) # a bare `s`, not requests
217
+
218
+ def __init__(self, tree, where: _Where, out: _Out, consts: dict):
219
+ self.tree, self.where, self.out = tree, where, out
220
+ self.consts = {k: v[0] for k, v in consts.items() if len(v) == 1}
221
+ self.sql_consts = {k: sql_tables(v) for k, v in self.consts.items()}
222
+ self.sql_consts = {k: v for k, v in self.sql_consts.items() if v}
223
+ self.rpc_vars: dict[str, str] = {}
224
+ self.var_types: dict[tuple, str] = {}
225
+
226
+ def _lit(self, node) -> Optional[str]:
227
+ s = _py_str(node)
228
+ if s is None and node is not None and node.type == "identifier":
229
+ s = self.consts.get(_t(node))
230
+ return s or None
231
+
232
+ def run(self) -> None:
233
+ stack = [self.tree.root_node]
234
+ while stack:
235
+ n = stack.pop()
236
+ t = n.type
237
+ if t == "call":
238
+ self._call(n)
239
+ elif t == "decorator":
240
+ self._decorator(n)
241
+ elif t == "string":
242
+ s = _py_str(n)
243
+ if s:
244
+ self._sql(s, n.start_point[0] + 1)
245
+ elif t == "identifier" and _t(n) in self.sql_consts:
246
+ cid = self.where.fn(n.start_point[0] + 1)
247
+ if cid and not cid.endswith("<module>"):
248
+ for role, table in self.sql_consts[_t(n)]:
249
+ self.out.add("db", role, cid, table, n.start_point[0] + 1, "sql")
250
+ elif t == "assignment":
251
+ self._assign(n)
252
+ elif t == "class_definition":
253
+ self._class(n)
254
+ stack.extend(reversed(n.children))
255
+
256
+ def _sql(self, text: str, line: int) -> None:
257
+ cid = self.where.fn(line)
258
+ if cid:
259
+ for role, table in sql_tables(text):
260
+ self.out.add("db", role, cid, table, line, "sql")
261
+
262
+ def _assign(self, n) -> None:
263
+ left, right = n.child_by_field_name("left"), n.child_by_field_name("right")
264
+ if left is None or right is None:
265
+ return
266
+ line = n.start_point[0] + 1
267
+ lname = _t(left)
268
+ if right.type == "call":
269
+ fn = _t(right.child_by_field_name("function"))
270
+ last = fn.rsplit(".", 1)[-1]
271
+ svc = None
272
+ if last.endswith("Stub") and len(last) > 4 and last[0].isupper():
273
+ svc = last[:-4] # helloworld_pb2_grpc.GreeterStub(channel)
274
+ elif last == "Client" and "." in fn:
275
+ svc = fn.rsplit(".", 2)[-2] # Thrift: Calculator.Client(protocol)
276
+ if svc:
277
+ self.rpc_vars[lname] = svc
278
+ cid = self.where.fn(line)
279
+ if cid and left.type == "identifier":
280
+ head = fn.rsplit(".", 2)[-2] if "." in fn else ""
281
+ rpos, _ = self._args(right.child_by_field_name("arguments"))
282
+ first = _py_name(rpos[0]) if rpos else None
283
+ if last[:1].isupper():
284
+ self.var_types[(cid, lname)] = last # x = User(...)
285
+ elif head[:1].isupper() and not head.isupper():
286
+ self.var_types[(cid, lname)] = head # x = User.model_validate(...)
287
+ elif first and first.rsplit(".", 1)[-1][:1].isupper() and self.SESSION.search(fn.rsplit(".", 1)[0]):
288
+ self.var_types[(cid, lname)] = first.rsplit(".", 1)[-1] # x = session.get(User, id)
289
+ elif left.type == "identifier" and lname in ("__tablename__", "db_table", "__collection__"):
290
+ table = self._lit(right)
291
+ tid = self.where.type(line)
292
+ if table and tid:
293
+ node = self.where.nodes[tid]
294
+ if node.name == "Meta" and node.parent_id in self.where.nodes:
295
+ tid = node.parent_id # Django: class Meta: db_table = "x"
296
+ self.out.add("db", "table", tid, table, line, "orm", [self.where.nodes[tid].name])
297
+
298
+ def _class(self, n) -> None:
299
+ sup = n.child_by_field_name("superclasses")
300
+ if sup is None:
301
+ return
302
+ line = n.start_point[0] + 1
303
+ tid = self.where.type(line)
304
+ if not tid:
305
+ return
306
+ text = _t(sup)
307
+ for svc in re.findall(r"(\w+)Servicer\b", text):
308
+ self.out.add("rpc", "serve", tid, svc.rsplit(".", 1)[-1], line, "grpc")
309
+ for svc in re.findall(r"(\w+)\.Iface\b", text):
310
+ self.out.add("rpc", "serve", tid, svc, line, "thrift")
311
+ if re.search(r"\b(Model|Base|SQLModel|DeclarativeBase|Document|DynamicDocument|EmbeddedDocument)\b", text) \
312
+ and not re.search(r"\b(BaseModel|BaseSettings)\b", text):
313
+ # A class built on an ORM base is a table whether or not it names one.
314
+ self.out.add("db", "table", tid, "", line, "orm", [self.where.nodes[tid].name])
315
+
316
+ def _args(self, args):
317
+ pos, kw = [], {}
318
+ if args is None or args.type != "argument_list":
319
+ return pos, kw
320
+ for a in args.named_children:
321
+ if a.type == "keyword_argument":
322
+ kw[_t(a.child_by_field_name("name"))] = a.child_by_field_name("value")
323
+ elif a.type not in ("comment", "list_splat", "dictionary_splat"):
324
+ pos.append(a)
325
+ return pos, kw
326
+
327
+ def _strings_of(self, nodes) -> list[str]:
328
+ out = []
329
+ for a in nodes:
330
+ if a is None:
331
+ continue
332
+ if a.type in ("list", "tuple", "set"):
333
+ out += [s for s in (self._lit(x) for x in a.named_children) if s]
334
+ else:
335
+ s = self._lit(a)
336
+ if s:
337
+ out.append(s)
338
+ return out
339
+
340
+ def _topic(self, pos, kw) -> list[str]:
341
+ for k in TOPIC_KW:
342
+ if k in kw:
343
+ found = self._strings_of([kw[k]])
344
+ if found:
345
+ return found
346
+ return self._strings_of(pos)
347
+
348
+ def _handler(self, pos, kw) -> Optional[str]:
349
+ for k in HANDLER_KW:
350
+ if k in kw and _py_name(kw[k]):
351
+ return _py_name(kw[k])
352
+ for a in pos:
353
+ if _py_name(a) and not _t(a) in self.consts:
354
+ return _py_name(a)
355
+ return None
356
+
357
+ def _call(self, n) -> None:
358
+ fn = n.child_by_field_name("function")
359
+ if fn is None:
360
+ return
361
+ line = n.start_point[0] + 1
362
+ cid = self.where.fn(line)
363
+ if not cid:
364
+ return
365
+ full = _t(fn)
366
+ last = full.rsplit(".", 1)[-1]
367
+ obj = fn.child_by_field_name("object") if fn.type == "attribute" else None
368
+ recv = _t(obj) if obj is not None else ""
369
+ pos, kw = self._args(n.child_by_field_name("arguments"))
370
+ out = self.out
371
+
372
+ # Dependency injection.
373
+ if last in ("Depends", "Security"):
374
+ dep = pos[0] if pos else kw.get("dependency")
375
+ name = _py_name(dep)
376
+ if name is None and dep is None:
377
+ name = self._annotated_type(n)
378
+ if name:
379
+ out.add("di", "depend", cid, name, line, "fastapi")
380
+ return
381
+ if last in self.DI_REGISTER and len(pos) >= 1:
382
+ svc = _py_name(pos[0])
383
+ impl = _py_name(pos[1]) if len(pos) > 1 else None
384
+ for k in ("to", "implementation", "concrete", "impl", "provider"):
385
+ impl = impl or _py_name(kw.get(k))
386
+ if svc and impl and svc != impl and svc.rsplit(".", 1)[-1][:1].isupper() and impl.rsplit(".", 1)[-1][:1].isupper():
387
+ out.add("di", "provide", cid, svc, line, "type", [impl])
388
+ return
389
+ if last == "to" and obj is not None and obj.type == "call": # binder.bind(IFoo).to(Foo)
390
+ inner = obj.child_by_field_name("function")
391
+ ipos, _ = self._args(obj.child_by_field_name("arguments"))
392
+ if _t(inner).endswith("bind") and ipos and _py_name(ipos[0]) and pos and _py_name(pos[0]):
393
+ out.add("di", "provide", cid, _py_name(ipos[0]), line, "type", [_py_name(pos[0])])
394
+ return
395
+
396
+ # RPC: a call on a stub made from generated code.
397
+ svc = self.rpc_vars.get(recv)
398
+ if svc is None and obj is not None and obj.type == "call":
399
+ inner = _t(obj.child_by_field_name("function")).rsplit(".", 1)[-1]
400
+ if inner.endswith("Stub") and len(inner) > 4:
401
+ svc = inner[:-4]
402
+ if svc:
403
+ out.add("rpc", "call", cid, f"{svc}/{last}", line, "grpc")
404
+ return
405
+
406
+ # Task queues: the task function itself is named, so it is resolved like a call.
407
+ if last in ("delay", "apply_async") and _py_name(obj):
408
+ out.add("queue", "publish", cid, recv.rsplit(".", 1)[-1], line, "task", handler=recv)
409
+ return
410
+ if last == "send_task" and pos and self._lit(pos[0]):
411
+ out.add("queue", "publish", cid, self._lit(pos[0]).rsplit(".", 1)[-1], line, "task")
412
+ return
413
+ if last in ("enqueue", "enqueue_call", "enqueue_in", "enqueue_at", "add_job", "schedule"):
414
+ h = _py_name(kw.get("func") or kw.get("f")) or next((_py_name(a) for a in pos if _py_name(a)), None)
415
+ if h and h.rsplit(".", 1)[-1][:1].islower():
416
+ out.add("queue", "publish", cid, h.rsplit(".", 1)[-1], line, "task", handler=h)
417
+ return
418
+ # Django signals: post_paid.send(sender=...) to @receiver(post_paid) and post_paid.connect(handler).
419
+ if last in ("send", "send_robust") and obj is not None and obj.type == "identifier" and "sender" in kw:
420
+ out.add("queue", "publish", cid, recv, line, "signal")
421
+ return
422
+ if last == "connect" and obj is not None and obj.type == "identifier" and pos and _py_name(pos[0]):
423
+ out.add("queue", "subscribe", cid, recv, line, "signal", handler=_py_name(pos[0]))
424
+ return
425
+ # In-process and socket events.
426
+ if last == "emit" and pos:
427
+ topic = self._lit(pos[0])
428
+ if topic:
429
+ out.add("queue", "publish", cid, topic, line, "event")
430
+ return
431
+ if last in ("on", "once", "add_listener", "on_event") and pos and fn.type == "attribute":
432
+ topic = self._lit(pos[0])
433
+ if topic:
434
+ out.add("queue", "subscribe", cid, topic, line, "event", handler=self._handler(pos[1:], kw))
435
+ return
436
+ # Brokers: Kafka, RabbitMQ, Redis, NATS and the like, matched by topic or queue name.
437
+ if last in self.PUB and (last not in self.PUB_LOOSE or BROKER.search(recv)):
438
+ for topic in self._topic(pos, kw)[:1]:
439
+ out.add("queue", "publish", cid, topic, line, "topic")
440
+ return
441
+ if last in self.SUB:
442
+ if last == "basic_consume" and not kw and len(pos) >= 2 and _py_name(pos[0]):
443
+ h = _py_name(pos[0]) # pika before 1.0: basic_consume(callback, queue=...)
444
+ else:
445
+ h = self._handler(pos, kw)
446
+ topics = [self._lit(kw[k]) for k in ("routing_key", "routingKey") if k in kw and self._lit(kw[k])] \
447
+ if last == "queue_bind" else []
448
+ for topic in topics or self._topic(pos, kw):
449
+ out.add("queue", "subscribe", cid, topic, line, "topic", handler=h)
450
+ return
451
+
452
+ # Databases.
453
+ self._db(n, fn, full, last, obj, pos, kw, cid, line)
454
+
455
+ def _annotated_type(self, call) -> Optional[str]:
456
+ """Depends() with no argument: the dependency is the parameter's own type."""
457
+ cur = call.parent
458
+ for _ in range(4):
459
+ if cur is None:
460
+ return None
461
+ if cur.type == "typed_default_parameter":
462
+ return (re.findall(r"[A-Z]\w*", _t(cur.child_by_field_name("type"))) or [None])[0]
463
+ if cur.type == "subscript" and _t(cur.child_by_field_name("value")).endswith("Annotated"):
464
+ first = cur.child_by_field_name("subscript")
465
+ return _py_name(first)
466
+ cur = cur.parent
467
+ return None
468
+
469
+ def _db(self, n, fn, full, last, obj, pos, kw, cid, line) -> None:
470
+ out = self.out
471
+ # Only the outermost call of a chain is read, so X.objects.filter().update() is one write, not a read too.
472
+ p = n.parent
473
+ if p is not None and p.type == "attribute" and p.parent is not None and p.parent.type == "call":
474
+ return
475
+ chain, base = [], fn
476
+ while base is not None:
477
+ if base.type == "call":
478
+ base = base.child_by_field_name("function")
479
+ elif base.type == "attribute":
480
+ chain.append((_t(base.child_by_field_name("attribute")), base))
481
+ base = base.child_by_field_name("object")
482
+ else:
483
+ break
484
+ names = [c[0] for c in chain]
485
+ write = any(_db_role(x) == "write" or re.match(r"(create|update|delete|bulk|insert|remove)_", x) for x in names)
486
+ # Django and other active-record models: User.objects.filter(...), User.select(), Book.create(...)
487
+ if base is not None and base.type == "identifier" and _t(base)[:1].isupper() and names:
488
+ first = names[-1]
489
+ if first == "objects" or _db_role(first):
490
+ out.add("db", "write" if write else "read", cid, "model:" + _t(base), line, "orm")
491
+ return
492
+ # Document stores: db.users.find(...), db["users"].insert_one(...), db.get_collection("users").find(...)
493
+ if fn.type == "attribute" and _op(last) in MONGO_OPS and obj is not None:
494
+ coll = None
495
+ if obj.type == "attribute" and re.search(r"(db|database|mongo)\w*$", _t(obj.child_by_field_name("object")), re.I):
496
+ coll = _t(obj.child_by_field_name("attribute"))
497
+ elif obj.type == "subscript":
498
+ coll = self._lit(obj.child_by_field_name("subscript"))
499
+ elif obj.type == "call" and _t(obj.child_by_field_name("function")).endswith(("get_collection", "collection")):
500
+ ipos, _ = self._args(obj.child_by_field_name("arguments"))
501
+ coll = self._lit(ipos[0]) if ipos else None
502
+ if coll:
503
+ out.add("db", _db_role(last) or ("write" if _op(last) in ("drop", "createindex") else "read"), cid,
504
+ coll, line, "document")
505
+ return
506
+ # SQLAlchemy: session.query(User).filter(...), select(User), insert(User), session.get(User, 1)
507
+ cur = n
508
+ while cur is not None and cur.type == "call":
509
+ cfn = cur.child_by_field_name("function")
510
+ cpos, _ = self._args(cur.child_by_field_name("arguments"))
511
+ cfull = _t(cfn)
512
+ clast = cfull.rsplit(".", 1)[-1]
513
+ model = _py_name(cpos[0]) if cpos else None
514
+ mname = model.rsplit(".", 1)[-1] if model else ""
515
+ if mname[:1].isupper() and not mname.isupper(): # a class, not a constant: requests.get(USER_URL)
516
+ if clast in ("query", "get", "scalars") and cfn.type == "attribute" \
517
+ and (clast != "get" or self.SESSION.search(_t(cfn.child_by_field_name("object")))):
518
+ out.add("db", "write" if write else "read", cid, "model:" + mname, line, "orm")
519
+ elif cfull in ("select", "sa.select", "sqlalchemy.select", "sqlmodel.select"):
520
+ out.add("db", "read", cid, "model:" + mname, line, "orm")
521
+ elif cfull in ("insert", "update", "delete") or cfull.endswith((".insert", ".update", ".delete")) and \
522
+ cfull.split(".")[0] in ("sa", "sqlalchemy", "sqlmodel"):
523
+ out.add("db", "write", cid, "model:" + mname, line, "orm")
524
+ cur = cfn.child_by_field_name("object") if cfn is not None and cfn.type == "attribute" else None
525
+ if last in ("add", "add_all", "delete", "merge") and fn.type == "attribute" and self.SESSION.search(_t(obj) if obj is not None else "") and pos:
526
+ a = pos[0]
527
+ items = a.named_children if a.type == "list" else [a]
528
+ for it in items:
529
+ m = None
530
+ if it.type == "call":
531
+ m = _t(it.child_by_field_name("function")).rsplit(".", 1)[-1]
532
+ elif it.type == "identifier":
533
+ m = self.var_types.get((cid, _t(it)))
534
+ if m and m[:1].isupper():
535
+ out.add("db", "write", cid, "model:" + m, line, "orm")
536
+
537
+ def _decorator(self, n) -> None:
538
+ line = n.start_point[0] + 1
539
+ text = _t(n).lstrip("@").strip()
540
+ cid = self.where.fn(line)
541
+ defn = n.parent.child_by_field_name("definition") if n.parent is not None and n.parent.type == "decorated_definition" else None
542
+ fname = _t(defn.child_by_field_name("name")) if defn is not None and defn.type == "function_definition" else None
543
+ if not cid or not fname:
544
+ return
545
+ head = text.split("(", 1)[0]
546
+ last = head.rsplit(".", 1)[-1]
547
+ call = next((c for c in n.named_children if c.type == "call"), None)
548
+ pos, kw = self._args(call.child_by_field_name("arguments")) if call is not None else ([], {})
549
+ if last in ("task", "shared_task", "actor", "periodic_task", "job"):
550
+ names = [fname] + ([self._lit(kw["name"]).rsplit(".", 1)[-1]] if "name" in kw and self._lit(kw["name"]) else [])
551
+ for name in dict.fromkeys(names):
552
+ self.out.add("queue", "subscribe", cid, name, line, "task")
553
+ elif head == "receiver" or last == "receiver":
554
+ sigs = []
555
+ for a in pos:
556
+ sigs += [_t(x) for x in (a.named_children if a.type in ("list", "tuple") else [a]) if x.type == "identifier"]
557
+ for s in sigs:
558
+ self.out.add("queue", "subscribe", cid, s, line, "signal")
559
+ elif last == "event" and re.search(r"(sio|socket)", head, re.I):
560
+ self.out.add("queue", "subscribe", cid, fname, line, "event") # python-socketio: @sio.event
561
+ elif last in ("on", "once"):
562
+ for topic in self._strings_of(pos[:1]):
563
+ self.out.add("queue", "subscribe", cid, topic, line, "event")
564
+ elif last in ("subscriber", "subscribe", "consumer", "listener", "agent", "on_message", "event_handler",
565
+ "listen", "handler", "consume", "message_handler", "topic_handler"):
566
+ for topic in self._topic(pos, kw):
567
+ self.out.add("queue", "subscribe", cid, topic, line, "topic")
568
+ elif last == "publisher": # FastStream: what the function returns goes to the topic
569
+ for topic in self._topic(pos, kw)[:1]:
570
+ self.out.add("queue", "publish", cid, topic, line, "topic")
571
+
572
+
573
+ # -- C# ----------------------------------------------------------------------------------------------------
574
+
575
+ _CS_STRINGS = ("string_literal", "verbatim_string_literal", "raw_string_literal", "interpolated_string_expression")
576
+
577
+
578
+ def _cs_str(node) -> Optional[str]:
579
+ if node is None or node.type not in _CS_STRINGS:
580
+ return None
581
+ if node.type == "interpolated_string_expression":
582
+ return "".join("?" if c.type == "interpolation" else _t(c) for c in node.children
583
+ if c.type not in ('"', '$"', '@$"', '$@"', 'interpolation_start', 'interpolation_quote')).strip('"$@')
584
+ text = _t(node)
585
+ if node.type == "string_literal":
586
+ return "".join(_t(c) for c in node.children if c.type in ("string_literal_content", "escape_sequence"))
587
+ return text.lstrip("@").strip('"')
588
+
589
+
590
+ def _cs_generic(node) -> tuple[str, list[str]]:
591
+ """(name, type arguments as written) of a name node: AddSingleton<IFoo, Foo> -> (AddSingleton, [IFoo, Foo])."""
592
+ if node is None:
593
+ return "", []
594
+ if node.type == "generic_name":
595
+ ident = next((c for c in node.children if c.type == "identifier"), None)
596
+ targs = next((c for c in node.children if c.type == "type_argument_list"), None)
597
+ return _t(ident), [_cs_type(c) for c in targs.named_children] if targs is not None else []
598
+ return _t(node), []
599
+
600
+
601
+ def _cs_type(node) -> str:
602
+ """The simple name of a type: Foo.Bar<int> -> Bar, IList<Foo> -> IList."""
603
+ text = re.sub(r"<.*", "", _t(node)).strip().rstrip("?")
604
+ return text.rsplit(".", 1)[-1]
605
+
606
+
607
+ def _cs_qual_type(node) -> str:
608
+ return re.sub(r"<.*", "", _t(node)).strip().rstrip("?")
609
+
610
+
611
+ class _CSharp:
612
+ DI_ADD = {"AddSingleton", "AddScoped", "AddTransient", "TryAddSingleton", "TryAddScoped", "TryAddTransient",
613
+ "AddKeyedSingleton", "AddKeyedScoped", "AddKeyedTransient", "TryAddEnumerable", "RegisterType",
614
+ "Register", "RegisterSingleton", "AddHttpClient"}
615
+ SEND = {"Send", "Publish", "SendAsync", "PublishAsync", "SendLocal", "InvokeAsync", "Dispatch", "DispatchAsync",
616
+ "SchedulePublish", "ScheduleSend", "Raise", "RaiseAsync", "Enqueue", "EnqueueAsync",
617
+ "AddDomainEvent", "RaiseDomainEvent", "RegisterDomainEvent", "QueueDomainEvent"} # domain events, sent on save
618
+ # A type named like a message, created anywhere: handed to a wrapper or an outbox, it may still reach its handler.
619
+ MESSAGE_NAME = re.compile(r"[A-Z]\w*(Event|Command|Query|Message|Notification|Request)$")
620
+ PUB = {"Produce", "ProduceAsync", "BasicPublish", "BasicPublishAsync", "Publish", "PublishAsync", "CreateSender",
621
+ "SendToQueue", "PublishMessage", "SendMessageAsync"}
622
+ SUB = {"Subscribe", "SubscribeAsync", "BasicConsume", "BasicConsumeAsync", "CreateProcessor", "CreateReceiver",
623
+ "QueueBind", "QueueBindAsync", "CreateSessionProcessor"}
624
+ CTX = re.compile(r"(context|db|ctx|dbcontext|_uow|unitofwork)$", re.I)
625
+
626
+ def __init__(self, tree, where: _Where, out: _Out, consts: dict):
627
+ self.tree, self.where, self.out = tree, where, out
628
+ self.consts: dict[str, str] = {}
629
+ self.field_types: dict[tuple, str] = {} # (type id, member) -> type as written, type arguments and all
630
+ self.var_types: dict[tuple, str] = {} # (function id, local) -> type as written
631
+ self.methods: dict[tuple, list] = defaultdict(list) # (type id, name) -> methods
632
+ for n in where.nodes.values():
633
+ if n.kind == "callable":
634
+ self.methods[(n.attrs.get("type_id"), n.name)].append(n)
635
+
636
+ def _lit(self, node) -> Optional[str]:
637
+ s = _cs_str(node)
638
+ if s is None and node is not None and node.type in ("identifier", "member_access_expression"):
639
+ s = self.consts.get(_t(node).rsplit(".", 1)[-1])
640
+ return s or None
641
+
642
+ def run(self) -> None:
643
+ root = self.tree.root_node
644
+ nodes = []
645
+ stack = [root]
646
+ while stack: # first the declarations, so a use reads a type or constant declared further down
647
+ n = stack.pop()
648
+ nodes.append(n)
649
+ t = n.type
650
+ if t == "field_declaration" or t == "property_declaration":
651
+ self._member(n)
652
+ elif t in ("parameter", "local_declaration_statement"):
653
+ self._local(n)
654
+ stack.extend(reversed(n.children))
655
+ self.sql_consts = {k: v for k, v in ((k, sql_tables(v)) for k, v in self.consts.items()) if v}
656
+ for n in nodes:
657
+ t = n.type
658
+ if t == "invocation_expression":
659
+ self._invocation(n)
660
+ elif t in _CS_STRINGS:
661
+ line = n.start_point[0] + 1
662
+ cid = self.where.fn(line)
663
+ s = _cs_str(n)
664
+ if cid and s:
665
+ for role, table in sql_tables(s):
666
+ self.out.add("db", role, cid, table, line, "sql")
667
+ elif t == "identifier" and _t(n) in self.sql_consts:
668
+ cid = self.where.fn(n.start_point[0] + 1)
669
+ if cid:
670
+ for role, table in self.sql_consts[_t(n)]:
671
+ self.out.add("db", role, cid, table, n.start_point[0] + 1, "sql")
672
+ elif t == "member_access_expression":
673
+ self._dbset_read(n)
674
+ elif t in ("class_declaration", "record_declaration"):
675
+ self._class(n)
676
+ elif t == "object_creation_expression":
677
+ name = _cs_type(n.child_by_field_name("type"))
678
+ cid = self.where.fn(n.start_point[0] + 1)
679
+ if cid and self.MESSAGE_NAME.fullmatch(name) and not name.startswith("Http"):
680
+ self.out.add("queue", "publish", cid, name, n.start_point[0] + 1, "message-new")
681
+
682
+ def _member(self, n) -> None:
683
+ line = n.start_point[0] + 1
684
+ tid = self.where.type(line)
685
+ if n.type == "property_declaration":
686
+ tnode, name = n.child_by_field_name("type"), _t(n.child_by_field_name("name"))
687
+ if tid and tnode is not None:
688
+ self.field_types[(tid, name)] = _t(tnode).strip()
689
+ gname, targs = _cs_generic(tnode)
690
+ if gname == "DbSet" and targs:
691
+ self.out.add("db", "table", tid, name, line, "dbset", [targs[0]])
692
+ return
693
+ decl = next((c for c in n.named_children if c.type == "variable_declaration"), None)
694
+ if decl is None:
695
+ return
696
+ tnode = decl.child_by_field_name("type")
697
+ for d in decl.named_children:
698
+ if d.type != "variable_declarator":
699
+ continue
700
+ name = _t(d.child_by_field_name("name") or next((c for c in d.children if c.type == "identifier"), None))
701
+ if tid and tnode is not None:
702
+ self.field_types[(tid, name)] = _t(tnode).strip()
703
+ value = next((c for c in d.named_children if c.type in _CS_STRINGS), None)
704
+ if value is not None and _cs_str(value):
705
+ self.consts[name] = _cs_str(value)
706
+
707
+ def _local(self, n) -> None:
708
+ line = n.start_point[0] + 1
709
+ cid = self.where.fn(line)
710
+ if not cid:
711
+ return
712
+ if n.type == "parameter":
713
+ tnode = n.child_by_field_name("type")
714
+ if tnode is not None:
715
+ self.var_types[(cid, _t(n.child_by_field_name("name")))] = _t(tnode).strip()
716
+ return
717
+ decl = next((c for c in n.named_children if c.type == "variable_declaration"), None)
718
+ if decl is None:
719
+ return
720
+ tnode = decl.child_by_field_name("type")
721
+ for d in decl.named_children:
722
+ if d.type != "variable_declarator":
723
+ continue
724
+ name = _t(next((c for c in d.children if c.type == "identifier"), None))
725
+ if tnode is not None and _t(tnode) != "var":
726
+ self.var_types[(cid, name)] = _t(tnode).strip()
727
+ else:
728
+ made = next((c for c in d.named_children if c.type == "object_creation_expression"), None)
729
+ if made is not None:
730
+ self.var_types[(cid, name)] = _cs_qual_type(made.child_by_field_name("type"))
731
+
732
+ def _type_of(self, expr, cid) -> Optional[str]:
733
+ raw = self._raw_type_of(expr, cid)
734
+ return re.sub(r"<.*", "", raw).strip().rstrip("?") if raw else None
735
+
736
+ def _raw_type_of(self, expr, cid) -> Optional[str]:
737
+ if expr is None:
738
+ return None
739
+ if expr.type == "object_creation_expression":
740
+ return _t(expr.child_by_field_name("type"))
741
+ if expr.type == "invocation_expression":
742
+ # GetClient().Call(): typed by what a method of this class declares it returns.
743
+ f = expr.child_by_field_name("function")
744
+ if f is not None and f.type == "member_access_expression" and _t(f.child_by_field_name("expression")) == "this":
745
+ f = f.child_by_field_name("name")
746
+ own = self.where.nodes.get(cid)
747
+ if f is None or f.type != "identifier" or own is None:
748
+ return None
749
+ found = self.methods.get((own.attrs.get("type_id"), _t(f)))
750
+ if not found:
751
+ return None
752
+ m = re.search(r"([\w.<>,? ]+?)\s+" + re.escape(found[0].name) + r"\s*[<(]", found[0].attrs.get("signature", ""))
753
+ ret = m.group(1).split()[-1] if m else ""
754
+ inner = re.fullmatch(r"(?:Value)?Task<(.+)>", ret)
755
+ return (inner.group(1) if inner else ret) or None
756
+ name = _t(expr)
757
+ if expr.type == "member_access_expression" and _t(expr.child_by_field_name("expression")) == "this":
758
+ name = _t(expr.child_by_field_name("name"))
759
+ elif expr.type != "identifier":
760
+ return None
761
+ own = self.where.nodes.get(cid)
762
+ tid = own.attrs.get("type_id") if own is not None else None
763
+ return self.var_types.get((cid, name)) or (self.field_types.get((tid, name)) if tid else None)
764
+
765
+ def _args(self, args):
766
+ pos, kw = [], {}
767
+ if args is None:
768
+ return pos, kw
769
+ for a in args.named_children:
770
+ if a.type != "argument" or not a.named_children:
771
+ continue
772
+ kids = a.named_children
773
+ if len(kids) >= 2 and kids[0].type == "identifier" and any(_t(c) == ":" for c in a.children):
774
+ kw[_t(kids[0])] = kids[-1]
775
+ else:
776
+ pos.append(kids[-1])
777
+ return pos, kw
778
+
779
+ def _topic(self, pos, kw) -> list[str]:
780
+ for k in TOPIC_KW:
781
+ if k in kw and self._lit(kw[k]):
782
+ return [self._lit(kw[k])]
783
+ return [s for s in (self._lit(a) for a in pos) if s][:1]
784
+
785
+ def _invocation(self, n) -> None:
786
+ fn = n.child_by_field_name("function")
787
+ line = n.start_point[0] + 1
788
+ cid = self.where.fn(line)
789
+ if fn is None or not cid:
790
+ return
791
+ expr = None
792
+ if fn.type == "member_access_expression":
793
+ name, targs = _cs_generic(fn.child_by_field_name("name"))
794
+ expr = fn.child_by_field_name("expression")
795
+ else:
796
+ name, targs = _cs_generic(fn)
797
+ pos, kw = self._args(n.child_by_field_name("arguments"))
798
+ out = self.out
799
+
800
+ # Dependency injection: services.AddScoped<IFoo, Foo>(), AddHostedService<Worker>(), Autofac's As<IFoo>().
801
+ if name in self.DI_ADD:
802
+ typeofs = [_cs_type(a.named_children[0]) for a in pos if a.type == "typeof_expression" and a.named_children]
803
+ if len(targs) >= 2:
804
+ out.add("di", "provide", cid, targs[0], line, "type", [targs[1]])
805
+ elif len(typeofs) >= 2:
806
+ out.add("di", "provide", cid, typeofs[0], line, "type", [typeofs[1]])
807
+ elif len(targs) == 1 or len(typeofs) == 1:
808
+ svc = targs[0] if targs else typeofs[0]
809
+ made = [_cs_type(m.child_by_field_name("type")) for a in pos for m in _descendants(a, "object_creation_expression")]
810
+ # services.AddScoped<IFoo>(sp => sp.GetRequiredService<Foo>()): the factory hands back another registration.
811
+ made += [ts[0] for a in pos for g in _descendants(a, "generic_name") for nm, ts in [_cs_generic(g)]
812
+ if nm in ("GetRequiredService", "GetService") and ts]
813
+ made = [m for m in made if m != svc]
814
+ out.add("di", "provide", cid, svc, line, "factory" if made else "self", made[:1])
815
+ return
816
+ if name == "AddHostedService" and targs:
817
+ out.add("di", "start", cid, targs[0], line, "hosted")
818
+ return
819
+ if name == "As" and targs and expr is not None and expr.type == "invocation_expression":
820
+ inner = expr.child_by_field_name("function")
821
+ iname, itargs = _cs_generic(inner.child_by_field_name("name") if inner is not None and inner.type == "member_access_expression" else inner)
822
+ if iname == "RegisterType" and itargs:
823
+ out.add("di", "provide", cid, targs[0], line, "type", [itargs[0]])
824
+ return
825
+
826
+ # Messages sent by type, to the handler for that type: MediatR, MassTransit, NServiceBus, Wolverine.
827
+ if name in self.SEND:
828
+ first = pos[0] if pos else None
829
+ if first is not None and first.type not in _CS_STRINGS and not (first.type == "identifier" and _t(first) in self.consts) \
830
+ and not re.search("http", _t(expr), re.I): # HttpClient.SendAsync(request) sends a request, not a message
831
+ msg = targs[0] if targs else None
832
+ if msg is None and first is not None:
833
+ msg = self._type_of(first, cid)
834
+ if msg and not msg.endswith("HttpRequestMessage"):
835
+ out.add("queue", "publish", cid, msg.rsplit(".", 1)[-1], line, "message")
836
+ return
837
+ if name in ("Subscribe", "SubscribeAsync") and len(targs) >= 2: # eventBus.Subscribe<TEvent, THandler>()
838
+ out.add("queue", "subscribe", cid, targs[0], line, "message", handler="type:" + targs[1])
839
+ return
840
+ # Brokers by topic or queue name.
841
+ if name in self.PUB:
842
+ for topic in self._topic(pos, kw):
843
+ out.add("queue", "publish", cid, topic, line, "topic")
844
+ return
845
+ if name in self.SUB:
846
+ for topic in self._topic(pos, kw):
847
+ out.add("queue", "subscribe", cid, topic, line, "topic")
848
+ return
849
+
850
+ # RPC: a call on a generated gRPC client, Greeter.GreeterClient.
851
+ rtype = self._type_of(expr, cid)
852
+ if rtype and rtype.endswith("Client") and len(rtype) > 6 and "Http" not in rtype:
853
+ parts = rtype.split(".")
854
+ svc = parts[-1][:-6]
855
+ if svc and (len(parts) > 1 and parts[-2] == svc or "Grpc" in rtype or svc[0].isupper()):
856
+ method = name[:-5] if name.endswith("Async") else name
857
+ out.add("rpc", "call", cid, f"{svc}/{method}", line, "grpc" if len(parts) > 1 and parts[-2] == svc else "maybe")
858
+ return
859
+
860
+ # Entity Framework: context.Users.Add(x), context.Add(user), context.Set<User>(), ToTable("users").
861
+ if name == "ToTable" and pos and self._lit(pos[0]) and expr is not None and expr.type == "invocation_expression":
862
+ inner = expr.child_by_field_name("function")
863
+ iname, itargs = _cs_generic(inner.child_by_field_name("name") if inner is not None and inner.type == "member_access_expression" else inner)
864
+ if iname == "Entity" and itargs:
865
+ tid = self.where.type(line) or cid
866
+ out.add("db", "table", tid, self._lit(pos[0]), line, "fluent", [itargs[0]])
867
+ return
868
+ role = _db_role(name.removesuffix("Async"))
869
+ repo = re.fullmatch(r"(?:[\w.]+\.)?I?\w*Repository(?:Base)?<\s*(?:[\w.]+\.)?(\w+)\s*>", self._raw_type_of(expr, cid) or "")
870
+ if repo and not role and _op(name.removesuffix("Async")) in ("list", "firstordefault", "singleordefault", "single"):
871
+ role = "read"
872
+ if role and repo:
873
+ # IRepository<Basket>: a repository typed by its entity reads and writes that entity's table.
874
+ out.add("db", role, cid, "model:" + repo.group(1), line, "repository")
875
+ return
876
+ if role == "write" and expr is not None:
877
+ if expr.type == "member_access_expression" and self.CTX.search(_t(expr.child_by_field_name("expression"))):
878
+ out.add("db", "write", cid, "dbset:" + _t(expr.child_by_field_name("name")), line, "ef")
879
+ elif self.CTX.search(_t(expr)) and pos:
880
+ m = self._type_of(pos[0], cid)
881
+ if m:
882
+ out.add("db", "write", cid, "model:" + m.rsplit(".", 1)[-1], line, "ef")
883
+ if name == "Set" and targs and expr is not None and self.CTX.search(_t(expr)):
884
+ out.add("db", "read", cid, "model:" + targs[0], line, "ef")
885
+
886
+ def _dbset_read(self, n) -> None:
887
+ """context.Users read anywhere but as the receiver of a write: a query over that table."""
888
+ inner = n.child_by_field_name("expression")
889
+ name = _t(n.child_by_field_name("name"))
890
+ if inner is None or not name[:1].isupper() or not self.CTX.search(_t(inner)):
891
+ return
892
+ p = n.parent
893
+ if p is not None and p.type == "invocation_expression" and p.child_by_field_name("function") == n:
894
+ return # context.SaveChanges(): a method, not a table
895
+ if p is not None and p.type == "member_access_expression" and p.parent is not None \
896
+ and p.parent.type == "invocation_expression" and _db_role(_t(p.child_by_field_name("name"))) == "write":
897
+ return
898
+ line = n.start_point[0] + 1
899
+ cid = self.where.fn(line)
900
+ if cid:
901
+ self.out.add("db", "read", cid, "dbset:" + name, line, "ef")
902
+
903
+ def _class(self, n) -> None:
904
+ line = n.start_point[0] + 1
905
+ tid = self.where.type(line)
906
+ if not tid:
907
+ return
908
+ for al in (c for c in n.children if c.type == "attribute_list"):
909
+ for a in (c for c in al.named_children if c.type == "attribute"):
910
+ if _t(a.child_by_field_name("name")).rsplit(".", 1)[-1] in ("Table", "TableAttribute"):
911
+ s = next((x for x in _descendants(a, *_CS_STRINGS)), None)
912
+ if s is not None and _cs_str(s):
913
+ self.out.add("db", "table", tid, _cs_str(s), line, "attr", [self.where.nodes[tid].name])
914
+ bases = next((c for c in n.children if c.type == "base_list"), None)
915
+ if bases is None:
916
+ return
917
+ for b in bases.named_children:
918
+ text = _t(b)
919
+ m = re.fullmatch(r"(?:[\w.]+\.)?(\w+)\.(\w+)Base", text)
920
+ if m and m.group(1) == m.group(2):
921
+ self.out.add("rpc", "serve", tid, m.group(1), line, "grpc")
922
+ continue
923
+ if b.type == "generic_name":
924
+ gname, targs = _cs_generic(b)
925
+ elif b.type == "qualified_name" and b.named_children and b.named_children[-1].type == "generic_name":
926
+ gname, targs = _cs_generic(b.named_children[-1])
927
+ else:
928
+ continue
929
+ if gname in HANDLER_IFACES and targs:
930
+ self.out.add("queue", "subscribe", tid, targs[0], line, "message", handler="type:")
931
+
932
+
933
+ def _descendants(node, *types):
934
+ stack = [node]
935
+ while stack:
936
+ n = stack.pop()
937
+ if n.type in types:
938
+ yield n
939
+ stack.extend(reversed(n.children))
940
+
941
+
942
+ # -- TypeScript --------------------------------------------------------------------------------------------
943
+
944
+ def _ts_str(node) -> Optional[str]:
945
+ if node is None:
946
+ return None
947
+ if node.type == "string":
948
+ return "".join(_t(c) for c in node.children if c.type in ("string_fragment", "escape_sequence"))
949
+ if node.type == "template_string":
950
+ return "".join("?" if c.type == "template_substitution" else _t(c) for c in node.children if c.type != "`")
951
+ return None
952
+
953
+
954
+ def _ts_unwrap(node):
955
+ while node is not None and node.type in ("parenthesized_expression", "await_expression", "as_expression",
956
+ "non_null_expression", "satisfies_expression") and node.named_children:
957
+ node = node.named_children[0]
958
+ return node
959
+
960
+
961
+ def _ts_name(node) -> Optional[str]:
962
+ node = _ts_unwrap(node)
963
+ if node is not None and node.type in ("identifier", "member_expression") and re.fullmatch(r"[\w.$]+", _t(node)):
964
+ return _t(node)
965
+ return None
966
+
967
+
968
+ def _ts_pairs(obj) -> dict:
969
+ out = {}
970
+ if obj is not None and obj.type == "object":
971
+ for p in obj.named_children:
972
+ if p.type == "pair":
973
+ k = _t(p.child_by_field_name("key")).strip("'\"")
974
+ out[k] = p.child_by_field_name("value")
975
+ elif p.type == "shorthand_property_identifier":
976
+ out[_t(p)] = p
977
+ return out
978
+
979
+
980
+ def _plural(name: str) -> str:
981
+ low = name.lower()
982
+ return low if low.endswith("s") else low[:-1] + "ies" if low.endswith("y") and low[-2:-1] not in "aeiou" else low + "s"
983
+
984
+
985
+ class _TypeScript:
986
+ KNEX = {"knex", "db", "trx", "k", "database", "sql"}
987
+ KNEX_WRITE = {"insert", "update", "del", "delete", "upsert", "merge", "truncate", "increment", "decrement", "onConflict"}
988
+
989
+ def __init__(self, tree, where: _Where, out: _Out, consts: dict):
990
+ self.tree, self.where, self.out = tree, where, out
991
+ self.consts = {k: v[0] for k, v in consts.items() if len(v) == 1}
992
+ self.field_types: dict[tuple, str] = {}
993
+ self.rpc_vars: dict[str, str] = {}
994
+ self.coll_vars: dict[str, str] = {}
995
+ self.grpc = b"grpc" in tree.root_node.text.lower() # new FooClient(...) is a stub only where gRPC is in sight
996
+
997
+ def _lit(self, node) -> Optional[str]:
998
+ node = _ts_unwrap(node)
999
+ s = _ts_str(node)
1000
+ if s is None and node is not None and node.type in ("identifier", "member_expression"):
1001
+ s = self.consts.get(_t(node)) or self.consts.get(_t(node).rsplit(".", 1)[-1])
1002
+ if s is None and node is not None and node.type == "object":
1003
+ # NestJS message patterns: { cmd: 'sum' }
1004
+ pairs = {k: _ts_str(v) for k, v in _ts_pairs(node).items()}
1005
+ if pairs and all(pairs.values()) and not ({"topic", "topics", "messages"} & set(pairs)):
1006
+ s = ",".join(f"{k}:{v}" for k, v in sorted(pairs.items()))
1007
+ return s or None
1008
+
1009
+ def run(self) -> None:
1010
+ nodes, stack = [], [self.tree.root_node]
1011
+ # Each node's parent, noted on the way down: tree-sitter finds a parent by walking down from the root, so
1012
+ # asking for it at every link of a long a.b().c()... chain was quadratic.
1013
+ self.up: dict = {}
1014
+ while stack:
1015
+ n = stack.pop()
1016
+ nodes.append(n)
1017
+ if n.type in ("call_expression", "member_expression"):
1018
+ for c in n.children:
1019
+ self.up[c.id] = n
1020
+ if n.type == "method_definition" and _t(n.child_by_field_name("name")) == "constructor":
1021
+ self._ctor_fields(n)
1022
+ elif n.type in ("public_field_definition", "field_definition"):
1023
+ tid = self.where.type(n.start_point[0] + 1)
1024
+ ann = n.child_by_field_name("type")
1025
+ if tid and ann is not None:
1026
+ self.field_types[(tid, _t(n.child_by_field_name("name")))] = _t(ann).lstrip(":").strip()
1027
+ stack.extend(reversed(n.children))
1028
+ self.sql_consts = {k: v for k, v in ((k, sql_tables(v)) for k, v in self.consts.items()) if v}
1029
+ for n in nodes:
1030
+ t = n.type
1031
+ if t == "call_expression":
1032
+ self._call(n)
1033
+ elif t == "decorator":
1034
+ self._decorator(n)
1035
+ elif t in ("string", "template_string"):
1036
+ s = _ts_str(n)
1037
+ line = n.start_point[0] + 1
1038
+ cid = self.where.fn(line)
1039
+ if s and cid:
1040
+ for role, table in sql_tables(s):
1041
+ self.out.add("db", role, cid, table, line, "sql")
1042
+ elif t == "identifier" and _t(n) in self.sql_consts:
1043
+ cid = self.where.fn(n.start_point[0] + 1)
1044
+ if cid and not cid.endswith("<module>"):
1045
+ for role, table in self.sql_consts[_t(n)]:
1046
+ self.out.add("db", role, cid, table, n.start_point[0] + 1, "sql")
1047
+ elif t == "object":
1048
+ self._provider(n)
1049
+ elif t == "variable_declarator" or t == "assignment_expression":
1050
+ self._assign(n)
1051
+
1052
+ def _ctor_fields(self, n) -> None:
1053
+ tid = self.where.type(n.start_point[0] + 1)
1054
+ params = n.child_by_field_name("parameters")
1055
+ for p in (params.named_children if params is not None and tid else []):
1056
+ pat, ann = p.child_by_field_name("pattern"), p.child_by_field_name("type")
1057
+ if pat is not None and ann is not None:
1058
+ self.field_types[(tid, _t(pat))] = _t(ann).lstrip(":").strip()
1059
+ deco = next((c for c in p.children if c.type == "decorator"), None)
1060
+ if deco is not None:
1061
+ call = next((c for c in deco.named_children if c.type == "call_expression"), None)
1062
+ if call is not None and _t(call.child_by_field_name("function")) in ("Inject", "inject"):
1063
+ args = [a for a in call.child_by_field_name("arguments").named_children]
1064
+ token = self._lit(args[0]) or _ts_name(args[0]) if args else None
1065
+ typ = re.sub(r"<.*", "", _t(ann).lstrip(":").strip())
1066
+ cid = self.where.fn(p.start_point[0] + 1)
1067
+ if token and cid and token != typ:
1068
+ self.out.add("di", "inject", cid, token, p.start_point[0] + 1, "token", [typ])
1069
+
1070
+ def _assign(self, n) -> None:
1071
+ if n.type == "variable_declarator":
1072
+ left, right = n.child_by_field_name("name"), _ts_unwrap(n.child_by_field_name("value"))
1073
+ else:
1074
+ left, right = n.child_by_field_name("left"), _ts_unwrap(n.child_by_field_name("right"))
1075
+ if left is None or right is None:
1076
+ return
1077
+ key = _t(left)
1078
+ line = n.start_point[0] + 1
1079
+ if right.type == "new_expression":
1080
+ ctor = _t(right.child_by_field_name("constructor"))
1081
+ args = right.child_by_field_name("arguments")
1082
+ last = ctor.rsplit(".", 1)[-1]
1083
+ if last.endswith("Client") and len(last) > 6 and last[0].isupper() and "Http" not in last and self.grpc:
1084
+ self.rpc_vars[key] = last[:-6] # grpc-js static code: new GreeterClient(addr, creds)
1085
+ elif "." in ctor and args is not None and "credentials" in _t(args):
1086
+ self.rpc_vars[key] = last # proto-loader: new hello_proto.Greeter(addr, creds)
1087
+ elif right.type == "call_expression":
1088
+ fn = _t(right.child_by_field_name("function"))
1089
+ args = [a for a in (right.child_by_field_name("arguments").named_children if right.child_by_field_name("arguments") else [])]
1090
+ last = fn.rsplit(".", 1)[-1]
1091
+ if last == "getService" and args and self._lit(args[0]):
1092
+ self.rpc_vars[key] = self._lit(args[0]) # NestJS: client.getService<HeroesService>('HeroesService')
1093
+ elif last == "collection" and args and self._lit(args[0]):
1094
+ self.coll_vars[key] = self._lit(args[0])
1095
+ elif last == "model" and args and self._lit(args[0]) and left.type == "identifier":
1096
+ name = self._lit(args[0]) # mongoose.model('User', schema[, 'collection'])
1097
+ coll = self._lit(args[2]) if len(args) > 2 else None
1098
+ src = self.where.fn(line) or self.where.file_id
1099
+ self.out.add("db", "table", src, coll or _plural(name), line, "mongoose", [key, name])
1100
+ elif last == "define" and args and self._lit(args[0]) and left.type == "identifier":
1101
+ src = self.where.fn(line) or self.where.file_id # sequelize.define('user', {...})
1102
+ table = self._lit(_ts_pairs(args[2]).get("tableName")) if len(args) > 2 else None
1103
+ self.out.add("db", "table", src, table or _plural(self._lit(args[0])), line, "sequelize", [key])
1104
+
1105
+ def _provider(self, n) -> None:
1106
+ """{ provide: X, useClass: Y }, as Angular and NestJS take them."""
1107
+ pairs = _ts_pairs(n)
1108
+ impl = _ts_name(pairs.get("useClass")) or _ts_name(pairs.get("useExisting"))
1109
+ if not impl:
1110
+ return
1111
+ line = n.start_point[0] + 1
1112
+ cid = self.where.fn(line) or self.where.file_id
1113
+ svc = self._lit(pairs["provide"]) or _ts_name(pairs["provide"]) if "provide" in pairs else None
1114
+ if svc is None: # tsyringe: container.register("Token", { useClass: Foo })
1115
+ call = n.parent.parent if n.parent is not None else None
1116
+ if call is not None and call.type == "call_expression" and _t(call.child_by_field_name("function")).endswith("register"):
1117
+ args = call.child_by_field_name("arguments").named_children
1118
+ svc = (self._lit(args[0]) or _ts_name(args[0])) if args else None
1119
+ if svc and svc != impl:
1120
+ self.out.add("di", "provide", cid, svc, line, "type", [impl])
1121
+
1122
+ def _call(self, n) -> None:
1123
+ fn = _ts_unwrap(n.child_by_field_name("function"))
1124
+ argsn = n.child_by_field_name("arguments")
1125
+ args = [a for a in argsn.named_children if a.type != "comment"] if argsn is not None and argsn.type == "arguments" else []
1126
+ line = n.start_point[0] + 1
1127
+ cid = self.where.fn(line)
1128
+ if fn is None or not cid:
1129
+ return
1130
+ out = self.out
1131
+ if fn.type == "identifier":
1132
+ name, obj, recv = _t(fn), None, ""
1133
+ elif fn.type == "member_expression":
1134
+ name, obj = _t(fn.child_by_field_name("property")), _ts_unwrap(fn.child_by_field_name("object"))
1135
+ # A receiver this long is a builder chain or an inline expression, not a named client or collection;
1136
+ # reading its text at every link of a.b().c().d()... made a long chain quadratic.
1137
+ recv = _t(obj) if obj is not None and obj.end_byte - obj.start_byte <= 512 else ""
1138
+ else:
1139
+ return
1140
+
1141
+ # Dependency injection: Inversify's container.bind<IFoo>(TYPES.Foo).to(Foo).
1142
+ if name in ("to", "toSelf") and obj is not None and obj.type == "call_expression":
1143
+ inner = _ts_unwrap(obj.child_by_field_name("function"))
1144
+ if inner is not None and inner.type == "member_expression" and _t(inner.child_by_field_name("property")) == "bind":
1145
+ targs = obj.child_by_field_name("type_arguments")
1146
+ iargs = obj.child_by_field_name("arguments").named_children
1147
+ svc = _t(targs.named_children[0]) if targs is not None and targs.named_children else (
1148
+ (self._lit(iargs[0]) or _ts_name(iargs[0])) if iargs else None)
1149
+ impl = _ts_name(args[0]) if args else (_ts_name(iargs[0]) if iargs else None)
1150
+ if svc and impl and svc != impl:
1151
+ out.add("di", "provide", cid, svc, line, "type", [impl])
1152
+ return
1153
+
1154
+ # RPC.
1155
+ if obj is not None and recv in self.rpc_vars:
1156
+ out.add("rpc", "call", cid, f"{self.rpc_vars[recv]}/{name}", line, "grpc")
1157
+ return
1158
+ if name == "addService" and len(args) >= 2:
1159
+ svc_text = _t(args[0])
1160
+ svc = svc_text.rsplit(".", 2)[-2] if svc_text.endswith(".service") else re.sub(r"Service$", "", svc_text.rsplit(".", 1)[-1])
1161
+ for k, v in _ts_pairs(_ts_unwrap(args[1])).items():
1162
+ h = _ts_name(v) if v is not None and v.type != "shorthand_property_identifier" else k
1163
+ out.add("rpc", "serve-fn", cid, f"{svc}/{k}", line, "grpc", handler=h)
1164
+ return
1165
+
1166
+ # Events and brokers.
1167
+ first = args[0] if args else None
1168
+ if name == "emit" and first is not None:
1169
+ topic = self._lit(first)
1170
+ if topic:
1171
+ fam = "topic" if re.search(r"(client|proxy)$", recv, re.I) else "event"
1172
+ out.add("queue", "publish", cid, topic, line, fam)
1173
+ return
1174
+ if name in ("on", "once", "addListener", "prependListener", "subscribe") and first is not None and self._lit(first) \
1175
+ and _ts_unwrap(first).type in ("string", "identifier", "member_expression"):
1176
+ fam = "event" if name != "subscribe" else "topic"
1177
+ h = _ts_name(args[1]) if len(args) > 1 else None
1178
+ out.add("queue", "subscribe", cid, self._lit(first), line, fam, handler=h)
1179
+ return
1180
+ if name in ("send", "publish", "sendToQueue", "produce", "xadd", "lpush", "rpush", "publishMessage", "emitAsync") \
1181
+ and first is not None and (name not in ("send",) or BROKER.search(recv)):
1182
+ pairs = _ts_pairs(_ts_unwrap(first))
1183
+ topic = self._lit(pairs.get("topic")) if "topic" in pairs else None
1184
+ if topic is None and name == "publish" and len(args) >= 2:
1185
+ topic = next((s for s in (self._lit(a) for a in args[:2]) if s), None) # amqplib: publish(exchange, key, ...)
1186
+ topic = topic or self._lit(first)
1187
+ if topic:
1188
+ out.add("queue", "publish", cid, topic, line, "event" if name == "emitAsync" else "topic")
1189
+ return
1190
+ if name in ("subscribe", "consume", "bindQueue", "psubscribe", "blpop", "brpop", "asyncIterator",
1191
+ "asyncIterableIterator") and first is not None: # the last two: graphql-subscriptions' PubSub
1192
+ pairs = _ts_pairs(_ts_unwrap(first))
1193
+ topics = []
1194
+ if "topic" in pairs and self._lit(pairs["topic"]):
1195
+ topics = [self._lit(pairs["topic"])]
1196
+ elif "topics" in pairs and pairs["topics"] is not None:
1197
+ topics = [s for s in (self._lit(x) for x in pairs["topics"].named_children) if s]
1198
+ elif name == "bindQueue" and len(args) >= 3:
1199
+ topics = [s for s in (self._lit(a) for a in args[1:3]) if s][-1:]
1200
+ elif self._lit(first):
1201
+ topics = [self._lit(first)]
1202
+ h = _ts_name(args[1]) if len(args) > 1 else None
1203
+ for topic in topics:
1204
+ out.add("queue", "subscribe", cid, topic, line, "topic", handler=h)
1205
+ return
1206
+
1207
+ # Databases: only the outermost call of a chain is read.
1208
+ p = self.up.get(n.id)
1209
+ if p is not None and p.type == "member_expression" and self.up.get(p.id) is not None \
1210
+ and self.up[p.id].type == "call_expression":
1211
+ return
1212
+ self._db(n, fn, name, obj, recv, args, cid, line)
1213
+
1214
+ def _db(self, n, fn, name, obj, recv, args, cid, line) -> None:
1215
+ out = self.out
1216
+ chain, base = [], n
1217
+ while base is not None:
1218
+ base = _ts_unwrap(base)
1219
+ if base is None:
1220
+ break
1221
+ if base.type == "call_expression":
1222
+ f = _ts_unwrap(base.child_by_field_name("function"))
1223
+ bargs = base.child_by_field_name("arguments")
1224
+ if f is not None and f.type == "member_expression":
1225
+ chain.append((_t(f.child_by_field_name("property")), bargs))
1226
+ base = f.child_by_field_name("object")
1227
+ else:
1228
+ chain.append((_t(f), bargs))
1229
+ break
1230
+ elif base.type == "member_expression":
1231
+ chain.append((_t(base.child_by_field_name("property")), None))
1232
+ base = base.child_by_field_name("object")
1233
+ else:
1234
+ break
1235
+ names = [c[0] for c in chain]
1236
+ write = any(x in self.KNEX_WRITE or _db_role(x) == "write" for x in names)
1237
+ # Prisma: prisma.user.findMany(), this.prisma.post.create(), tx.user.update()
1238
+ if name in PRISMA_OPS and obj is not None and obj.type == "member_expression" \
1239
+ and re.search(r"(prisma|db|tx|client)$", _t(obj.child_by_field_name("object")), re.I):
1240
+ out.add("db", "write" if name in PRISMA_WRITE else "read", cid, "prisma:" + _t(obj.child_by_field_name("property")),
1241
+ line, "prisma")
1242
+ return
1243
+ # Knex: knex('users').where(...), db('users').insert(...), knex.from('users')
1244
+ tables = []
1245
+ for nm, a in chain:
1246
+ al = [x for x in a.named_children if x.type != "comment"] if a is not None else []
1247
+ s = self._lit(al[0]) if al else None
1248
+ if s is None:
1249
+ continue
1250
+ if nm in self.KNEX or nm in ("from", "into", "table", "join", "innerJoin", "leftJoin", "rightJoin"):
1251
+ if re.fullmatch(r"[A-Za-z_][\w.]*", s):
1252
+ tables.append((nm, s.split(" ")[0]))
1253
+ if tables and (chain[-1][0] in self.KNEX or any(nm in ("from", "into", "table") for nm, _ in tables)):
1254
+ for nm, table in tables:
1255
+ role = "write" if write and nm not in ("join", "innerJoin", "leftJoin", "rightJoin", "from") else "read"
1256
+ out.add("db", role, cid, table, line, "knex")
1257
+ return
1258
+ # Document stores: db.collection('users').find(), users.insertOne() where users = db.collection('users')
1259
+ if obj is not None and _op(name) in MONGO_OPS:
1260
+ coll = None
1261
+ if obj.type == "call_expression" and _t(obj.child_by_field_name("function")).endswith("collection"):
1262
+ oa = obj.child_by_field_name("arguments").named_children
1263
+ coll = self._lit(oa[0]) if oa else None
1264
+ elif recv in self.coll_vars:
1265
+ coll = self.coll_vars[recv]
1266
+ if coll:
1267
+ out.add("db", _db_role(name) or "read", cid, coll, line, "document")
1268
+ return
1269
+ # Repositories typed by their entity: this.users.find() where users: Repository<User>.
1270
+ if obj is not None and obj.type == "member_expression" and _t(obj.child_by_field_name("object")) == "this":
1271
+ own = self.where.nodes.get(cid)
1272
+ tid = own.parent_id if own is not None else None
1273
+ ftype = self.field_types.get((tid, _t(obj.child_by_field_name("property"))), "")
1274
+ m = re.fullmatch(r"(?:\w+\.)?(?:Repository|MongoRepository|TreeRepository|Model|EntityRepository|ModelType)<\s*(\w+)", ftype.split(">")[0])
1275
+ if m and (_db_role(name) or name in DB_TYPEORM):
1276
+ out.add("db", "write" if write or _db_role(name) == "write" else "read", cid, "model:" + m.group(1), line, "orm")
1277
+ return
1278
+ # Active-record and model calls: User.findAll(), Post.create(), getRepository(User).find()
1279
+ if obj is not None and obj.type == "identifier" and _t(obj)[:1].isupper() and _db_role(name):
1280
+ out.add("db", _db_role(name), cid, "model:" + _t(obj), line, "orm")
1281
+ return
1282
+ if obj is not None and obj.type == "call_expression" and _db_role(name):
1283
+ inner = _t(obj.child_by_field_name("function")).rsplit(".", 1)[-1]
1284
+ ia = obj.child_by_field_name("arguments").named_children
1285
+ if inner == "getRepository" and ia and _ts_name(ia[0]):
1286
+ out.add("db", _db_role(name), cid, "model:" + _ts_name(ia[0]).rsplit(".", 1)[-1], line, "orm")
1287
+
1288
+ def _decorator(self, n) -> None:
1289
+ call = next((c for c in n.named_children if c.type == "call_expression"), None)
1290
+ ident = next((c for c in n.named_children if c.type in ("identifier", "member_expression")), None)
1291
+ dname = _t(call.child_by_field_name("function")) if call is not None else _t(ident)
1292
+ dname = dname.rsplit(".", 1)[-1]
1293
+ args = [a for a in call.child_by_field_name("arguments").named_children if a.type != "comment"] if call is not None else []
1294
+ line = n.start_point[0] + 1
1295
+ parent = n.parent
1296
+ if parent is not None and parent.type in ("class_declaration", "export_statement", "abstract_class_declaration"):
1297
+ cls = parent if parent.type != "export_statement" else next(
1298
+ (c for c in parent.named_children if c.type in ("class_declaration", "abstract_class_declaration")), None)
1299
+ tid = self.where.type(cls.start_point[0] + 1) if cls is not None else None
1300
+ if tid and dname in ("Entity", "Table", "Schema", "ViewEntity"):
1301
+ first = _ts_unwrap(args[0]) if args else None
1302
+ table = self._lit(first) if first is not None and first.type == "string" else None
1303
+ if table is None and first is not None:
1304
+ pairs = _ts_pairs(first)
1305
+ table = self._lit(pairs.get("name")) or self._lit(pairs.get("tableName")) or self._lit(pairs.get("collection"))
1306
+ self.out.add("db", "table", tid, table or "", line, "entity", [self.where.nodes[tid].name])
1307
+ return
1308
+ if parent is None or parent.type != "class_body":
1309
+ return
1310
+ tid = self.where.type(line)
1311
+ cid = self.where.next_fn(line, tid)
1312
+ if not cid:
1313
+ return
1314
+ first = args[0] if args else None
1315
+ if dname in ("EventPattern", "MessagePattern", "RabbitSubscribe", "Subscribe", "Consumer", "SqsMessageHandler",
1316
+ "KafkaListener", "Process"):
1317
+ pairs = _ts_pairs(_ts_unwrap(first)) if first is not None else {}
1318
+ topic = self._lit(pairs.get("routingKey")) or self._lit(pairs.get("topic")) or self._lit(pairs.get("queue")) \
1319
+ if dname == "RabbitSubscribe" else (self._lit(first) if first is not None else None)
1320
+ if topic:
1321
+ self.out.add("queue", "subscribe", cid, topic, line, "task" if dname == "Process" else "topic")
1322
+ elif dname in ("OnEvent", "SubscribeMessage", "OnMessage"):
1323
+ topic = self._lit(first) if first is not None else None
1324
+ if topic:
1325
+ self.out.add("queue", "subscribe", cid, topic, line, "event")
1326
+ elif dname == "GrpcMethod" and first is not None and self._lit(first):
1327
+ method = self._lit(args[1]) if len(args) > 1 else None
1328
+ if method is None:
1329
+ fname = self.where.nodes[cid].name
1330
+ method = fname[:1].upper() + fname[1:]
1331
+ self.out.add("rpc", "serve-fn", cid, f"{self._lit(first)}/{method}", line, "grpc")
1332
+
1333
+
1334
+ PRISMA_WRITE = frozenset("create createMany createManyAndReturn update updateMany upsert delete deleteMany".split())
1335
+ DB_TYPEORM = frozenset("save insert update delete remove softDelete restore upsert increment decrement clear find findOne "
1336
+ "findBy findOneBy findAndCount count createQueryBuilder exist exists query".split())
1337
+
1338
+
1339
+ # -- resolution --------------------------------------------------------------------------------------------
1340
+
1341
+ def _repo_of(node_id: str) -> str:
1342
+ return node_id.split(":", 1)[0]
1343
+
1344
+
1345
+ def _norm(name: str) -> str:
1346
+ return re.sub(r"[^a-z0-9_]", "", name.lower())
1347
+
1348
+
1349
+ def _stem(key: str) -> str:
1350
+ """Singular and plural folded together, so users and User meet (as a guess)."""
1351
+ if key.endswith("ies") and len(key) > 4:
1352
+ return key[:-3] + "y"
1353
+ if key.endswith(("sses", "xes", "ches", "shes")):
1354
+ return key[:-2]
1355
+ if key.endswith("s") and not key.endswith("ss") and len(key) > 3:
1356
+ return key[:-1]
1357
+ return key
1358
+
1359
+
1360
+ TEST_DIRS = frozenset("test tests __tests__ spec specs e2e testing".split())
1361
+ _TEST_SEG = re.compile(r"(^|[._-]|[a-z])Tests?$") # Basket.UnitTests, Application.FunctionalTests
1362
+ _TEST_FILE = re.compile(r"^(test_.*\.py|.*_test\.py|conftest\.py|.*\.(test|spec)\.[cm]?[jt]sx?|.*Tests?\.cs)$")
1363
+
1364
+
1365
+ def _test_path(path: str) -> bool:
1366
+ segs = path.replace("\\", "/").split("/")
1367
+ return any(s.lower() in TEST_DIRS or _TEST_SEG.search(s) for s in segs[:-1]) or bool(_TEST_FILE.match(segs[-1]))
1368
+
1369
+
1370
+ def _migration_path(path: str) -> bool:
1371
+ segs = [s.lower() for s in path.replace("\\", "/").split("/")[:-1]]
1372
+ return any(s in ("migrations", "migration") for s in segs) or ("alembic" in segs and "versions" in segs)
1373
+
1374
+
1375
+ def _shared_dirs(a: str, b: str) -> int:
1376
+ n = 0
1377
+ for x, y in zip(a.split("/")[:-1], b.split("/")[:-1]):
1378
+ if x != y:
1379
+ break
1380
+ n += 1
1381
+ return n
1382
+
1383
+
1384
+ class _Resolver:
1385
+ def __init__(self, ix):
1386
+ self.ix = ix
1387
+ self.by: dict[str, list] = defaultdict(list) # channel -> [(endpoint, file id, language)]
1388
+ for fid, res in ix.results.items():
1389
+ lang = ix.file_lang[fid]
1390
+ for e in res.endpoints:
1391
+ if e.channel in CHANNELS and (e.src_id in ix.nodes or e.src_id == fid):
1392
+ self.by[e.channel].append((e, fid, lang))
1393
+
1394
+ def edge(self, src, dst, precision, attrs) -> bool:
1395
+ ix = self.ix
1396
+ if src == dst or src not in ix.nodes or dst not in ix.nodes:
1397
+ return False
1398
+ ix.edges.append(Edge("communicates", src, dst, precision, attrs))
1399
+ return True
1400
+
1401
+ def callable_named(self, lang: str, fid: str, src: str, expr: str) -> Optional[str]:
1402
+ """The function a name written at `src` refers to: handle, self.on_message, tasks.add, this.onMessage."""
1403
+ ix = self.ix
1404
+ if expr.startswith("type:"):
1405
+ return None
1406
+ parts = expr.split(".")
1407
+ name = parts[-1]
1408
+ receiver = None if len(parts) == 1 else ("this" if parts[0] in ("self", "this") and len(parts) == 2 else ".".join(parts[:-1]))
1409
+ n = ix.nodes.get(src)
1410
+ enclosing = None
1411
+ cur = n
1412
+ while cur is not None:
1413
+ if cur.kind == "type":
1414
+ enclosing = cur.id
1415
+ break
1416
+ cur = ix.nodes.get(cur.parent_id) if cur.parent_id else None
1417
+ before_edges, before_stats = len(ix.edges), {k: dict(v) for k, v in ix.stats.items()}
1418
+ found = None
1419
+ for argc in (1, 0, 2, 3):
1420
+ call = CallSite(src, name, receiver, None, argc, 0, enclosing)
1421
+ ix._guessed, ix._call = False, None
1422
+ try:
1423
+ got = ix._resolve_call(lang, fid, call) or []
1424
+ except Exception:
1425
+ got = []
1426
+ got = [g for g in got if g.kind in ("callable", "test")]
1427
+ if got and not ix._guessed:
1428
+ found = got[0].id
1429
+ break
1430
+ del ix.edges[before_edges:]
1431
+ ix.stats.clear()
1432
+ ix.stats.update({k: Counter(v) for k, v in before_stats.items()})
1433
+ if found is None and receiver is None:
1434
+ # A function declared in the same file under that name.
1435
+ own = [c for c in ix.by_name.get((lang, name), ()) if ix.file_of.get(c.id) == fid]
1436
+ if len(own) == 1:
1437
+ found = own[0].id
1438
+ return found
1439
+
1440
+ def type_named(self, lang: str, name: str, src: str) -> Optional[str]:
1441
+ name = re.sub(r"<.*", "", name).strip().rsplit(".", 1)[-1]
1442
+ if not name:
1443
+ return None
1444
+ try:
1445
+ tid = self.ix._type(lang, name, src)
1446
+ except Exception:
1447
+ tid = None
1448
+ if tid is None:
1449
+ cands = self.ix.types_by_name.get((lang, name), [])
1450
+ tid = cands[0] if len(cands) == 1 else None
1451
+ return tid
1452
+
1453
+ def in_tests(self, nid: str) -> bool:
1454
+ n = self.ix.nodes.get(nid)
1455
+ cur = n
1456
+ while cur is not None and cur.kind in ("callable", "test"):
1457
+ if cur.kind == "test" or cur.attrs.get("is_test") or cur.attrs.get("is_fixture"):
1458
+ return True
1459
+ cur = self.ix.nodes.get(cur.parent_id)
1460
+ return n is not None and bool(n.path) and _test_path(n.path)
1461
+
1462
+ def members(self, tid: str) -> dict[str, list]:
1463
+ """Methods of a type and the types it builds on, nearest first."""
1464
+ out: dict[str, list] = {}
1465
+ for t in self.ix._chain(tid):
1466
+ for name, fns in self.ix.members.get(t, {}).items():
1467
+ out.setdefault(name, fns)
1468
+ return out
1469
+
1470
+ # -- dependency injection ----------------------------------------------------------------------------
1471
+
1472
+ def di(self) -> None:
1473
+ st = self.ix.channel_stats["di"]
1474
+ injects: dict[str, set] = defaultdict(set) # token -> types declared where it is injected
1475
+ for e, fid, lang in self.by["di"]:
1476
+ if e.role == "inject":
1477
+ for typ in e.literals:
1478
+ injects[e.address].add(typ)
1479
+ linked_pairs: set = set()
1480
+ for e, fid, lang in self.by["di"]:
1481
+ if e.role == "provide":
1482
+ st["registrations"] += 1
1483
+ if not e.literals:
1484
+ st["registered_as_itself"] += 1
1485
+ continue
1486
+ impl = self.type_named(lang, e.literals[0], e.src_id)
1487
+ services = [e.address]
1488
+ if not re.fullmatch(r"[\w.]+", e.address) or (e.address not in self._type_names(lang) and e.address in injects):
1489
+ services = sorted(injects.get(e.address, ())) # a token: the type is whatever is declared where it is injected
1490
+ svcs = [s for s in (self.type_named(lang, s, e.src_id) for s in services) if s]
1491
+ if impl is None or not svcs:
1492
+ st["registrations_outside"] += 1
1493
+ continue
1494
+ precision = "guess" if e.method == "factory" else "heuristic"
1495
+ linked = again = 0
1496
+ for svc in svcs:
1497
+ if svc == impl:
1498
+ continue
1499
+ impl_members = self.members(impl)
1500
+ for name, fns in self.ix.members.get(svc, {}).items():
1501
+ for f in fns:
1502
+ if f.kind != "callable" or name in (".ctor", "constructor", "__init__"):
1503
+ continue
1504
+ argc, params = f.attrs.get("argc_max"), f.id.rsplit("(", 1)[-1]
1505
+ cands = [c for c in impl_members.get(name, []) if c.kind == "callable" and c.id != f.id]
1506
+ same = [c for c in cands if c.id.rsplit("(", 1)[-1] == params] or \
1507
+ [c for c in cands if c.attrs.get("argc_max") == argc] or cands
1508
+ for c in same[:1]:
1509
+ if (f.id, c.id) in linked_pairs:
1510
+ again += 1 # registered twice (two hosts, or a test host): one link
1511
+ continue
1512
+ linked_pairs.add((f.id, c.id))
1513
+ if self.edge(f.id, c.id, precision, {
1514
+ "channel": "di", "address": f"{self.ix.nodes[svc].name} -> {self.ix.nodes[impl].name}",
1515
+ "registered_in": e.src_id, "registered_at": e.line}):
1516
+ linked += 1
1517
+ st["registrations_linked"] += bool(linked or again)
1518
+ st["links"] += linked
1519
+ elif e.role == "start":
1520
+ tid = self.type_named(lang, e.address, e.src_id)
1521
+ ms = self.members(tid) if tid else {}
1522
+ for name in ("ExecuteAsync", "StartAsync", "Run", "RunAsync"):
1523
+ fns = [f for f in ms.get(name, []) if f.kind == "callable"]
1524
+ if fns and self.edge(e.src_id, fns[0].id, "heuristic", {
1525
+ "channel": "di", "address": f"hosted {e.address}", "direction": "start", "line": e.line}):
1526
+ st["hosted_services_linked"] += 1
1527
+ st["links"] += 1
1528
+ break
1529
+ elif e.role == "depend":
1530
+ st["dependencies"] += 1
1531
+ target = self.callable_named(lang, fid, e.src_id, e.address)
1532
+ if target is None:
1533
+ tid = self.type_named(lang, e.address, e.src_id)
1534
+ if tid:
1535
+ ms = self.members(tid)
1536
+ target = next((f.id for f in ms.get("__init__", []) + ms.get("__call__", []) if f.kind == "callable"), None)
1537
+ if target and self.edge(e.src_id, target, "heuristic", {"channel": "di", "address": f"Depends({e.address})", "line": e.line}):
1538
+ st["links"] += 1
1539
+ st["dependencies_linked"] += 1
1540
+
1541
+ def _type_names(self, lang: str) -> set:
1542
+ if not hasattr(self, "_tn"):
1543
+ self._tn = {}
1544
+ if lang not in self._tn:
1545
+ self._tn[lang] = {k[1] for k in self.ix.types_by_name if k[0] == lang}
1546
+ return self._tn[lang]
1547
+
1548
+ # -- queues, buses and pub/sub -----------------------------------------------------------------------
1549
+
1550
+ def queues(self) -> None:
1551
+ st = self.ix.channel_stats["queue"]
1552
+ pubs, subs = [], defaultdict(list) # family -> [(address, target, endpoint)]
1553
+ for e, fid, lang in self.by["queue"]:
1554
+ if e.role == "publish":
1555
+ pubs.append((e, fid, lang))
1556
+ continue
1557
+ target = e.src_id
1558
+ if e.handler and e.handler.startswith("type:"):
1559
+ tid = e.src_id if e.handler == "type:" else self.type_named(lang, e.handler[5:], e.src_id)
1560
+ ms = self.members(tid) if tid else {}
1561
+ target = next((f.id for m in HANDLE_METHODS for f in ms.get(m, []) if f.kind == "callable"), None)
1562
+ if target is None:
1563
+ continue
1564
+ elif e.handler:
1565
+ target = self.callable_named(lang, fid, e.src_id, e.handler) or e.src_id
1566
+ if target not in self.ix.nodes or self.ix.nodes[target].kind not in ("callable", "test"):
1567
+ continue
1568
+ subs[e.method].append((e.address, target, e))
1569
+ # Messages only created come last, so a message also sent by name keeps its heuristic link.
1570
+ pubs.sort(key=lambda p: p[0].method == "message-new")
1571
+ st["publish_sites"] = sum(1 for e, _, _ in pubs if e.method not in ("signal", "message-new"))
1572
+ st["subscriptions"] = sum(len(v) for v in subs.values())
1573
+ exact: dict[tuple, list] = defaultdict(list)
1574
+ patterns = []
1575
+ for fam, lst in subs.items():
1576
+ for address, target, e in lst:
1577
+ if fam == "topic" and re.search(r"[*#>]|\+", address):
1578
+ rx = re.escape(address).replace(r"\*", r"[^.]+").replace(r"\#", r".*").replace(r"\>", r".+").replace(r"\+", r"[^/]+")
1579
+ patterns.append((fam, re.compile(rx + r"\Z"), address, target, e))
1580
+ else:
1581
+ exact[(fam, address)].append((address, target, e))
1582
+ pairs = []
1583
+ for e, fid, lang in pubs:
1584
+ if e.method == "task" and e.handler:
1585
+ # The task is named in code (add.delay(...)): it is that function, wherever it is declared.
1586
+ target = self.callable_named(lang, fid, e.src_id, e.handler)
1587
+ if target:
1588
+ pairs.append((e, target, e.address, "heuristic", None))
1589
+ continue
1590
+ if e.method == "message-new":
1591
+ # Created, not visibly sent: a guess that it reaches the handler for its type.
1592
+ for a, t, s in exact.get(("message", e.address), []):
1593
+ pairs.append((e, t, a, "guess", s))
1594
+ continue
1595
+ hits = [(a, t, s) for a, t, s in exact.get((e.method, e.address), [])]
1596
+ hits += [(a, t, s) for fam, rx, a, t, s in patterns if fam == e.method and rx.match(e.address)]
1597
+ if e.method == "event" and e.address in COMMON_EVENTS:
1598
+ st["too_common_to_link"] += bool(hits)
1599
+ continue
1600
+ if not hits:
1601
+ st["published_with_no_subscriber_here"] += e.method != "signal"
1602
+ continue
1603
+ for a, t, s in hits:
1604
+ pairs.append((e, t, a, "heuristic", s))
1605
+ fan = Counter((e.method, a) for e, _t, a, _p, _s in pairs)
1606
+ seen = set()
1607
+ for e, target, address, precision, sub in pairs:
1608
+ if fan[(e.method, address)] > 60:
1609
+ st["too_common_to_link"] += 1
1610
+ continue
1611
+ key = (e.src_id, target, address)
1612
+ if key in seen:
1613
+ continue
1614
+ seen.add(key)
1615
+ attrs = {"channel": "queue", "address": address, "kind": "message" if e.method == "message-new" else e.method,
1616
+ "line": e.line}
1617
+ if e.method == "message-new":
1618
+ attrs["created_only"] = True
1619
+ st["linked_by_creation"] += 1
1620
+ if sub is not None and sub.src_id != target:
1621
+ attrs["subscriber"] = sub.src_id
1622
+ if self.edge(e.src_id, target, precision, attrs):
1623
+ st["links"] += 1
1624
+
1625
+ # -- databases ---------------------------------------------------------------------------------------
1626
+
1627
+ def db(self) -> None:
1628
+ ix = self.ix
1629
+ st = ix.channel_stats["db"]
1630
+ table_of: dict[str, str] = {} # type id -> table name
1631
+ models: set[str] = set() # type ids that are mapped to a table
1632
+ named: dict[str, str] = {} # a model's name as code writes it (mongoose, sequelize) -> table
1633
+ dbsets: dict[str, Optional[str]] = {} # DbSet property name -> its entity type
1634
+ for e, fid, lang in self.by["db"]:
1635
+ if e.role != "table":
1636
+ continue
1637
+ if e.method == "dbset":
1638
+ ent = self.type_named(lang, e.literals[0], e.src_id) if e.literals else None
1639
+ dbsets[e.address] = ent
1640
+ if ent:
1641
+ models.add(ent)
1642
+ continue
1643
+ if e.method == "fluent":
1644
+ ent = self.type_named(lang, e.literals[0], e.src_id) if e.literals else None
1645
+ if ent:
1646
+ table_of[ent] = e.address
1647
+ models.add(ent)
1648
+ continue
1649
+ if e.method in ("mongoose", "sequelize"):
1650
+ for lit in e.literals:
1651
+ named[lit] = e.address
1652
+ continue
1653
+ node = ix.nodes.get(e.src_id)
1654
+ if node is not None and node.kind == "type":
1655
+ models.add(node.id)
1656
+ if e.address:
1657
+ table_of[node.id] = e.address
1658
+ for name, ent in dbsets.items(): # an entity's own [Table] or ToTable wins over its DbSet's name
1659
+ if ent:
1660
+ table_of.setdefault(ent, name)
1661
+ uses = []
1662
+ for e, fid, lang in self.by["db"]:
1663
+ if e.role not in ("read", "write"):
1664
+ continue
1665
+ node = ix.nodes.get(e.src_id)
1666
+ if node is not None and _migration_path(node.path or ""):
1667
+ st["in_migrations"] += 1 # a schema change, not data one side leaves for the other
1668
+ continue
1669
+ if self.in_tests(e.src_id):
1670
+ st["in_tests"] += 1 # a test sets up and reads back its own rows
1671
+ continue
1672
+ addr = e.address
1673
+ table = None
1674
+ if addr.startswith("model:"):
1675
+ name = addr[6:]
1676
+ tid = self.type_named(lang, name, e.src_id)
1677
+ if tid and (tid in models or any(t in models for t in ix._chain(tid)[1:])):
1678
+ table = table_of.get(tid) or ix.nodes[tid].name
1679
+ elif name in named:
1680
+ table = named[name]
1681
+ else:
1682
+ st["not_a_model"] += 1
1683
+ continue
1684
+ elif addr.startswith("dbset:"):
1685
+ name = addr[6:]
1686
+ if name not in dbsets:
1687
+ continue
1688
+ table = table_of.get(dbsets[name] or "", name)
1689
+ elif addr.startswith("prisma:"):
1690
+ table = addr[7:]
1691
+ else:
1692
+ table = addr
1693
+ uses.append((e, table))
1694
+ st["read_sites"] = sum(1 for e, _ in uses if e.role == "read")
1695
+ st["write_sites"] = sum(1 for e, _ in uses if e.role == "write")
1696
+ st["tables"] = len({_norm(t) for _, t in uses})
1697
+ readers: dict[str, list] = defaultdict(list)
1698
+ stems: dict[str, list] = defaultdict(list)
1699
+ for e, table in uses:
1700
+ if e.role == "read":
1701
+ readers[_norm(table)].append((e, table))
1702
+ stems[_stem(_norm(table))].append((e, table))
1703
+ pairs = {}
1704
+ for e, table in uses:
1705
+ if e.role != "write":
1706
+ continue
1707
+ key = _norm(table)
1708
+ for r, rt in readers.get(key, []):
1709
+ pairs.setdefault((e.src_id, r.src_id), (table, "heuristic"))
1710
+ for r, rt in stems.get(_stem(key), []):
1711
+ if _norm(rt) != key:
1712
+ pairs.setdefault((e.src_id, r.src_id), (f"{table} ~ {rt}", "guess"))
1713
+ fan = Counter(_stem(_norm(t.split(" ~ ")[0])) for t, _ in pairs.values())
1714
+ for (w, r), (table, precision) in sorted(pairs.items()):
1715
+ if w == r:
1716
+ continue
1717
+ if fan[_stem(_norm(table.split(" ~ ")[0]))] > 400:
1718
+ st["too_common_to_link"] += 1
1719
+ continue
1720
+ if self.edge(w, r, precision, {"channel": "db", "address": table}):
1721
+ st["links"] += 1
1722
+
1723
+ # -- RPC ---------------------------------------------------------------------------------------------
1724
+
1725
+ def rpc(self) -> None:
1726
+ ix = self.ix
1727
+ st = ix.channel_stats["rpc"]
1728
+ servers: dict[str, list] = defaultdict(list) # service (lower case) -> [(method lower case, function id)]
1729
+ for e, fid, lang in self.by["rpc"]:
1730
+ if e.role == "serve":
1731
+ st["services"] += 1
1732
+ for name, fns in self.members(e.src_id).items():
1733
+ for f in fns:
1734
+ if f.kind == "callable":
1735
+ servers[e.address.lower()].append((name.lower(), f.id))
1736
+ elif e.role == "serve-fn":
1737
+ st["services"] += 0
1738
+ svc, _, method = e.address.partition("/")
1739
+ target = self.callable_named(lang, fid, e.src_id, e.handler) if e.handler else e.src_id
1740
+ if target:
1741
+ servers[svc.lower()].append((method.lower(), target))
1742
+ seen = set()
1743
+ for e, fid, lang in self.by["rpc"]:
1744
+ if e.role != "call":
1745
+ continue
1746
+ svc, _, method = e.address.partition("/")
1747
+ impls = servers.get(svc.lower(), [])
1748
+ if not impls:
1749
+ if e.method != "maybe":
1750
+ st["calls_to_services_outside"] += 1
1751
+ continue
1752
+ st["call_sites"] += 1
1753
+ m = method.lower()
1754
+ hit = list(dict.fromkeys(f for name, f in impls if name == m or name == m + "async"))
1755
+ if not hit:
1756
+ st["calls_with_no_method_here"] += 1
1757
+ continue
1758
+ if len(hit) > 1:
1759
+ # Several servers of one service (examples, or one per language): the nearest by directory.
1760
+ here = ix.nodes[e.src_id].path or ""
1761
+ near = [_shared_dirs(here, ix.nodes[f].path or "") for f in hit]
1762
+ best = max(near)
1763
+ if near.count(best) < len(hit):
1764
+ st["servers_further_away"] += len(hit) - near.count(best)
1765
+ hit = [f for f, d in zip(hit, near) if d == best]
1766
+ for target in hit:
1767
+ if (e.src_id, target) in seen:
1768
+ continue
1769
+ seen.add((e.src_id, target))
1770
+ if self.edge(e.src_id, target, "heuristic", {"channel": "rpc", "address": e.address, "line": e.line}):
1771
+ st["links"] += 1
1772
+
1773
+
1774
+ def resolve(ix) -> None:
1775
+ """Link the two ends of each channel across the workspace."""
1776
+ r = _Resolver(ix)
1777
+ r.di()
1778
+ r.queues()
1779
+ r.db()
1780
+ r.rpc()
1781
+ from . import formats
1782
+ formats.resolve(ix)