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.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- 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)
|