leyline-code 0.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/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()
|