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/explain.py
ADDED
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
"""Asking how something works: find where a described behavior starts, and walk it step by step.
|
|
2
|
+
|
|
3
|
+
Leyline holds no language model. An agent (or a person at the command line) asks in words, and these two functions
|
|
4
|
+
give it what to reason over:
|
|
5
|
+
|
|
6
|
+
find_flows(con, description) where the behavior could start: entry points, route handlers, UI handlers,
|
|
7
|
+
message handlers, tests named for the behavior, and other functions, ranked
|
|
8
|
+
by the words they share with the description, each with why it matched
|
|
9
|
+
explain_path(con, frm, to, through) an ordered walk from one of them, across calls and channels (a request, a
|
|
10
|
+
message, a launched program), each step with the line that reaches it and the
|
|
11
|
+
callee's declaration; data written for a reader that runs later is marked so
|
|
12
|
+
|
|
13
|
+
Words are matched loosely: code names are split (`saveDialogue` is save and dialogue), endings are cut (saves, saved
|
|
14
|
+
and saving are save), and a small table makes words that mean the same thing in code one (save, write, persist,
|
|
15
|
+
store, put). A word rare on the map counts for more than one found everywhere. A candidate that starts something (an
|
|
16
|
+
entry point, a route, an event handler) or a test whose name states the behavior is ranked up.
|
|
17
|
+
|
|
18
|
+
The words index is built once per map run and kept, so later questions cost milliseconds.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import math
|
|
25
|
+
import re
|
|
26
|
+
import threading
|
|
27
|
+
from collections import defaultdict
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from . import diagrams, query, store
|
|
32
|
+
|
|
33
|
+
# -- words ---------------------------------------------------------------------------------------------------------
|
|
34
|
+
STOP = set("""a an the and or of to in on for with from by is are was were be been being it its this that these those
|
|
35
|
+
how what where when which who whom whose why does do did done doing happen happens happened there their then than
|
|
36
|
+
into as at via after before through about our my your we you i me can could will would should shall may might must us
|
|
37
|
+
them they he she his her if so not no yes also just one any all some each every way thing things work works get gets
|
|
38
|
+
got going go goes like use used using let lets please show tell explain trace find walk step-by-step""".split())
|
|
39
|
+
WEAK = set("user users writer writers author authors person people someone somebody customer customers client"
|
|
40
|
+
" flow code function functions method methods class classes".split())
|
|
41
|
+
|
|
42
|
+
SYNONYMS = [
|
|
43
|
+
"save write persist store put commit flush upsert",
|
|
44
|
+
"delete remove destroy drop erase unlink purge",
|
|
45
|
+
"create add new make spawn instantiate insert build construct generate init",
|
|
46
|
+
"load read fetch get open retrieve hydrate",
|
|
47
|
+
"run execute exec play start launch invoke perform",
|
|
48
|
+
"validate validation check verify lint audit",
|
|
49
|
+
"step tick advance update iterate",
|
|
50
|
+
"send emit publish post dispatch broadcast notify",
|
|
51
|
+
"receive handle consume listen subscribe",
|
|
52
|
+
"render draw display show paint",
|
|
53
|
+
"train learn fit",
|
|
54
|
+
"search find query lookup filter",
|
|
55
|
+
"login signin auth authenticate",
|
|
56
|
+
"error fail failure exception crash",
|
|
57
|
+
"close stop shutdown quit exit",
|
|
58
|
+
"rename move relocate",
|
|
59
|
+
"copy duplicate clone",
|
|
60
|
+
"import ingest parse",
|
|
61
|
+
"export serialize dump",
|
|
62
|
+
"edit change modify update",
|
|
63
|
+
"reset clear",
|
|
64
|
+
"vehicle car",
|
|
65
|
+
"test spec",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
WEIGHT = {"name": 3.0, "route": 3.0, "test": 2.0, "owner": 1.5, "file": 1.2, "doc": 1.0, "language": 1.0, "path": 0.5}
|
|
69
|
+
LANGUAGE_WORDS = {"csharp": "csharp cs c# dotnet", "typescript": "typescript ts", "javascript": "javascript js",
|
|
70
|
+
"python": "python py", "gdscript": "gdscript godot", "cpp": "cpp c++"}
|
|
71
|
+
EXACT, NEAR, SYNONYM = 1.0, 0.8, 0.6
|
|
72
|
+
|
|
73
|
+
# What a candidate is, how to say it, and how much it is ranked up.
|
|
74
|
+
KINDS = {
|
|
75
|
+
"route": ("route handler", 1.6),
|
|
76
|
+
"message": ("message handler", 1.5),
|
|
77
|
+
"entry": ("program entry", 1.5),
|
|
78
|
+
"command": ("command", 1.5),
|
|
79
|
+
"ui": ("UI event handler", 1.4),
|
|
80
|
+
"engine": ("engine callback", 1.3),
|
|
81
|
+
"test": ("test", 1.1),
|
|
82
|
+
"component": ("UI component", 1.1),
|
|
83
|
+
"function": ("function", 1.0),
|
|
84
|
+
}
|
|
85
|
+
ENGINE_CALLBACKS = {"Awake", "Start", "Update", "FixedUpdate", "LateUpdate", "OnEnable", "_ready", "_process",
|
|
86
|
+
"_physics_process", "_input"}
|
|
87
|
+
TOP = diagrams.TOP
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def split_words(text: str) -> list[str]:
|
|
91
|
+
"""Words of text or of a code name, lower case: `saveDialogue` and `save_dialogue` are save, dialogue."""
|
|
92
|
+
out = []
|
|
93
|
+
for part in re.findall(r"[A-Za-z0-9]+", text or ""):
|
|
94
|
+
for w in re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+", part):
|
|
95
|
+
out.append(w.lower())
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def stem(w: str) -> str:
|
|
100
|
+
"""A word with its ending cut, so saves, saved and saving, or validate, validation and validator, meet."""
|
|
101
|
+
w = w.lower()
|
|
102
|
+
if not w.endswith("ss"):
|
|
103
|
+
for suf in ("ations", "ions", "ion", "ings", "ing", "ers", "er", "ors", "or", "ies", "ied", "sses", "es", "ed", "s"):
|
|
104
|
+
if w.endswith(suf) and len(w) - len(suf) >= 3:
|
|
105
|
+
w = w[:-len(suf)] + ("y" if suf in ("ies", "ied") else "ss" if suf == "sses" else "")
|
|
106
|
+
if suf in ("ing", "ed", "er", "ers") and len(w) > 3 and w[-1] == w[-2] and w[-1] not in "ls":
|
|
107
|
+
w = w[:-1] # stepping, stepped: step
|
|
108
|
+
break
|
|
109
|
+
if w.endswith("e") and len(w) > 3:
|
|
110
|
+
w = w[:-1]
|
|
111
|
+
return w
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
GROUPS: dict[str, set] = defaultdict(set)
|
|
115
|
+
for _k, _line in enumerate(SYNONYMS):
|
|
116
|
+
for _w in _line.split():
|
|
117
|
+
GROUPS[stem(_w)].add(_k)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# -- the index of candidates ------------------------------------------------------------------------------------------
|
|
121
|
+
class _Lines:
|
|
122
|
+
"""Source lines read from the working tree, one file at a time, kept."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, con):
|
|
125
|
+
self.roots = store.roots(con)
|
|
126
|
+
self.files: dict = {}
|
|
127
|
+
|
|
128
|
+
def get(self, node_id: str, path: Optional[str]) -> list[str]:
|
|
129
|
+
repo = node_id.split(":", 1)[0]
|
|
130
|
+
root = self.roots.get(repo)
|
|
131
|
+
if root is None or not path:
|
|
132
|
+
return []
|
|
133
|
+
key = (repo, path)
|
|
134
|
+
if key not in self.files:
|
|
135
|
+
from .indexer import source_lines
|
|
136
|
+
try:
|
|
137
|
+
self.files[key] = source_lines(Path(root) / path)
|
|
138
|
+
except OSError:
|
|
139
|
+
self.files[key] = []
|
|
140
|
+
return self.files[key]
|
|
141
|
+
|
|
142
|
+
def line(self, node_id: str, path: Optional[str], n: Optional[int]) -> Optional[str]:
|
|
143
|
+
lines = self.get(node_id, path)
|
|
144
|
+
if not n or not (0 < n <= len(lines)):
|
|
145
|
+
return None
|
|
146
|
+
text = " ".join(lines[n - 1].split())
|
|
147
|
+
return text if len(text) <= 160 else text[:157] + "..."
|
|
148
|
+
|
|
149
|
+
def declaration(self, node_id: str, path: Optional[str], n: Optional[int]) -> Optional[str]:
|
|
150
|
+
"""The line that declares a node whose span starts at line n: past its decorators or attributes
|
|
151
|
+
(`@app.route(...)`, `[HttpGet]`), which may run over several lines."""
|
|
152
|
+
lines = self.get(node_id, path)
|
|
153
|
+
if not n or not (0 < n <= len(lines)):
|
|
154
|
+
return None
|
|
155
|
+
k, depth = n, 0
|
|
156
|
+
while k <= min(len(lines), n + 30):
|
|
157
|
+
s = lines[k - 1].strip()
|
|
158
|
+
if not (depth > 0 or s.startswith(("@", "["))):
|
|
159
|
+
return self.line(node_id, path, k)
|
|
160
|
+
depth = max(0, depth + s.count("(") + s.count("[") - s.count(")") - s.count("]"))
|
|
161
|
+
k += 1
|
|
162
|
+
return self.line(node_id, path, n)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
COMMENT = re.compile(r"^\s*(//+|#+|/\*+|\*+/?|<!--|--)\s?")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def leading_doc(lines: list[str], start: int) -> str:
|
|
169
|
+
"""The comment just above a declaration, and a Python docstring just below it: what its author said it does."""
|
|
170
|
+
if not lines or not start:
|
|
171
|
+
return ""
|
|
172
|
+
out: list[str] = []
|
|
173
|
+
k = start - 2
|
|
174
|
+
while k >= 0 and len(out) < 10:
|
|
175
|
+
s = lines[k].strip()
|
|
176
|
+
if s.startswith("@") or s.startswith("["): # a decorator or an attribute sits between the comment and the code
|
|
177
|
+
k -= 1
|
|
178
|
+
continue
|
|
179
|
+
if not COMMENT.match(s) or not s:
|
|
180
|
+
break
|
|
181
|
+
out.append(re.sub(r"\s*(\*/|-->)$", "", COMMENT.sub("", s)))
|
|
182
|
+
k -= 1
|
|
183
|
+
out.reverse()
|
|
184
|
+
for k in range(start, min(start + 3, len(lines))): # the docstring, on the lines after `def ...:`
|
|
185
|
+
s = lines[k].strip()
|
|
186
|
+
if s.startswith(('"""', "'''", 'r"""')):
|
|
187
|
+
q = s.lstrip("r")[:3]
|
|
188
|
+
body = [s.lstrip("r")[3:]]
|
|
189
|
+
j = k
|
|
190
|
+
while q not in body[-1] and j + 1 < len(lines) and len(body) < 8:
|
|
191
|
+
j += 1
|
|
192
|
+
body.append(lines[j].strip())
|
|
193
|
+
out.append(" ".join(body).split(q)[0])
|
|
194
|
+
break
|
|
195
|
+
if s and not s.endswith((":", "(", ",")):
|
|
196
|
+
break
|
|
197
|
+
text = re.sub(r"<[^>]+>", " ", " ".join(out)) # C# /// <summary>
|
|
198
|
+
return " ".join(text.split())[:400]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _path_fields(path: Optional[str]) -> dict:
|
|
202
|
+
"""A file's own name, which says what it is about, and the folders it is in, which say less."""
|
|
203
|
+
folders, _, base = (path or "").rpartition("/")
|
|
204
|
+
return {"file": base.split(".")[0], "path": folders}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Index:
|
|
208
|
+
"""Everything find_flows ranks: one row per callable and test, with its words by field."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, con):
|
|
211
|
+
rows = con.execute("SELECT id, kind, name, parent_id, path, span_start, attrs, language FROM nodes"
|
|
212
|
+
" WHERE kind IN ('callable', 'test', 'type', 'file')").fetchall()
|
|
213
|
+
info = {r[0]: r for r in rows}
|
|
214
|
+
self.entry = {r[0]: json.loads(r[1] or "{}").get("trigger") for r in con.execute(
|
|
215
|
+
"SELECT e.dst_id, n.attrs FROM edges e JOIN nodes n ON n.id = e.src_id WHERE e.kind = 'exposes'")}
|
|
216
|
+
self.channel_in: dict[str, list] = defaultdict(list)
|
|
217
|
+
for d, a in con.execute("SELECT dst_id, attrs FROM edges WHERE kind = 'communicates'"):
|
|
218
|
+
try:
|
|
219
|
+
at = json.loads(a or "{}")
|
|
220
|
+
except ValueError:
|
|
221
|
+
at = {}
|
|
222
|
+
self.channel_in[d].append((at.get("channel") or "channel", str(at.get("address") or "")))
|
|
223
|
+
self.flows_from: dict[str, list] = defaultdict(list)
|
|
224
|
+
for fid, eid in con.execute("SELECT id, entry_id FROM flows"):
|
|
225
|
+
self.flows_from[eid].append(fid)
|
|
226
|
+
lines = _Lines(con)
|
|
227
|
+
self.ids: list[str] = []
|
|
228
|
+
self.rows: list[dict] = []
|
|
229
|
+
self.fields: list[dict] = []
|
|
230
|
+
self.postings: dict[str, list] = defaultdict(list) # stem -> [(row, field)]
|
|
231
|
+
self.surface: dict[str, str] = {} # stem -> the shortest word on the map it came from
|
|
232
|
+
for r in rows:
|
|
233
|
+
nid, kind, name, parent, path, line, raw, lang = r
|
|
234
|
+
if kind not in ("callable", "test"):
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
a = json.loads(raw or "{}")
|
|
238
|
+
except ValueError:
|
|
239
|
+
a = {}
|
|
240
|
+
p = info.get(parent)
|
|
241
|
+
top = name in TOP
|
|
242
|
+
if top and nid not in self.entry:
|
|
243
|
+
continue
|
|
244
|
+
what = self._kind(nid, kind, name, a, p, path)
|
|
245
|
+
owner = p[2] if p is not None and p[1] in ("type", "callable") and p[2] not in TOP else ""
|
|
246
|
+
label = (f"top level of {path}" if top else f"test \"{name}\"" if what == "test" else
|
|
247
|
+
f"{owner}.{name}" if owner and what != "route" else name)
|
|
248
|
+
route = a.get("route") or next((addr for ch, addr in self.channel_in.get(nid, ()) if ch == "http"), "")
|
|
249
|
+
if what == "test":
|
|
250
|
+
fields = {"test": a.get("full_name") or name, **_path_fields(path)}
|
|
251
|
+
else:
|
|
252
|
+
# A program's top level is named by its file: train.py is the trainer.
|
|
253
|
+
fields = {"name": "" if name == route else name if not top else _path_fields(path)["file"],
|
|
254
|
+
"owner": owner, "route": route,
|
|
255
|
+
**_path_fields(path)}
|
|
256
|
+
if not top:
|
|
257
|
+
fields["doc"] = leading_doc(lines.get(nid, path), line or 0)
|
|
258
|
+
fields["language"] = LANGUAGE_WORDS.get(lang or "", lang or "")
|
|
259
|
+
k = len(self.ids)
|
|
260
|
+
self.ids.append(nid)
|
|
261
|
+
self.rows.append({"id": nid, "kind": what, "label": label, "path": path, "line": line, "route": route,
|
|
262
|
+
"address": (self.entry.get(nid) or "") if what == "entry" else ""})
|
|
263
|
+
stems_by_field = {}
|
|
264
|
+
for f, text in fields.items():
|
|
265
|
+
ws = set()
|
|
266
|
+
for w in split_words(text):
|
|
267
|
+
if len(w) > 1 and w not in STOP:
|
|
268
|
+
st = stem(w)
|
|
269
|
+
ws.add(st)
|
|
270
|
+
if len(w) < len(self.surface.get(st, w + "_")):
|
|
271
|
+
self.surface[st] = w
|
|
272
|
+
stems_by_field[f] = ws
|
|
273
|
+
for s in ws:
|
|
274
|
+
self.postings[s].append((k, f))
|
|
275
|
+
self.fields.append(stems_by_field)
|
|
276
|
+
self.vocab = sorted(self.postings)
|
|
277
|
+
self.df = {s: len({k for k, _ in ps}) for s, ps in self.postings.items()}
|
|
278
|
+
self.n = max(len(self.ids), 1)
|
|
279
|
+
|
|
280
|
+
def _kind(self, nid, kind, name, a, parent, path) -> str:
|
|
281
|
+
if kind == "test" or a.get("is_test"):
|
|
282
|
+
return "test"
|
|
283
|
+
if a.get("native_kind") == "route_handler" or a.get("route"):
|
|
284
|
+
return "route"
|
|
285
|
+
chans = {ch for ch, _ in self.channel_in.get(nid, ())}
|
|
286
|
+
if "http" in chans:
|
|
287
|
+
return "route"
|
|
288
|
+
if chans & {"queue", "event", "rpc"}:
|
|
289
|
+
return "message"
|
|
290
|
+
if nid in self.entry:
|
|
291
|
+
return "entry"
|
|
292
|
+
decorators = " ".join(map(str, a.get("decorators") or []))
|
|
293
|
+
if re.search(r"\.command\b|\bcommand\(|\bcli\b", decorators):
|
|
294
|
+
return "command"
|
|
295
|
+
if re.match(r"(on|handle)[A-Z_]", name):
|
|
296
|
+
return "ui"
|
|
297
|
+
if name in ENGINE_CALLBACKS:
|
|
298
|
+
return "engine"
|
|
299
|
+
if a.get("native_kind") == "component":
|
|
300
|
+
return "component"
|
|
301
|
+
if parent is not None and parent[1] == "callable" and "component" in (parent[6] or ""):
|
|
302
|
+
return "ui" # a function declared inside a UI component: a handler or a callback it hands out
|
|
303
|
+
return "function"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
_cache: dict = {}
|
|
307
|
+
_cache_lock = threading.Lock()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _stamp(con):
|
|
311
|
+
gen = con.execute("SELECT value FROM meta WHERE key = 'generation'").fetchone()
|
|
312
|
+
return (gen[0] if gen else None, con.execute("SELECT COUNT(*) FROM nodes").fetchone()[0],
|
|
313
|
+
con.execute("SELECT COUNT(*) FROM flows").fetchone()[0])
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def index(con) -> Index:
|
|
317
|
+
"""The words index of a store, built once per map run and kept."""
|
|
318
|
+
path, stamp = str(store.store_file(con)), _stamp(con)
|
|
319
|
+
with _cache_lock:
|
|
320
|
+
hit = _cache.get(("index", path))
|
|
321
|
+
if hit is not None and hit[0] == stamp and stamp[0] is not None:
|
|
322
|
+
return hit[1]
|
|
323
|
+
ix = Index(con)
|
|
324
|
+
with _cache_lock:
|
|
325
|
+
_cache[("index", path)] = (stamp, ix)
|
|
326
|
+
return ix
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# -- find_flows -------------------------------------------------------------------------------------------------------
|
|
330
|
+
def terms(description: str) -> list[dict]:
|
|
331
|
+
"""The words of a description that count, each with its stem, its weight and the words that mean the same. A word
|
|
332
|
+
for who does it (a user, a writer) counts little and has no synonyms; nor does a noun made from a verb (editor is
|
|
333
|
+
not edit)."""
|
|
334
|
+
out, seen = [], set()
|
|
335
|
+
raw = re.findall(r"[A-Za-z0-9]+", description or "")
|
|
336
|
+
# A word written with a capital inside the sentence names something (Play mode, the Python trainer).
|
|
337
|
+
named = {w.lower() for k, w in enumerate(raw) if k > 0 and w[:1].isupper() and not w.isupper()}
|
|
338
|
+
for w in split_words(description):
|
|
339
|
+
if w in STOP or len(w) < 2:
|
|
340
|
+
continue
|
|
341
|
+
s = stem(w)
|
|
342
|
+
if s in seen:
|
|
343
|
+
continue
|
|
344
|
+
seen.add(s)
|
|
345
|
+
listed = any(w in line.split() for line in SYNONYMS)
|
|
346
|
+
agent = re.search(r"(er|or|ers|ors)$", w) and not listed
|
|
347
|
+
same = [] if (w in WEAK or agent) else sorted(
|
|
348
|
+
{x for line_k in GROUPS.get(s, ()) for x in SYNONYMS[line_k].split()} - {w})
|
|
349
|
+
out.append({"word": w, "stem": s, "weak": w in WEAK, "same": same, **({"named": True} if w in named else {})})
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _matches(ix: Index, t: dict) -> dict[str, float]:
|
|
354
|
+
"""The stems on the map a term matches, and how well: the same word, a longer or shorter form, a synonym."""
|
|
355
|
+
s = t["stem"]
|
|
356
|
+
if t["weak"]:
|
|
357
|
+
return {s: EXACT} if s in ix.postings else {}
|
|
358
|
+
out = {s: EXACT} if s in ix.postings else {}
|
|
359
|
+
if len(s) >= 4:
|
|
360
|
+
import bisect
|
|
361
|
+
k = bisect.bisect_left(ix.vocab, s)
|
|
362
|
+
while k < len(ix.vocab) and ix.vocab[k].startswith(s):
|
|
363
|
+
v = ix.vocab[k]
|
|
364
|
+
if v != s and len(v) - len(s) <= 4:
|
|
365
|
+
out.setdefault(v, NEAR)
|
|
366
|
+
k += 1
|
|
367
|
+
for w in t["same"]:
|
|
368
|
+
for v in (stem(w),):
|
|
369
|
+
if v != s and v in ix.postings:
|
|
370
|
+
out[v] = max(out.get(v, 0.0), SYNONYM)
|
|
371
|
+
return out
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _flows_through(con, nid: str, most: int = 2, kind: str = "entry") -> list[dict]:
|
|
375
|
+
"""Flows of one kind (entry, or test) that pass a function, shortest way in first."""
|
|
376
|
+
rows = con.execute("SELECT f.id, f.name, s.depth FROM flow_steps s JOIN flows f ON f.id = s.flow_id WHERE"
|
|
377
|
+
" s.callable_id = ? AND json_extract(f.attrs, '$.kind') = ? AND f.entry_id != ?"
|
|
378
|
+
" ORDER BY s.depth, f.id LIMIT ?", (nid, kind, nid, most)).fetchall()
|
|
379
|
+
return [{"flow": r[0], "depth": r[2]} for r in rows]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def find_flows(con, description: str, limit: int = 10) -> dict:
|
|
383
|
+
"""Where a described behavior could start, best first: {"terms", "candidates", "ambiguous", "note"}."""
|
|
384
|
+
import time
|
|
385
|
+
t0 = time.perf_counter()
|
|
386
|
+
ts = terms(description)
|
|
387
|
+
if not ts:
|
|
388
|
+
return {"error": "the description has no words to look for: say what happens, such as \"a writer saves a"
|
|
389
|
+
" dialogue\" or \"how a vehicle is spawned\"."}
|
|
390
|
+
ix = index(con)
|
|
391
|
+
t_index = time.perf_counter() - t0
|
|
392
|
+
own = {t["stem"] for t in ts}
|
|
393
|
+
# A word another word of the description already is (play and run, both asked for) is not matched twice.
|
|
394
|
+
matches = {t["stem"]: {v: q for v, q in _matches(ix, t).items() if v == t["stem"] or v not in own} for t in ts}
|
|
395
|
+
weight = {}
|
|
396
|
+
for t in ts: # a word rare on the map counts for more; one not on it at all, by its rarest match
|
|
397
|
+
df = ix.df.get(t["stem"]) or min([ix.df[v] for v in matches[t["stem"]]] or [0])
|
|
398
|
+
weight[t["stem"]] = (0.3 if t["weak"] else 1.5 if t.get("named") else 1.0) * math.log(1 + ix.n / (1 + df))
|
|
399
|
+
weak = {t["stem"]: t["weak"] for t in ts}
|
|
400
|
+
total = sum(weight.values()) or 1.0
|
|
401
|
+
found: dict[int, dict] = defaultdict(lambda: defaultdict(list)) # row -> term stem -> [(score, field, stem, quality)]
|
|
402
|
+
for t in ts:
|
|
403
|
+
for v, q in matches[t["stem"]].items():
|
|
404
|
+
for k, f in ix.postings[v]:
|
|
405
|
+
found[k][t["stem"]].append((WEIGHT[f] * q, f, v, q))
|
|
406
|
+
best: dict[int, dict] = {} # row -> term stem -> (score, field, matched stem, quality)
|
|
407
|
+
scored = []
|
|
408
|
+
for k, per_term in found.items():
|
|
409
|
+
row = ix.rows[k]
|
|
410
|
+
# Each word of the code answers one word of the description: play and run both matching `start` count once.
|
|
411
|
+
# The words that count most choose first. A word found in its best place counts whole, and a little more for
|
|
412
|
+
# each other place it is found (its name and its file).
|
|
413
|
+
# The same word first, then the words that mean the same: play finds PlayPanel before run takes `start`.
|
|
414
|
+
hits, term, used = {}, {}, set()
|
|
415
|
+
for exact_only in (True, False):
|
|
416
|
+
for s in sorted(per_term, key=lambda s: -weight[s] * max(x[0] for x in per_term[s])):
|
|
417
|
+
if s in hits:
|
|
418
|
+
continue
|
|
419
|
+
free = [x for x in per_term[s] if x[2] not in used and (x[3] == EXACT or not exact_only)]
|
|
420
|
+
if not free:
|
|
421
|
+
continue
|
|
422
|
+
top = max(free)
|
|
423
|
+
used.add(top[2])
|
|
424
|
+
hits[s] = top
|
|
425
|
+
places = {}
|
|
426
|
+
for x in free:
|
|
427
|
+
places[x[1]] = max(places.get(x[1], 0.0), x[0])
|
|
428
|
+
term[s] = top[0] + 0.3 * (sum(places.values()) - top[0])
|
|
429
|
+
best[k] = hits
|
|
430
|
+
covered = sum(weight[s] * min(1.0, term[s] / 2.0) for s in hits)
|
|
431
|
+
raw = sum(weight[s] * term[s] for s in hits)
|
|
432
|
+
strong = any(h[1] in ("name", "route", "test") and not weak[s] for s, h in hits.items())
|
|
433
|
+
score = raw * (0.4 + covered / total) * KINDS[row["kind"]][1] * (1.0 if strong else 0.6)
|
|
434
|
+
scored.append((score, k))
|
|
435
|
+
scored.sort(key=lambda x: (-x[0], ix.ids[x[1]]))
|
|
436
|
+
picked, per_file, tests = [], defaultdict(int), 0
|
|
437
|
+
test_cap = max(2, limit // 3)
|
|
438
|
+
for score, k in scored:
|
|
439
|
+
row = ix.rows[k]
|
|
440
|
+
if per_file[row["path"]] >= 3 or (row["kind"] == "test" and tests >= test_cap):
|
|
441
|
+
continue
|
|
442
|
+
per_file[row["path"]] += 1
|
|
443
|
+
tests += row["kind"] == "test"
|
|
444
|
+
picked.append((score, k))
|
|
445
|
+
if len(picked) >= limit:
|
|
446
|
+
break
|
|
447
|
+
words = {t["stem"]: t["word"] for t in ts}
|
|
448
|
+
out = []
|
|
449
|
+
for score, k in picked:
|
|
450
|
+
row, nid = ix.rows[k], ix.ids[k]
|
|
451
|
+
why = []
|
|
452
|
+
for s, (sc, f, v, q) in sorted(best[k].items(), key=lambda x: -x[1][0]):
|
|
453
|
+
where = {"name": "its name", "route": "its route", "test": "the test's name", "owner": "the name of what"
|
|
454
|
+
" it is in", "doc": "its comment", "file": "its file's name", "path": "its folder", "language": "its language"}[f]
|
|
455
|
+
said = ix.surface.get(v, v)
|
|
456
|
+
how = "" if q == EXACT else f" (as {said})" if q == NEAR else f" (as {said}, which means the same here)"
|
|
457
|
+
why.append(f"{words[s]}{how} in {where}")
|
|
458
|
+
kind_word = KINDS[row["kind"]][0]
|
|
459
|
+
if row["kind"] == "route" and row["route"]:
|
|
460
|
+
kind_word += f" for {row['route']}"
|
|
461
|
+
elif row["kind"] == "entry" and row["address"]:
|
|
462
|
+
kind_word += f" ({row['address']})"
|
|
463
|
+
item = {"id": nid, "name": row["label"], "kind": kind_word, "at": f"{row['path']}:{row['line']}",
|
|
464
|
+
"score": round(score, 2), "why": why, "flows": ix.flows_from.get(nid, [])[:3]}
|
|
465
|
+
if not item["flows"]: # no flow starts here: the entry points' flows that reach it, and the tests'
|
|
466
|
+
item["reached_from"] = _flows_through(con, nid)
|
|
467
|
+
item["tests_reaching"] = _flows_through(con, nid, 3, "test")
|
|
468
|
+
out.append(item)
|
|
469
|
+
# Close: another of the first four, in another file, scores near the first.
|
|
470
|
+
ambiguous = any(sc >= 0.7 * picked[0][0] and ix.rows[k]["path"] != ix.rows[picked[0][1]]["path"]
|
|
471
|
+
for sc, k in picked[1:4]) if picked else False
|
|
472
|
+
note = ("The first candidates score close together and sit in different files: show the person the top few and"
|
|
473
|
+
" ask which they mean, or pick one and say why." if ambiguous else
|
|
474
|
+
"Read the top candidate's code (`source`) before relying on it; the ranking matches words, not meaning.")
|
|
475
|
+
if not out:
|
|
476
|
+
note = "Nothing on the map shares a word with the description. Try the words the code would use."
|
|
477
|
+
return {"description": description,
|
|
478
|
+
"words": [{"word": t["word"], **({"also": t["same"][:8]} if t["same"] else {}),
|
|
479
|
+
**({"weak": True} if t["weak"] else {})} for t in ts],
|
|
480
|
+
"candidates": out, "ambiguous": ambiguous, "note": note,
|
|
481
|
+
"next": (f"explain_path with start={out[0]['id']!r} walks it step by step." if out else ""),
|
|
482
|
+
"seconds": {"index": round(t_index, 3), "total": round(time.perf_counter() - t0, 3)}}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def find_text(r: dict) -> str:
|
|
486
|
+
"""find_flows as a person reads it at the command line."""
|
|
487
|
+
if "error" in r:
|
|
488
|
+
return r["error"]
|
|
489
|
+
ws = ", ".join(w["word"] + (f" (or {', '.join(w['also'][:4])})" if w.get("also") else "")
|
|
490
|
+
+ (" (counts little)" if w.get("weak") else "") for w in r["words"])
|
|
491
|
+
L = [f"Where \"{r['description']}\" could start. Words looked for: {ws}.", ""]
|
|
492
|
+
for k, c in enumerate(r["candidates"], 1):
|
|
493
|
+
L.append(f"{k:>2}. {c['name']} [{c['kind']}] {c['at']}")
|
|
494
|
+
L.append(f" {c['id']}")
|
|
495
|
+
L.append(f" matched: {'; '.join(c['why'])}")
|
|
496
|
+
if c["flows"]:
|
|
497
|
+
L.append(f" flows that start here: {', '.join(c['flows'])}")
|
|
498
|
+
else:
|
|
499
|
+
if c.get("reached_from"):
|
|
500
|
+
L.append(f" reached from: {', '.join(x['flow'] for x in c['reached_from'])}")
|
|
501
|
+
if c.get("tests_reaching"):
|
|
502
|
+
L.append(f" tests that reach it: {', '.join(x['flow'] for x in c['tests_reaching'])}")
|
|
503
|
+
L += ["", r["note"]]
|
|
504
|
+
if r.get("next"):
|
|
505
|
+
L.append("Next: leyline explain-path " + json.dumps(r["candidates"][0]["id"]))
|
|
506
|
+
return "\n".join(L)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# -- explain_path -----------------------------------------------------------------------------------------------------
|
|
510
|
+
DATA = ("db", "file", "format") # nothing runs across these: a reader reads it later
|
|
511
|
+
ELSEWHERE = ("http", "process", "rpc", "queue") # the far side runs in another process, or later
|
|
512
|
+
HELPER_CALLERS = 8 # a leaf called from this many places is a helper: counted, not shown
|
|
513
|
+
MAX_DEPTH, MAX_WALK = 8, 300
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class _Graph:
|
|
517
|
+
"""Calls and channel links out of each function, in the order the code makes them (read once per map run)."""
|
|
518
|
+
|
|
519
|
+
def __init__(self, con):
|
|
520
|
+
self.out: dict[str, list] = defaultdict(list) # id -> [(order, dst, via, guess)]
|
|
521
|
+
self.callers: dict[str, int] = defaultdict(int) # callers outside test code
|
|
522
|
+
tests = {r[0] for r in con.execute("SELECT id, path, kind FROM nodes WHERE kind IN ('callable', 'test')")
|
|
523
|
+
if r[2] == "test" or diagrams.TEST_PATH.search(r[1] or "")}
|
|
524
|
+
for s, d, line, guess in con.execute("SELECT src_id, dst_id, MIN(site_start), MIN(precision = 'guess') FROM calls"
|
|
525
|
+
" WHERE src_id != dst_id GROUP BY src_id, dst_id"):
|
|
526
|
+
self.out[s].append((line or 0, d, "calls", bool(guess)))
|
|
527
|
+
if s not in tests:
|
|
528
|
+
self.callers[d] += 1
|
|
529
|
+
self.data: dict[str, list] = defaultdict(list) # id -> [(channel, address, reader, guess)]
|
|
530
|
+
for s, d, prec, a in con.execute("SELECT src_id, dst_id, precision, attrs FROM edges WHERE kind = 'communicates'"):
|
|
531
|
+
try:
|
|
532
|
+
at = json.loads(a or "{}")
|
|
533
|
+
except ValueError:
|
|
534
|
+
at = {}
|
|
535
|
+
ch = at.get("channel") or "channel"
|
|
536
|
+
if ch in DATA:
|
|
537
|
+
self.data[s].append((ch, str(at.get("address") or ""), d, prec == "guess"))
|
|
538
|
+
continue
|
|
539
|
+
order = at.get("launched_at") or at.get("line") or 10 ** 9
|
|
540
|
+
self.out[s].append((order, d, ch, prec == "guess"))
|
|
541
|
+
for impl, base, prec in con.execute("SELECT src_id, dst_id, precision FROM edges WHERE kind = 'overrides'"):
|
|
542
|
+
self.out[base].append((10 ** 9 - 1, impl, "dispatch", prec == "guess"))
|
|
543
|
+
for lst in self.out.values():
|
|
544
|
+
lst.sort(key=lambda x: (x[0], x[1]))
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def graph(con) -> _Graph:
|
|
548
|
+
path, stamp = str(store.store_file(con)), _stamp(con)
|
|
549
|
+
with _cache_lock:
|
|
550
|
+
hit = _cache.get(("graph", path))
|
|
551
|
+
if hit is not None and hit[0] == stamp and stamp[0] is not None:
|
|
552
|
+
return hit[1]
|
|
553
|
+
g = _Graph(con)
|
|
554
|
+
with _cache_lock:
|
|
555
|
+
_cache[("graph", path)] = (stamp, g)
|
|
556
|
+
return g
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _resolve(con, text: str) -> dict:
|
|
560
|
+
"""{"id"} for a node id, a flow id, an entry point, a route (`PUT /api/x`) or a name; else an error."""
|
|
561
|
+
text = (text or "").strip()
|
|
562
|
+
if not text:
|
|
563
|
+
return {"error": "nothing given to start from"}
|
|
564
|
+
f = con.execute("SELECT entry_id FROM flows WHERE id = ?", (text,)).fetchone()
|
|
565
|
+
if f is not None:
|
|
566
|
+
return {"id": f[0], "flow": text}
|
|
567
|
+
row = query._node(con, text)
|
|
568
|
+
if row is not None:
|
|
569
|
+
if row["kind"] == "entry_point":
|
|
570
|
+
t = con.execute("SELECT dst_id FROM edges WHERE kind = 'exposes' AND src_id = ?", (text,)).fetchone()
|
|
571
|
+
if t is not None:
|
|
572
|
+
return {"id": t[0]}
|
|
573
|
+
return {"id": text}
|
|
574
|
+
if re.match(r"[A-Z]+ /", text): # a route, as its handler is named
|
|
575
|
+
rows = con.execute("SELECT id FROM nodes WHERE kind = 'callable' AND name = ?", (text,)).fetchall()
|
|
576
|
+
if not rows: # a route a named function serves (a decorator, an attribute): by its route, as find_flows reads it
|
|
577
|
+
rows = con.execute(
|
|
578
|
+
"SELECT id FROM nodes WHERE kind = 'callable' AND json_extract(attrs, '$.route') = ? UNION"
|
|
579
|
+
" SELECT dst_id FROM edges WHERE kind = 'communicates' AND json_extract(attrs, '$.channel') = 'http'"
|
|
580
|
+
" AND json_extract(attrs, '$.address') = ?", (text, text)).fetchall()
|
|
581
|
+
if len(rows) == 1:
|
|
582
|
+
return {"id": rows[0][0]}
|
|
583
|
+
return query.resolve(con, text)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _walk(con, g: _Graph, start: str) -> tuple[list[dict], bool, Optional[str]]:
|
|
587
|
+
"""The steps from `start`: the stored flow that starts there if there is one, else a walk made now the same way
|
|
588
|
+
(depth first, in the order the code makes its calls, each function once). (steps, truncated, flow id)."""
|
|
589
|
+
f = con.execute("SELECT id, attrs FROM flows WHERE entry_id = ? ORDER BY id LIMIT 1", (start,)).fetchone()
|
|
590
|
+
if f is not None:
|
|
591
|
+
steps = [{"seq": r[0], "depth": r[1], "id": r[2], "via": r[3], "line": r[4], "parent": r[5]} for r in con.execute(
|
|
592
|
+
"SELECT seq, depth, callable_id, via, site_line, parent_seq FROM flow_steps WHERE flow_id = ? ORDER BY seq",
|
|
593
|
+
(f[0],))]
|
|
594
|
+
return steps, bool(json.loads(f[1] or "{}").get("truncated")), f[0]
|
|
595
|
+
steps = [{"seq": 0, "depth": 0, "id": start, "via": "start", "line": None, "parent": None}]
|
|
596
|
+
seen, truncated = {start}, False
|
|
597
|
+
|
|
598
|
+
def walk(node, depth, parent):
|
|
599
|
+
nonlocal truncated
|
|
600
|
+
if depth >= MAX_DEPTH:
|
|
601
|
+
truncated = truncated or bool(g.out.get(node))
|
|
602
|
+
return
|
|
603
|
+
for order, dst, via, _guess in g.out.get(node, ()):
|
|
604
|
+
if dst in seen:
|
|
605
|
+
continue
|
|
606
|
+
if len(steps) >= MAX_WALK:
|
|
607
|
+
truncated = True
|
|
608
|
+
return
|
|
609
|
+
seen.add(dst)
|
|
610
|
+
seq = len(steps)
|
|
611
|
+
steps.append({"seq": seq, "depth": depth + 1, "id": dst, "via": via,
|
|
612
|
+
"line": order if isinstance(order, int) and order < 10 ** 9 - 1 else None, "parent": parent})
|
|
613
|
+
walk(dst, depth + 1, seq)
|
|
614
|
+
walk(start, 0, 0)
|
|
615
|
+
return steps, truncated, None
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _shortest(con, g: _Graph, a: str, b: str, max_depth: int = 16) -> Optional[list[dict]]:
|
|
619
|
+
"""The shortest chain of calls and channels from a to b, as steps; first without data hops, then with them."""
|
|
620
|
+
for with_data in (False, True):
|
|
621
|
+
prev = {a: None}
|
|
622
|
+
frontier = [a]
|
|
623
|
+
for _ in range(max_depth):
|
|
624
|
+
if b in prev or not frontier:
|
|
625
|
+
break
|
|
626
|
+
nxt = []
|
|
627
|
+
for cur in frontier:
|
|
628
|
+
outs = list(g.out.get(cur, ()))
|
|
629
|
+
if with_data:
|
|
630
|
+
outs += [(10 ** 9, d, ch, guess) for ch, _addr, d, guess in g.data.get(cur, ())]
|
|
631
|
+
for order, dst, via, _guess in outs:
|
|
632
|
+
if dst not in prev:
|
|
633
|
+
prev[dst] = (cur, via, order)
|
|
634
|
+
nxt.append(dst)
|
|
635
|
+
frontier = nxt
|
|
636
|
+
if b in prev:
|
|
637
|
+
chain, cur = [], b
|
|
638
|
+
while prev[cur] is not None:
|
|
639
|
+
p, via, order = prev[cur]
|
|
640
|
+
chain.append((cur, via, order))
|
|
641
|
+
cur = p
|
|
642
|
+
chain.reverse()
|
|
643
|
+
steps = [{"seq": 0, "depth": 0, "id": a, "via": "start", "line": None, "parent": None}]
|
|
644
|
+
for k, (nid, via, order) in enumerate(chain, 1):
|
|
645
|
+
steps.append({"seq": k, "depth": k, "id": nid, "via": via, "parent": k - 1,
|
|
646
|
+
"line": order if isinstance(order, int) and order < 10 ** 9 - 1 else None})
|
|
647
|
+
return steps
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _select(g: _Graph, steps: list[dict], most: int) -> tuple[list[dict], list[dict]]:
|
|
652
|
+
"""The steps to show, at most `most`: every hop across a channel and what leads to it, then the shallowest steps,
|
|
653
|
+
those that lead to more first. Leaves called from many places (helpers) are counted, not shown."""
|
|
654
|
+
kids: dict = defaultdict(list)
|
|
655
|
+
for s in steps:
|
|
656
|
+
if s["parent"] is not None:
|
|
657
|
+
kids[s["parent"]].append(s["seq"])
|
|
658
|
+
size: dict = {}
|
|
659
|
+
for s in reversed(steps):
|
|
660
|
+
size[s["seq"]] = 1 + sum(size.get(c, 0) for c in kids[s["seq"]])
|
|
661
|
+
by_seq = {s["seq"]: s for s in steps}
|
|
662
|
+
# A helper: called from many places outside tests, and calling next to nothing itself (not cut off by the depth).
|
|
663
|
+
helpers = [s for s in steps if s["via"] == "calls" and not kids[s["seq"]] and len(g.out.get(s["id"], ())) <= 1
|
|
664
|
+
and g.callers.get(s["id"], 0) >= HELPER_CALLERS]
|
|
665
|
+
helper_seqs = {s["seq"] for s in helpers}
|
|
666
|
+
keep: set = set()
|
|
667
|
+
|
|
668
|
+
def with_parents(seq):
|
|
669
|
+
chain = []
|
|
670
|
+
while seq is not None and seq not in keep:
|
|
671
|
+
chain.append(seq)
|
|
672
|
+
seq = by_seq[seq]["parent"]
|
|
673
|
+
return chain
|
|
674
|
+
for s in steps:
|
|
675
|
+
if s["via"] not in ("start", "calls", "runs", "dispatch"):
|
|
676
|
+
chain = with_parents(s["seq"])
|
|
677
|
+
if len(keep) + len(chain) <= most:
|
|
678
|
+
keep.update(chain)
|
|
679
|
+
for s in sorted(steps, key=lambda s: (s["depth"], -size[s["seq"]], s["seq"])):
|
|
680
|
+
if len(keep) >= most:
|
|
681
|
+
break
|
|
682
|
+
if s["seq"] in helper_seqs or s["seq"] in keep:
|
|
683
|
+
continue
|
|
684
|
+
chain = with_parents(s["seq"])
|
|
685
|
+
if len(keep) + len(chain) <= most:
|
|
686
|
+
keep.update(chain)
|
|
687
|
+
return [s for s in steps if s["seq"] in keep], helpers
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _label(con, nid: str, nodes: diagrams._Nodes) -> str:
|
|
691
|
+
n = nodes.get(nid)
|
|
692
|
+
if n is None:
|
|
693
|
+
return nid.rsplit(":", 1)[-1]
|
|
694
|
+
if n["name"] in TOP:
|
|
695
|
+
return f"the top level of {n['path']}" if n.get("path") else "a file's top level"
|
|
696
|
+
p = nodes.get(n["parent_id"])
|
|
697
|
+
if n["kind"] == "callable" and re.match(r"[A-Z]+ /", n["name"]):
|
|
698
|
+
return f"the {n['name']} handler"
|
|
699
|
+
if n["kind"] == "test":
|
|
700
|
+
return f"test \"{n['name']}\""
|
|
701
|
+
if p is not None and p["kind"] in ("type", "callable") and p["name"] not in TOP:
|
|
702
|
+
return f"{p['name']}.{n['name']}"
|
|
703
|
+
return n["name"]
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _how(via: str, address: str) -> str:
|
|
707
|
+
return {"start": "start", "calls": "call", "runs": "run by the test runner",
|
|
708
|
+
"dispatch": "call through an interface or base method, landing in this implementation",
|
|
709
|
+
"di": "the implementation a container registers"}.get(via) or (
|
|
710
|
+
f"starts {address or 'another program'}" if via == "process" else f"{via} {address}".strip())
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def explain_path(con, frm: str, to: Optional[str] = None, through: Optional[str] = None, max_steps: int = 40) -> dict:
|
|
714
|
+
"""An ordered walk from `frm`: the main flow from it, the shortest path to `to`, or the paths through `through`."""
|
|
715
|
+
max_steps = max(2, min(int(max_steps or 40), 200))
|
|
716
|
+
ends = {}
|
|
717
|
+
for key, text in (("start", frm), ("to", to), ("through", through)):
|
|
718
|
+
if text is None or not str(text).strip():
|
|
719
|
+
continue
|
|
720
|
+
r = _resolve(con, str(text))
|
|
721
|
+
if "id" not in r:
|
|
722
|
+
return {"error": f"{key}: {r.get('error')}", "candidates": r.get("candidates", [])[:8]}
|
|
723
|
+
ends[key] = r
|
|
724
|
+
if "start" not in ends:
|
|
725
|
+
return {"error": "start is needed: a node id, a name, a route (`PUT /api/x`) or a flow id. find_flows finds one."}
|
|
726
|
+
g = graph(con)
|
|
727
|
+
start = ends["start"]["id"]
|
|
728
|
+
truncated, flow_id, mode = False, None, "walk"
|
|
729
|
+
if "through" in ends or "to" in ends:
|
|
730
|
+
mid = ends.get("through", {}).get("id")
|
|
731
|
+
end = ends.get("to", {}).get("id")
|
|
732
|
+
first = _shortest(con, g, start, mid or end)
|
|
733
|
+
if first is None:
|
|
734
|
+
what = mid or end
|
|
735
|
+
return {"from": start, "found": False, "to" if end and not mid else "through": what,
|
|
736
|
+
"note": "No path on the map from one to the other. The link may run through something the map cannot"
|
|
737
|
+
" follow: a callback, a framework, a value whose type is not written, or a channel not analyzed.",
|
|
738
|
+
"next": "Try explain_path from the second one alone, or impact on it to see what reaches it."}
|
|
739
|
+
steps, mode = first, "path"
|
|
740
|
+
if mid and end:
|
|
741
|
+
second = _shortest(con, g, mid, end)
|
|
742
|
+
if second is None:
|
|
743
|
+
return {"from": start, "through": mid, "to": end, "found": False,
|
|
744
|
+
"note": "The map reaches the middle point but finds no path on from it to the end."}
|
|
745
|
+
base = len(steps) - 1
|
|
746
|
+
for s in second[1:]:
|
|
747
|
+
steps.append({**s, "seq": base + s["seq"], "depth": base + s["depth"], "parent": base + s["parent"]})
|
|
748
|
+
elif mid: # and on from the middle point, along its own walk
|
|
749
|
+
mode = "through"
|
|
750
|
+
rest, truncated, _ = _walk(con, g, mid)
|
|
751
|
+
base, depth0, have = len(steps) - 1, steps[-1]["depth"], {s["id"] for s in steps}
|
|
752
|
+
remap = {0: base}
|
|
753
|
+
for s in rest[1:]:
|
|
754
|
+
if s["id"] in have or s["parent"] not in remap:
|
|
755
|
+
continue
|
|
756
|
+
remap[s["seq"]] = len(steps)
|
|
757
|
+
steps.append({**s, "seq": len(steps), "depth": depth0 + s["depth"], "parent": remap[s["parent"]]})
|
|
758
|
+
else:
|
|
759
|
+
steps, truncated, flow_id = _walk(con, g, start)
|
|
760
|
+
shown, helpers = _select(g, steps, max_steps) if mode != "path" else (steps[:max_steps], [])
|
|
761
|
+
nodes, lines = diagrams._Nodes(con), _Lines(con)
|
|
762
|
+
by_seq = {s["seq"]: s for s in steps}
|
|
763
|
+
out_steps, notes, guessed, dispatch = [], [], [], []
|
|
764
|
+
for k, s in enumerate(shown, 1):
|
|
765
|
+
n = nodes.get(s["id"]) or {"path": None, "span_start": None, "kind": "?", "name": s["id"]}
|
|
766
|
+
parent = by_seq.get(s["parent"]) if s["parent"] is not None else None
|
|
767
|
+
e = diagrams._backing(con, nodes, parent["id"], s["id"], s["via"]) if parent else None
|
|
768
|
+
address = (e or {}).get("address") or ""
|
|
769
|
+
line = s.get("line") or (e or {}).get("line")
|
|
770
|
+
step = {"n": k, "id": s["id"], "name": _label(con, s["id"], nodes), "at": f"{n['path']}:{n['span_start']}",
|
|
771
|
+
"depth": s["depth"], "how": _how(s["via"], address)}
|
|
772
|
+
if parent is not None:
|
|
773
|
+
step["from"] = _label(con, parent["id"], nodes)
|
|
774
|
+
pn = nodes.get(parent["id"]) or {}
|
|
775
|
+
call = lines.line(parent["id"], pn.get("path"), line) if line else None
|
|
776
|
+
if line:
|
|
777
|
+
step["call_line"] = f"{pn.get('path')}:{line}"
|
|
778
|
+
if call:
|
|
779
|
+
step["call"] = call
|
|
780
|
+
decl = lines.declaration(s["id"], n["path"], n["span_start"]) if n.get("name") not in TOP else None
|
|
781
|
+
if decl:
|
|
782
|
+
step["declaration"] = decl
|
|
783
|
+
if s["via"] in ELSEWHERE:
|
|
784
|
+
step["crosses"] = ("into another program" if s["via"] == "process" else
|
|
785
|
+
"to another process or service: the caller does not wait on this code's stack"
|
|
786
|
+
if s["via"] == "queue" else "to another process or service")
|
|
787
|
+
if e is not None and e.get("guess"):
|
|
788
|
+
step["guessed"] = True
|
|
789
|
+
guessed.append(k)
|
|
790
|
+
if s["via"] == "dispatch":
|
|
791
|
+
dispatch.append(k)
|
|
792
|
+
later = [{"what": diagrams._channel_text(ch, addr, nodes, reader), "reader": _label(con, reader, nodes),
|
|
793
|
+
"reader_id": reader, **({"guessed": True} if guess else {})}
|
|
794
|
+
for ch, addr, reader, guess in g.data.get(s["id"], ())[:3]]
|
|
795
|
+
if later:
|
|
796
|
+
step["later_elsewhere"] = later
|
|
797
|
+
out_steps.append(step)
|
|
798
|
+
from .outline import labels # the named part (module_outline, name_part) each step sits in
|
|
799
|
+
named = labels(con, [s["id"] for s in out_steps])
|
|
800
|
+
for step in out_steps:
|
|
801
|
+
if step["id"] in named:
|
|
802
|
+
step["part"] = named[step["id"]]
|
|
803
|
+
if guessed:
|
|
804
|
+
notes.append(f"Step{'s' if len(guessed) > 1 else ''} {_nums(guessed)} follow{'s' if len(guessed) == 1 else ''}"
|
|
805
|
+
" a link the map guessed by name: read the code before relying on it.")
|
|
806
|
+
if dispatch:
|
|
807
|
+
notes.append(f"Step{'s' if len(dispatch) > 1 else ''} {_nums(dispatch)} go{'es' if len(dispatch) == 1 else ''}"
|
|
808
|
+
" through an interface or base method: which implementation runs is decided at run time, and the map"
|
|
809
|
+
" lists every one it knows.")
|
|
810
|
+
if any("later_elsewhere" in s for s in out_steps):
|
|
811
|
+
notes.append("A step marked later_elsewhere writes data (a file, a table, a key) that other code reads later; the"
|
|
812
|
+
" reader does not run as part of this walk.")
|
|
813
|
+
pipes = _pipes(con, nodes, shown, {s["id"] for s in steps})
|
|
814
|
+
for p in pipes:
|
|
815
|
+
notes.append(f"{p['type']} started {p['starts']} in {p['in']} ({p['at']}) and talks to it over its pipes: what"
|
|
816
|
+
f" it writes there is read by {p['program']}, and the walk does not follow those messages as calls."
|
|
817
|
+
f" Walk from {p['program']} to see the other side.")
|
|
818
|
+
if truncated:
|
|
819
|
+
notes.append(f"The walk stops at {MAX_DEPTH} calls deep or {MAX_WALK} steps; code past that is not in it.")
|
|
820
|
+
if len(steps) == 1:
|
|
821
|
+
notes.append("Nothing on the map is called from here. It may call through something the map cannot follow: a"
|
|
822
|
+
" callback it is handed, a framework, a dynamic import or a value whose type is not written.")
|
|
823
|
+
notes.append("The map follows calls whose target it can work out from the text, and channels it can match by name"
|
|
824
|
+
" or address. Calls through callbacks, framework hooks, reflection or values of unknown type are not in"
|
|
825
|
+
" the walk.")
|
|
826
|
+
d = walk_diagram(con, nodes, [s for s in shown], by_seq, g)
|
|
827
|
+
left = len(steps) - len(shown) - len(helpers)
|
|
828
|
+
out = {"from": start, **({"to": ends["to"]["id"]} if "to" in ends else {}),
|
|
829
|
+
**({"through": ends["through"]["id"]} if "through" in ends else {}),
|
|
830
|
+
"mode": mode, **({"flow": flow_id} if flow_id else {}), "found": True,
|
|
831
|
+
"total_steps": len(steps), "shown": len(shown),
|
|
832
|
+
"steps": out_steps,
|
|
833
|
+
**({"helpers": {"count": len(helpers), "names": sorted({_label(con, h["id"], nodes) for h in helpers})[:12],
|
|
834
|
+
"why": f"functions called from {HELPER_CALLERS} or more places outside tests that call next"
|
|
835
|
+
" to nothing themselves: counted, not shown"}} if helpers else {}),
|
|
836
|
+
**({"left_out": left} if left > 0 else {}),
|
|
837
|
+
**({"talks_over_pipes": pipes} if pipes else {}),
|
|
838
|
+
"not_seen": notes, "mermaid": d.get("mermaid", ""), "diagram_legend": diagrams.legend(d, "") if d.get("mermaid") else ""}
|
|
839
|
+
out["text"] = walk_text(out)
|
|
840
|
+
out["next"] = ("Read the code of the steps that matter (`source` with the step's id) before stating what they do."
|
|
841
|
+
" To keep this walk, save it as a tour (`save_tour`, one stop per step that matters).")
|
|
842
|
+
return out
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _pipes(con, nodes: diagrams._Nodes, shown: list[dict], walked: set) -> list[dict]:
|
|
846
|
+
"""Programs that a type in the walk started with pipes and that the walk does not enter: the walk's messages to
|
|
847
|
+
them (a `send`, a `write` to stdin) are not calls the map can follow, so the other side is said in words."""
|
|
848
|
+
out, seen = [], set()
|
|
849
|
+
for unit in dict.fromkeys(nodes.unit(s["id"]) for s in shown):
|
|
850
|
+
u = nodes.get(unit)
|
|
851
|
+
if u is None or u["kind"] != "type":
|
|
852
|
+
continue
|
|
853
|
+
for src, dst, raw in con.execute("SELECT e.src_id, e.dst_id, e.attrs FROM edges e JOIN nodes n ON n.id = e.src_id"
|
|
854
|
+
" WHERE e.kind = 'communicates' AND n.parent_id = ?", (unit,)):
|
|
855
|
+
a = diagrams._attrs(raw)
|
|
856
|
+
if a.get("channel") != "process" or not a.get("pipes") or dst in walked or (unit, dst) in seen:
|
|
857
|
+
continue
|
|
858
|
+
seen.add((unit, dst))
|
|
859
|
+
sn = nodes.get(src) or {}
|
|
860
|
+
out.append({"type": u["name"], "starts": a.get("address") or "a program", "in": _label(con, src, nodes),
|
|
861
|
+
"at": f"{sn.get('path')}:{a.get('launched_at') or sn.get('span_start')}",
|
|
862
|
+
"program": _label(con, dst, nodes), "program_id": dst})
|
|
863
|
+
return out[:4]
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _nums(ks: list[int]) -> str:
|
|
867
|
+
ks = [str(k) for k in ks[:8]]
|
|
868
|
+
return ", ".join(ks[:-1]) + " and " + ks[-1] if len(ks) > 1 else ks[0]
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def walk_diagram(con, nodes: diagrams._Nodes, shown: list[dict], by_seq: dict, g: _Graph,
|
|
872
|
+
max_participants: int = 8, max_messages: int = 25) -> dict:
|
|
873
|
+
"""A Mermaid sequence diagram of a walk: one arrow per step, in the walk's order, each an edge on the map."""
|
|
874
|
+
pid: dict[str, str] = {}
|
|
875
|
+
lines, arrows, left = [], [], 0
|
|
876
|
+
|
|
877
|
+
def part(u):
|
|
878
|
+
if u not in pid:
|
|
879
|
+
pid[u] = f"P{len(pid) + 1}"
|
|
880
|
+
return pid[u]
|
|
881
|
+
if shown:
|
|
882
|
+
part(nodes.unit(shown[0]["id"]))
|
|
883
|
+
for s in shown:
|
|
884
|
+
parent = by_seq.get(s["parent"]) if s["parent"] is not None else None
|
|
885
|
+
if parent is None:
|
|
886
|
+
continue
|
|
887
|
+
e = diagrams._backing(con, nodes, parent["id"], s["id"], s["via"])
|
|
888
|
+
a, b = nodes.unit(parent["id"]), nodes.unit(s["id"])
|
|
889
|
+
if e is None or len(arrows) >= max_messages or len(set(pid) | {a, b}) > max_participants:
|
|
890
|
+
left += 1
|
|
891
|
+
continue
|
|
892
|
+
if e["kind"] == "channel":
|
|
893
|
+
head = "--)" if e["guess"] else "-)"
|
|
894
|
+
label = diagrams._channel_text(e["channel"], e.get("address") or "", nodes, s["id"])
|
|
895
|
+
else:
|
|
896
|
+
head = "-->>" if e["guess"] else "->>"
|
|
897
|
+
label = f"{nodes.fn(s['id'])}()" + (" (implementation)" if e["kind"] == "dispatch" else "")
|
|
898
|
+
if e["kind"] == "call" and "/route:" in s["id"]:
|
|
899
|
+
label = f"registers {nodes.fn(s['id'])}"
|
|
900
|
+
if e["kind"] == "runs":
|
|
901
|
+
label = f"runs {nodes.fn(s['id'])}"
|
|
902
|
+
lines.append(f" {part(a)}{head}{part(b)}: {diagrams._text(label)}")
|
|
903
|
+
arrows.append({"from": parent["id"], "to": s["id"], "kind": e["kind"], "guess": e["guess"],
|
|
904
|
+
**({"channel": e["channel"], "address": e.get("address") or ""} if e["kind"] == "channel" else {})})
|
|
905
|
+
if left and pid:
|
|
906
|
+
order = sorted(pid, key=lambda u: int(pid[u][1:]))
|
|
907
|
+
span = pid[order[0]] + ("," + pid[order[-1]] if len(order) > 1 else "")
|
|
908
|
+
lines.append(f" Note over {span}: and {left} more step{'s' if left != 1 else ''} not drawn")
|
|
909
|
+
labels = {}
|
|
910
|
+
for u in pid:
|
|
911
|
+
n = nodes.get(u) or {"name": u, "kind": "", "path": ""}
|
|
912
|
+
labels[u] = n["name"] if n["kind"] != "module" else (n.get("path") or n["name"])
|
|
913
|
+
head = ["sequenceDiagram"] + [f" participant {pid[u]} as {diagrams._text(labels[u])}"
|
|
914
|
+
for u in sorted(pid, key=lambda u: int(pid[u][1:]))]
|
|
915
|
+
return {"mermaid": "\n".join(head + lines) if arrows else "", "arrows": arrows, "left_out": left,
|
|
916
|
+
"guessed": any(a["guess"] for a in arrows), "channels": any(a["kind"] == "channel" for a in arrows)}
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def walk_text(r: dict) -> str:
|
|
920
|
+
"""The walk as numbered lines, indented by call depth."""
|
|
921
|
+
if not r.get("found", True):
|
|
922
|
+
return r.get("note", "")
|
|
923
|
+
first = r["steps"][0] if r["steps"] else None
|
|
924
|
+
head = {"walk": "Walk from", "path": "Shortest path from", "through": "Path from"}[r["mode"]]
|
|
925
|
+
L = [f"{head} {first['name'] if first else r['from']}"
|
|
926
|
+
+ (f" to {r['steps'][-1]['name']}" if r["mode"] == "path" and r["steps"] else "")
|
|
927
|
+
+ (f" through {r['through']}" if r["mode"] == "through" else "")
|
|
928
|
+
+ f": {r['shown']} of {r['total_steps']} steps shown" + (f" (along the flow {r['flow']})" if r.get("flow") else "")
|
|
929
|
+
+ "."]
|
|
930
|
+
base = min((s["depth"] for s in r["steps"]), default=0)
|
|
931
|
+
part = None
|
|
932
|
+
for s in r["steps"]:
|
|
933
|
+
pad = " " * min(s["depth"] - base, 8)
|
|
934
|
+
# A named part of the code (module_outline, name_part), said where the walk enters it.
|
|
935
|
+
into = f" [in {s['part']}]" if s.get("part") and s["part"] != part else ""
|
|
936
|
+
part = s.get("part")
|
|
937
|
+
if s["how"] == "start":
|
|
938
|
+
L.append(f"{s['n']:>3}. {pad}{s['name']} ({s['at']}){into}")
|
|
939
|
+
else:
|
|
940
|
+
L.append(f"{s['n']:>3}. {pad}{s['how']} -> {s['name']} ({s['at']})" + (" [guessed link]" if s.get("guessed") else "")
|
|
941
|
+
+ into)
|
|
942
|
+
if s.get("call"):
|
|
943
|
+
L.append(f" {pad} at {s.get('call_line', '')}: {s['call']}")
|
|
944
|
+
if s.get("declaration") and s["how"] != "call":
|
|
945
|
+
L.append(f" {pad} declared: {s['declaration']}")
|
|
946
|
+
if s.get("crosses"):
|
|
947
|
+
L.append(f" {pad} crosses {s['crosses']}")
|
|
948
|
+
for x in s.get("later_elsewhere", ()):
|
|
949
|
+
L.append(f" {pad} later, elsewhere: {x['what']} ({x['reader']})")
|
|
950
|
+
if r.get("helpers"):
|
|
951
|
+
L.append(f"Helpers counted, not shown ({r['helpers']['count']}): {', '.join(r['helpers']['names'])}.")
|
|
952
|
+
if r.get("left_out"):
|
|
953
|
+
L.append(f"Left out: {r['left_out']} deeper steps; raise max_steps to see more.")
|
|
954
|
+
L += ["", "What the map could not see:"] + [f"- {n}" for n in r["not_seen"]]
|
|
955
|
+
return "\n".join(L)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def diagram(con, ids: list[str]) -> dict:
|
|
959
|
+
"""diagrams.sequence for any functions or types, named by id or by name."""
|
|
960
|
+
got, missing = [], []
|
|
961
|
+
for i in ids:
|
|
962
|
+
r = _resolve(con, i)
|
|
963
|
+
(got.append(r["id"]) if "id" in r else missing.append({"name": i, "error": r.get("error"),
|
|
964
|
+
"candidates": r.get("candidates", [])[:5]}))
|
|
965
|
+
if not got:
|
|
966
|
+
return {"error": "none of the ids is on the map; find them with `search` or `find_flows`.", "missing": missing}
|
|
967
|
+
d = diagrams.sequence(con, got, marked="focus")
|
|
968
|
+
if not d.get("mermaid"):
|
|
969
|
+
return {"error": "nothing to draw: give functions or types (a type stands for its methods).", "missing": missing}
|
|
970
|
+
return {"mermaid": d["mermaid"], "participants": d["participants"], "focus": d["focus"],
|
|
971
|
+
"legend": diagrams.legend(d, "The code you named is shaded."), "left_out": d["left_out"],
|
|
972
|
+
"arrows": len(d["arrows"]), **({"missing": missing} if missing else {}),
|
|
973
|
+
**({"not_drawn": d["focus_left_out"]} if d["focus_left_out"] else {})}
|