stackthread 0.1.0.dev2__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.

Potentially problematic release.


This version of stackthread might be problematic. Click here for more details.

Files changed (40) hide show
  1. stackthread/__init__.py +6 -0
  2. stackthread/__main__.py +3 -0
  3. stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
  4. stackthread/_vendor/extract.mjs +15 -0
  5. stackthread/adapters.py +130 -0
  6. stackthread/cli.py +342 -0
  7. stackthread/config.py +409 -0
  8. stackthread/detect.py +150 -0
  9. stackthread/lang/__init__.py +1 -0
  10. stackthread/lang/js/__init__.py +1 -0
  11. stackthread/lang/js/aliases.py +133 -0
  12. stackthread/lang/js/build.py +455 -0
  13. stackthread/lang/js/worker.py +186 -0
  14. stackthread/lang/python/__init__.py +1 -0
  15. stackthread/lang/python/build.py +249 -0
  16. stackthread/lang/python/extract.py +494 -0
  17. stackthread/lang/python/frameworks.py +700 -0
  18. stackthread/lang/python/orm.py +300 -0
  19. stackthread/lang/python/resolve.py +448 -0
  20. stackthread/lineage.py +965 -0
  21. stackthread/link.py +130 -0
  22. stackthread/logs.py +314 -0
  23. stackthread/model.py +161 -0
  24. stackthread/paths.py +173 -0
  25. stackthread/project.py +414 -0
  26. stackthread/report.py +203 -0
  27. stackthread/rules.py +48 -0
  28. stackthread/server/__init__.py +5 -0
  29. stackthread/server/app.py +696 -0
  30. stackthread/server/edit.py +108 -0
  31. stackthread/server/security.py +73 -0
  32. stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
  33. stackthread/static/app.css +1 -0
  34. stackthread/static/app.js +29 -0
  35. stackthread/static/index.html +16 -0
  36. stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
  37. stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
  38. stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
  39. stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  40. stackthread-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
stackthread/lineage.py ADDED
@@ -0,0 +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-param", "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)