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/link.py ADDED
@@ -0,0 +1,130 @@
1
+ """Frontend requests -> backend endpoints (design §9)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass
8
+ from typing import cast
9
+
10
+ from stackthread.config import Config, HttpLink
11
+ from stackthread.lang.js.build import Request
12
+ from stackthread.model import CONFIDENCE_RANK, Confidence, Edge, Finding, Graph, Node, Site
13
+
14
+ _PARAM = re.compile(r"^(\{.*\}|\$.+|:.+|\*)$")
15
+
16
+
17
+ def segments(path: str) -> list[str]:
18
+ """`/api/items/{id}/` -> ['api', 'items', '{}']. Parameters in any style become `{}`."""
19
+ out = []
20
+ for seg in path.split("?")[0].split("/"):
21
+ if not seg:
22
+ continue
23
+ if _PARAM.match(seg) or "{}" in seg or ("{" in seg and "}" in seg):
24
+ out.append("{}")
25
+ else:
26
+ out.append(seg)
27
+ return out
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Target:
32
+ node: str
33
+ method: str
34
+ segs: tuple[str, ...]
35
+ source: str
36
+
37
+
38
+ def match_score(request: list[str], endpoint: tuple[str, ...]) -> int | None:
39
+ """None when they cannot match; otherwise how many segments matched literally (higher is better)."""
40
+ if len(request) != len(endpoint):
41
+ return None
42
+ score = 0
43
+ for a, b in zip(request, endpoint, strict=True):
44
+ if a == b:
45
+ score += 2 if a != "{}" else 1
46
+ elif a == "{}" or b == "{}":
47
+ continue
48
+ else:
49
+ return None
50
+ return score
51
+
52
+
53
+ def _apply(url: str, link: HttpLink) -> str:
54
+ if link.client_prefix and (url == link.client_prefix or url.startswith(link.client_prefix.rstrip("/") + "/")):
55
+ return link.server_prefix.rstrip("/") + "/" + url[len(link.client_prefix) :].lstrip("/")
56
+ return url
57
+
58
+
59
+ def link_requests(
60
+ graph: Graph, config: Config, requests: Iterable[Request], proxies: dict[str, list[tuple[str, str]]]
61
+ ) -> None:
62
+ targets_by_source: dict[str, list[Target]] = {}
63
+ for node in graph.of_kind("endpoint"):
64
+ targets_by_source.setdefault(node.source, []).append(
65
+ Target(node.id, node.attrs.get("method", "ANY"), tuple(segments(node.attrs.get("path", ""))), node.source)
66
+ )
67
+ backends = [s.name for s in config.sources if s.role == "backend"]
68
+ for req in requests:
69
+ links = [lk for lk in config.http.links if lk.frm == req.source]
70
+ if not links:
71
+ proxy = proxies.get(req.source, [])
72
+ links = [HttpLink(req.source, b, c, s) for b in backends for c, s in proxy] or [
73
+ HttpLink(req.source, b) for b in backends
74
+ ]
75
+ if proxy:
76
+ links += [HttpLink(req.source, b) for b in backends]
77
+ best: list[tuple[int, Target]] = []
78
+ for link in links:
79
+ url = _apply(req.url, link)
80
+ req_segs = segments(url)
81
+ for target in targets_by_source.get(link.to, []):
82
+ if req.method not in ("?", "ANY") and target.method not in ("ANY", req.method):
83
+ continue
84
+ score = match_score(req_segs, target.segs)
85
+ if score is None:
86
+ continue
87
+ if not best or score > best[0][0]:
88
+ best = [(score, target)]
89
+ elif score == best[0][0] and target not in [t for _, t in best]:
90
+ best.append((score, target))
91
+ site = Site(req.file, req.line)
92
+ if not best:
93
+ if req.url.startswith("/") or req.url.startswith("{}"):
94
+ graph.finding(
95
+ Finding(
96
+ "unmatched-request",
97
+ "warning",
98
+ f"{req.method} {req.url} has no matching backend endpoint",
99
+ req.file,
100
+ req.line,
101
+ req.node,
102
+ )
103
+ )
104
+ if req.node in graph.nodes:
105
+ graph.nodes[req.node].attrs.setdefault("requests", []).append(
106
+ {"method": req.method, "url": req.url, "line": req.line, "matched": False}
107
+ )
108
+ continue
109
+ for _, target in best:
110
+ confidence = cast(Confidence, req.confidence)
111
+ if len(best) > 1 and CONFIDENCE_RANK[confidence] < CONFIDENCE_RANK["inferred"]:
112
+ confidence = "inferred"
113
+ graph.add_edge(
114
+ Edge(
115
+ req.node,
116
+ target.node,
117
+ "http",
118
+ confidence,
119
+ site=site,
120
+ label=f"{req.method} {req.url}",
121
+ )
122
+ )
123
+ if req.node in graph.nodes:
124
+ graph.nodes[req.node].attrs.setdefault("requests", []).append(
125
+ {"method": req.method, "url": req.url, "line": req.line, "matched": True}
126
+ )
127
+
128
+
129
+ def endpoint_callers(graph: Graph, node: Node) -> list[Edge]:
130
+ return graph.into(node.id, "http")
stackthread/logs.py ADDED
@@ -0,0 +1,314 @@
1
+ """Logging: where messages go, what they look like, and how to read them back.
2
+
3
+ Every part of stackthread logs through the standard `logging` module, under named loggers:
4
+
5
+ stackthread.cli commands and start-up
6
+ stackthread.index discovery, extraction, graph builds, the facts cache
7
+ stackthread.worker the Node process that reads JavaScript and TypeScript
8
+ stackthread.server the HTTP server, sessions, the Vite plugin announcing itself
9
+ stackthread.http one line per request (debug), refusals (warning), failures (error)
10
+ stackthread.edit saves from the code pane
11
+ stackthread.adapters project adapters
12
+ stackthread.client errors reported by the browser: the UI, the in-app overlay, the Vite plugin
13
+
14
+ Two destinations:
15
+
16
+ - the log file, `.stackthread/logs/stackthread.log`: one JSON object per line, rotated by size, kept on this
17
+ computer only. It records everything at the file level (debug by default) so a problem can be understood
18
+ after the fact.
19
+ - the console (stderr): short human lines at the console level (info for `serve`, warning otherwise).
20
+
21
+ The session token is masked wherever it would appear. File contents are never logged; paths are project-relative.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import logging.handlers
29
+ import os
30
+ import sys
31
+ import threading
32
+ import time
33
+ import uuid
34
+ from collections.abc import Iterator
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ ROOT = "stackthread"
39
+ FILE_NAME = "stackthread.log"
40
+ LEVELS = {"debug": logging.DEBUG, "info": logging.INFO, "warning": logging.WARNING, "error": logging.ERROR}
41
+ RUN = uuid.uuid4().hex[:8] # ties together every line written by one process
42
+ MASK = "[hidden]"
43
+
44
+
45
+ def get(area: str) -> logging.Logger:
46
+ """The logger for one part of stackthread, such as `get("index")`."""
47
+ return logging.getLogger(f"{ROOT}.{area}")
48
+
49
+
50
+ def ctx(**fields: Any) -> dict[str, Any]:
51
+ """Structured fields for one log line: `log.info("indexed", extra=ctx(files=12, ms=40))`."""
52
+ return {"ctx": fields}
53
+
54
+
55
+ # ---------------------------------------------------------------- formatting
56
+
57
+
58
+ def _iso(created: float) -> str:
59
+ local = time.localtime(created)
60
+ offset = time.strftime("%z", local)
61
+ return time.strftime("%Y-%m-%dT%H:%M:%S", local) + f".{int((created % 1) * 1000):03d}{offset[:3]}:{offset[3:]}"
62
+
63
+
64
+ class JsonFormatter(logging.Formatter):
65
+ """One JSON object per line: easy to read back, filter and attach to a report."""
66
+
67
+ def format(self, record: logging.LogRecord) -> str:
68
+ data: dict[str, Any] = {
69
+ "ts": _iso(record.created),
70
+ "level": record.levelname.lower(),
71
+ "area": record.name.removeprefix(f"{ROOT}.") if record.name != ROOT else "main",
72
+ "msg": record.getMessage(),
73
+ "run": RUN,
74
+ }
75
+ fields = getattr(record, "ctx", None)
76
+ if isinstance(fields, dict):
77
+ for key, value in fields.items():
78
+ if key not in data:
79
+ data[key] = value
80
+ if record.exc_info or record.exc_text:
81
+ data["error"] = record.exc_text or self.formatException(record.exc_info) # type: ignore[arg-type]
82
+ return json.dumps(data, ensure_ascii=False, default=str)
83
+
84
+
85
+ class ConsoleFormatter(logging.Formatter):
86
+ """Short lines for a terminal. Warnings and errors say so; information does not."""
87
+
88
+ def format(self, record: logging.LogRecord) -> str:
89
+ text = record.getMessage()
90
+ if record.levelno >= logging.ERROR:
91
+ text = f"error: {text}"
92
+ elif record.levelno >= logging.WARNING:
93
+ text = f"warning: {text}"
94
+ elif record.levelno <= logging.DEBUG:
95
+ text = f"debug [{record.name.removeprefix(ROOT + '.')}] {text}"
96
+ if (record.exc_info or record.exc_text) and record.levelno >= logging.ERROR:
97
+ text += "\n" + (record.exc_text or self.formatException(record.exc_info)) # type: ignore[arg-type]
98
+ return text
99
+
100
+
101
+ class Redact(logging.Filter):
102
+ """Masks secrets (the session token) in messages, fields and tracebacks before any handler sees them."""
103
+
104
+ def __init__(self) -> None:
105
+ super().__init__()
106
+ self.secrets: set[str] = set()
107
+
108
+ def scrub(self, value: Any) -> Any:
109
+ if isinstance(value, str):
110
+ for secret in self.secrets:
111
+ value = value.replace(secret, MASK)
112
+ return value
113
+ if isinstance(value, dict):
114
+ return {k: self.scrub(v) for k, v in value.items()}
115
+ if isinstance(value, (list, tuple)):
116
+ return [self.scrub(v) for v in value]
117
+ return value
118
+
119
+ def filter(self, record: logging.LogRecord) -> bool:
120
+ if not self.secrets:
121
+ return True
122
+ record.msg = self.scrub(record.getMessage())
123
+ record.args = None
124
+ fields = getattr(record, "ctx", None)
125
+ if isinstance(fields, dict):
126
+ record.ctx = self.scrub(fields)
127
+ if record.exc_info and not record.exc_text:
128
+ record.exc_text = self.scrub(logging.Formatter().formatException(record.exc_info))
129
+ elif record.exc_text:
130
+ record.exc_text = self.scrub(record.exc_text)
131
+ return True
132
+
133
+
134
+ _redact = Redact()
135
+
136
+
137
+ def hide(secret: str) -> None:
138
+ """Never write this value to any log. Short values are refused: masking them would garble every message."""
139
+ if len(secret) >= 12:
140
+ _redact.secrets.add(secret)
141
+
142
+
143
+ # ---------------------------------------------------------------- set-up
144
+
145
+
146
+ def level_of(name: str | None, default: int) -> int:
147
+ return LEVELS.get((name or "").strip().lower(), default)
148
+
149
+
150
+ def log_dir(state_dir: Path) -> Path:
151
+ return state_dir / "logs"
152
+
153
+
154
+ def setup(
155
+ state_dir: Path | None,
156
+ console: str | int = "warning",
157
+ file_level: str | int = "debug",
158
+ size_kb: int = 1024,
159
+ keep: int = 5,
160
+ ) -> Path | None:
161
+ """Configure logging for this process. Safe to call again: earlier stackthread handlers are replaced.
162
+
163
+ Returns the log file path, or None when only the console is used.
164
+ """
165
+ root = logging.getLogger(ROOT)
166
+ root.setLevel(logging.DEBUG)
167
+ root.propagate = False
168
+ for handler in list(root.handlers):
169
+ root.removeHandler(handler)
170
+ handler.close()
171
+
172
+ con = logging.StreamHandler(sys.stderr)
173
+ con.setLevel(console if isinstance(console, int) else level_of(console, logging.WARNING))
174
+ con.setFormatter(ConsoleFormatter())
175
+ con.addFilter(_redact)
176
+ root.addHandler(con)
177
+
178
+ path: Path | None = None
179
+ if state_dir is not None:
180
+ try:
181
+ folder = log_dir(state_dir)
182
+ folder.mkdir(parents=True, exist_ok=True)
183
+ ignore = state_dir / ".gitignore"
184
+ if not ignore.exists():
185
+ ignore.write_text("*\n", encoding="utf-8")
186
+ path = folder / FILE_NAME
187
+ fh = logging.handlers.RotatingFileHandler(
188
+ path, maxBytes=max(64, size_kb) * 1024, backupCount=max(1, keep), encoding="utf-8", delay=True
189
+ )
190
+ fh.setLevel(file_level if isinstance(file_level, int) else level_of(file_level, logging.DEBUG))
191
+ fh.setFormatter(JsonFormatter())
192
+ fh.addFilter(_redact)
193
+ root.addHandler(fh)
194
+ except OSError as e:
195
+ path = None
196
+ get("cli").warning("cannot write logs in %s: %s", state_dir, e)
197
+ _install_hooks()
198
+ return path
199
+
200
+
201
+ _hooked = False
202
+
203
+
204
+ def _install_hooks() -> None:
205
+ """Uncaught errors, in the main thread or any other, end up in the log with their traceback."""
206
+ global _hooked
207
+ if _hooked:
208
+ return
209
+ _hooked = True
210
+ previous = sys.excepthook
211
+
212
+ def on_error(kind: type[BaseException], value: BaseException, tb: Any) -> None:
213
+ if not issubclass(kind, KeyboardInterrupt):
214
+ get("main").critical("unexpected error: %s", value, exc_info=(kind, value, tb))
215
+ previous(kind, value, tb)
216
+
217
+ def on_thread_error(args: threading.ExceptHookArgs) -> None:
218
+ if args.exc_type is SystemExit or args.exc_value is None:
219
+ return
220
+ name = args.thread.name if args.thread else "?"
221
+ get("main").critical(
222
+ "unexpected error in thread %s: %s",
223
+ name,
224
+ args.exc_value,
225
+ exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
226
+ )
227
+
228
+ previous_thread = threading.excepthook
229
+
230
+ def on_thread_error_and_pass_on(args: threading.ExceptHookArgs) -> None:
231
+ on_thread_error(args)
232
+ previous_thread(args)
233
+
234
+ sys.excepthook = on_error
235
+ threading.excepthook = on_thread_error_and_pass_on
236
+
237
+
238
+ # ---------------------------------------------------------------- reading back
239
+
240
+
241
+ def files(state_dir: Path) -> list[Path]:
242
+ """The log file and its rotated copies, oldest first."""
243
+ folder = log_dir(state_dir)
244
+ if not folder.is_dir():
245
+ return []
246
+ rotated = sorted(
247
+ (p for p in folder.glob(f"{FILE_NAME}.*") if p.suffix.lstrip(".").isdigit()),
248
+ key=lambda p: -int(p.suffix.lstrip(".")),
249
+ )
250
+ current = folder / FILE_NAME
251
+ return rotated + ([current] if current.is_file() else [])
252
+
253
+
254
+ def entries(state_dir: Path) -> Iterator[dict[str, Any]]:
255
+ for path in files(state_dir):
256
+ try:
257
+ with path.open(encoding="utf-8", errors="replace") as fh:
258
+ for line in fh:
259
+ line = line.strip()
260
+ if not line:
261
+ continue
262
+ try:
263
+ item = json.loads(line)
264
+ except ValueError:
265
+ item = {"level": "info", "area": "?", "msg": line}
266
+ if isinstance(item, dict):
267
+ yield item
268
+ except OSError:
269
+ continue
270
+
271
+
272
+ def recent(state_dir: Path, level: str = "debug", limit: int = 200, area: str | None = None) -> list[dict[str, Any]]:
273
+ floor = level_of(level, logging.DEBUG)
274
+ out = [
275
+ e
276
+ for e in entries(state_dir)
277
+ if level_of(str(e.get("level")), logging.INFO) >= floor and (area is None or e.get("area") == area)
278
+ ]
279
+ return out[-limit:] if limit > 0 else out
280
+
281
+
282
+ def render(entry: dict[str, Any]) -> str:
283
+ """One entry as a terminal line: time, level, area, message, then any fields."""
284
+ ts = str(entry.get("ts", ""))[11:19]
285
+ level = str(entry.get("level", ""))
286
+ skip = {"ts", "level", "area", "msg", "run", "error"}
287
+ extra = " ".join(
288
+ f"{k}={json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v}"
289
+ for k, v in entry.items()
290
+ if k not in skip
291
+ )
292
+ line = f"{ts} {level:<8} {entry.get('area', '')!s:<9} {entry.get('msg', '')}"
293
+ if extra:
294
+ line += f" ({extra})"
295
+ if entry.get("error"):
296
+ line += "\n" + "\n".join(" " + x for x in str(entry["error"]).splitlines())
297
+ return line
298
+
299
+
300
+ def environment() -> dict[str, Any]:
301
+ """What a support request needs to know about this machine, without anything personal."""
302
+ import platform
303
+
304
+ from stackthread import __version__
305
+
306
+ return {
307
+ "stackthread": __version__,
308
+ "python": platform.python_version(),
309
+ "implementation": platform.python_implementation(),
310
+ "os": f"{platform.system()} {platform.release()}",
311
+ "machine": platform.machine(),
312
+ "pid": os.getpid(),
313
+ "run": RUN,
314
+ }
stackthread/model.py ADDED
@@ -0,0 +1,161 @@
1
+ """The graph: nodes and edges built from every source's facts. One model behind every view (design §8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Iterable, Iterator
7
+ from dataclasses import asdict, dataclass, field
8
+ from typing import Any, Literal
9
+
10
+ NodeKind = Literal[
11
+ "file", "module", "class", "function", "method", "component", "hook", "page", "endpoint", "model", "store"
12
+ ]
13
+ EdgeKind = Literal["contains", "imports", "calls", "renders", "routes", "http", "reads", "writes", "uses", "inherits"]
14
+ Layer = Literal["screen", "client", "network", "server", "data", "none"]
15
+ Confidence = Literal["exact", "inferred", "heuristic"]
16
+
17
+ LAYERS: tuple[Layer, ...] = ("screen", "client", "network", "server", "data")
18
+ CONFIDENCE_RANK = {"exact": 0, "inferred": 1, "heuristic": 2}
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class Node:
23
+ id: str
24
+ kind: NodeKind
25
+ name: str
26
+ lang: str = ""
27
+ source: str = ""
28
+ file: str = ""
29
+ line: int = 0
30
+ end: int = 0
31
+ col: int = 0
32
+ qualname: str = ""
33
+ layer: Layer = "none"
34
+ doc: str = ""
35
+ tags: list[str] = field(default_factory=list)
36
+ attrs: dict[str, Any] = field(default_factory=dict)
37
+
38
+ def to_json(self) -> dict[str, Any]:
39
+ data = asdict(self)
40
+ return {k: v for k, v in data.items() if v not in ("", 0, [], {}, None) or k in ("id", "kind", "name")}
41
+
42
+
43
+ @dataclass(slots=True, frozen=True)
44
+ class Site:
45
+ file: str
46
+ line: int
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class Edge:
51
+ src: str
52
+ dst: str
53
+ kind: EdgeKind
54
+ confidence: Confidence = "exact"
55
+ site: Site | None = None
56
+ label: str = ""
57
+
58
+ def key(self) -> tuple[str, str, str, str]:
59
+ return (self.src, self.dst, self.kind, self.label)
60
+
61
+ def to_json(self) -> dict[str, Any]:
62
+ data: dict[str, Any] = {"from": self.src, "to": self.dst, "kind": self.kind}
63
+ if self.confidence != "exact":
64
+ data["confidence"] = self.confidence
65
+ if self.site:
66
+ data["site"] = f"{self.site.file}:{self.site.line}"
67
+ if self.label:
68
+ data["label"] = self.label
69
+ return data
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class Finding:
74
+ rule: str
75
+ severity: Literal["error", "warning", "info"]
76
+ message: str
77
+ file: str = ""
78
+ line: int = 0
79
+ node: str = ""
80
+
81
+ def to_json(self) -> dict[str, Any]:
82
+ return {k: v for k, v in asdict(self).items() if v not in ("", 0)}
83
+
84
+
85
+ class Graph:
86
+ """Nodes and edges with indexes. Built once per revision and then only read, so it is safe to share."""
87
+
88
+ def __init__(self) -> None:
89
+ self.nodes: dict[str, Node] = {}
90
+ self.edges: list[Edge] = []
91
+ self.findings: list[Finding] = []
92
+ self._out: dict[str, list[Edge]] = defaultdict(list)
93
+ self._in: dict[str, list[Edge]] = defaultdict(list)
94
+ self._edge_keys: set[tuple[str, str, str, str]] = set()
95
+ self._by_file: dict[str, list[Node]] = defaultdict(list)
96
+
97
+ # ---- building ----
98
+
99
+ def add_node(self, node: Node) -> Node:
100
+ existing = self.nodes.get(node.id)
101
+ if existing is not None:
102
+ return existing
103
+ self.nodes[node.id] = node
104
+ if node.file:
105
+ self._by_file[node.file].append(node)
106
+ return node
107
+
108
+ def add_edge(self, edge: Edge) -> bool:
109
+ """Add an edge once. A repeated edge keeps the most confident version."""
110
+ if edge.src == edge.dst and edge.kind not in ("calls",):
111
+ return False
112
+ key = edge.key()
113
+ if key in self._edge_keys:
114
+ for existing in self._out[edge.src]:
115
+ if existing.key() == key and CONFIDENCE_RANK[edge.confidence] < CONFIDENCE_RANK[existing.confidence]:
116
+ existing.confidence = edge.confidence
117
+ return False
118
+ self._edge_keys.add(key)
119
+ self.edges.append(edge)
120
+ self._out[edge.src].append(edge)
121
+ self._in[edge.dst].append(edge)
122
+ return True
123
+
124
+ def finding(self, finding: Finding) -> None:
125
+ self.findings.append(finding)
126
+
127
+ # ---- reading ----
128
+
129
+ def out(self, node_id: str, *kinds: EdgeKind) -> list[Edge]:
130
+ edges = self._out.get(node_id, [])
131
+ return [e for e in edges if e.kind in kinds] if kinds else list(edges)
132
+
133
+ def into(self, node_id: str, *kinds: EdgeKind) -> list[Edge]:
134
+ edges = self._in.get(node_id, [])
135
+ return [e for e in edges if e.kind in kinds] if kinds else list(edges)
136
+
137
+ def in_file(self, file: str) -> list[Node]:
138
+ return list(self._by_file.get(file, []))
139
+
140
+ def files(self) -> Iterable[str]:
141
+ return self._by_file.keys()
142
+
143
+ def at(self, file: str, line: int) -> Node | None:
144
+ """The innermost definition in a file that spans a line."""
145
+ best: Node | None = None
146
+ for node in self._by_file.get(file, []):
147
+ if node.kind in ("file", "module", "page", "endpoint"):
148
+ continue
149
+ if node.line <= line <= max(node.end, node.line) and (best is None or node.line >= best.line):
150
+ best = node
151
+ return best
152
+
153
+ def of_kind(self, *kinds: NodeKind) -> Iterator[Node]:
154
+ return (n for n in self.nodes.values() if n.kind in kinds)
155
+
156
+ def to_json(self) -> dict[str, Any]:
157
+ return {
158
+ "nodes": [n.to_json() for n in self.nodes.values()],
159
+ "edges": [e.to_json() for e in self.edges],
160
+ "findings": [f.to_json() for f in self.findings],
161
+ }