leyline-code 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/diagrams.py ADDED
@@ -0,0 +1,680 @@
1
+ """Sequence diagrams of how execution reaches and leaves some code, drawn from the map.
2
+
3
+ A diagram a language model draws for a pull request reads well and is often wrong: it invents a participant, or
4
+ turns a message on a queue into a call that waits for its answer. Here every arrow is an edge on the map: a call,
5
+ a channel link (http, a queue, a table, a launched program), or a call through an interface into one of its
6
+ implementations. The path in comes from the stored flows, which walk the calls from an entry point in source order;
7
+ the calls out of the changed code are listed in the order they appear in its text.
8
+
9
+ sequence(con, ids) the diagram, as Mermaid text, and the arrows it draws
10
+ unbacked(con, d) arrows of a diagram with no edge behind them on the map (should be none)
11
+ edge_changes(before, after, ids) calls and channel links into or out of changed code, added and removed
12
+ markdown(d) / change_lines(c) the lines a page shows
13
+
14
+ GitHub renders a ```mermaid block in Markdown, which is where leyline.md and the pull request page are read.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ from typing import Optional
22
+
23
+ CHANNEL_VIA = ("event", "process", "http", "channel", "di", "queue", "rpc", "file", "db")
24
+ SHADE = "rgba(255, 196, 0, 0.18)"
25
+ TEST_PATH = re.compile(r"(^|/)(tests?|__tests__|specs?)(/|$)|[._-](test|spec)s?\.[A-Za-z]+$|(^|/)test_[^/]+$|Tests?\.cs$")
26
+ TOP = ("<module>", "<top-level>")
27
+
28
+
29
+ def cut(name: str, n: int) -> str:
30
+ """A long name (a test's sentence) cut at a word, marked as cut, so it does not read as a whole sentence."""
31
+ if len(name) <= n:
32
+ return name
33
+ head = name[:n - 1]
34
+ if " " in head:
35
+ head = head.rsplit(" ", 1)[0]
36
+ return head.rstrip(" ,;:-") + "…"
37
+
38
+
39
+ def _cols(con, table: str) -> set:
40
+ try:
41
+ return {r[1] for r in con.execute(f"PRAGMA table_info({table})")}
42
+ except Exception:
43
+ return set()
44
+
45
+
46
+ def drawable(con) -> bool:
47
+ """Whether a store keeps what a diagram needs: where each call sits, how sure each link is, which channel a link
48
+ crosses, and the order of each flow. A comparison's slim baseline keeps only which pairs are linked, so a
49
+ diagram of the code as it was cannot be drawn from it honestly."""
50
+ return ({"site_start", "precision"} <= _cols(con, "calls") and {"attrs", "precision"} <= _cols(con, "edges")
51
+ and {"parent_seq", "seq"} <= _cols(con, "flow_steps"))
52
+
53
+
54
+ class _Nodes:
55
+ def __init__(self, con):
56
+ self.con, self.rows = con, {}
57
+
58
+ def get(self, i: Optional[str]) -> Optional[dict]:
59
+ if i is None:
60
+ return None
61
+ if i not in self.rows:
62
+ r = self.con.execute("SELECT id, kind, name, parent_id, path, span_start FROM nodes WHERE id = ?", (i,)).fetchone()
63
+ self.rows[i] = dict(zip(("id", "kind", "name", "parent_id", "path", "span_start"), r)) if r else None
64
+ return self.rows[i]
65
+
66
+ def unit(self, i: str) -> str:
67
+ """The participant a function belongs to: its type, else its file."""
68
+ cur, seen = i, set()
69
+ while cur and cur not in seen:
70
+ seen.add(cur)
71
+ n = self.get(cur)
72
+ if n is None:
73
+ break
74
+ if n["kind"] in ("type", "file", "module"):
75
+ return cur
76
+ cur = n["parent_id"]
77
+ return i
78
+
79
+ def fn(self, i: str) -> str:
80
+ n = self.get(i) or {"name": i.rsplit(".", 1)[-1], "kind": "callable"}
81
+ if n["name"] in TOP:
82
+ return "top level"
83
+ return cut(n["name"], 48)
84
+
85
+ def label(self, i: str) -> str:
86
+ """`Owner.name`, or `name` for a function at the top of a file."""
87
+ n = self.get(i)
88
+ if n is None:
89
+ return i.rsplit(":", 1)[-1]
90
+ if n["kind"] in ("callable", "field"):
91
+ u = self.get(self.unit(i))
92
+ if n["name"] in TOP:
93
+ return f"the top level of {n['path']}" if n.get("path") else "a file's top level"
94
+ if u is not None and u["kind"] in ("type", "file"): # Owner.name, or file.name, as the other pages say it
95
+ return f"{u['name']}.{self.fn(i)}"
96
+ return self.fn(i)
97
+
98
+ def is_test(self, i: str) -> bool:
99
+ n = self.get(i)
100
+ return bool(n) and (n["kind"] == "test" or bool(TEST_PATH.search(n["path"] or "")))
101
+
102
+
103
+ def _text(s: str) -> str:
104
+ """Text Mermaid shows as written: no statement breaks, entity codes, comments or tags."""
105
+ s = s.replace(";", ",").replace("#", "").replace("%%", "%").replace("<", "[").replace(">", "]")
106
+ return " ".join(s.split())
107
+
108
+
109
+ def _attrs(raw) -> dict:
110
+ try:
111
+ return json.loads(raw) if raw else {}
112
+ except (TypeError, ValueError):
113
+ return {}
114
+
115
+
116
+ def _channel_text(channel: str, address: str, nodes: _Nodes, dst: str) -> str:
117
+ if channel in ("db", "file"): # nothing runs across: the reader reads what was written, later
118
+ what = ("table " if channel == "db" else "file ") + address if address else "a " + ("table" if channel == "db" else "file")
119
+ return f"writes {what}, which {nodes.fn(dst)}() reads later"
120
+ if channel == "format": # a key built here and taken apart there
121
+ return f"builds keys {address}, which {nodes.fn(dst)}() takes apart"
122
+ if channel == "process":
123
+ return f"starts {address or 'a program'}"
124
+ if channel == "di":
125
+ return f"{nodes.fn(dst)}(), the registered implementation"
126
+ return f"{channel} {address}".strip()
127
+
128
+
129
+ def _backing(con, nodes: _Nodes, src: str, dst: str, via: str) -> Optional[dict]:
130
+ """The edge on the map behind one step of a flow, or None."""
131
+ if via == "calls":
132
+ r = con.execute("SELECT COUNT(*), MIN(site_start), MIN(precision = 'guess') FROM calls WHERE src_id = ? AND dst_id = ?",
133
+ (src, dst)).fetchone()
134
+ return {"kind": "call", "guess": bool(r[2]), "line": r[1]} if r[0] else None
135
+ if via == "dispatch":
136
+ r = con.execute("SELECT precision FROM edges WHERE kind = 'overrides' AND src_id = ? AND dst_id = ?", (dst, src)).fetchone()
137
+ return {"kind": "dispatch", "guess": r[0] == "guess"} if r else None
138
+ if via == "runs":
139
+ n = nodes.get(dst)
140
+ return {"kind": "runs", "guess": False} if n and n["parent_id"] == src else None
141
+ rows = con.execute("SELECT precision, attrs FROM edges WHERE kind = 'communicates' AND src_id = ? AND dst_id = ?",
142
+ (src, dst)).fetchall()
143
+ rows = sorted(rows, key=lambda r: _attrs(r[1]).get("channel") != via)
144
+ if not rows:
145
+ return None
146
+ a = _attrs(rows[0][1])
147
+ return {"kind": "channel", "channel": a.get("channel", via), "address": a.get("address") or "",
148
+ "guess": rows[0][0] == "guess", "line": a.get("launched_at") or a.get("line")}
149
+
150
+
151
+ SIDE_ENTRY = re.compile(r"(^|/)(scripts?|tools?|bench\w*|perf\w*|benchmarks?|examples?|tests?|__tests__)/|"
152
+ r"(^|/)(bench|perf)[^/]*$")
153
+
154
+
155
+ def _side_entry(path: str) -> bool:
156
+ """An entry point that is not the product: a script, a benchmark, an example or a test. A drawing starts at the
157
+ product's own entry point when one reaches the code."""
158
+ return bool(SIDE_ENTRY.search(path) or TEST_PATH.search(path))
159
+
160
+
161
+ def _chain(con, nodes: _Nodes, fid: str) -> Optional[dict]:
162
+ """The path a stored flow takes from its start to `fid`: from an entry point if one reaches it, else from a
163
+ test; the one that reaches it in the fewest calls."""
164
+ has_layer = "layer" in _cols(con, "flows")
165
+ rows = con.execute("SELECT s.flow_id, s.seq, s.depth, f.entry_id, f.name, f.attrs FROM flow_steps s JOIN flows f ON f.id = s.flow_id"
166
+ " WHERE s.callable_id = ?" + (" AND f.layer = 'fact'" if has_layer else ""), (fid,)).fetchall()
167
+ if not rows:
168
+ return None
169
+ best = min(rows, key=lambda r: (_attrs(r[5]).get("kind") != "entry", _side_entry((nodes.get(r[3]) or {}).get("path") or ""),
170
+ r[2] or 0, r[4] or "", r[0]))
171
+ flow, seq = best[0], best[1]
172
+ links, guard = [], 0
173
+ while seq is not None and guard < 64:
174
+ guard += 1
175
+ s = con.execute("SELECT seq, callable_id, via, site_line, parent_seq FROM flow_steps WHERE flow_id = ? AND seq = ?",
176
+ (flow, seq)).fetchone()
177
+ if s is None or s[4] is None or s[2] == "start":
178
+ break
179
+ p = con.execute("SELECT callable_id FROM flow_steps WHERE flow_id = ? AND seq = ?", (flow, s[4])).fetchone()
180
+ if p is None:
181
+ break
182
+ links.append((p[0], s[1], s[2], s[3]))
183
+ seq = s[4]
184
+ links.reverse()
185
+ return {"flow": best[4], "kind": _attrs(best[5]).get("kind") or "entry", "entry": best[3], "links": links}
186
+
187
+
188
+ def _focus(con, nodes: _Nodes, ids: list[str]) -> list[str]:
189
+ """Functions to draw for the ids given: a type stands for its methods. Tests only when nothing else changed."""
190
+ out = []
191
+ for i in dict.fromkeys(ids):
192
+ n = nodes.get(i)
193
+ if n is None:
194
+ continue
195
+ if n["kind"] in ("callable", "test"):
196
+ out.append(i)
197
+ elif n["kind"] == "type":
198
+ out += [r[0] for r in con.execute("SELECT id FROM nodes WHERE parent_id = ? AND kind = 'callable' ORDER BY span_start, id", (i,))]
199
+ out = list(dict.fromkeys(out))
200
+ product = [i for i in out if not nodes.is_test(i)] or out
201
+ # A file's top level whose text changed only because a function in it did is not drawn when that function is.
202
+ return [i for i in product if (nodes.get(i) or {}).get("name") not in TOP] or product
203
+
204
+
205
+ def _callees(con, f: str) -> list[tuple]:
206
+ """What a function calls, in the order its text first calls each: (callee id, line, guessed)."""
207
+ return [r for r in con.execute("SELECT dst_id, MIN(site_start) AS line, MIN(precision = 'guess') FROM calls"
208
+ " WHERE src_id = ? AND dst_id != src_id GROUP BY dst_id ORDER BY line, dst_id", (f,))]
209
+
210
+
211
+ def sequence(con, focus_ids: list[str], max_participants: int = 8, max_messages: int = 25, max_focus: int = 4,
212
+ max_hops_in: int = 3, max_channels_in: int = 3, marked: str = "changed",
213
+ rank: Optional[dict] = None, caps: Optional[dict] = None) -> dict:
214
+ """A Mermaid sequence diagram of how execution reaches the functions in `focus_ids` (types stand for their
215
+ methods) and what they call. Participants are the types around the functions, or the file for a function at the
216
+ top of a file. Returns {"mermaid", "arrows", "participants", "focus", "focus_left_out", "left_out", ...}; "arrows"
217
+ lists each drawn arrow with the edge behind it, so the drawing can be checked against the map (`unbacked`).
218
+ `marked` is the word on the note over each focus function ("changed" on a change's pages).
219
+ `rank` and `caps` let two drawings of one change (before and after) pick a focus function's callees the same
220
+ way: rank maps (caller label, callee label) to a place in one order shared by both, and caps a caller label to
221
+ how many places of that order to draw."""
222
+ nodes = _Nodes(con)
223
+ every = _focus(con, nodes, focus_ids)
224
+ focus = every[:max_focus]
225
+ # Changed code past the cap is not drawn for its own sake, but when the drawing reaches it anyway (a changed
226
+ # function calls it) it is shaded like the rest, and is not said to be left out.
227
+ fset = set(every)
228
+ children: dict[str, list] = {}
229
+ keys: set = set()
230
+ roots: list[tuple] = [] # (node, note)
231
+ must: list[str] = []
232
+ ranked: dict[str, list] = {} # focus function -> (place in the shared order, callee) for each callee it has
233
+
234
+ def add(src, dst, e, order):
235
+ key = (src, dst, e["kind"])
236
+ if key in keys:
237
+ return
238
+ keys.add(key)
239
+ children.setdefault(src, []).append((order if order is not None else 10 ** 9, len(keys), dst, e))
240
+
241
+ for f in focus:
242
+ must.append(nodes.unit(f))
243
+ ch = _chain(con, nodes, f)
244
+ if ch and ch["links"]:
245
+ backed = []
246
+ for src, dst, via, line in ch["links"]:
247
+ e = _backing(con, nodes, src, dst, via)
248
+ if e is None: # a step the map cannot show as an edge: start the drawing below it
249
+ backed = []
250
+ continue
251
+ backed.append((src, dst, e, line))
252
+ hidden = len(ch["links"]) - len(backed)
253
+ kept = backed[-max_hops_in:]
254
+ hidden += len(backed) - len(kept)
255
+ if kept:
256
+ start = kept[0][0]
257
+ e_row = nodes.get(ch["entry"])
258
+ entry = (e_row["path"] if e_row and e_row["name"] in TOP and e_row["path"] else
259
+ nodes.label(ch["entry"]) if e_row else ch["flow"])
260
+ what = "the test" if ch["kind"] == "test" else "the entry point"
261
+ if e_row and e_row["kind"] == "test": # a test's name is a sentence: quote it
262
+ entry = f'"{entry}"'
263
+ note = (f"starts at {what} {entry}" if not hidden else
264
+ f"reached from {what} {entry} through {hidden} more call{'s' if hidden != 1 else ''}")
265
+ roots.append((start, note))
266
+ for src, dst, e, line in kept:
267
+ add(src, dst, e, line)
268
+ must += [nodes.unit(src), nodes.unit(dst)]
269
+ # Code reached over a channel (a request, a message): the senders, outside tests first.
270
+ ins = con.execute("SELECT src_id, precision, attrs FROM edges WHERE kind = 'communicates' AND dst_id = ?"
271
+ " ORDER BY src_id", (f,)).fetchall()
272
+ ins = sorted(ins, key=lambda r: (nodes.is_test(r[0]), r[0]))
273
+ for src, prec, raw in ins[:max_channels_in]:
274
+ a = _attrs(raw)
275
+ add(src, f, {"kind": "channel", "channel": a.get("channel", "channel"), "address": a.get("address") or "",
276
+ "guess": prec == "guess"}, None)
277
+ if not any(r[0] == src for r in roots):
278
+ roots.append((src, None))
279
+ for k in range(max_channels_in, len(ins)):
280
+ keys.add(("more-in", k, f))
281
+ if not any(r[0] == f for r in roots) and not any(f == c[2] for cs in children.values() for c in cs):
282
+ roots.append((f, None))
283
+ # What it calls, in the order its text calls it (or the order shared with another drawing), and the channels
284
+ # it sends on.
285
+ me = nodes.label(f)
286
+ shared = rank is not None and any(k[0] == me for k in rank)
287
+ for dst, line, guess in _callees(con, f):
288
+ if nodes.get(dst) is None:
289
+ continue
290
+ place = rank.get((me, nodes.label(dst))) if shared else None
291
+ if place is not None and caps and me in caps and place >= caps[me]:
292
+ keys.add(("capped", None, f, dst)) # counted as not drawn (and is no link into anything)
293
+ continue
294
+ if place is not None:
295
+ ranked.setdefault(f, []).append((place, dst))
296
+ add(f, dst, {"kind": "call", "guess": bool(guess), "line": line, "rank": place},
297
+ place if place is not None else (10 ** 6 + (line or 0) if shared else line))
298
+ for dst, prec, raw in con.execute("SELECT dst_id, precision, attrs FROM edges WHERE kind = 'communicates' AND src_id = ?"
299
+ " ORDER BY dst_id", (f,)):
300
+ a = _attrs(raw)
301
+ line = a.get("launched_at") or a.get("line")
302
+ add(f, dst, {"kind": "channel", "channel": a.get("channel", "channel"), "address": a.get("address") or "",
303
+ "guess": prec == "guess", "line": line}, 10 ** 6 + (line or 0) if shared else line)
304
+ for cs in children.values():
305
+ cs.sort(key=lambda c: (c[0], c[1]))
306
+ must = list(dict.fromkeys(must))
307
+
308
+ pid: dict[str, str] = {}
309
+ lines: list[str] = []
310
+ arrows: list[dict] = []
311
+ expanded: set = set()
312
+
313
+ def fits(units):
314
+ new = [u for u in dict.fromkeys(units) if u not in pid]
315
+ return not [u for u in new if u not in must] or len(pid) + len(new) <= max_participants
316
+
317
+ def part(u):
318
+ if u not in pid:
319
+ pid[u] = f"P{len(pid) + 1}"
320
+ return pid[u]
321
+
322
+ def arrow(src, dst, e):
323
+ a, b = part(nodes.unit(src)), part(nodes.unit(dst))
324
+ if e["kind"] == "channel":
325
+ head = "--)" if e["guess"] else "-)"
326
+ label = _channel_text(e["channel"], e.get("address") or "", nodes, dst)
327
+ else:
328
+ head = "-->>" if e["guess"] else "->>"
329
+ label = f"{nodes.fn(dst)}()" + (" (implementation)" if e["kind"] == "dispatch" else "")
330
+ if e["kind"] == "call" and "/route:" in dst: # a route's inline handler, which its registrar hands over
331
+ label = f"registers {nodes.fn(dst)}"
332
+ if e["kind"] == "runs":
333
+ label = f"runs {nodes.fn(dst)}"
334
+ lines.append(f" {a}{head}{b}: {_text(label)}")
335
+ arrows.append({"from": src, "to": dst, "kind": e["kind"], "guess": e["guess"],
336
+ **({"channel": e["channel"], "address": e.get("address") or ""} if e["kind"] == "channel" else {})})
337
+
338
+ def shaded(node, draw_in):
339
+ """The changed function's run, shaded: the arrow into it (if any), a note, and what it calls."""
340
+ lines.append(f" rect {SHADE}")
341
+ draw_in()
342
+ lines.append(f" Note over {part(nodes.unit(node))}: {marked}: {_text(nodes.label(node))}")
343
+ emit(node)
344
+ lines.append(" end")
345
+
346
+ def emit(node):
347
+ if node in expanded:
348
+ return
349
+ expanded.add(node)
350
+ for _order, _k, dst, e in children.get(node, ()):
351
+ if len(arrows) >= max_messages or not fits([nodes.unit(node), nodes.unit(dst)]):
352
+ continue
353
+ if dst in fset and dst not in expanded:
354
+ shaded(dst, lambda: arrow(node, dst, e))
355
+ else:
356
+ arrow(node, dst, e)
357
+ emit(dst)
358
+
359
+ for node, note in roots:
360
+ if len(arrows) >= max_messages:
361
+ break
362
+ if note and node not in expanded:
363
+ lines.append(f" Note over {part(nodes.unit(node))}: {_text(note)}")
364
+ if node in fset and node not in expanded:
365
+ shaded(node, lambda: None)
366
+ else:
367
+ emit(node)
368
+ for f in focus:
369
+ if f not in expanded and fits([nodes.unit(f)]):
370
+ shaded(f, lambda: None)
371
+ left = len(keys) - len(arrows)
372
+ order = sorted(pid, key=lambda u: int(pid[u][1:]))
373
+ if left > 0 and order:
374
+ span = pid[order[0]] + ("," + pid[order[-1]] if len(order) > 1 else "")
375
+ lines.append(f" Note over {span}: and {left} more call{'s' if left != 1 else ''} not drawn")
376
+
377
+ labels: dict[str, str] = {}
378
+ for u in order:
379
+ n = nodes.get(u) or {"name": u, "kind": "", "path": ""}
380
+ labels[u] = n["name"] if n["kind"] != "module" else (n.get("path") or n["name"])
381
+ seen: dict[str, int] = {}
382
+ for u in order:
383
+ seen[labels[u]] = seen.get(labels[u], 0) + 1
384
+ for u in order: # two files named index.ts: say which folder
385
+ n = nodes.get(u)
386
+ if seen[labels[u]] > 1 and n and n.get("path"):
387
+ labels[u] = "/".join(n["path"].split("/")[-2:])
388
+ head = ["sequenceDiagram"] + [f" participant {pid[u]} as {_text(labels[u])}" for u in order]
389
+ return {"mermaid": "\n".join(head + lines) if order else "",
390
+ "arrows": arrows, "participants": [labels[u] for u in order],
391
+ "focus": [nodes.label(f) for f in focus + [g for g in every[max_focus:] if g in expanded]],
392
+ "focus_left_out": [nodes.label(f) for f in every[max_focus:] if f not in expanded],
393
+ # no call or channel on the map leads in, and no flow starts there: nothing in the repository runs it
394
+ "nothing_reaches": [nodes.label(f) for f in focus if not any(k[1] == f for k in keys)
395
+ and con.execute("SELECT 1 FROM calls WHERE dst_id = ? LIMIT 1", (f,)).fetchone() is None
396
+ and con.execute("SELECT 1 FROM flows WHERE entry_id = ? LIMIT 1", (f,)).fetchone() is None],
397
+ "left_out": max(left, 0),
398
+ # how many places of the shared order each drawn focus function's callees were drawn through
399
+ "places": {nodes.label(f): min((p for p, dst in places if (f, dst) not in drawn), default=10 ** 6)
400
+ for f, places in ranked.items() if f in expanded
401
+ for drawn in [{(a["from"], a["to"]) for a in arrows if a["kind"] == "call"}]},
402
+ "guessed": any(a["guess"] for a in arrows), "channels": any(a["kind"] == "channel" for a in arrows)}
403
+
404
+
405
+ def unbacked(con, d: dict) -> list[dict]:
406
+ """The arrows of a diagram with no edge behind them on the map. Every arrow should have one. `con` is the store
407
+ the diagram claims to show: the map as it is, or a change's baseline (a diagram of the code as it was is checked
408
+ against the baseline, never the store)."""
409
+ nodes = _Nodes(con)
410
+ bad = []
411
+ for a in d.get("arrows") or []:
412
+ if nodes.get(a["from"]) is None or nodes.get(a["to"]) is None: # an end this map does not hold
413
+ ok = False
414
+ elif a["kind"] == "call":
415
+ ok = con.execute("SELECT 1 FROM calls WHERE src_id = ? AND dst_id = ? LIMIT 1", (a["from"], a["to"])).fetchone()
416
+ elif a["kind"] == "dispatch":
417
+ ok = con.execute("SELECT 1 FROM edges WHERE kind = 'overrides' AND src_id = ? AND dst_id = ?", (a["to"], a["from"])).fetchone()
418
+ elif a["kind"] == "runs":
419
+ ok = (nodes.get(a["to"]) or {}).get("parent_id") == a["from"]
420
+ else:
421
+ ok = any(_attrs(r[0]).get("channel", "channel") == a["channel"] and (_attrs(r[0]).get("address") or "") == a["address"]
422
+ for r in con.execute("SELECT attrs FROM edges WHERE kind = 'communicates' AND src_id = ? AND dst_id = ?",
423
+ (a["from"], a["to"])))
424
+ if not ok:
425
+ bad.append(a)
426
+ return bad
427
+
428
+
429
+ # -- before and after ---------------------------------------------------------------------------------------
430
+ def _base(i: str) -> str:
431
+ head, sep, _ = i.rpartition("(")
432
+ return head if sep and i.endswith(")") else i
433
+
434
+
435
+ def _links(con, ids: set) -> tuple[set, dict]:
436
+ """Calls and channel links with an end in `ids`: {(kind, src, dst)} and the channel of each channel link, when the
437
+ store keeps it."""
438
+ if not ids:
439
+ return set(), {}
440
+ marks = ",".join("?" * len(ids))
441
+ args = list(ids) * 2
442
+ out, info = set(), {}
443
+ for s, d in con.execute(f"SELECT DISTINCT src_id, dst_id FROM calls WHERE src_id IN ({marks}) OR dst_id IN ({marks})", args):
444
+ if s != d:
445
+ out.add(("calls", s, d))
446
+ with_attrs = "attrs" in _cols(con, "edges")
447
+ for r in con.execute(f"SELECT src_id, dst_id{', attrs' if with_attrs else ''} FROM edges WHERE kind = 'communicates'"
448
+ f" AND (src_id IN ({marks}) OR dst_id IN ({marks}))", args):
449
+ out.add(("channel", r[0], r[1]))
450
+ if with_attrs:
451
+ a = _attrs(r[2])
452
+ info[(r[0], r[1])] = {"channel": a.get("channel", "channel"), "address": a.get("address") or ""}
453
+ return out, info
454
+
455
+
456
+ def edge_changes(before, after, changed: list[str], removed: list[str] = (), limit: int = 400) -> dict:
457
+ """Calls and channel links into or out of the changed code, added and removed between the baseline (before) and
458
+ the store (after). `changed` are ids in the store (edited, added, re-signed); `removed` ids only the baseline has.
459
+ A method whose parameter list changed is matched to its old id."""
460
+ a_nodes, b_nodes = _Nodes(after), _Nodes(before)
461
+ now = {i for i in list(changed)[:limit] if a_nodes.get(i) is not None}
462
+ was = {i for i in now if b_nodes.get(i) is not None} | {i for i in list(removed)[:limit] if b_nodes.get(i) is not None}
463
+ remap = {}
464
+ for i in now:
465
+ base = _base(i)
466
+ if base != i and i not in was:
467
+ for (old,) in before.execute("SELECT id FROM nodes WHERE substr(id, 1, ?) = ? AND substr(id, ?, 1) = '('",
468
+ (len(base), base, len(base) + 1)):
469
+ if a_nodes.get(old) is None:
470
+ remap[old] = i
471
+ was.add(old)
472
+ lb, info_b = _links(before, was)
473
+ la, info_a = _links(after, now)
474
+ lb = {(k, remap.get(s, s), remap.get(d, d)) for k, s, d in lb}
475
+ touched = now | {remap.get(i, i) for i in was}
476
+ la = {x for x in la if x[1] in touched or x[2] in touched}
477
+ lb = {x for x in lb if x[1] in touched or x[2] in touched}
478
+
479
+ def item(con_nodes, k, s, d, info):
480
+ x = {"from": con_nodes.get(s) and con_nodes.label(s) or s, "to": con_nodes.get(d) and con_nodes.label(d) or d,
481
+ "from_id": s, "to_id": d}
482
+ if k == "channel":
483
+ x.update(info.get((s, d)) or {"channel": None, "address": ""})
484
+ return x
485
+
486
+ def guessed(s, d):
487
+ r = after.execute("SELECT MIN(precision = 'guess') FROM calls WHERE src_id = ? AND dst_id = ?", (s, d)).fetchone()
488
+ return bool(r and r[0])
489
+ old_label = lambda i: i if b_nodes.get(i) is not None else next((o for o, n in remap.items() if n == i), i)
490
+ return {
491
+ "calls_added": [{**item(a_nodes, k, s, d, info_a), "guess": guessed(s, d)} for k, s, d in sorted(la - lb) if k == "calls"],
492
+ "calls_removed": [{**item(_Either(b_nodes, a_nodes, old_label), k, s, d, info_b),
493
+ # the callee is gone but the caller is not: the call is still written, and now breaks
494
+ "callee_removed": d in set(removed) and a_nodes.get(s) is not None,
495
+ "caller_edited": s in now or remap.get(s, s) in now}
496
+ for k, s, d in sorted(lb - la) if k == "calls"],
497
+ "channels_added": [item(a_nodes, k, s, d, info_a) for k, s, d in sorted(la - lb) if k == "channel"],
498
+ "channels_removed": [item(_Either(b_nodes, a_nodes, old_label), k, s, d,
499
+ {(remap.get(s2, s2), remap.get(d2, d2)): v for (s2, d2), v in info_b.items()})
500
+ for k, s, d in sorted(lb - la) if k == "channel"],
501
+ }
502
+
503
+
504
+ class _Either:
505
+ """Labels from the baseline, else from the store (a re-signed method's new id)."""
506
+ def __init__(self, first: _Nodes, second: _Nodes, old):
507
+ self.first, self.second, self.old = first, second, old
508
+
509
+ def get(self, i):
510
+ return self.first.get(self.old(i)) or self.second.get(i)
511
+
512
+ def label(self, i):
513
+ o = self.old(i)
514
+ return self.first.label(o) if self.first.get(o) is not None else self.second.label(i)
515
+
516
+
517
+ def for_change(before, after, changed: list[str], removed: list[str] = ()) -> dict:
518
+ """What a page shows for a change after it is made: the diagram of the changed code as it is now, the calls and
519
+ channel links it gained and lost, and a diagram of it as it was when the baseline keeps enough to draw one."""
520
+ nodes = _Nodes(after)
521
+ changed = [i for i in changed if not nodes.is_test(i)] or list(changed)
522
+ out = {"after": sequence(after, changed), "changes": edge_changes(before, after, changed, removed)}
523
+ if not drawable(before):
524
+ out["before_missing"] = "old"
525
+ return out
526
+ was = _was(before, nodes, changed, removed)
527
+ if not was:
528
+ out["before_missing"] = "new"
529
+ return out
530
+ # Drawn from the baseline alone: every node, call, step and channel link it reads is the code as it was.
531
+ d = sequence(before, was)
532
+ # The two drawings pick each changed function's callees the same way, so that what differs between them is
533
+ # only what changed: one order over the callees of both versions, and the same number of its places drawn.
534
+ rank = _shared_order(before, after, was, changed)
535
+ if rank:
536
+ first_b, first_a = sequence(before, was, rank=rank), sequence(after, changed, rank=rank)
537
+ caps = {}
538
+ for src in {k[0] for k in rank}:
539
+ got = [x.get("places", {}).get(src) for x in (first_b, first_a)]
540
+ if all(g is not None for g in got) and min(got) < 10 ** 6:
541
+ caps[src] = min(got)
542
+ d = sequence(before, was, rank=rank, caps=caps)
543
+ out["after"] = sequence(after, changed, rank=rank, caps=caps)
544
+ bad = unbacked(before, d)
545
+ if bad: # never show an arrow the baseline does not hold
546
+ out["before_missing"], out["before_unbacked"] = "unbacked", bad
547
+ elif d.get("mermaid"):
548
+ out["before"] = d
549
+ return out
550
+
551
+
552
+ def _shared_order(before, after, was: list[str], changed: list[str]) -> dict:
553
+ """One order over what each changed function calls, before and after: the order its text calls them now, with a
554
+ callee only the old text had placed after the one it followed then. {(caller label, callee label): place}."""
555
+ nb, na = _Nodes(before), _Nodes(after)
556
+ old = {nb.label(f): f for f in _focus(before, nb, was)}
557
+ new = {na.label(f): f for f in _focus(after, na, changed)}
558
+ out = {}
559
+ for lab in dict.fromkeys(list(new) + list(old)):
560
+ now = [na.label(d) for d, _l, _g in _callees(after, new[lab]) if na.get(d) is not None] if lab in new else []
561
+ then = [nb.label(d) for d, _l, _g in _callees(before, old[lab]) if nb.get(d) is not None] if lab in old else []
562
+ merged = list(dict.fromkeys(now))
563
+ for k, c in enumerate(then):
564
+ if c in merged:
565
+ continue
566
+ prev = next((then[j] for j in range(k - 1, -1, -1) if then[j] in merged), None)
567
+ merged.insert(merged.index(prev) + 1 if prev is not None else 0, c)
568
+ out.update({(lab, c): i for i, c in enumerate(merged)})
569
+ return out
570
+
571
+
572
+ def _was(before, now: _Nodes, changed: list[str], removed: list[str]) -> list[str]:
573
+ """The baseline's ids for the changed code: the same id, or for a method whose parameter list changed the one
574
+ old id with its owner and name, and the removed code."""
575
+ nodes = _Nodes(before)
576
+ out = []
577
+ for i in list(changed) + list(removed):
578
+ if nodes.get(i) is not None:
579
+ out.append(i)
580
+ continue
581
+ base = _base(i)
582
+ if base != i:
583
+ old = [r[0] for r in before.execute("SELECT id FROM nodes WHERE substr(id, 1, ?) = ? AND substr(id, ?, 1) = '('",
584
+ (len(base), base, len(base) + 1))]
585
+ if len(old) == 1 and now.get(old[0]) is None:
586
+ out.append(old[0])
587
+ return list(dict.fromkeys(out))
588
+
589
+
590
+ # -- the lines a page shows ---------------------------------------------------------------------------------------
591
+ def legend(d: dict, shaded: str = "The changed code is shaded.") -> str:
592
+ out = ("Drawn from the map: every arrow is a call or a channel link found in the code, in the order the code makes"
593
+ " them where that order is known. " + shaded)
594
+ if d.get("channels"):
595
+ out += (" An open arrowhead crosses a channel (a request, a message, a table, another program): the far side runs"
596
+ " later, or in another process, and nothing checks the two sides against each other.")
597
+ if d.get("guessed"):
598
+ out += " A dotted arrow is a link the map guessed by name: read the code before relying on it."
599
+ if d.get("focus_left_out"):
600
+ out += f" Not drawn, to keep it readable: {', '.join(d['focus_left_out'][:6])}" + (
601
+ f" and {len(d['focus_left_out']) - 6} more" if len(d["focus_left_out"]) > 6 else "") + "."
602
+ if d.get("nothing_reaches"):
603
+ out += f" Nothing on the map reaches {', '.join(d['nothing_reaches'][:4])}."
604
+ return out
605
+
606
+
607
+ def markdown(d: Optional[dict], intro: str = "", shaded: str = "The changed code is shaded.") -> list[str]:
608
+ """A diagram as a page shows it: a sentence, the ```mermaid block, and what its arrows mean."""
609
+ if not d or not d.get("mermaid"):
610
+ return []
611
+ return ([intro, ""] if intro else []) + ["```mermaid", d["mermaid"], "```", "", legend(d, shaded)]
612
+
613
+
614
+ def _cap(xs: list[str], n: int = 12) -> list[str]:
615
+ return xs[:n] + ([f"- and {len(xs) - n} more"] if len(xs) > n else [])
616
+
617
+
618
+ def change_lines(c: Optional[dict]) -> list[str]:
619
+ """What changed in how the code runs, one link a line."""
620
+ if not c:
621
+ return []
622
+
623
+ def over(x):
624
+ if not x.get("channel"):
625
+ return "over a channel (the baseline does not keep which)"
626
+ return "over " + (f"{x['channel']} {x['address']}".strip())
627
+ L = _cap([f"- `{x['from']}` now calls `{x['to']}`" + (" (a link guessed by name)" if x.get("guess") else "") for x in c["calls_added"]])
628
+ L += _cap([(f"- `{x['from']}` called `{x['to']}`, which was removed; `{x['from']}` was edited too"
629
+ if x.get("caller_edited") else f"- `{x['from']}` still calls `{x['to']}`, which was removed")
630
+ if x.get("callee_removed") else f"- `{x['from']}` no longer calls `{x['to']}`" for x in c["calls_removed"]])
631
+ L += _cap([f"- `{x['from']}` now reaches `{x['to']}` {over(x)}" for x in c["channels_added"]], 8)
632
+ L += _cap([f"- `{x['from']}` no longer reaches `{x['to']}` {over(x)}" for x in c["channels_removed"]], 8)
633
+ return L or ["No call or channel link into or out of the changed code was added or removed."]
634
+
635
+
636
+ def section(view: Optional[dict], heading: str) -> list[str]:
637
+ """A change's diagrams and its list of changed links, under a heading (for the check page and the pull request
638
+ page)."""
639
+ if not view or view.get("error"):
640
+ return []
641
+ L = ["", heading, ""]
642
+ if view.get("before"):
643
+ L += markdown(view["before"], "**Before**: the code the change edits, as it ran at the baseline (drawn from the"
644
+ " baseline alone):", "The code the change edits is shaded.") + [""]
645
+ elif view.get("before_missing"):
646
+ L += [_NO_BEFORE.get(view["before_missing"], _NO_BEFORE["unbacked"]), ""]
647
+ L += markdown(view.get("after"), "**After**: the changed code as it runs now:") or [
648
+ "Nothing in the changed code is on the map as a function to draw."]
649
+ L += ["", "**What changed in how it runs** (calls and channel links into or out of the changed code, compared with"
650
+ " the baseline):", ""] + change_lines(view.get("changes"))
651
+ return L
652
+
653
+
654
+ _NO_BEFORE = {
655
+ "old": "The baseline was taken by an older Leyline, which kept which functions called which but not in what"
656
+ " order, so the code as it was is listed below, not drawn.",
657
+ "new": "All of the changed code is new, so there is nothing to draw as it was.",
658
+ "unbacked": "The code as it was is not drawn: some of its arrows have no link behind them in the baseline.",
659
+ }
660
+
661
+
662
+ def safe(fn, *args, **kw) -> Optional[dict]:
663
+ """A diagram is an aid to reading: one that cannot be drawn must not stop the page it sits on."""
664
+ try:
665
+ return fn(*args, **kw)
666
+ except Exception as e: # noqa: BLE001
667
+ return {"error": f"{type(e).__name__}: {e}"}
668
+
669
+
670
+ def for_snapshot(snap, after, changed: list[str], removed: list[str] = ()) -> Optional[dict]:
671
+ """for_change() against a change's baseline file. None when there is no baseline."""
672
+ from pathlib import Path
673
+ from . import diff
674
+ if not Path(snap).exists():
675
+ return None
676
+ before = diff._open(Path(snap))
677
+ try:
678
+ return for_change(before, after, changed, removed)
679
+ finally:
680
+ before.close()