stackthread 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev3__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.
- stackthread/__init__.py +1 -1
- stackthread/lang/python/extract.py +494 -494
- stackthread/lineage.py +965 -965
- stackthread/static/app.js +1 -1
- stackthread-0.1.0.dev3.dist-info/METADATA +532 -0
- {stackthread-0.1.0.dev2.dist-info → stackthread-0.1.0.dev3.dist-info}/RECORD +9 -9
- stackthread-0.1.0.dev2.dist-info/METADATA +0 -51
- {stackthread-0.1.0.dev2.dist-info → stackthread-0.1.0.dev3.dist-info}/WHEEL +0 -0
- {stackthread-0.1.0.dev2.dist-info → stackthread-0.1.0.dev3.dist-info}/entry_points.txt +0 -0
- {stackthread-0.1.0.dev2.dist-info → stackthread-0.1.0.dev3.dist-info}/licenses/LICENSE +0 -0
stackthread/lineage.py
CHANGED
|
@@ -1,965 +1,965 @@
|
|
|
1
|
-
"""Lineage: from a clicked element, or any code location, to every definition it reaches (design §10).
|
|
2
|
-
|
|
3
|
-
A selection arrives from the browser:
|
|
4
|
-
|
|
5
|
-
{"url": "/orders/7",
|
|
6
|
-
"target": {"loc": "web/src/Order.tsx:41:9", "tag": "button", "text": "Pay"},
|
|
7
|
-
"chain": [{"loc": ..., "tag": ...}, ...], # stamped DOM ancestors, nearest first, target included
|
|
8
|
-
"owners": [{"name": "OrderPage", "loc": "web/src/routes.tsx:12:20"}, ...], # components, nearest first
|
|
9
|
-
"attrs": [{"name": "data-
|
|
10
|
-
|
|
11
|
-
The answer is a list of layers (screen, client, network, server, data, plus concept and adapter layers), each a
|
|
12
|
-
list of rows. Every row names a real definition with file and line, or says plainly that it is outside the code.
|
|
13
|
-
|
|
14
|
-
Rows carry a role. `action` rows are what the element does when used: its event handlers and everything they call,
|
|
15
|
-
followed deeply to the database. `reads` rows are the data it shows or an action reads (state, props, conditions,
|
|
16
|
-
the requests that load them), followed shallowly so they inform without drowning the action.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
import re
|
|
22
|
-
import time
|
|
23
|
-
from collections import deque
|
|
24
|
-
from dataclasses import dataclass, field
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
from typing import TYPE_CHECKING, Any
|
|
27
|
-
|
|
28
|
-
from stackthread.model import CONFIDENCE_RANK, Graph, Node
|
|
29
|
-
|
|
30
|
-
if TYPE_CHECKING:
|
|
31
|
-
from stackthread.lang.js.build import JsBuild
|
|
32
|
-
from stackthread.project import Project, Snapshot
|
|
33
|
-
|
|
34
|
-
DEPTH = {"action": 4, "reads": 2}
|
|
35
|
-
SERVER_DEPTH = {"action": 6, "reads": 1}
|
|
36
|
-
SERVER_ROWS = {"action": 60, "reads": 8}
|
|
37
|
-
MAX_MENTIONS = 40
|
|
38
|
-
SKIP_ATTRS = frozenset({"key", "className", "class", "style", "id", "htmlFor", "role", "type", "ref", "tabIndex"})
|
|
39
|
-
|
|
40
|
-
LAYER_TITLES = {
|
|
41
|
-
"screen": ("On screen", "what drew the thing you clicked"),
|
|
42
|
-
"client": ("In the browser", "handlers, state, props and helpers"),
|
|
43
|
-
"network": ("Over the wire", "requests"),
|
|
44
|
-
"server": ("On the server", "endpoint, then the functions it calls"),
|
|
45
|
-
"data": ("Data", "models and stores read or written"),
|
|
46
|
-
}
|
|
47
|
-
BINDING_WORDS = {
|
|
48
|
-
"prop": "prop",
|
|
49
|
-
"state": "state",
|
|
50
|
-
"setter": "state setter",
|
|
51
|
-
"memo": "computed value",
|
|
52
|
-
"callback": "callback",
|
|
53
|
-
"function": "local function",
|
|
54
|
-
"hook": "hook result",
|
|
55
|
-
"ref": "ref",
|
|
56
|
-
"context": "context value",
|
|
57
|
-
"reducer": "reducer state",
|
|
58
|
-
"local": "local value",
|
|
59
|
-
"param": "parameter",
|
|
60
|
-
"lazy": "lazy component",
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
@dataclass
|
|
65
|
-
class Row:
|
|
66
|
-
label: str
|
|
67
|
-
kind: str
|
|
68
|
-
reason: str = ""
|
|
69
|
-
file: str = ""
|
|
70
|
-
line: int = 0
|
|
71
|
-
end: int = 0
|
|
72
|
-
node: str = ""
|
|
73
|
-
confidence: str = "exact"
|
|
74
|
-
depth: int = 0
|
|
75
|
-
external: bool = False
|
|
76
|
-
loc: str = "" # a stamp location the UI can highlight in the page
|
|
77
|
-
role: str = "" # action | reads
|
|
78
|
-
|
|
79
|
-
def to_json(self) -> dict[str, Any]:
|
|
80
|
-
data = {
|
|
81
|
-
"label": self.label,
|
|
82
|
-
"kind": self.kind,
|
|
83
|
-
"reason": self.reason,
|
|
84
|
-
"file": self.file,
|
|
85
|
-
"line": self.line,
|
|
86
|
-
"end": self.end,
|
|
87
|
-
"node": self.node,
|
|
88
|
-
"confidence": self.confidence,
|
|
89
|
-
"depth": self.depth,
|
|
90
|
-
"external": self.external,
|
|
91
|
-
"loc": self.loc,
|
|
92
|
-
"role": self.role,
|
|
93
|
-
}
|
|
94
|
-
return {k: v for k, v in data.items() if v not in ("", 0, False) or k in ("label", "kind")}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
@dataclass
|
|
98
|
-
class Layer:
|
|
99
|
-
id: str
|
|
100
|
-
title: str
|
|
101
|
-
note: str
|
|
102
|
-
rows: list[Row] = field(default_factory=list)
|
|
103
|
-
_seen: set[str] = field(default_factory=set)
|
|
104
|
-
|
|
105
|
-
def add(self, row: Row) -> bool:
|
|
106
|
-
key = row.node or f"{row.kind}:{row.file}:{row.line}:{row.label}"
|
|
107
|
-
if key in self._seen:
|
|
108
|
-
return False
|
|
109
|
-
self._seen.add(key)
|
|
110
|
-
self.rows.append(row)
|
|
111
|
-
return True
|
|
112
|
-
|
|
113
|
-
def ordered(self) -> list[Row]:
|
|
114
|
-
"""Action rows first, keeping the order they were found in; the rest after."""
|
|
115
|
-
return [r for r in self.rows if r.role != "reads"] + [r for r in self.rows if r.role == "reads"]
|
|
116
|
-
|
|
117
|
-
def to_json(self) -> dict[str, Any]:
|
|
118
|
-
return {"id": self.id, "title": self.title, "note": self.note, "rows": [r.to_json() for r in self.ordered()]}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
@dataclass(frozen=True)
|
|
122
|
-
class Seed:
|
|
123
|
-
name: str # a reference (`cell.can_act`) or a callee (`calls.save`)
|
|
124
|
-
how: str
|
|
125
|
-
kind: str # ref | call
|
|
126
|
-
role: str # action | reads
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _loc(loc: str) -> tuple[str, int, int]:
|
|
130
|
-
m = re.match(r"^(.*?):(\d+)(?::(\d+))?$", loc or "")
|
|
131
|
-
if not m:
|
|
132
|
-
return loc or "", 0, 0
|
|
133
|
-
return m.group(1), int(m.group(2)), int(m.group(3) or 0)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _fn_label(node: Node) -> str:
|
|
137
|
-
if node.kind in ("component", "function", "method", "hook"):
|
|
138
|
-
return f"{node.qualname or node.name}()" if node.lang == "js" else f"{node.name}()"
|
|
139
|
-
return node.name
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def page_for(graph: Graph, url: str) -> Node | None:
|
|
143
|
-
"""The page whose route pattern matches a URL path, preferring the most literal match."""
|
|
144
|
-
path = (url or "/").split("?")[0].split("#")[0].rstrip("/") or "/"
|
|
145
|
-
parts = [p for p in path.split("/") if p]
|
|
146
|
-
best: tuple[int, Node] | None = None
|
|
147
|
-
for page in graph.of_kind("page"):
|
|
148
|
-
pattern = [p for p in page.attrs.get("path", "").split("/") if p]
|
|
149
|
-
compare = parts
|
|
150
|
-
if pattern and pattern[-1] in ("*", "$"):
|
|
151
|
-
pattern = pattern[:-1]
|
|
152
|
-
if len(parts) < len(pattern):
|
|
153
|
-
continue
|
|
154
|
-
compare = parts[: len(pattern)]
|
|
155
|
-
elif len(pattern) != len(parts):
|
|
156
|
-
continue
|
|
157
|
-
score, ok = 0, True
|
|
158
|
-
for want, got in zip(pattern, compare, strict=True):
|
|
159
|
-
if want == got:
|
|
160
|
-
score += 2
|
|
161
|
-
elif want.startswith((":", "$", "{")):
|
|
162
|
-
score += 1
|
|
163
|
-
else:
|
|
164
|
-
ok = False
|
|
165
|
-
break
|
|
166
|
-
if ok and (best is None or score > best[0]):
|
|
167
|
-
best = (score, page)
|
|
168
|
-
return best[1] if best else None
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
class Lineage:
|
|
172
|
-
def __init__(self, project: Project, snapshot: Snapshot) -> None:
|
|
173
|
-
self.project = project
|
|
174
|
-
self.s = snapshot
|
|
175
|
-
self.g = snapshot.graph
|
|
176
|
-
self.layers = {k: Layer(k, *LAYER_TITLES[k]) for k in LAYER_TITLES}
|
|
177
|
-
self.extra: list[Layer] = []
|
|
178
|
-
self.reached: dict[str, str] = {} # frontend function node -> role it was reached in
|
|
179
|
-
self.server_nodes: dict[str, str] = {} # backend node -> role
|
|
180
|
-
self.strings: list[str] = []
|
|
181
|
-
|
|
182
|
-
def _result(self, header: dict[str, Any], started: float) -> dict[str, Any]:
|
|
183
|
-
layers = [layer.to_json() for layer in [*self.layers.values(), *self.extra] if layer.rows]
|
|
184
|
-
return {
|
|
185
|
-
"target": header,
|
|
186
|
-
"layers": layers,
|
|
187
|
-
"rev": self.s.rev,
|
|
188
|
-
"ms": round((time.perf_counter() - started) * 1000, 1),
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
# ------------------------------------------------------------------ entry points
|
|
192
|
-
|
|
193
|
-
def selection(self, sel: dict[str, Any]) -> dict[str, Any]:
|
|
194
|
-
started = time.perf_counter()
|
|
195
|
-
target = sel.get("target") or {}
|
|
196
|
-
loc = target.get("loc", "")
|
|
197
|
-
found = self.s.element(loc)
|
|
198
|
-
owners = sel.get("owners") or []
|
|
199
|
-
chain = sel.get("chain") or []
|
|
200
|
-
header: dict[str, Any] = {"loc": loc, "tag": target.get("tag", ""), "text": target.get("text", "")}
|
|
201
|
-
if found is None:
|
|
202
|
-
file, line, _ = _loc(loc)
|
|
203
|
-
header.update(file=file, line=line, known=False)
|
|
204
|
-
self._screen_owners(owners)
|
|
205
|
-
else:
|
|
206
|
-
build, el = found
|
|
207
|
-
header.update(file=el["file"], line=el["line"], known=True, element=el["tag"], component=el.get("fn", ""))
|
|
208
|
-
self._screen(build, el, chain, owners)
|
|
209
|
-
self._client(build, el, owners, chain)
|
|
210
|
-
page = page_for(self.g, sel.get("url", ""))
|
|
211
|
-
if page is not None:
|
|
212
|
-
self._page_row(page)
|
|
213
|
-
header["page"] = page.attrs.get("path")
|
|
214
|
-
self._network_and_server()
|
|
215
|
-
self._concepts(sel.get("attrs") or [])
|
|
216
|
-
result = self._result(header, started)
|
|
217
|
-
from stackthread import adapters
|
|
218
|
-
|
|
219
|
-
adapters.lineage(self.project, sel, result["layers"])
|
|
220
|
-
return result
|
|
221
|
-
|
|
222
|
-
def location(self, loc: str) -> dict[str, Any]:
|
|
223
|
-
"""Lineage for a code location: an element if one starts there, otherwise the definition around it."""
|
|
224
|
-
started = time.perf_counter()
|
|
225
|
-
file, line, col = _loc(loc)
|
|
226
|
-
for build in self.s.js.values():
|
|
227
|
-
for el in build.elements.values():
|
|
228
|
-
if el["file"] == file and el["line"] == line and (not col or el["col"] == col):
|
|
229
|
-
return self.selection({"target": {"loc": f"{file}:{line}:{el['col']}"}})
|
|
230
|
-
node = self.g.at(file, line)
|
|
231
|
-
if node is None:
|
|
232
|
-
return {"target": {"loc": loc, "known": False}, "layers": [], "rev": self.s.rev}
|
|
233
|
-
self._node(node)
|
|
234
|
-
header = {
|
|
235
|
-
"loc": loc,
|
|
236
|
-
"node": node.id,
|
|
237
|
-
"label": _fn_label(node),
|
|
238
|
-
"file": node.file,
|
|
239
|
-
"line": node.line,
|
|
240
|
-
"known": True,
|
|
241
|
-
}
|
|
242
|
-
return self._result(header, started)
|
|
243
|
-
|
|
244
|
-
# ------------------------------------------------------------------ screen
|
|
245
|
-
|
|
246
|
-
def _screen(
|
|
247
|
-
self, build: JsBuild, el: dict[str, Any], chain: list[dict[str, Any]], owners: list[dict[str, Any]]
|
|
248
|
-
) -> None:
|
|
249
|
-
screen = self.layers["screen"]
|
|
250
|
-
text = f' "{el["text"]}"' if el.get("text") else ""
|
|
251
|
-
written_in = el.get("fn") or el["file"].rsplit("/", 1)[-1]
|
|
252
|
-
screen.add(
|
|
253
|
-
Row(
|
|
254
|
-
f"<{el['tag']}>{text}",
|
|
255
|
-
"element",
|
|
256
|
-
f"written in {written_in}",
|
|
257
|
-
el["file"],
|
|
258
|
-
el["line"],
|
|
259
|
-
loc=f"{el['file']}:{el['line']}:{el['col']}",
|
|
260
|
-
)
|
|
261
|
-
)
|
|
262
|
-
for link in chain[1:6]:
|
|
263
|
-
file, line, col = _loc(link.get("loc", ""))
|
|
264
|
-
if not file or file == el["file"]:
|
|
265
|
-
continue
|
|
266
|
-
found = self.s.element(f"{file}:{line}:{col}")
|
|
267
|
-
where = (found[1].get("fn") if found else None) or file.rsplit("/", 1)[-1]
|
|
268
|
-
screen.add(
|
|
269
|
-
Row(
|
|
270
|
-
f"<{link.get('tag', '?')}>",
|
|
271
|
-
"element",
|
|
272
|
-
f"placed inside {where}",
|
|
273
|
-
file,
|
|
274
|
-
line,
|
|
275
|
-
loc=f"{file}:{line}:{col}",
|
|
276
|
-
)
|
|
277
|
-
)
|
|
278
|
-
break
|
|
279
|
-
if el.get("fn"):
|
|
280
|
-
node = self.g.nodes.get(f"js:{build.source.name}:{el['file']}#{el['fn']}")
|
|
281
|
-
if node is not None:
|
|
282
|
-
screen.add(
|
|
283
|
-
Row(
|
|
284
|
-
_fn_label(node),
|
|
285
|
-
node.kind,
|
|
286
|
-
"the component it is written in",
|
|
287
|
-
node.file,
|
|
288
|
-
node.line,
|
|
289
|
-
node.end,
|
|
290
|
-
node.id,
|
|
291
|
-
)
|
|
292
|
-
)
|
|
293
|
-
self._screen_owners(owners)
|
|
294
|
-
|
|
295
|
-
def _screen_owners(self, owners: list[dict[str, Any]]) -> None:
|
|
296
|
-
screen = self.layers["screen"]
|
|
297
|
-
skipped = 0
|
|
298
|
-
for owner in owners:
|
|
299
|
-
node = self._owner_node(owner)
|
|
300
|
-
if node is None:
|
|
301
|
-
skipped += 1
|
|
302
|
-
continue
|
|
303
|
-
file, line, _ = _loc(owner.get("loc", ""))
|
|
304
|
-
reason = f"rendered at {file.rsplit('/', 1)[-1]}:{line}" if file else "rendered by the app"
|
|
305
|
-
screen.add(
|
|
306
|
-
Row(
|
|
307
|
-
_fn_label(node),
|
|
308
|
-
node.kind,
|
|
309
|
-
reason,
|
|
310
|
-
node.file,
|
|
311
|
-
node.line,
|
|
312
|
-
node.end,
|
|
313
|
-
node.id,
|
|
314
|
-
loc=owner.get("loc") or "",
|
|
315
|
-
)
|
|
316
|
-
)
|
|
317
|
-
if skipped:
|
|
318
|
-
screen.rows.append(
|
|
319
|
-
Row(
|
|
320
|
-
f"{skipped} library component{'s' * (skipped > 1)}",
|
|
321
|
-
"external",
|
|
322
|
-
"outside your code: router, UI kit, providers",
|
|
323
|
-
external=True,
|
|
324
|
-
)
|
|
325
|
-
)
|
|
326
|
-
|
|
327
|
-
def _owner_node(self, owner: dict[str, Any]) -> Node | None:
|
|
328
|
-
"""The component node for a fiber owner: through its call-site stamp, else a unique name."""
|
|
329
|
-
loc = owner.get("loc")
|
|
330
|
-
if loc:
|
|
331
|
-
found = self.s.element(loc)
|
|
332
|
-
if found:
|
|
333
|
-
build, el = found
|
|
334
|
-
target = build.component_for(el["file"], el["tag"])
|
|
335
|
-
if target and target in self.g.nodes:
|
|
336
|
-
return self.g.nodes[target]
|
|
337
|
-
name = owner.get("name") or ""
|
|
338
|
-
matches = [n for n in self.g.of_kind("component") if n.name == name]
|
|
339
|
-
return matches[0] if len(matches) == 1 else None
|
|
340
|
-
|
|
341
|
-
def _page_row(self, page: Node) -> None:
|
|
342
|
-
comp = self.g.nodes.get(page.attrs.get("component", ""))
|
|
343
|
-
reason = f"route for {comp.name}" if comp else "route"
|
|
344
|
-
self.layers["screen"].add(
|
|
345
|
-
Row(page.attrs.get("path", page.name), "page", reason, page.file, page.line, node=page.id)
|
|
346
|
-
)
|
|
347
|
-
|
|
348
|
-
# ------------------------------------------------------------------ client
|
|
349
|
-
|
|
350
|
-
def _client(
|
|
351
|
-
self, build: JsBuild, el: dict[str, Any], owners: list[dict[str, Any]], chain: list[dict[str, Any]]
|
|
352
|
-
) -> None:
|
|
353
|
-
fn = build.fn_index.get(el["file"], {}).get(el.get("fn") or "")
|
|
354
|
-
actions: list[Seed] = []
|
|
355
|
-
reads: list[Seed] = []
|
|
356
|
-
for a in el["attrs"]:
|
|
357
|
-
if a.get("handler"):
|
|
358
|
-
self.strings += a.get("strings", [])
|
|
359
|
-
how = f"{a['name']} handler"
|
|
360
|
-
actions += [Seed(c, how, "call", "action") for c in a.get("calls", [])]
|
|
361
|
-
actions += [Seed(r, how, "ref", "action") for r in a.get("refs", [])]
|
|
362
|
-
elif a["name"] not in SKIP_ATTRS:
|
|
363
|
-
how = f"sets {a['name']}"
|
|
364
|
-
reads += [Seed(c, how, "call", "reads") for c in a.get("calls", [])]
|
|
365
|
-
reads += [Seed(r, how, "ref", "reads") for r in a.get("refs", [])]
|
|
366
|
-
if not actions and el["tag"].lower() in ("button", "input"):
|
|
367
|
-
actions += self._form_submit(chain or self._written_chain(el))
|
|
368
|
-
reads = [Seed(r, "decides whether it shows", "ref", "reads") for r in el.get("conds", [])] + reads
|
|
369
|
-
reads += [Seed(r, "shown as text", "ref", "reads") for r in el.get("refs", [])]
|
|
370
|
-
reads += [Seed(c, "shown as text", "call", "reads") for c in el.get("calls", [])]
|
|
371
|
-
if el.get("each"):
|
|
372
|
-
reads.append(Seed(el["each"], "one per item of", "ref", "reads"))
|
|
373
|
-
self._follow(build, el["file"], fn, actions + reads, owners, 0, set())
|
|
374
|
-
|
|
375
|
-
def _written_chain(self, el: dict[str, Any]) -> list[dict[str, Any]]:
|
|
376
|
-
"""Ancestors written in the same file, for a selection that arrives without the live DOM chain."""
|
|
377
|
-
out = [{"loc": f"{el['file']}:{el['line']}:{el['col']}"}]
|
|
378
|
-
parent = el.get("parent")
|
|
379
|
-
while parent and len(out) < 12:
|
|
380
|
-
loc = f"{el['file']}:{parent}"
|
|
381
|
-
found = self.s.element(loc)
|
|
382
|
-
if found is None:
|
|
383
|
-
break
|
|
384
|
-
out.append({"loc": loc})
|
|
385
|
-
parent = found[1].get("parent")
|
|
386
|
-
return out
|
|
387
|
-
|
|
388
|
-
def _form_submit(self, chain: list[dict[str, Any]]) -> list[Seed]:
|
|
389
|
-
for link in chain[1:]:
|
|
390
|
-
found = self.s.element(link.get("loc", ""))
|
|
391
|
-
if found and found[1]["tag"] in ("form", "Form"):
|
|
392
|
-
out = []
|
|
393
|
-
for a in found[1]["attrs"]:
|
|
394
|
-
if a.get("handler") and a["name"] in ("onSubmit", "action"):
|
|
395
|
-
self.strings += a.get("strings", [])
|
|
396
|
-
out += [Seed(c, "submits the form", "call", "action") for c in a.get("calls", [])]
|
|
397
|
-
out += [Seed(r, "submits the form", "ref", "action") for r in a.get("refs", [])]
|
|
398
|
-
return out
|
|
399
|
-
return []
|
|
400
|
-
|
|
401
|
-
def _follow(
|
|
402
|
-
self,
|
|
403
|
-
build: JsBuild,
|
|
404
|
-
file: str,
|
|
405
|
-
fn: dict[str, Any] | None,
|
|
406
|
-
seeds: list[Seed],
|
|
407
|
-
owners: list[dict[str, Any]],
|
|
408
|
-
depth: int,
|
|
409
|
-
seen: set[str],
|
|
410
|
-
) -> None:
|
|
411
|
-
client = self.layers["client"]
|
|
412
|
-
bindings = fn.get("bindings", {}) if fn else {}
|
|
413
|
-
for seed in seeds:
|
|
414
|
-
if depth > DEPTH[seed.role]:
|
|
415
|
-
continue
|
|
416
|
-
root = seed.name.split(".")[0].split("[")[0].split("(")[0]
|
|
417
|
-
key = f"{file}#{fn['id'] if fn else ''}:{root}:{seed.kind}:{seed.role}"
|
|
418
|
-
if not root or key in seen:
|
|
419
|
-
continue
|
|
420
|
-
seen.add(key)
|
|
421
|
-
if root in bindings:
|
|
422
|
-
b = bindings[root]
|
|
423
|
-
called = any(c["callee"].split(".")[0].split("(")[0] == root for c in (fn or {}).get("calls", []))
|
|
424
|
-
value_read = (
|
|
425
|
-
seed.kind == "ref"
|
|
426
|
-
and b["kind"] not in ("function", "callback", "setter")
|
|
427
|
-
and not (b["kind"] == "prop" and called)
|
|
428
|
-
)
|
|
429
|
-
method_on_value = seed.kind == "call" and "." in seed.name and b["kind"] in ("state", "reducer", "memo")
|
|
430
|
-
if seed.role == "action" and depth > 0 and (value_read or method_on_value):
|
|
431
|
-
seed = Seed(seed.name, seed.how, seed.kind, "reads") # a value the action reads, like title.trim()
|
|
432
|
-
self._binding(build, file, fn, root, b, seed, owners, depth, seen)
|
|
433
|
-
continue
|
|
434
|
-
target, confidence = build.resolve_callee(file, fn, seed.name if seed.kind == "call" else root)
|
|
435
|
-
if target is None and seed.kind == "ref":
|
|
436
|
-
target = build.resolve_name(file, root)
|
|
437
|
-
node = self.g.nodes.get(target or "")
|
|
438
|
-
if node is None or node.kind not in ("component", "function", "hook", "method"):
|
|
439
|
-
continue
|
|
440
|
-
client.add(
|
|
441
|
-
Row(
|
|
442
|
-
_fn_label(node),
|
|
443
|
-
node.kind,
|
|
444
|
-
seed.how,
|
|
445
|
-
node.file,
|
|
446
|
-
node.line,
|
|
447
|
-
node.end,
|
|
448
|
-
node.id,
|
|
449
|
-
confidence,
|
|
450
|
-
depth=depth,
|
|
451
|
-
role=seed.role,
|
|
452
|
-
)
|
|
453
|
-
)
|
|
454
|
-
self._walk_client(node, depth + 1, seed.role)
|
|
455
|
-
|
|
456
|
-
def _binding(
|
|
457
|
-
self,
|
|
458
|
-
build: JsBuild,
|
|
459
|
-
file: str,
|
|
460
|
-
fn: dict[str, Any] | None,
|
|
461
|
-
root: str,
|
|
462
|
-
b: dict[str, Any],
|
|
463
|
-
seed: Seed,
|
|
464
|
-
owners: list[dict[str, Any]],
|
|
465
|
-
depth: int,
|
|
466
|
-
seen: set[str],
|
|
467
|
-
) -> None:
|
|
468
|
-
label = f"{root}()" if b["kind"] in ("function", "callback") else root
|
|
469
|
-
word = BINDING_WORDS.get(b["kind"], b["kind"])
|
|
470
|
-
detail = f" from {b['init']}()" if b.get("init") and b["kind"] in ("state", "setter", "hook", "memo") else ""
|
|
471
|
-
self.layers["client"].add(
|
|
472
|
-
Row(label, "binding", f"{word}{detail}; {seed.how}", file, b["line"], depth=depth, role=seed.role)
|
|
473
|
-
)
|
|
474
|
-
if b["kind"] == "prop" and depth < 3:
|
|
475
|
-
self._prop(build, file, fn, root, b, owners, depth, seed.role)
|
|
476
|
-
inner = [Seed(b["init"], f"creates {root}", "call", seed.role)] if b.get("init") else []
|
|
477
|
-
inner += [Seed(c, f"used by {root}", "call", seed.role) for c in b.get("calls", [])]
|
|
478
|
-
inner += [Seed(r, f"used by {root}", "ref", seed.role) for r in b.get("refs", []) if r.split(".")[0] != root]
|
|
479
|
-
self._follow(build, file, fn, inner, owners, depth + 1, seen)
|
|
480
|
-
for call in (fn or {}).get("calls", []): # `calls[kind](...)`: follow the member the click chose
|
|
481
|
-
callee = call["callee"]
|
|
482
|
-
if "[]" in callee and call.get("in") == root:
|
|
483
|
-
self._computed_member(build, file, fn, callee.split("[")[0], depth + 1, seed.role)
|
|
484
|
-
|
|
485
|
-
def _computed_member(
|
|
486
|
-
self, build: JsBuild, file: str, fn: dict[str, Any] | None, base: str, depth: int, role: str
|
|
487
|
-
) -> None:
|
|
488
|
-
"""`calls[kind](...)`: when the element passed a string such as 'complete', follow that member."""
|
|
489
|
-
b = (fn or {}).get("bindings", {}).get(base)
|
|
490
|
-
if not b:
|
|
491
|
-
return
|
|
492
|
-
for inner in b.get("calls", []) + ([b["init"]] if b.get("init") else []):
|
|
493
|
-
owner, _ = build.resolve_callee(file, None, inner)
|
|
494
|
-
if not owner:
|
|
495
|
-
continue
|
|
496
|
-
for s in dict.fromkeys(self.strings):
|
|
497
|
-
target = build.member(owner, s)
|
|
498
|
-
node = self.g.nodes.get(target or "")
|
|
499
|
-
if node is not None:
|
|
500
|
-
self.layers["client"].add(
|
|
501
|
-
Row(
|
|
502
|
-
_fn_label(node),
|
|
503
|
-
node.kind,
|
|
504
|
-
f"chosen by '{s}'",
|
|
505
|
-
node.file,
|
|
506
|
-
node.line,
|
|
507
|
-
node.end,
|
|
508
|
-
node.id,
|
|
509
|
-
"inferred",
|
|
510
|
-
depth=depth,
|
|
511
|
-
role=role,
|
|
512
|
-
)
|
|
513
|
-
)
|
|
514
|
-
self._walk_client(node, depth + 1, role)
|
|
515
|
-
|
|
516
|
-
def _prop(
|
|
517
|
-
self,
|
|
518
|
-
build: JsBuild,
|
|
519
|
-
file: str,
|
|
520
|
-
fn: dict[str, Any] | None,
|
|
521
|
-
local: str,
|
|
522
|
-
b: dict[str, Any],
|
|
523
|
-
owners: list[dict[str, Any]],
|
|
524
|
-
depth: int,
|
|
525
|
-
role: str,
|
|
526
|
-
) -> None:
|
|
527
|
-
"""Follow a prop to the JSX that passed it, in the parent that rendered this component."""
|
|
528
|
-
if fn is None:
|
|
529
|
-
return
|
|
530
|
-
prop = b.get("prop") if b.get("prop") not in (None, "*", "...") else local
|
|
531
|
-
sites: list[dict[str, Any]] = []
|
|
532
|
-
for owner in owners:
|
|
533
|
-
if owner.get("name") == fn["name"] and owner.get("loc"):
|
|
534
|
-
found = self.s.element(owner["loc"])
|
|
535
|
-
if found:
|
|
536
|
-
sites.append(found[1])
|
|
537
|
-
break
|
|
538
|
-
if not sites: # no live selection: every place that renders this component
|
|
539
|
-
node_id = f"js:{build.source.name}:{file}#{fn['id']}"
|
|
540
|
-
for edge in self.g.into(node_id, "renders")[:3]:
|
|
541
|
-
if edge.site:
|
|
542
|
-
sites += [
|
|
543
|
-
el
|
|
544
|
-
for el in build.elements.values()
|
|
545
|
-
if el["file"] == edge.site.file
|
|
546
|
-
and el["line"] == edge.site.line
|
|
547
|
-
and el["tag"].split(".")[-1] == fn["name"]
|
|
548
|
-
][:1]
|
|
549
|
-
for parent_el in sites:
|
|
550
|
-
attr = next((a for a in parent_el["attrs"] if a["name"] == prop), None)
|
|
551
|
-
if attr is None:
|
|
552
|
-
continue
|
|
553
|
-
parent_fn = build.fn_index.get(parent_el["file"], {}).get(parent_el.get("fn") or "")
|
|
554
|
-
summary = attr.get("literal") or ", ".join([*attr.get("calls", []), *attr.get("refs", [])][:4]) or "…"
|
|
555
|
-
self.layers["client"].add(
|
|
556
|
-
Row(
|
|
557
|
-
f"{prop}={{…}}",
|
|
558
|
-
"prop",
|
|
559
|
-
f"passed by {parent_el.get('fn', 'parent')}: {summary}",
|
|
560
|
-
parent_el["file"],
|
|
561
|
-
parent_el["line"],
|
|
562
|
-
depth=depth + 1,
|
|
563
|
-
role=role,
|
|
564
|
-
loc=f"{parent_el['file']}:{parent_el['line']}:{parent_el['col']}",
|
|
565
|
-
)
|
|
566
|
-
)
|
|
567
|
-
self.strings += attr.get("strings", [])
|
|
568
|
-
seeds = [Seed(c, f"in {prop} from the parent", "call", role) for c in attr.get("calls", [])]
|
|
569
|
-
seeds += [Seed(r, f"in {prop} from the parent", "ref", role) for r in attr.get("refs", [])]
|
|
570
|
-
self._follow(build, parent_el["file"], parent_fn, seeds, owners, depth + 2, set())
|
|
571
|
-
|
|
572
|
-
def _walk_client(self, start: Node, depth: int, role: str) -> None:
|
|
573
|
-
"""Frontend call graph below a function, breadth first, remembering functions that send requests."""
|
|
574
|
-
if start.id in self.reached and (self.reached[start.id] == "action" or role == "reads"):
|
|
575
|
-
return
|
|
576
|
-
self.reached[start.id] = role
|
|
577
|
-
limit = DEPTH[role] + 2
|
|
578
|
-
queue = deque([(start, depth)])
|
|
579
|
-
while queue:
|
|
580
|
-
node, d = queue.popleft()
|
|
581
|
-
if d > limit:
|
|
582
|
-
continue
|
|
583
|
-
for edge in self.g.out(node.id, "calls"):
|
|
584
|
-
target = self.g.nodes[edge.dst]
|
|
585
|
-
if target.lang != "js" or target.kind == "component":
|
|
586
|
-
continue
|
|
587
|
-
if target.id in self.reached and (self.reached[target.id] == "action" or role == "reads"):
|
|
588
|
-
continue
|
|
589
|
-
self.reached[target.id] = role
|
|
590
|
-
self.layers["client"].add(
|
|
591
|
-
Row(
|
|
592
|
-
_fn_label(target),
|
|
593
|
-
target.kind,
|
|
594
|
-
f"called by {node.name}",
|
|
595
|
-
target.file,
|
|
596
|
-
target.line,
|
|
597
|
-
target.end,
|
|
598
|
-
target.id,
|
|
599
|
-
edge.confidence,
|
|
600
|
-
depth=min(d, 6),
|
|
601
|
-
role=role,
|
|
602
|
-
)
|
|
603
|
-
)
|
|
604
|
-
queue.append((target, d + 1))
|
|
605
|
-
|
|
606
|
-
# ------------------------------------------------------------------ network, server, data
|
|
607
|
-
|
|
608
|
-
def _network_and_server(self) -> None:
|
|
609
|
-
network = self.layers["network"]
|
|
610
|
-
endpoints: list[tuple[Node, str, str]] = []
|
|
611
|
-
for fn_id, role in self.reached.items():
|
|
612
|
-
node = self.g.nodes[fn_id]
|
|
613
|
-
when = "on use" if role == "action" else "loads data it reads"
|
|
614
|
-
for edge in self.g.out(fn_id, "http"):
|
|
615
|
-
ep = self.g.nodes[edge.dst]
|
|
616
|
-
site = edge.site
|
|
617
|
-
network.add(
|
|
618
|
-
Row(
|
|
619
|
-
edge.label or ep.name,
|
|
620
|
-
"request",
|
|
621
|
-
f"{when}; sent by {node.qualname or node.name}",
|
|
622
|
-
site.file if site else node.file,
|
|
623
|
-
site.line if site else node.line,
|
|
624
|
-
confidence=edge.confidence,
|
|
625
|
-
role=role,
|
|
626
|
-
)
|
|
627
|
-
)
|
|
628
|
-
endpoints.append((ep, edge.confidence, role))
|
|
629
|
-
for req in node.attrs.get("requests", []):
|
|
630
|
-
if not req.get("matched"):
|
|
631
|
-
network.add(
|
|
632
|
-
Row(
|
|
633
|
-
f"{req['method']} {req['url']}",
|
|
634
|
-
"request",
|
|
635
|
-
f"{when}; sent by {node.name}; no backend endpoint matches",
|
|
636
|
-
node.file,
|
|
637
|
-
req["line"],
|
|
638
|
-
confidence="heuristic",
|
|
639
|
-
role=role,
|
|
640
|
-
)
|
|
641
|
-
)
|
|
642
|
-
endpoints.sort(key=lambda e: e[2] != "action")
|
|
643
|
-
done: set[str] = set()
|
|
644
|
-
for ep, confidence, role in endpoints:
|
|
645
|
-
if ep.id not in done:
|
|
646
|
-
done.add(ep.id)
|
|
647
|
-
self._server_from_endpoint(ep, confidence, role)
|
|
648
|
-
self._data()
|
|
649
|
-
|
|
650
|
-
def _server_from_endpoint(self, ep: Node, confidence: str, role: str) -> None:
|
|
651
|
-
server = self.layers["server"]
|
|
652
|
-
note = ep.attrs.get("note", "")
|
|
653
|
-
server.add(
|
|
654
|
-
Row(
|
|
655
|
-
ep.name,
|
|
656
|
-
"endpoint",
|
|
657
|
-
f"{ep.attrs.get('framework', '')} endpoint{'; ' + note if note else ''}",
|
|
658
|
-
ep.file,
|
|
659
|
-
ep.line,
|
|
660
|
-
node=ep.id,
|
|
661
|
-
confidence=confidence,
|
|
662
|
-
role=role,
|
|
663
|
-
)
|
|
664
|
-
)
|
|
665
|
-
count = 0
|
|
666
|
-
for handler in (self.g.nodes[e.dst] for e in self.g.out(ep.id, "routes")):
|
|
667
|
-
server.add(
|
|
668
|
-
Row(
|
|
669
|
-
_fn_label(handler),
|
|
670
|
-
handler.kind,
|
|
671
|
-
f"handles {ep.name}",
|
|
672
|
-
handler.file,
|
|
673
|
-
handler.line,
|
|
674
|
-
handler.end,
|
|
675
|
-
handler.id,
|
|
676
|
-
depth=1,
|
|
677
|
-
role=role,
|
|
678
|
-
)
|
|
679
|
-
)
|
|
680
|
-
self._mark_server(handler.id, role)
|
|
681
|
-
queue = deque([(handler, 2)])
|
|
682
|
-
visited = {handler.id}
|
|
683
|
-
while queue and count < SERVER_ROWS[role]:
|
|
684
|
-
node, depth = queue.popleft()
|
|
685
|
-
if depth > SERVER_DEPTH[role] + 1:
|
|
686
|
-
continue
|
|
687
|
-
for edge in sorted(self.g.out(node.id, "calls", "uses"), key=lambda e: e.site.line if e.site else 0):
|
|
688
|
-
target = self.g.nodes[edge.dst]
|
|
689
|
-
if (
|
|
690
|
-
target.id in visited
|
|
691
|
-
or "test" in target.tags
|
|
692
|
-
or target.lang != "python"
|
|
693
|
-
or target.kind not in ("function", "method", "class")
|
|
694
|
-
):
|
|
695
|
-
continue
|
|
696
|
-
visited.add(target.id)
|
|
697
|
-
how = _how(edge, node)
|
|
698
|
-
server.add(
|
|
699
|
-
Row(
|
|
700
|
-
_fn_label(target),
|
|
701
|
-
target.kind,
|
|
702
|
-
how,
|
|
703
|
-
target.file,
|
|
704
|
-
target.line,
|
|
705
|
-
target.end,
|
|
706
|
-
target.id,
|
|
707
|
-
edge.confidence,
|
|
708
|
-
depth=min(depth, 6),
|
|
709
|
-
role=role,
|
|
710
|
-
)
|
|
711
|
-
)
|
|
712
|
-
self._mark_server(target.id, role)
|
|
713
|
-
count += 1
|
|
714
|
-
if count >= SERVER_ROWS[role]:
|
|
715
|
-
break
|
|
716
|
-
if edge.label != "raises":
|
|
717
|
-
queue.append((target, depth + 1))
|
|
718
|
-
|
|
719
|
-
def _mark_server(self, node_id: str, role: str) -> None:
|
|
720
|
-
if self.server_nodes.get(node_id) != "action":
|
|
721
|
-
self.server_nodes[node_id] = role
|
|
722
|
-
|
|
723
|
-
def _data(self) -> None:
|
|
724
|
-
touches: dict[str, dict[str, Any]] = {}
|
|
725
|
-
for node_id, role in self.server_nodes.items():
|
|
726
|
-
for edge in self.g.out(node_id, "reads", "writes"):
|
|
727
|
-
entry = touches.setdefault(edge.dst, {"reads": [], "writes": [], "conf": "exact", "role": "reads"})
|
|
728
|
-
name = self.g.nodes[node_id].name
|
|
729
|
-
if name not in entry[edge.kind]:
|
|
730
|
-
entry[edge.kind].append(name)
|
|
731
|
-
if CONFIDENCE_RANK[edge.confidence] > CONFIDENCE_RANK[entry["conf"]]:
|
|
732
|
-
entry["conf"] = edge.confidence
|
|
733
|
-
if role == "action":
|
|
734
|
-
entry["role"] = "action"
|
|
735
|
-
for target_id, entry in sorted(
|
|
736
|
-
touches.items(), key=lambda kv: (kv[1]["role"] != "action", not kv[1]["writes"])
|
|
737
|
-
):
|
|
738
|
-
target = self.g.nodes[target_id]
|
|
739
|
-
parts = []
|
|
740
|
-
if entry["writes"]:
|
|
741
|
-
parts.append("written by " + ", ".join(entry["writes"][:4]))
|
|
742
|
-
if entry["reads"]:
|
|
743
|
-
parts.append("read by " + ", ".join(entry["reads"][:4]))
|
|
744
|
-
table = target.attrs.get("table") or target.attrs.get("collection")
|
|
745
|
-
label = f"{target.name} ({table})" if table else target.name
|
|
746
|
-
self.layers["data"].add(
|
|
747
|
-
Row(
|
|
748
|
-
label,
|
|
749
|
-
target.kind,
|
|
750
|
-
"; ".join(parts),
|
|
751
|
-
target.file,
|
|
752
|
-
target.line,
|
|
753
|
-
target.end,
|
|
754
|
-
target.id,
|
|
755
|
-
entry["conf"],
|
|
756
|
-
role=entry["role"],
|
|
757
|
-
)
|
|
758
|
-
)
|
|
759
|
-
|
|
760
|
-
# ------------------------------------------------------------------ concepts
|
|
761
|
-
|
|
762
|
-
def _concepts(self, attrs: list[dict[str, Any]]) -> None:
|
|
763
|
-
concepts = self.project.config.concepts
|
|
764
|
-
if not concepts or not attrs:
|
|
765
|
-
return
|
|
766
|
-
root = self.project.config.root
|
|
767
|
-
for concept in concepts:
|
|
768
|
-
value = next((a.get("value") for a in attrs if a.get("name") == concept.attribute and a.get("value")), None)
|
|
769
|
-
if not value:
|
|
770
|
-
continue
|
|
771
|
-
layer = Layer(
|
|
772
|
-
f"concept:{concept.attribute}", concept.label.replace("{value}", value), f"from {concept.attribute}"
|
|
773
|
-
)
|
|
774
|
-
for find in concept.find:
|
|
775
|
-
pattern = find.pattern.replace("{value}", value)
|
|
776
|
-
for line_no, text in _lines(root / find.file):
|
|
777
|
-
if pattern in text:
|
|
778
|
-
layer.add(Row(text.strip()[:90], "definition", f"defined in {find.file}", find.file, line_no))
|
|
779
|
-
break
|
|
780
|
-
if concept.mentions:
|
|
781
|
-
word = re.compile(rf"(?<![\w]){re.escape(value)}(?![\w])")
|
|
782
|
-
count = 0
|
|
783
|
-
for files in self.s.facts.values():
|
|
784
|
-
for file in files:
|
|
785
|
-
for line_no, text in _lines(root / file):
|
|
786
|
-
if word.search(text) and count < MAX_MENTIONS:
|
|
787
|
-
layer.add(Row(text.strip()[:90], "mention", "mentions it", file, line_no))
|
|
788
|
-
count += 1
|
|
789
|
-
if layer.rows:
|
|
790
|
-
self.extra.append(layer)
|
|
791
|
-
|
|
792
|
-
# ------------------------------------------------------------------ code locations
|
|
793
|
-
|
|
794
|
-
def _node(self, node: Node) -> None:
|
|
795
|
-
"""Lineage of a definition: who reaches it (screen, client) and what it reaches (server, data)."""
|
|
796
|
-
if node.lang == "js":
|
|
797
|
-
layer = "screen" if node.kind == "component" else "client"
|
|
798
|
-
self.layers[layer].add(
|
|
799
|
-
Row(
|
|
800
|
-
_fn_label(node),
|
|
801
|
-
node.kind,
|
|
802
|
-
"this definition",
|
|
803
|
-
node.file,
|
|
804
|
-
node.line,
|
|
805
|
-
node.end,
|
|
806
|
-
node.id,
|
|
807
|
-
role="action",
|
|
808
|
-
)
|
|
809
|
-
)
|
|
810
|
-
self._renderers(node, 0, set())
|
|
811
|
-
self._walk_client(node, 1, "action")
|
|
812
|
-
self._network_and_server()
|
|
813
|
-
return
|
|
814
|
-
self.layers["server"].add(
|
|
815
|
-
Row(_fn_label(node), node.kind, "this definition", node.file, node.line, node.end, node.id, role="action")
|
|
816
|
-
)
|
|
817
|
-
self._mark_server(node.id, "action")
|
|
818
|
-
for ep in self._endpoints_reaching(node.id):
|
|
819
|
-
self.layers["server"].add(Row(ep.name, "endpoint", "reaches this definition", ep.file, ep.line, node=ep.id))
|
|
820
|
-
for edge in self.g.into(ep.id, "http"):
|
|
821
|
-
caller = self.g.nodes[edge.src]
|
|
822
|
-
site = edge.site
|
|
823
|
-
self.layers["network"].add(
|
|
824
|
-
Row(
|
|
825
|
-
edge.label or ep.name,
|
|
826
|
-
"request",
|
|
827
|
-
f"sent by {caller.qualname or caller.name}",
|
|
828
|
-
site.file if site else caller.file,
|
|
829
|
-
site.line if site else caller.line,
|
|
830
|
-
confidence=edge.confidence,
|
|
831
|
-
)
|
|
832
|
-
)
|
|
833
|
-
self.layers["client"].add(
|
|
834
|
-
Row(
|
|
835
|
-
_fn_label(caller),
|
|
836
|
-
caller.kind,
|
|
837
|
-
f"calls {ep.name}",
|
|
838
|
-
caller.file,
|
|
839
|
-
caller.line,
|
|
840
|
-
caller.end,
|
|
841
|
-
caller.id,
|
|
842
|
-
)
|
|
843
|
-
)
|
|
844
|
-
self._renderers(caller, 0, set())
|
|
845
|
-
queue = deque([(node, 1)])
|
|
846
|
-
visited = {node.id}
|
|
847
|
-
while queue:
|
|
848
|
-
current, depth = queue.popleft()
|
|
849
|
-
if depth > SERVER_DEPTH["action"]:
|
|
850
|
-
continue
|
|
851
|
-
for edge in self.g.out(current.id, "calls", "uses"):
|
|
852
|
-
target = self.g.nodes[edge.dst]
|
|
853
|
-
if target.id in visited or target.lang != "python" or "test" in target.tags:
|
|
854
|
-
continue
|
|
855
|
-
visited.add(target.id)
|
|
856
|
-
self.layers["server"].add(
|
|
857
|
-
Row(
|
|
858
|
-
_fn_label(target),
|
|
859
|
-
target.kind,
|
|
860
|
-
_how(edge, current),
|
|
861
|
-
target.file,
|
|
862
|
-
target.line,
|
|
863
|
-
target.end,
|
|
864
|
-
target.id,
|
|
865
|
-
edge.confidence,
|
|
866
|
-
depth=min(depth, 6),
|
|
867
|
-
role="action",
|
|
868
|
-
)
|
|
869
|
-
)
|
|
870
|
-
self._mark_server(target.id, "action")
|
|
871
|
-
if edge.label != "raises":
|
|
872
|
-
queue.append((target, depth + 1))
|
|
873
|
-
self._data()
|
|
874
|
-
|
|
875
|
-
def _endpoints_reaching(self, node_id: str) -> list[Node]:
|
|
876
|
-
out: list[Node] = []
|
|
877
|
-
queue = deque([node_id])
|
|
878
|
-
seen = {node_id}
|
|
879
|
-
while queue:
|
|
880
|
-
current = queue.popleft()
|
|
881
|
-
for edge in self.g.into(current, "routes"):
|
|
882
|
-
ep = self.g.nodes[edge.src]
|
|
883
|
-
if ep.kind == "endpoint" and ep not in out:
|
|
884
|
-
out.append(ep)
|
|
885
|
-
for edge in self.g.into(current, "calls", "uses"):
|
|
886
|
-
if edge.src not in seen and len(seen) < 400:
|
|
887
|
-
seen.add(edge.src)
|
|
888
|
-
queue.append(edge.src)
|
|
889
|
-
return out
|
|
890
|
-
|
|
891
|
-
def _renderers(self, node: Node, depth: int, seen: set[str]) -> None:
|
|
892
|
-
"""Components and functions that reach this one, up to the pages that route to them."""
|
|
893
|
-
if depth > 5 or node.id in seen:
|
|
894
|
-
return
|
|
895
|
-
seen.add(node.id)
|
|
896
|
-
for edge in self.g.into(node.id, "renders", "calls", "routes"):
|
|
897
|
-
src = self.g.nodes[edge.src]
|
|
898
|
-
if src.kind == "page":
|
|
899
|
-
self.layers["screen"].add(
|
|
900
|
-
Row(
|
|
901
|
-
src.attrs.get("path", src.name),
|
|
902
|
-
"page",
|
|
903
|
-
f"route for {node.name}",
|
|
904
|
-
src.file,
|
|
905
|
-
src.line,
|
|
906
|
-
node=src.id,
|
|
907
|
-
)
|
|
908
|
-
)
|
|
909
|
-
elif src.kind == "component":
|
|
910
|
-
verb = "renders" if edge.kind == "renders" else "calls"
|
|
911
|
-
line = edge.site.line if edge.site else src.line
|
|
912
|
-
self.layers["screen"].add(
|
|
913
|
-
Row(
|
|
914
|
-
_fn_label(src),
|
|
915
|
-
"component",
|
|
916
|
-
f"{verb} {node.name} at line {line}",
|
|
917
|
-
src.file,
|
|
918
|
-
src.line,
|
|
919
|
-
src.end,
|
|
920
|
-
src.id,
|
|
921
|
-
depth=depth,
|
|
922
|
-
)
|
|
923
|
-
)
|
|
924
|
-
self._renderers(src, depth + 1, seen)
|
|
925
|
-
elif src.kind in ("hook", "function", "method") and src.lang == "js":
|
|
926
|
-
self.layers["client"].add(
|
|
927
|
-
Row(
|
|
928
|
-
_fn_label(src), src.kind, f"calls {node.name}", src.file, src.line, src.end, src.id, depth=depth
|
|
929
|
-
)
|
|
930
|
-
)
|
|
931
|
-
self._renderers(src, depth + 1, seen)
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
USES_WORDS = {
|
|
935
|
-
"Depends": "injected into {} (Depends)",
|
|
936
|
-
"Security": "injected into {} (Security)",
|
|
937
|
-
"response": "response model of {}",
|
|
938
|
-
"type": "parameter type of {}",
|
|
939
|
-
"returns": "return type of {}",
|
|
940
|
-
"decorator": "decorates {}",
|
|
941
|
-
"passes": "passed as a callback by {}",
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
def _how(edge: Any, caller: Node) -> str:
|
|
946
|
-
if edge.label == "raises":
|
|
947
|
-
return f"raised by {caller.name}"
|
|
948
|
-
if edge.kind == "uses":
|
|
949
|
-
return USES_WORDS.get(edge.label, "used by {}").format(caller.name)
|
|
950
|
-
return f"called by {caller.name}"
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
def _lines(path: Path) -> list[tuple[int, str]]:
|
|
954
|
-
try:
|
|
955
|
-
return list(enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1))
|
|
956
|
-
except OSError:
|
|
957
|
-
return []
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
def for_selection(project: Project, snapshot: Snapshot, selection: dict[str, Any]) -> dict[str, Any]:
|
|
961
|
-
return Lineage(project, snapshot).selection(selection)
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
def for_location(project: Project, snapshot: Snapshot, loc: str) -> dict[str, Any]:
|
|
965
|
-
return Lineage(project, snapshot).location(loc)
|
|
1
|
+
"""Lineage: from a clicked element, or any code location, to every definition it reaches (design §10).
|
|
2
|
+
|
|
3
|
+
A selection arrives from the browser:
|
|
4
|
+
|
|
5
|
+
{"url": "/orders/7",
|
|
6
|
+
"target": {"loc": "web/src/Order.tsx:41:9", "tag": "button", "text": "Pay"},
|
|
7
|
+
"chain": [{"loc": ..., "tag": ...}, ...], # stamped DOM ancestors, nearest first, target included
|
|
8
|
+
"owners": [{"name": "OrderPage", "loc": "web/src/routes.tsx:12:20"}, ...], # components, nearest first
|
|
9
|
+
"attrs": [{"name": "data-i18n", "value": "x", "loc": ...}]}
|
|
10
|
+
|
|
11
|
+
The answer is a list of layers (screen, client, network, server, data, plus concept and adapter layers), each a
|
|
12
|
+
list of rows. Every row names a real definition with file and line, or says plainly that it is outside the code.
|
|
13
|
+
|
|
14
|
+
Rows carry a role. `action` rows are what the element does when used: its event handlers and everything they call,
|
|
15
|
+
followed deeply to the database. `reads` rows are the data it shows or an action reads (state, props, conditions,
|
|
16
|
+
the requests that load them), followed shallowly so they inform without drowning the action.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from collections import deque
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from stackthread.model import CONFIDENCE_RANK, Graph, Node
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from stackthread.lang.js.build import JsBuild
|
|
32
|
+
from stackthread.project import Project, Snapshot
|
|
33
|
+
|
|
34
|
+
DEPTH = {"action": 4, "reads": 2}
|
|
35
|
+
SERVER_DEPTH = {"action": 6, "reads": 1}
|
|
36
|
+
SERVER_ROWS = {"action": 60, "reads": 8}
|
|
37
|
+
MAX_MENTIONS = 40
|
|
38
|
+
SKIP_ATTRS = frozenset({"key", "className", "class", "style", "id", "htmlFor", "role", "type", "ref", "tabIndex"})
|
|
39
|
+
|
|
40
|
+
LAYER_TITLES = {
|
|
41
|
+
"screen": ("On screen", "what drew the thing you clicked"),
|
|
42
|
+
"client": ("In the browser", "handlers, state, props and helpers"),
|
|
43
|
+
"network": ("Over the wire", "requests"),
|
|
44
|
+
"server": ("On the server", "endpoint, then the functions it calls"),
|
|
45
|
+
"data": ("Data", "models and stores read or written"),
|
|
46
|
+
}
|
|
47
|
+
BINDING_WORDS = {
|
|
48
|
+
"prop": "prop",
|
|
49
|
+
"state": "state",
|
|
50
|
+
"setter": "state setter",
|
|
51
|
+
"memo": "computed value",
|
|
52
|
+
"callback": "callback",
|
|
53
|
+
"function": "local function",
|
|
54
|
+
"hook": "hook result",
|
|
55
|
+
"ref": "ref",
|
|
56
|
+
"context": "context value",
|
|
57
|
+
"reducer": "reducer state",
|
|
58
|
+
"local": "local value",
|
|
59
|
+
"param": "parameter",
|
|
60
|
+
"lazy": "lazy component",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Row:
|
|
66
|
+
label: str
|
|
67
|
+
kind: str
|
|
68
|
+
reason: str = ""
|
|
69
|
+
file: str = ""
|
|
70
|
+
line: int = 0
|
|
71
|
+
end: int = 0
|
|
72
|
+
node: str = ""
|
|
73
|
+
confidence: str = "exact"
|
|
74
|
+
depth: int = 0
|
|
75
|
+
external: bool = False
|
|
76
|
+
loc: str = "" # a stamp location the UI can highlight in the page
|
|
77
|
+
role: str = "" # action | reads
|
|
78
|
+
|
|
79
|
+
def to_json(self) -> dict[str, Any]:
|
|
80
|
+
data = {
|
|
81
|
+
"label": self.label,
|
|
82
|
+
"kind": self.kind,
|
|
83
|
+
"reason": self.reason,
|
|
84
|
+
"file": self.file,
|
|
85
|
+
"line": self.line,
|
|
86
|
+
"end": self.end,
|
|
87
|
+
"node": self.node,
|
|
88
|
+
"confidence": self.confidence,
|
|
89
|
+
"depth": self.depth,
|
|
90
|
+
"external": self.external,
|
|
91
|
+
"loc": self.loc,
|
|
92
|
+
"role": self.role,
|
|
93
|
+
}
|
|
94
|
+
return {k: v for k, v in data.items() if v not in ("", 0, False) or k in ("label", "kind")}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Layer:
|
|
99
|
+
id: str
|
|
100
|
+
title: str
|
|
101
|
+
note: str
|
|
102
|
+
rows: list[Row] = field(default_factory=list)
|
|
103
|
+
_seen: set[str] = field(default_factory=set)
|
|
104
|
+
|
|
105
|
+
def add(self, row: Row) -> bool:
|
|
106
|
+
key = row.node or f"{row.kind}:{row.file}:{row.line}:{row.label}"
|
|
107
|
+
if key in self._seen:
|
|
108
|
+
return False
|
|
109
|
+
self._seen.add(key)
|
|
110
|
+
self.rows.append(row)
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
def ordered(self) -> list[Row]:
|
|
114
|
+
"""Action rows first, keeping the order they were found in; the rest after."""
|
|
115
|
+
return [r for r in self.rows if r.role != "reads"] + [r for r in self.rows if r.role == "reads"]
|
|
116
|
+
|
|
117
|
+
def to_json(self) -> dict[str, Any]:
|
|
118
|
+
return {"id": self.id, "title": self.title, "note": self.note, "rows": [r.to_json() for r in self.ordered()]}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Seed:
|
|
123
|
+
name: str # a reference (`cell.can_act`) or a callee (`calls.save`)
|
|
124
|
+
how: str
|
|
125
|
+
kind: str # ref | call
|
|
126
|
+
role: str # action | reads
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _loc(loc: str) -> tuple[str, int, int]:
|
|
130
|
+
m = re.match(r"^(.*?):(\d+)(?::(\d+))?$", loc or "")
|
|
131
|
+
if not m:
|
|
132
|
+
return loc or "", 0, 0
|
|
133
|
+
return m.group(1), int(m.group(2)), int(m.group(3) or 0)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _fn_label(node: Node) -> str:
|
|
137
|
+
if node.kind in ("component", "function", "method", "hook"):
|
|
138
|
+
return f"{node.qualname or node.name}()" if node.lang == "js" else f"{node.name}()"
|
|
139
|
+
return node.name
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def page_for(graph: Graph, url: str) -> Node | None:
|
|
143
|
+
"""The page whose route pattern matches a URL path, preferring the most literal match."""
|
|
144
|
+
path = (url or "/").split("?")[0].split("#")[0].rstrip("/") or "/"
|
|
145
|
+
parts = [p for p in path.split("/") if p]
|
|
146
|
+
best: tuple[int, Node] | None = None
|
|
147
|
+
for page in graph.of_kind("page"):
|
|
148
|
+
pattern = [p for p in page.attrs.get("path", "").split("/") if p]
|
|
149
|
+
compare = parts
|
|
150
|
+
if pattern and pattern[-1] in ("*", "$"):
|
|
151
|
+
pattern = pattern[:-1]
|
|
152
|
+
if len(parts) < len(pattern):
|
|
153
|
+
continue
|
|
154
|
+
compare = parts[: len(pattern)]
|
|
155
|
+
elif len(pattern) != len(parts):
|
|
156
|
+
continue
|
|
157
|
+
score, ok = 0, True
|
|
158
|
+
for want, got in zip(pattern, compare, strict=True):
|
|
159
|
+
if want == got:
|
|
160
|
+
score += 2
|
|
161
|
+
elif want.startswith((":", "$", "{")):
|
|
162
|
+
score += 1
|
|
163
|
+
else:
|
|
164
|
+
ok = False
|
|
165
|
+
break
|
|
166
|
+
if ok and (best is None or score > best[0]):
|
|
167
|
+
best = (score, page)
|
|
168
|
+
return best[1] if best else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Lineage:
|
|
172
|
+
def __init__(self, project: Project, snapshot: Snapshot) -> None:
|
|
173
|
+
self.project = project
|
|
174
|
+
self.s = snapshot
|
|
175
|
+
self.g = snapshot.graph
|
|
176
|
+
self.layers = {k: Layer(k, *LAYER_TITLES[k]) for k in LAYER_TITLES}
|
|
177
|
+
self.extra: list[Layer] = []
|
|
178
|
+
self.reached: dict[str, str] = {} # frontend function node -> role it was reached in
|
|
179
|
+
self.server_nodes: dict[str, str] = {} # backend node -> role
|
|
180
|
+
self.strings: list[str] = []
|
|
181
|
+
|
|
182
|
+
def _result(self, header: dict[str, Any], started: float) -> dict[str, Any]:
|
|
183
|
+
layers = [layer.to_json() for layer in [*self.layers.values(), *self.extra] if layer.rows]
|
|
184
|
+
return {
|
|
185
|
+
"target": header,
|
|
186
|
+
"layers": layers,
|
|
187
|
+
"rev": self.s.rev,
|
|
188
|
+
"ms": round((time.perf_counter() - started) * 1000, 1),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------ entry points
|
|
192
|
+
|
|
193
|
+
def selection(self, sel: dict[str, Any]) -> dict[str, Any]:
|
|
194
|
+
started = time.perf_counter()
|
|
195
|
+
target = sel.get("target") or {}
|
|
196
|
+
loc = target.get("loc", "")
|
|
197
|
+
found = self.s.element(loc)
|
|
198
|
+
owners = sel.get("owners") or []
|
|
199
|
+
chain = sel.get("chain") or []
|
|
200
|
+
header: dict[str, Any] = {"loc": loc, "tag": target.get("tag", ""), "text": target.get("text", "")}
|
|
201
|
+
if found is None:
|
|
202
|
+
file, line, _ = _loc(loc)
|
|
203
|
+
header.update(file=file, line=line, known=False)
|
|
204
|
+
self._screen_owners(owners)
|
|
205
|
+
else:
|
|
206
|
+
build, el = found
|
|
207
|
+
header.update(file=el["file"], line=el["line"], known=True, element=el["tag"], component=el.get("fn", ""))
|
|
208
|
+
self._screen(build, el, chain, owners)
|
|
209
|
+
self._client(build, el, owners, chain)
|
|
210
|
+
page = page_for(self.g, sel.get("url", ""))
|
|
211
|
+
if page is not None:
|
|
212
|
+
self._page_row(page)
|
|
213
|
+
header["page"] = page.attrs.get("path")
|
|
214
|
+
self._network_and_server()
|
|
215
|
+
self._concepts(sel.get("attrs") or [])
|
|
216
|
+
result = self._result(header, started)
|
|
217
|
+
from stackthread import adapters
|
|
218
|
+
|
|
219
|
+
adapters.lineage(self.project, sel, result["layers"])
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
def location(self, loc: str) -> dict[str, Any]:
|
|
223
|
+
"""Lineage for a code location: an element if one starts there, otherwise the definition around it."""
|
|
224
|
+
started = time.perf_counter()
|
|
225
|
+
file, line, col = _loc(loc)
|
|
226
|
+
for build in self.s.js.values():
|
|
227
|
+
for el in build.elements.values():
|
|
228
|
+
if el["file"] == file and el["line"] == line and (not col or el["col"] == col):
|
|
229
|
+
return self.selection({"target": {"loc": f"{file}:{line}:{el['col']}"}})
|
|
230
|
+
node = self.g.at(file, line)
|
|
231
|
+
if node is None:
|
|
232
|
+
return {"target": {"loc": loc, "known": False}, "layers": [], "rev": self.s.rev}
|
|
233
|
+
self._node(node)
|
|
234
|
+
header = {
|
|
235
|
+
"loc": loc,
|
|
236
|
+
"node": node.id,
|
|
237
|
+
"label": _fn_label(node),
|
|
238
|
+
"file": node.file,
|
|
239
|
+
"line": node.line,
|
|
240
|
+
"known": True,
|
|
241
|
+
}
|
|
242
|
+
return self._result(header, started)
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------ screen
|
|
245
|
+
|
|
246
|
+
def _screen(
|
|
247
|
+
self, build: JsBuild, el: dict[str, Any], chain: list[dict[str, Any]], owners: list[dict[str, Any]]
|
|
248
|
+
) -> None:
|
|
249
|
+
screen = self.layers["screen"]
|
|
250
|
+
text = f' "{el["text"]}"' if el.get("text") else ""
|
|
251
|
+
written_in = el.get("fn") or el["file"].rsplit("/", 1)[-1]
|
|
252
|
+
screen.add(
|
|
253
|
+
Row(
|
|
254
|
+
f"<{el['tag']}>{text}",
|
|
255
|
+
"element",
|
|
256
|
+
f"written in {written_in}",
|
|
257
|
+
el["file"],
|
|
258
|
+
el["line"],
|
|
259
|
+
loc=f"{el['file']}:{el['line']}:{el['col']}",
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
for link in chain[1:6]:
|
|
263
|
+
file, line, col = _loc(link.get("loc", ""))
|
|
264
|
+
if not file or file == el["file"]:
|
|
265
|
+
continue
|
|
266
|
+
found = self.s.element(f"{file}:{line}:{col}")
|
|
267
|
+
where = (found[1].get("fn") if found else None) or file.rsplit("/", 1)[-1]
|
|
268
|
+
screen.add(
|
|
269
|
+
Row(
|
|
270
|
+
f"<{link.get('tag', '?')}>",
|
|
271
|
+
"element",
|
|
272
|
+
f"placed inside {where}",
|
|
273
|
+
file,
|
|
274
|
+
line,
|
|
275
|
+
loc=f"{file}:{line}:{col}",
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
break
|
|
279
|
+
if el.get("fn"):
|
|
280
|
+
node = self.g.nodes.get(f"js:{build.source.name}:{el['file']}#{el['fn']}")
|
|
281
|
+
if node is not None:
|
|
282
|
+
screen.add(
|
|
283
|
+
Row(
|
|
284
|
+
_fn_label(node),
|
|
285
|
+
node.kind,
|
|
286
|
+
"the component it is written in",
|
|
287
|
+
node.file,
|
|
288
|
+
node.line,
|
|
289
|
+
node.end,
|
|
290
|
+
node.id,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
self._screen_owners(owners)
|
|
294
|
+
|
|
295
|
+
def _screen_owners(self, owners: list[dict[str, Any]]) -> None:
|
|
296
|
+
screen = self.layers["screen"]
|
|
297
|
+
skipped = 0
|
|
298
|
+
for owner in owners:
|
|
299
|
+
node = self._owner_node(owner)
|
|
300
|
+
if node is None:
|
|
301
|
+
skipped += 1
|
|
302
|
+
continue
|
|
303
|
+
file, line, _ = _loc(owner.get("loc", ""))
|
|
304
|
+
reason = f"rendered at {file.rsplit('/', 1)[-1]}:{line}" if file else "rendered by the app"
|
|
305
|
+
screen.add(
|
|
306
|
+
Row(
|
|
307
|
+
_fn_label(node),
|
|
308
|
+
node.kind,
|
|
309
|
+
reason,
|
|
310
|
+
node.file,
|
|
311
|
+
node.line,
|
|
312
|
+
node.end,
|
|
313
|
+
node.id,
|
|
314
|
+
loc=owner.get("loc") or "",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
if skipped:
|
|
318
|
+
screen.rows.append(
|
|
319
|
+
Row(
|
|
320
|
+
f"{skipped} library component{'s' * (skipped > 1)}",
|
|
321
|
+
"external",
|
|
322
|
+
"outside your code: router, UI kit, providers",
|
|
323
|
+
external=True,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _owner_node(self, owner: dict[str, Any]) -> Node | None:
|
|
328
|
+
"""The component node for a fiber owner: through its call-site stamp, else a unique name."""
|
|
329
|
+
loc = owner.get("loc")
|
|
330
|
+
if loc:
|
|
331
|
+
found = self.s.element(loc)
|
|
332
|
+
if found:
|
|
333
|
+
build, el = found
|
|
334
|
+
target = build.component_for(el["file"], el["tag"])
|
|
335
|
+
if target and target in self.g.nodes:
|
|
336
|
+
return self.g.nodes[target]
|
|
337
|
+
name = owner.get("name") or ""
|
|
338
|
+
matches = [n for n in self.g.of_kind("component") if n.name == name]
|
|
339
|
+
return matches[0] if len(matches) == 1 else None
|
|
340
|
+
|
|
341
|
+
def _page_row(self, page: Node) -> None:
|
|
342
|
+
comp = self.g.nodes.get(page.attrs.get("component", ""))
|
|
343
|
+
reason = f"route for {comp.name}" if comp else "route"
|
|
344
|
+
self.layers["screen"].add(
|
|
345
|
+
Row(page.attrs.get("path", page.name), "page", reason, page.file, page.line, node=page.id)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# ------------------------------------------------------------------ client
|
|
349
|
+
|
|
350
|
+
def _client(
|
|
351
|
+
self, build: JsBuild, el: dict[str, Any], owners: list[dict[str, Any]], chain: list[dict[str, Any]]
|
|
352
|
+
) -> None:
|
|
353
|
+
fn = build.fn_index.get(el["file"], {}).get(el.get("fn") or "")
|
|
354
|
+
actions: list[Seed] = []
|
|
355
|
+
reads: list[Seed] = []
|
|
356
|
+
for a in el["attrs"]:
|
|
357
|
+
if a.get("handler"):
|
|
358
|
+
self.strings += a.get("strings", [])
|
|
359
|
+
how = f"{a['name']} handler"
|
|
360
|
+
actions += [Seed(c, how, "call", "action") for c in a.get("calls", [])]
|
|
361
|
+
actions += [Seed(r, how, "ref", "action") for r in a.get("refs", [])]
|
|
362
|
+
elif a["name"] not in SKIP_ATTRS:
|
|
363
|
+
how = f"sets {a['name']}"
|
|
364
|
+
reads += [Seed(c, how, "call", "reads") for c in a.get("calls", [])]
|
|
365
|
+
reads += [Seed(r, how, "ref", "reads") for r in a.get("refs", [])]
|
|
366
|
+
if not actions and el["tag"].lower() in ("button", "input"):
|
|
367
|
+
actions += self._form_submit(chain or self._written_chain(el))
|
|
368
|
+
reads = [Seed(r, "decides whether it shows", "ref", "reads") for r in el.get("conds", [])] + reads
|
|
369
|
+
reads += [Seed(r, "shown as text", "ref", "reads") for r in el.get("refs", [])]
|
|
370
|
+
reads += [Seed(c, "shown as text", "call", "reads") for c in el.get("calls", [])]
|
|
371
|
+
if el.get("each"):
|
|
372
|
+
reads.append(Seed(el["each"], "one per item of", "ref", "reads"))
|
|
373
|
+
self._follow(build, el["file"], fn, actions + reads, owners, 0, set())
|
|
374
|
+
|
|
375
|
+
def _written_chain(self, el: dict[str, Any]) -> list[dict[str, Any]]:
|
|
376
|
+
"""Ancestors written in the same file, for a selection that arrives without the live DOM chain."""
|
|
377
|
+
out = [{"loc": f"{el['file']}:{el['line']}:{el['col']}"}]
|
|
378
|
+
parent = el.get("parent")
|
|
379
|
+
while parent and len(out) < 12:
|
|
380
|
+
loc = f"{el['file']}:{parent}"
|
|
381
|
+
found = self.s.element(loc)
|
|
382
|
+
if found is None:
|
|
383
|
+
break
|
|
384
|
+
out.append({"loc": loc})
|
|
385
|
+
parent = found[1].get("parent")
|
|
386
|
+
return out
|
|
387
|
+
|
|
388
|
+
def _form_submit(self, chain: list[dict[str, Any]]) -> list[Seed]:
|
|
389
|
+
for link in chain[1:]:
|
|
390
|
+
found = self.s.element(link.get("loc", ""))
|
|
391
|
+
if found and found[1]["tag"] in ("form", "Form"):
|
|
392
|
+
out = []
|
|
393
|
+
for a in found[1]["attrs"]:
|
|
394
|
+
if a.get("handler") and a["name"] in ("onSubmit", "action"):
|
|
395
|
+
self.strings += a.get("strings", [])
|
|
396
|
+
out += [Seed(c, "submits the form", "call", "action") for c in a.get("calls", [])]
|
|
397
|
+
out += [Seed(r, "submits the form", "ref", "action") for r in a.get("refs", [])]
|
|
398
|
+
return out
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
def _follow(
|
|
402
|
+
self,
|
|
403
|
+
build: JsBuild,
|
|
404
|
+
file: str,
|
|
405
|
+
fn: dict[str, Any] | None,
|
|
406
|
+
seeds: list[Seed],
|
|
407
|
+
owners: list[dict[str, Any]],
|
|
408
|
+
depth: int,
|
|
409
|
+
seen: set[str],
|
|
410
|
+
) -> None:
|
|
411
|
+
client = self.layers["client"]
|
|
412
|
+
bindings = fn.get("bindings", {}) if fn else {}
|
|
413
|
+
for seed in seeds:
|
|
414
|
+
if depth > DEPTH[seed.role]:
|
|
415
|
+
continue
|
|
416
|
+
root = seed.name.split(".")[0].split("[")[0].split("(")[0]
|
|
417
|
+
key = f"{file}#{fn['id'] if fn else ''}:{root}:{seed.kind}:{seed.role}"
|
|
418
|
+
if not root or key in seen:
|
|
419
|
+
continue
|
|
420
|
+
seen.add(key)
|
|
421
|
+
if root in bindings:
|
|
422
|
+
b = bindings[root]
|
|
423
|
+
called = any(c["callee"].split(".")[0].split("(")[0] == root for c in (fn or {}).get("calls", []))
|
|
424
|
+
value_read = (
|
|
425
|
+
seed.kind == "ref"
|
|
426
|
+
and b["kind"] not in ("function", "callback", "setter")
|
|
427
|
+
and not (b["kind"] == "prop" and called)
|
|
428
|
+
)
|
|
429
|
+
method_on_value = seed.kind == "call" and "." in seed.name and b["kind"] in ("state", "reducer", "memo")
|
|
430
|
+
if seed.role == "action" and depth > 0 and (value_read or method_on_value):
|
|
431
|
+
seed = Seed(seed.name, seed.how, seed.kind, "reads") # a value the action reads, like title.trim()
|
|
432
|
+
self._binding(build, file, fn, root, b, seed, owners, depth, seen)
|
|
433
|
+
continue
|
|
434
|
+
target, confidence = build.resolve_callee(file, fn, seed.name if seed.kind == "call" else root)
|
|
435
|
+
if target is None and seed.kind == "ref":
|
|
436
|
+
target = build.resolve_name(file, root)
|
|
437
|
+
node = self.g.nodes.get(target or "")
|
|
438
|
+
if node is None or node.kind not in ("component", "function", "hook", "method"):
|
|
439
|
+
continue
|
|
440
|
+
client.add(
|
|
441
|
+
Row(
|
|
442
|
+
_fn_label(node),
|
|
443
|
+
node.kind,
|
|
444
|
+
seed.how,
|
|
445
|
+
node.file,
|
|
446
|
+
node.line,
|
|
447
|
+
node.end,
|
|
448
|
+
node.id,
|
|
449
|
+
confidence,
|
|
450
|
+
depth=depth,
|
|
451
|
+
role=seed.role,
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
self._walk_client(node, depth + 1, seed.role)
|
|
455
|
+
|
|
456
|
+
def _binding(
|
|
457
|
+
self,
|
|
458
|
+
build: JsBuild,
|
|
459
|
+
file: str,
|
|
460
|
+
fn: dict[str, Any] | None,
|
|
461
|
+
root: str,
|
|
462
|
+
b: dict[str, Any],
|
|
463
|
+
seed: Seed,
|
|
464
|
+
owners: list[dict[str, Any]],
|
|
465
|
+
depth: int,
|
|
466
|
+
seen: set[str],
|
|
467
|
+
) -> None:
|
|
468
|
+
label = f"{root}()" if b["kind"] in ("function", "callback") else root
|
|
469
|
+
word = BINDING_WORDS.get(b["kind"], b["kind"])
|
|
470
|
+
detail = f" from {b['init']}()" if b.get("init") and b["kind"] in ("state", "setter", "hook", "memo") else ""
|
|
471
|
+
self.layers["client"].add(
|
|
472
|
+
Row(label, "binding", f"{word}{detail}; {seed.how}", file, b["line"], depth=depth, role=seed.role)
|
|
473
|
+
)
|
|
474
|
+
if b["kind"] == "prop" and depth < 3:
|
|
475
|
+
self._prop(build, file, fn, root, b, owners, depth, seed.role)
|
|
476
|
+
inner = [Seed(b["init"], f"creates {root}", "call", seed.role)] if b.get("init") else []
|
|
477
|
+
inner += [Seed(c, f"used by {root}", "call", seed.role) for c in b.get("calls", [])]
|
|
478
|
+
inner += [Seed(r, f"used by {root}", "ref", seed.role) for r in b.get("refs", []) if r.split(".")[0] != root]
|
|
479
|
+
self._follow(build, file, fn, inner, owners, depth + 1, seen)
|
|
480
|
+
for call in (fn or {}).get("calls", []): # `calls[kind](...)`: follow the member the click chose
|
|
481
|
+
callee = call["callee"]
|
|
482
|
+
if "[]" in callee and call.get("in") == root:
|
|
483
|
+
self._computed_member(build, file, fn, callee.split("[")[0], depth + 1, seed.role)
|
|
484
|
+
|
|
485
|
+
def _computed_member(
|
|
486
|
+
self, build: JsBuild, file: str, fn: dict[str, Any] | None, base: str, depth: int, role: str
|
|
487
|
+
) -> None:
|
|
488
|
+
"""`calls[kind](...)`: when the element passed a string such as 'complete', follow that member."""
|
|
489
|
+
b = (fn or {}).get("bindings", {}).get(base)
|
|
490
|
+
if not b:
|
|
491
|
+
return
|
|
492
|
+
for inner in b.get("calls", []) + ([b["init"]] if b.get("init") else []):
|
|
493
|
+
owner, _ = build.resolve_callee(file, None, inner)
|
|
494
|
+
if not owner:
|
|
495
|
+
continue
|
|
496
|
+
for s in dict.fromkeys(self.strings):
|
|
497
|
+
target = build.member(owner, s)
|
|
498
|
+
node = self.g.nodes.get(target or "")
|
|
499
|
+
if node is not None:
|
|
500
|
+
self.layers["client"].add(
|
|
501
|
+
Row(
|
|
502
|
+
_fn_label(node),
|
|
503
|
+
node.kind,
|
|
504
|
+
f"chosen by '{s}'",
|
|
505
|
+
node.file,
|
|
506
|
+
node.line,
|
|
507
|
+
node.end,
|
|
508
|
+
node.id,
|
|
509
|
+
"inferred",
|
|
510
|
+
depth=depth,
|
|
511
|
+
role=role,
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
self._walk_client(node, depth + 1, role)
|
|
515
|
+
|
|
516
|
+
def _prop(
|
|
517
|
+
self,
|
|
518
|
+
build: JsBuild,
|
|
519
|
+
file: str,
|
|
520
|
+
fn: dict[str, Any] | None,
|
|
521
|
+
local: str,
|
|
522
|
+
b: dict[str, Any],
|
|
523
|
+
owners: list[dict[str, Any]],
|
|
524
|
+
depth: int,
|
|
525
|
+
role: str,
|
|
526
|
+
) -> None:
|
|
527
|
+
"""Follow a prop to the JSX that passed it, in the parent that rendered this component."""
|
|
528
|
+
if fn is None:
|
|
529
|
+
return
|
|
530
|
+
prop = b.get("prop") if b.get("prop") not in (None, "*", "...") else local
|
|
531
|
+
sites: list[dict[str, Any]] = []
|
|
532
|
+
for owner in owners:
|
|
533
|
+
if owner.get("name") == fn["name"] and owner.get("loc"):
|
|
534
|
+
found = self.s.element(owner["loc"])
|
|
535
|
+
if found:
|
|
536
|
+
sites.append(found[1])
|
|
537
|
+
break
|
|
538
|
+
if not sites: # no live selection: every place that renders this component
|
|
539
|
+
node_id = f"js:{build.source.name}:{file}#{fn['id']}"
|
|
540
|
+
for edge in self.g.into(node_id, "renders")[:3]:
|
|
541
|
+
if edge.site:
|
|
542
|
+
sites += [
|
|
543
|
+
el
|
|
544
|
+
for el in build.elements.values()
|
|
545
|
+
if el["file"] == edge.site.file
|
|
546
|
+
and el["line"] == edge.site.line
|
|
547
|
+
and el["tag"].split(".")[-1] == fn["name"]
|
|
548
|
+
][:1]
|
|
549
|
+
for parent_el in sites:
|
|
550
|
+
attr = next((a for a in parent_el["attrs"] if a["name"] == prop), None)
|
|
551
|
+
if attr is None:
|
|
552
|
+
continue
|
|
553
|
+
parent_fn = build.fn_index.get(parent_el["file"], {}).get(parent_el.get("fn") or "")
|
|
554
|
+
summary = attr.get("literal") or ", ".join([*attr.get("calls", []), *attr.get("refs", [])][:4]) or "…"
|
|
555
|
+
self.layers["client"].add(
|
|
556
|
+
Row(
|
|
557
|
+
f"{prop}={{…}}",
|
|
558
|
+
"prop",
|
|
559
|
+
f"passed by {parent_el.get('fn', 'parent')}: {summary}",
|
|
560
|
+
parent_el["file"],
|
|
561
|
+
parent_el["line"],
|
|
562
|
+
depth=depth + 1,
|
|
563
|
+
role=role,
|
|
564
|
+
loc=f"{parent_el['file']}:{parent_el['line']}:{parent_el['col']}",
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
self.strings += attr.get("strings", [])
|
|
568
|
+
seeds = [Seed(c, f"in {prop} from the parent", "call", role) for c in attr.get("calls", [])]
|
|
569
|
+
seeds += [Seed(r, f"in {prop} from the parent", "ref", role) for r in attr.get("refs", [])]
|
|
570
|
+
self._follow(build, parent_el["file"], parent_fn, seeds, owners, depth + 2, set())
|
|
571
|
+
|
|
572
|
+
def _walk_client(self, start: Node, depth: int, role: str) -> None:
|
|
573
|
+
"""Frontend call graph below a function, breadth first, remembering functions that send requests."""
|
|
574
|
+
if start.id in self.reached and (self.reached[start.id] == "action" or role == "reads"):
|
|
575
|
+
return
|
|
576
|
+
self.reached[start.id] = role
|
|
577
|
+
limit = DEPTH[role] + 2
|
|
578
|
+
queue = deque([(start, depth)])
|
|
579
|
+
while queue:
|
|
580
|
+
node, d = queue.popleft()
|
|
581
|
+
if d > limit:
|
|
582
|
+
continue
|
|
583
|
+
for edge in self.g.out(node.id, "calls"):
|
|
584
|
+
target = self.g.nodes[edge.dst]
|
|
585
|
+
if target.lang != "js" or target.kind == "component":
|
|
586
|
+
continue
|
|
587
|
+
if target.id in self.reached and (self.reached[target.id] == "action" or role == "reads"):
|
|
588
|
+
continue
|
|
589
|
+
self.reached[target.id] = role
|
|
590
|
+
self.layers["client"].add(
|
|
591
|
+
Row(
|
|
592
|
+
_fn_label(target),
|
|
593
|
+
target.kind,
|
|
594
|
+
f"called by {node.name}",
|
|
595
|
+
target.file,
|
|
596
|
+
target.line,
|
|
597
|
+
target.end,
|
|
598
|
+
target.id,
|
|
599
|
+
edge.confidence,
|
|
600
|
+
depth=min(d, 6),
|
|
601
|
+
role=role,
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
queue.append((target, d + 1))
|
|
605
|
+
|
|
606
|
+
# ------------------------------------------------------------------ network, server, data
|
|
607
|
+
|
|
608
|
+
def _network_and_server(self) -> None:
|
|
609
|
+
network = self.layers["network"]
|
|
610
|
+
endpoints: list[tuple[Node, str, str]] = []
|
|
611
|
+
for fn_id, role in self.reached.items():
|
|
612
|
+
node = self.g.nodes[fn_id]
|
|
613
|
+
when = "on use" if role == "action" else "loads data it reads"
|
|
614
|
+
for edge in self.g.out(fn_id, "http"):
|
|
615
|
+
ep = self.g.nodes[edge.dst]
|
|
616
|
+
site = edge.site
|
|
617
|
+
network.add(
|
|
618
|
+
Row(
|
|
619
|
+
edge.label or ep.name,
|
|
620
|
+
"request",
|
|
621
|
+
f"{when}; sent by {node.qualname or node.name}",
|
|
622
|
+
site.file if site else node.file,
|
|
623
|
+
site.line if site else node.line,
|
|
624
|
+
confidence=edge.confidence,
|
|
625
|
+
role=role,
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
endpoints.append((ep, edge.confidence, role))
|
|
629
|
+
for req in node.attrs.get("requests", []):
|
|
630
|
+
if not req.get("matched"):
|
|
631
|
+
network.add(
|
|
632
|
+
Row(
|
|
633
|
+
f"{req['method']} {req['url']}",
|
|
634
|
+
"request",
|
|
635
|
+
f"{when}; sent by {node.name}; no backend endpoint matches",
|
|
636
|
+
node.file,
|
|
637
|
+
req["line"],
|
|
638
|
+
confidence="heuristic",
|
|
639
|
+
role=role,
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
endpoints.sort(key=lambda e: e[2] != "action")
|
|
643
|
+
done: set[str] = set()
|
|
644
|
+
for ep, confidence, role in endpoints:
|
|
645
|
+
if ep.id not in done:
|
|
646
|
+
done.add(ep.id)
|
|
647
|
+
self._server_from_endpoint(ep, confidence, role)
|
|
648
|
+
self._data()
|
|
649
|
+
|
|
650
|
+
def _server_from_endpoint(self, ep: Node, confidence: str, role: str) -> None:
|
|
651
|
+
server = self.layers["server"]
|
|
652
|
+
note = ep.attrs.get("note", "")
|
|
653
|
+
server.add(
|
|
654
|
+
Row(
|
|
655
|
+
ep.name,
|
|
656
|
+
"endpoint",
|
|
657
|
+
f"{ep.attrs.get('framework', '')} endpoint{'; ' + note if note else ''}",
|
|
658
|
+
ep.file,
|
|
659
|
+
ep.line,
|
|
660
|
+
node=ep.id,
|
|
661
|
+
confidence=confidence,
|
|
662
|
+
role=role,
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
count = 0
|
|
666
|
+
for handler in (self.g.nodes[e.dst] for e in self.g.out(ep.id, "routes")):
|
|
667
|
+
server.add(
|
|
668
|
+
Row(
|
|
669
|
+
_fn_label(handler),
|
|
670
|
+
handler.kind,
|
|
671
|
+
f"handles {ep.name}",
|
|
672
|
+
handler.file,
|
|
673
|
+
handler.line,
|
|
674
|
+
handler.end,
|
|
675
|
+
handler.id,
|
|
676
|
+
depth=1,
|
|
677
|
+
role=role,
|
|
678
|
+
)
|
|
679
|
+
)
|
|
680
|
+
self._mark_server(handler.id, role)
|
|
681
|
+
queue = deque([(handler, 2)])
|
|
682
|
+
visited = {handler.id}
|
|
683
|
+
while queue and count < SERVER_ROWS[role]:
|
|
684
|
+
node, depth = queue.popleft()
|
|
685
|
+
if depth > SERVER_DEPTH[role] + 1:
|
|
686
|
+
continue
|
|
687
|
+
for edge in sorted(self.g.out(node.id, "calls", "uses"), key=lambda e: e.site.line if e.site else 0):
|
|
688
|
+
target = self.g.nodes[edge.dst]
|
|
689
|
+
if (
|
|
690
|
+
target.id in visited
|
|
691
|
+
or "test" in target.tags
|
|
692
|
+
or target.lang != "python"
|
|
693
|
+
or target.kind not in ("function", "method", "class")
|
|
694
|
+
):
|
|
695
|
+
continue
|
|
696
|
+
visited.add(target.id)
|
|
697
|
+
how = _how(edge, node)
|
|
698
|
+
server.add(
|
|
699
|
+
Row(
|
|
700
|
+
_fn_label(target),
|
|
701
|
+
target.kind,
|
|
702
|
+
how,
|
|
703
|
+
target.file,
|
|
704
|
+
target.line,
|
|
705
|
+
target.end,
|
|
706
|
+
target.id,
|
|
707
|
+
edge.confidence,
|
|
708
|
+
depth=min(depth, 6),
|
|
709
|
+
role=role,
|
|
710
|
+
)
|
|
711
|
+
)
|
|
712
|
+
self._mark_server(target.id, role)
|
|
713
|
+
count += 1
|
|
714
|
+
if count >= SERVER_ROWS[role]:
|
|
715
|
+
break
|
|
716
|
+
if edge.label != "raises":
|
|
717
|
+
queue.append((target, depth + 1))
|
|
718
|
+
|
|
719
|
+
def _mark_server(self, node_id: str, role: str) -> None:
|
|
720
|
+
if self.server_nodes.get(node_id) != "action":
|
|
721
|
+
self.server_nodes[node_id] = role
|
|
722
|
+
|
|
723
|
+
def _data(self) -> None:
|
|
724
|
+
touches: dict[str, dict[str, Any]] = {}
|
|
725
|
+
for node_id, role in self.server_nodes.items():
|
|
726
|
+
for edge in self.g.out(node_id, "reads", "writes"):
|
|
727
|
+
entry = touches.setdefault(edge.dst, {"reads": [], "writes": [], "conf": "exact", "role": "reads"})
|
|
728
|
+
name = self.g.nodes[node_id].name
|
|
729
|
+
if name not in entry[edge.kind]:
|
|
730
|
+
entry[edge.kind].append(name)
|
|
731
|
+
if CONFIDENCE_RANK[edge.confidence] > CONFIDENCE_RANK[entry["conf"]]:
|
|
732
|
+
entry["conf"] = edge.confidence
|
|
733
|
+
if role == "action":
|
|
734
|
+
entry["role"] = "action"
|
|
735
|
+
for target_id, entry in sorted(
|
|
736
|
+
touches.items(), key=lambda kv: (kv[1]["role"] != "action", not kv[1]["writes"])
|
|
737
|
+
):
|
|
738
|
+
target = self.g.nodes[target_id]
|
|
739
|
+
parts = []
|
|
740
|
+
if entry["writes"]:
|
|
741
|
+
parts.append("written by " + ", ".join(entry["writes"][:4]))
|
|
742
|
+
if entry["reads"]:
|
|
743
|
+
parts.append("read by " + ", ".join(entry["reads"][:4]))
|
|
744
|
+
table = target.attrs.get("table") or target.attrs.get("collection")
|
|
745
|
+
label = f"{target.name} ({table})" if table else target.name
|
|
746
|
+
self.layers["data"].add(
|
|
747
|
+
Row(
|
|
748
|
+
label,
|
|
749
|
+
target.kind,
|
|
750
|
+
"; ".join(parts),
|
|
751
|
+
target.file,
|
|
752
|
+
target.line,
|
|
753
|
+
target.end,
|
|
754
|
+
target.id,
|
|
755
|
+
entry["conf"],
|
|
756
|
+
role=entry["role"],
|
|
757
|
+
)
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
# ------------------------------------------------------------------ concepts
|
|
761
|
+
|
|
762
|
+
def _concepts(self, attrs: list[dict[str, Any]]) -> None:
|
|
763
|
+
concepts = self.project.config.concepts
|
|
764
|
+
if not concepts or not attrs:
|
|
765
|
+
return
|
|
766
|
+
root = self.project.config.root
|
|
767
|
+
for concept in concepts:
|
|
768
|
+
value = next((a.get("value") for a in attrs if a.get("name") == concept.attribute and a.get("value")), None)
|
|
769
|
+
if not value:
|
|
770
|
+
continue
|
|
771
|
+
layer = Layer(
|
|
772
|
+
f"concept:{concept.attribute}", concept.label.replace("{value}", value), f"from {concept.attribute}"
|
|
773
|
+
)
|
|
774
|
+
for find in concept.find:
|
|
775
|
+
pattern = find.pattern.replace("{value}", value)
|
|
776
|
+
for line_no, text in _lines(root / find.file):
|
|
777
|
+
if pattern in text:
|
|
778
|
+
layer.add(Row(text.strip()[:90], "definition", f"defined in {find.file}", find.file, line_no))
|
|
779
|
+
break
|
|
780
|
+
if concept.mentions:
|
|
781
|
+
word = re.compile(rf"(?<![\w]){re.escape(value)}(?![\w])")
|
|
782
|
+
count = 0
|
|
783
|
+
for files in self.s.facts.values():
|
|
784
|
+
for file in files:
|
|
785
|
+
for line_no, text in _lines(root / file):
|
|
786
|
+
if word.search(text) and count < MAX_MENTIONS:
|
|
787
|
+
layer.add(Row(text.strip()[:90], "mention", "mentions it", file, line_no))
|
|
788
|
+
count += 1
|
|
789
|
+
if layer.rows:
|
|
790
|
+
self.extra.append(layer)
|
|
791
|
+
|
|
792
|
+
# ------------------------------------------------------------------ code locations
|
|
793
|
+
|
|
794
|
+
def _node(self, node: Node) -> None:
|
|
795
|
+
"""Lineage of a definition: who reaches it (screen, client) and what it reaches (server, data)."""
|
|
796
|
+
if node.lang == "js":
|
|
797
|
+
layer = "screen" if node.kind == "component" else "client"
|
|
798
|
+
self.layers[layer].add(
|
|
799
|
+
Row(
|
|
800
|
+
_fn_label(node),
|
|
801
|
+
node.kind,
|
|
802
|
+
"this definition",
|
|
803
|
+
node.file,
|
|
804
|
+
node.line,
|
|
805
|
+
node.end,
|
|
806
|
+
node.id,
|
|
807
|
+
role="action",
|
|
808
|
+
)
|
|
809
|
+
)
|
|
810
|
+
self._renderers(node, 0, set())
|
|
811
|
+
self._walk_client(node, 1, "action")
|
|
812
|
+
self._network_and_server()
|
|
813
|
+
return
|
|
814
|
+
self.layers["server"].add(
|
|
815
|
+
Row(_fn_label(node), node.kind, "this definition", node.file, node.line, node.end, node.id, role="action")
|
|
816
|
+
)
|
|
817
|
+
self._mark_server(node.id, "action")
|
|
818
|
+
for ep in self._endpoints_reaching(node.id):
|
|
819
|
+
self.layers["server"].add(Row(ep.name, "endpoint", "reaches this definition", ep.file, ep.line, node=ep.id))
|
|
820
|
+
for edge in self.g.into(ep.id, "http"):
|
|
821
|
+
caller = self.g.nodes[edge.src]
|
|
822
|
+
site = edge.site
|
|
823
|
+
self.layers["network"].add(
|
|
824
|
+
Row(
|
|
825
|
+
edge.label or ep.name,
|
|
826
|
+
"request",
|
|
827
|
+
f"sent by {caller.qualname or caller.name}",
|
|
828
|
+
site.file if site else caller.file,
|
|
829
|
+
site.line if site else caller.line,
|
|
830
|
+
confidence=edge.confidence,
|
|
831
|
+
)
|
|
832
|
+
)
|
|
833
|
+
self.layers["client"].add(
|
|
834
|
+
Row(
|
|
835
|
+
_fn_label(caller),
|
|
836
|
+
caller.kind,
|
|
837
|
+
f"calls {ep.name}",
|
|
838
|
+
caller.file,
|
|
839
|
+
caller.line,
|
|
840
|
+
caller.end,
|
|
841
|
+
caller.id,
|
|
842
|
+
)
|
|
843
|
+
)
|
|
844
|
+
self._renderers(caller, 0, set())
|
|
845
|
+
queue = deque([(node, 1)])
|
|
846
|
+
visited = {node.id}
|
|
847
|
+
while queue:
|
|
848
|
+
current, depth = queue.popleft()
|
|
849
|
+
if depth > SERVER_DEPTH["action"]:
|
|
850
|
+
continue
|
|
851
|
+
for edge in self.g.out(current.id, "calls", "uses"):
|
|
852
|
+
target = self.g.nodes[edge.dst]
|
|
853
|
+
if target.id in visited or target.lang != "python" or "test" in target.tags:
|
|
854
|
+
continue
|
|
855
|
+
visited.add(target.id)
|
|
856
|
+
self.layers["server"].add(
|
|
857
|
+
Row(
|
|
858
|
+
_fn_label(target),
|
|
859
|
+
target.kind,
|
|
860
|
+
_how(edge, current),
|
|
861
|
+
target.file,
|
|
862
|
+
target.line,
|
|
863
|
+
target.end,
|
|
864
|
+
target.id,
|
|
865
|
+
edge.confidence,
|
|
866
|
+
depth=min(depth, 6),
|
|
867
|
+
role="action",
|
|
868
|
+
)
|
|
869
|
+
)
|
|
870
|
+
self._mark_server(target.id, "action")
|
|
871
|
+
if edge.label != "raises":
|
|
872
|
+
queue.append((target, depth + 1))
|
|
873
|
+
self._data()
|
|
874
|
+
|
|
875
|
+
def _endpoints_reaching(self, node_id: str) -> list[Node]:
|
|
876
|
+
out: list[Node] = []
|
|
877
|
+
queue = deque([node_id])
|
|
878
|
+
seen = {node_id}
|
|
879
|
+
while queue:
|
|
880
|
+
current = queue.popleft()
|
|
881
|
+
for edge in self.g.into(current, "routes"):
|
|
882
|
+
ep = self.g.nodes[edge.src]
|
|
883
|
+
if ep.kind == "endpoint" and ep not in out:
|
|
884
|
+
out.append(ep)
|
|
885
|
+
for edge in self.g.into(current, "calls", "uses"):
|
|
886
|
+
if edge.src not in seen and len(seen) < 400:
|
|
887
|
+
seen.add(edge.src)
|
|
888
|
+
queue.append(edge.src)
|
|
889
|
+
return out
|
|
890
|
+
|
|
891
|
+
def _renderers(self, node: Node, depth: int, seen: set[str]) -> None:
|
|
892
|
+
"""Components and functions that reach this one, up to the pages that route to them."""
|
|
893
|
+
if depth > 5 or node.id in seen:
|
|
894
|
+
return
|
|
895
|
+
seen.add(node.id)
|
|
896
|
+
for edge in self.g.into(node.id, "renders", "calls", "routes"):
|
|
897
|
+
src = self.g.nodes[edge.src]
|
|
898
|
+
if src.kind == "page":
|
|
899
|
+
self.layers["screen"].add(
|
|
900
|
+
Row(
|
|
901
|
+
src.attrs.get("path", src.name),
|
|
902
|
+
"page",
|
|
903
|
+
f"route for {node.name}",
|
|
904
|
+
src.file,
|
|
905
|
+
src.line,
|
|
906
|
+
node=src.id,
|
|
907
|
+
)
|
|
908
|
+
)
|
|
909
|
+
elif src.kind == "component":
|
|
910
|
+
verb = "renders" if edge.kind == "renders" else "calls"
|
|
911
|
+
line = edge.site.line if edge.site else src.line
|
|
912
|
+
self.layers["screen"].add(
|
|
913
|
+
Row(
|
|
914
|
+
_fn_label(src),
|
|
915
|
+
"component",
|
|
916
|
+
f"{verb} {node.name} at line {line}",
|
|
917
|
+
src.file,
|
|
918
|
+
src.line,
|
|
919
|
+
src.end,
|
|
920
|
+
src.id,
|
|
921
|
+
depth=depth,
|
|
922
|
+
)
|
|
923
|
+
)
|
|
924
|
+
self._renderers(src, depth + 1, seen)
|
|
925
|
+
elif src.kind in ("hook", "function", "method") and src.lang == "js":
|
|
926
|
+
self.layers["client"].add(
|
|
927
|
+
Row(
|
|
928
|
+
_fn_label(src), src.kind, f"calls {node.name}", src.file, src.line, src.end, src.id, depth=depth
|
|
929
|
+
)
|
|
930
|
+
)
|
|
931
|
+
self._renderers(src, depth + 1, seen)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
USES_WORDS = {
|
|
935
|
+
"Depends": "injected into {} (Depends)",
|
|
936
|
+
"Security": "injected into {} (Security)",
|
|
937
|
+
"response": "response model of {}",
|
|
938
|
+
"type": "parameter type of {}",
|
|
939
|
+
"returns": "return type of {}",
|
|
940
|
+
"decorator": "decorates {}",
|
|
941
|
+
"passes": "passed as a callback by {}",
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _how(edge: Any, caller: Node) -> str:
|
|
946
|
+
if edge.label == "raises":
|
|
947
|
+
return f"raised by {caller.name}"
|
|
948
|
+
if edge.kind == "uses":
|
|
949
|
+
return USES_WORDS.get(edge.label, "used by {}").format(caller.name)
|
|
950
|
+
return f"called by {caller.name}"
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _lines(path: Path) -> list[tuple[int, str]]:
|
|
954
|
+
try:
|
|
955
|
+
return list(enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1))
|
|
956
|
+
except OSError:
|
|
957
|
+
return []
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def for_selection(project: Project, snapshot: Snapshot, selection: dict[str, Any]) -> dict[str, Any]:
|
|
961
|
+
return Lineage(project, snapshot).selection(selection)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def for_location(project: Project, snapshot: Snapshot, loc: str) -> dict[str, Any]:
|
|
965
|
+
return Lineage(project, snapshot).location(loc)
|