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/context.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""Context: a short outline of the code around a focus, ranked and cut to fit a token budget, for an agent that is
|
|
2
|
+
about to read or edit that code (after Aider's repository map).
|
|
3
|
+
|
|
4
|
+
The focus is what the agent is working on: node ids, names (`Owner.method`, `method`), file paths, a change
|
|
5
|
+
(`spec-<id>`, `pr-<id>`: the code its plan or review marked) or words to search for. Every function, type, field
|
|
6
|
+
and test on the map is ranked by personalized PageRank, seeded at the focus, over the links the map has:
|
|
7
|
+
|
|
8
|
+
calls, overrides 1.0 each way
|
|
9
|
+
channels (communicates) 1.0 each way: a request and the route that serves it, a writer and a reader
|
|
10
|
+
extends, implements 0.8
|
|
11
|
+
instantiates 0.6
|
|
12
|
+
uses_type 0.4
|
|
13
|
+
reads, writes (a field) 0.3
|
|
14
|
+
contains 0.3 between a type and its members, 0.2 between a file and what sits at its top
|
|
15
|
+
|
|
16
|
+
Links run both ways: callers matter (they break when a signature changes) and so do callees (the agent calls
|
|
17
|
+
them). A broad hub (an error class every script raises, a route most of the client requests) links code that shares
|
|
18
|
+
nothing else, so a link through a node with more than HUB links counts for less, and such a node, unless it is
|
|
19
|
+
linked to the focus, is shown in proportion to how few links it has. The rank is worked out by forward push (Andersen, Chung and Lang), which only visits code near the focus, so
|
|
20
|
+
it costs milliseconds however large the map. Code a test reaches, and code an entry point runs first, get a small
|
|
21
|
+
boost. The outline then shows, file by file, each chosen symbol's declaration line (not its body), with the focus
|
|
22
|
+
marked `>`, each channel end said in words ("answers GET /api/x", "writes table t"), and the symbols that call the
|
|
23
|
+
focus or that it calls said so, until the budget (tokens estimated as characters / 4) is spent. The last line says
|
|
24
|
+
what was left out.
|
|
25
|
+
|
|
26
|
+
The graph is read once per store and kept, keyed by the store's generation, so a server answers later calls
|
|
27
|
+
without reading it again.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import threading
|
|
35
|
+
from collections import defaultdict, deque
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Optional
|
|
38
|
+
|
|
39
|
+
from . import diff, query, store
|
|
40
|
+
from .diagrams import cut as diagrams_cut
|
|
41
|
+
|
|
42
|
+
WEIGHTS = {"calls": 1.0, "overrides": 1.0, "communicates": 1.0, "extends": 0.8, "implements": 0.8,
|
|
43
|
+
"instantiates": 0.6, "uses_type": 0.4, "reads": 0.3, "writes": 0.3}
|
|
44
|
+
IN_TYPE, IN_FILE = 0.3, 0.2 # containment: a member and its type; something at the top of a file and the file
|
|
45
|
+
MAX_PAIR = 1.5 # two nodes linked several ways count a little more, not without bound
|
|
46
|
+
HUB = 12 # a node with more links than this is a hub: each of its links counts sqrt(HUB/links)
|
|
47
|
+
RESTART = 0.25 # the walk's chance of going back to the focus at each step: higher keeps it nearer
|
|
48
|
+
EPS = 2e-6 # forward push stops when what is left at a node is below this times its degree
|
|
49
|
+
TESTED_BOOST, ENTRY_BOOST = 1.15, 1.3
|
|
50
|
+
SHOWN = ("callable", "type", "field", "test")
|
|
51
|
+
LINE_MAX = 160 # a declaration longer than this is cut
|
|
52
|
+
MIN_TOKENS, MAX_TOKENS = 200, 8000
|
|
53
|
+
|
|
54
|
+
# What a channel end does, from the sender's side and the receiver's; the address follows. A DI link names the other end.
|
|
55
|
+
CHANNEL_WORDS = {
|
|
56
|
+
"http": ("requests", "answers"),
|
|
57
|
+
"db": ("writes table", "reads table"),
|
|
58
|
+
"file": ("writes file", "reads file"),
|
|
59
|
+
"queue": ("publishes", "handles"),
|
|
60
|
+
"event": ("raises", "handles event"),
|
|
61
|
+
"process": ("launches", "is launched as"),
|
|
62
|
+
"rpc": ("calls rpc", "serves rpc"),
|
|
63
|
+
"di": ("is bound by DI to", "is registered by DI for"),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Graph:
|
|
68
|
+
"""The map as the ranking reads it: nodes by number, weighted links both ways, and what each node is."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, con):
|
|
71
|
+
self.ids: list[str] = []
|
|
72
|
+
self.index: dict[str, int] = {}
|
|
73
|
+
self.kind: list[str] = []
|
|
74
|
+
self.name: list[str] = []
|
|
75
|
+
self.parent: list[int] = []
|
|
76
|
+
self.path: list[Optional[str]] = []
|
|
77
|
+
self.line: list[int] = []
|
|
78
|
+
self.raw: list[Optional[str]] = []
|
|
79
|
+
for r in con.execute("SELECT id, kind, name, parent_id, path, span_start, attrs FROM nodes"
|
|
80
|
+
" WHERE kind IN ('file', 'type', 'callable', 'field', 'test', 'entry_point')"):
|
|
81
|
+
self.index[r[0]] = len(self.ids)
|
|
82
|
+
self.ids.append(r[0])
|
|
83
|
+
self.kind.append(r[1])
|
|
84
|
+
self.name.append(r[2])
|
|
85
|
+
self.parent.append(r[3])
|
|
86
|
+
self.path.append(r[4])
|
|
87
|
+
self.line.append(r[5] or 0)
|
|
88
|
+
self.raw.append(r[6])
|
|
89
|
+
ix = self.index
|
|
90
|
+
self.parent = [ix.get(p, -1) if p else -1 for p in self.parent]
|
|
91
|
+
pair: dict[tuple, dict] = defaultdict(dict)
|
|
92
|
+
|
|
93
|
+
def link(a, b, kind, w):
|
|
94
|
+
if a is None or b is None or a == b:
|
|
95
|
+
return
|
|
96
|
+
key = (a, b) if a < b else (b, a)
|
|
97
|
+
pair[key][kind] = max(pair[key].get(kind, 0.0), w)
|
|
98
|
+
|
|
99
|
+
for s, d in con.execute("SELECT DISTINCT src_id, dst_id FROM calls"):
|
|
100
|
+
link(ix.get(s), ix.get(d), "calls", WEIGHTS["calls"])
|
|
101
|
+
kinds = [k for k in WEIGHTS if k not in ("calls", "communicates")]
|
|
102
|
+
for k, s, d in con.execute(f"SELECT kind, src_id, dst_id FROM edges WHERE kind IN ({','.join('?' * len(kinds))})", kinds):
|
|
103
|
+
link(ix.get(s), ix.get(d), k, WEIGHTS[k])
|
|
104
|
+
# Channel ends, kept to be said in words: node -> [(side, channel, address, other end)].
|
|
105
|
+
self.channels: dict[int, list] = defaultdict(list)
|
|
106
|
+
for s, d, a in con.execute("SELECT src_id, dst_id, attrs FROM edges WHERE kind = 'communicates'"):
|
|
107
|
+
si, di = ix.get(s), ix.get(d)
|
|
108
|
+
link(si, di, "communicates", WEIGHTS["communicates"])
|
|
109
|
+
try:
|
|
110
|
+
attrs = json.loads(a) if a else {}
|
|
111
|
+
except ValueError:
|
|
112
|
+
attrs = {}
|
|
113
|
+
ch, addr = attrs.get("channel") or "channel", str(attrs.get("address") or "")
|
|
114
|
+
if si is not None:
|
|
115
|
+
self.channels[si].append((0, ch, addr, di))
|
|
116
|
+
if di is not None:
|
|
117
|
+
self.channels[di].append((1, ch, addr, si))
|
|
118
|
+
for i, p in enumerate(self.parent):
|
|
119
|
+
if p < 0 or self.kind[i] == "entry_point":
|
|
120
|
+
continue
|
|
121
|
+
if self.kind[p] == "file":
|
|
122
|
+
link(i, p, "contains", IN_FILE)
|
|
123
|
+
elif self.kind[p] in ("type", "callable"):
|
|
124
|
+
link(i, p, "contains", IN_TYPE)
|
|
125
|
+
n = len(self.ids)
|
|
126
|
+
self.adj: list[list] = [[] for _ in range(n)]
|
|
127
|
+
# A broad hub (an error class every scenario raises, a route half the client requests, a registrar of every
|
|
128
|
+
# route) links things that share nothing else: a link through it counts for less, the more it links.
|
|
129
|
+
links = [0] * n
|
|
130
|
+
for a, b in pair:
|
|
131
|
+
links[a] += 1
|
|
132
|
+
links[b] += 1
|
|
133
|
+
damp = [min(1.0, (HUB / c) ** 0.5) if c else 1.0 for c in links]
|
|
134
|
+
for (a, b), ks in pair.items():
|
|
135
|
+
w = min(sum(ks.values()), MAX_PAIR) * damp[a] * damp[b]
|
|
136
|
+
self.adj[a].append((b, w))
|
|
137
|
+
self.adj[b].append((a, w))
|
|
138
|
+
self.deg = [sum(w for _, w in nb) for nb in self.adj]
|
|
139
|
+
# Direct calls, to say "calls the focus" and "called by the focus".
|
|
140
|
+
self.callees: dict[int, set] = defaultdict(set)
|
|
141
|
+
self.callers: dict[int, set] = defaultdict(set)
|
|
142
|
+
for s, d in con.execute("SELECT DISTINCT src_id, dst_id FROM calls"):
|
|
143
|
+
si, di = ix.get(s), ix.get(d)
|
|
144
|
+
if si is not None and di is not None and si != di:
|
|
145
|
+
self.callees[si].add(di)
|
|
146
|
+
self.callers[di].add(si)
|
|
147
|
+
# A walk piles up at a hub because so much leads to it, not because it is near: past HUB links, a hub's score
|
|
148
|
+
# is taken in proportion to how many it has, as a reason to show it (unless it is linked to the focus itself).
|
|
149
|
+
self.hub = [min(1.0, HUB / c) if c else 1.0 for c in links]
|
|
150
|
+
self.boost = [1.0] * n
|
|
151
|
+
for (cid,) in con.execute(store.TESTED):
|
|
152
|
+
if cid in ix:
|
|
153
|
+
self.boost[ix[cid]] = TESTED_BOOST
|
|
154
|
+
for (eid,) in con.execute("SELECT dst_id FROM edges WHERE kind = 'exposes'"):
|
|
155
|
+
e = ix.get(eid)
|
|
156
|
+
if e is None:
|
|
157
|
+
continue
|
|
158
|
+
# What a program runs first: the code an entry point exposes, and what that calls directly.
|
|
159
|
+
for x in (e, *self.callees.get(e, ())):
|
|
160
|
+
self.boost[x] = max(self.boost[x], ENTRY_BOOST)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_cache: dict = {}
|
|
164
|
+
_cache_lock = threading.Lock()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def graph(con) -> Graph:
|
|
168
|
+
"""The store's graph, read once per index run (the store's generation) and kept."""
|
|
169
|
+
path = str(store.store_file(con))
|
|
170
|
+
gen = con.execute("SELECT value FROM meta WHERE key = 'generation'").fetchone()
|
|
171
|
+
stamp = (gen[0] if gen else None, con.execute("SELECT COUNT(*) FROM nodes").fetchone()[0])
|
|
172
|
+
with _cache_lock:
|
|
173
|
+
hit = _cache.get(path)
|
|
174
|
+
if hit is not None and hit[0] == stamp and gen is not None:
|
|
175
|
+
return hit[1]
|
|
176
|
+
g = Graph(con)
|
|
177
|
+
with _cache_lock:
|
|
178
|
+
_cache[path] = (stamp, g)
|
|
179
|
+
return g
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def rank(g: Graph, seeds: dict[int, float]) -> dict[int, float]:
|
|
183
|
+
"""Personalized PageRank from `seeds` (node -> weight), by forward push: {node: score}, scores summing to
|
|
184
|
+
about 1. Only nodes the walk reaches get a score."""
|
|
185
|
+
total = sum(seeds.values()) or 1.0
|
|
186
|
+
r: dict[int, float] = {u: w / total for u, w in seeds.items()}
|
|
187
|
+
p: dict[int, float] = defaultdict(float)
|
|
188
|
+
queue, queued = deque(r), set(r)
|
|
189
|
+
adj, deg = g.adj, g.deg
|
|
190
|
+
while queue:
|
|
191
|
+
u = queue.popleft()
|
|
192
|
+
queued.discard(u)
|
|
193
|
+
ru = r.get(u, 0.0)
|
|
194
|
+
du = deg[u]
|
|
195
|
+
if ru <= 0.0 or (du and ru < EPS * du):
|
|
196
|
+
continue
|
|
197
|
+
r[u] = 0.0
|
|
198
|
+
if not du: # nothing leads on: what reaches it stays there
|
|
199
|
+
p[u] += ru
|
|
200
|
+
continue
|
|
201
|
+
p[u] += RESTART * ru
|
|
202
|
+
share = (1.0 - RESTART) * ru / du
|
|
203
|
+
for v, w in adj[u]:
|
|
204
|
+
rv = r.get(v, 0.0) + share * w
|
|
205
|
+
r[v] = rv
|
|
206
|
+
if v not in queued and rv >= EPS * deg[v]:
|
|
207
|
+
queue.append(v)
|
|
208
|
+
queued.add(v)
|
|
209
|
+
return p
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# -- what the focus names ------------------------------------------------------------------------
|
|
213
|
+
def _change_nodes(con, cid: str) -> list[str]:
|
|
214
|
+
"""The code a change's plan or review marked: what it changes, adds, must edit, and contracts it must keep."""
|
|
215
|
+
ids: list[str] = []
|
|
216
|
+
row = con.execute("SELECT spec FROM views WHERE id = ?", ("view-" + cid,)).fetchone()
|
|
217
|
+
if row is not None:
|
|
218
|
+
try:
|
|
219
|
+
marks = json.loads(row[0] or "{}").get("marks", [])
|
|
220
|
+
except ValueError:
|
|
221
|
+
marks = []
|
|
222
|
+
ids += [m["id"] for m in marks if isinstance(m, dict) and m.get("role") in ("changed", "new", "must_edit", "contract")]
|
|
223
|
+
for (nodes,) in con.execute("SELECT nodes FROM spec_items WHERE change_id = ? AND kind = 'task'", (cid,)):
|
|
224
|
+
try:
|
|
225
|
+
ids += json.loads(nodes or "[]")
|
|
226
|
+
except ValueError:
|
|
227
|
+
pass
|
|
228
|
+
out = []
|
|
229
|
+
for i in dict.fromkeys(ids):
|
|
230
|
+
if con.execute("SELECT 1 FROM nodes WHERE id = ?", (i,)).fetchone() is None and i.endswith(")"):
|
|
231
|
+
# A method marked when the change was planned whose parameters have changed since: the same owner and name
|
|
232
|
+
base = diff._base(i)
|
|
233
|
+
out += [r[0] for r in con.execute("SELECT id FROM nodes WHERE id > ? AND id < ? AND kind = 'callable'",
|
|
234
|
+
(base + "(", base + "*"))
|
|
235
|
+
if diff._base(r[0]) == base]
|
|
236
|
+
else:
|
|
237
|
+
out.append(i)
|
|
238
|
+
return list(dict.fromkeys(out))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_change(con, cid: str) -> bool:
|
|
242
|
+
return con.execute("SELECT 1 FROM views WHERE id = ? UNION SELECT 1 FROM spec_items WHERE change_id = ?",
|
|
243
|
+
("view-" + cid, cid)).fetchone() is not None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
WORD_STOP = set("a an the and or of to in on for with from by is are was be it its this that how what where when"
|
|
247
|
+
" which who does do code function method class file files".split())
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _search(con, g: Graph, text: str, most: int = 6) -> list[str]:
|
|
251
|
+
"""Nodes for words: the whole text searched as one name, and each word alone. A node found scores by how many of
|
|
252
|
+
the words its id and path hold, then by kind (functions and types before files, fields last), then by how near
|
|
253
|
+
the top of a search it came."""
|
|
254
|
+
words = [w.lower() for w in re.findall(r"[A-Za-z0-9_]+", text) if len(w) > 2 and w.lower() not in WORD_STOP]
|
|
255
|
+
near: dict[str, float] = defaultdict(float)
|
|
256
|
+
for q in [text] + (words[:8] if len(words) > 1 else []):
|
|
257
|
+
for k, h in enumerate(query.search(con, q, limit=30)["results"]):
|
|
258
|
+
if h["id"] in g.index and _search_ok(g, h["id"]):
|
|
259
|
+
near[h["id"]] = max(near[h["id"]], 1.0 / (k + 1))
|
|
260
|
+
kind_rank = {"callable": 1.0, "type": 1.0, "test": 0.6, "file": 0.5, "field": 0.0}
|
|
261
|
+
|
|
262
|
+
def score(i):
|
|
263
|
+
u = g.index[i]
|
|
264
|
+
hay = (i + " " + (g.path[u] or "")).lower()
|
|
265
|
+
return (sum(w in hay for w in words), kind_rank.get(g.kind[u], 0.0) + near[i])
|
|
266
|
+
return sorted(near, key=lambda i: (tuple(-x for x in score(i)), i))[:most]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _search_ok(g: Graph, i: str) -> bool:
|
|
270
|
+
u = g.index[i]
|
|
271
|
+
return g.kind[u] == "file" or _shown(g, u)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def resolve_focus(con, g: Graph, focus: list[str]) -> tuple[dict, list[dict]]:
|
|
275
|
+
"""Each focus item turned into nodes on the map. ({node: weight}, [{"focus", "as", "ids"} or {"focus", "error"}])."""
|
|
276
|
+
seeds: dict[int, float] = defaultdict(float)
|
|
277
|
+
found: list[dict] = []
|
|
278
|
+
for raw in focus:
|
|
279
|
+
text = (raw or "").strip()
|
|
280
|
+
if not text:
|
|
281
|
+
continue
|
|
282
|
+
ids, how, cid = [], "", None
|
|
283
|
+
if text in g.index:
|
|
284
|
+
ids, how = [text], "node"
|
|
285
|
+
elif re.fullmatch(r"(spec|pr)-\S+", text) and _is_change(con, text):
|
|
286
|
+
cid = text
|
|
287
|
+
if not ids and cid is None and " " not in text:
|
|
288
|
+
r = query.resolve(con, text)
|
|
289
|
+
if "id" in r:
|
|
290
|
+
ids, how = [r["id"]], "name"
|
|
291
|
+
elif r.get("candidates") and "could be" in r.get("error", ""):
|
|
292
|
+
ids, how = [c["id"] for c in r["candidates"][:8]], "name, several things"
|
|
293
|
+
elif "/" not in text and _is_change(con, "spec-" + text): # a change folder's id, as plan takes it
|
|
294
|
+
cid = "spec-" + text
|
|
295
|
+
if cid is not None:
|
|
296
|
+
ids, how = _change_nodes(con, cid), f"change {cid}"
|
|
297
|
+
if not ids:
|
|
298
|
+
found.append({"focus": text, "error": f"the change {cid} marks no code yet: run `leyline plan` on it"})
|
|
299
|
+
continue
|
|
300
|
+
if not ids:
|
|
301
|
+
ids, how = _search(con, g, text), "search"
|
|
302
|
+
ids = [i for i in ids if i in g.index]
|
|
303
|
+
if not ids:
|
|
304
|
+
found.append({"focus": text, "error": "nothing on the map matches it"})
|
|
305
|
+
continue
|
|
306
|
+
# Each focus item weighs the same, shared among its nodes; search hits nearer the top weigh more.
|
|
307
|
+
ws = [1.0 / (k + 1) if how == "search" else 1.0 for k in range(len(ids))]
|
|
308
|
+
for i, w in zip(ids, ws):
|
|
309
|
+
u = g.index[i]
|
|
310
|
+
seeds[u] += w / sum(ws)
|
|
311
|
+
if g.kind[u] == "file": # a file: what sits at its top, as well as the file
|
|
312
|
+
kids = [k for k, p in enumerate(g.parent) if p == u and g.kind[k] in SHOWN]
|
|
313
|
+
for k in kids:
|
|
314
|
+
seeds[k] += w / sum(ws) / max(len(kids), 1)
|
|
315
|
+
found.append({"focus": text, "as": how, "ids": ids})
|
|
316
|
+
return dict(seeds), found
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# -- the outline ---------------------------------------------------------------------------------
|
|
320
|
+
def _short(g: Graph, u: int) -> str:
|
|
321
|
+
if g.kind[u] == "test":
|
|
322
|
+
n = g.name[u]
|
|
323
|
+
return "test " + (n if len(n) <= 40 else n[:37].rstrip() + "...")
|
|
324
|
+
p = g.parent[u]
|
|
325
|
+
if p >= 0 and g.kind[p] == "type":
|
|
326
|
+
return f"{g.name[p]}.{g.name[u]}"
|
|
327
|
+
return g.name[u]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _shown(g: Graph, u: int) -> bool:
|
|
331
|
+
return g.kind[u] in SHOWN and g.name[u] != "<module>" and bool(g.path[u])
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _ancestors(g: Graph, u: int) -> list[int]:
|
|
335
|
+
"""The types and functions a node sits in, outermost first, up to its file."""
|
|
336
|
+
out, p, seen = [], g.parent[u], 0
|
|
337
|
+
while p >= 0 and g.kind[p] != "file" and seen < 20:
|
|
338
|
+
if _shown(g, p):
|
|
339
|
+
out.append(p)
|
|
340
|
+
p, seen = g.parent[p], seen + 1
|
|
341
|
+
return out[::-1]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class _Sources:
|
|
345
|
+
"""Declaration lines read from the working tree, for nodes the map keeps no signature for."""
|
|
346
|
+
|
|
347
|
+
def __init__(self, con):
|
|
348
|
+
self.roots = store.roots(con)
|
|
349
|
+
self.files: dict = {}
|
|
350
|
+
|
|
351
|
+
def line(self, node_id: str, path: str, line: int) -> Optional[str]:
|
|
352
|
+
repo = node_id.split(":", 1)[0]
|
|
353
|
+
root = self.roots.get(repo)
|
|
354
|
+
if root is None or not path or not line:
|
|
355
|
+
return None
|
|
356
|
+
key = (repo, path)
|
|
357
|
+
if key not in self.files:
|
|
358
|
+
from .indexer import source_lines
|
|
359
|
+
try:
|
|
360
|
+
self.files[key] = source_lines(Path(root) / path)
|
|
361
|
+
except OSError:
|
|
362
|
+
self.files[key] = []
|
|
363
|
+
lines = self.files[key]
|
|
364
|
+
return lines[line - 1].strip() if 0 < line <= len(lines) else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _declaration(g: Graph, u: int, sources: _Sources) -> str:
|
|
368
|
+
try:
|
|
369
|
+
a = json.loads(g.raw[u]) if g.raw[u] else {}
|
|
370
|
+
except ValueError:
|
|
371
|
+
a = {}
|
|
372
|
+
kind, name = g.kind[u], g.name[u]
|
|
373
|
+
if kind == "test":
|
|
374
|
+
text = "test " + json.dumps(a.get("full_name") or a.get("signature") or name, ensure_ascii=False)
|
|
375
|
+
elif kind == "field":
|
|
376
|
+
text = f"{name}: {a['declared_type']}" if a.get("declared_type") else name
|
|
377
|
+
else:
|
|
378
|
+
text = a.get("signature") or sources.line(g.ids[u], g.path[u], g.line[u]) or f"{kind} {name}"
|
|
379
|
+
text = re.sub(r"\s+", " ", text).strip().rstrip("{").rstrip()
|
|
380
|
+
# Some signatures are kept cut just before the body, losing the last `>` of a generic return type.
|
|
381
|
+
opened = text.count("<") - text.count(">") + text.count("=>")
|
|
382
|
+
if 0 < opened <= 2 and re.search(r"<[\w\s,.|\[\]<>]*$", text):
|
|
383
|
+
text += ">" * opened
|
|
384
|
+
return text if len(text) <= LINE_MAX else text[:LINE_MAX - 3].rstrip() + "..."
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _notes(g: Graph, u: int, focus: set, names: dict) -> str:
|
|
388
|
+
"""What to know about a node besides its declaration: its channel ends in words, and how it meets the focus."""
|
|
389
|
+
said: dict[str, list] = {}
|
|
390
|
+
for side, ch, addr, end in g.channels.get(u, ()):
|
|
391
|
+
verb = CHANNEL_WORDS.get(ch, ("sends on " + ch, "receives on " + ch))[side]
|
|
392
|
+
what = (_short(g, end) if end is not None else "?") if ch == "di" else (addr or "?")
|
|
393
|
+
if what not in said.setdefault(verb, []):
|
|
394
|
+
said[verb].append(what)
|
|
395
|
+
phrases = [verb + " " + ", ".join(xs[:3]) + (f" and {len(xs) - 3} more" if len(xs) > 3 else "")
|
|
396
|
+
for verb, xs in said.items()]
|
|
397
|
+
if len(phrases) > 3:
|
|
398
|
+
phrases = phrases[:3] + [f"{len(phrases) - 3} more kinds of link"]
|
|
399
|
+
said, other = phrases, []
|
|
400
|
+
notes = ["; ".join(said)] if said else []
|
|
401
|
+
if u in focus:
|
|
402
|
+
n_in, n_out = len(g.callers.get(u, ())), len(g.callees.get(u, ()))
|
|
403
|
+
counts = ([f"called from {n_in} place{'s' * (n_in != 1)}"] if n_in else []) + ([f"calls {n_out}"] if n_out else [])
|
|
404
|
+
if counts:
|
|
405
|
+
notes.append(", ".join(counts))
|
|
406
|
+
else:
|
|
407
|
+
calls = [names[f] for f in focus if f in g.callees.get(u, ())]
|
|
408
|
+
called = [names[f] for f in focus if f in g.callers.get(u, ())]
|
|
409
|
+
for verb, xs in (("calls", calls), ("called by", called)):
|
|
410
|
+
if xs:
|
|
411
|
+
other.append(f"{verb} " + ", ".join(sorted(xs)[:2]) + (f" and {len(xs) - 2} more" if len(xs) > 2 else ""))
|
|
412
|
+
return "; ".join(notes + other)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def build(con, focus: list[str] | str, budget_tokens: int = 2000) -> dict:
|
|
416
|
+
"""The outline for a focus within a token budget: {"text", "tokens", "focus", "shown", "left_out", ...}."""
|
|
417
|
+
import time
|
|
418
|
+
t0 = time.perf_counter()
|
|
419
|
+
focus = [focus] if isinstance(focus, str) else list(focus or [])
|
|
420
|
+
budget = max(MIN_TOKENS, min(int(budget_tokens or 2000), MAX_TOKENS))
|
|
421
|
+
g = graph(con)
|
|
422
|
+
t_load = time.perf_counter() - t0
|
|
423
|
+
seeds, found = resolve_focus(con, g, focus)
|
|
424
|
+
if not seeds:
|
|
425
|
+
why = "; ".join(f"{f['focus']!r}: {f['error']}" for f in found) or "no focus given"
|
|
426
|
+
return {"error": f"Nothing to start from ({why}). Give node ids, names such as Owner.method, file paths, a"
|
|
427
|
+
" change id (spec-<id>, pr-<id>) or words to search for.", "focus": found}
|
|
428
|
+
scores = rank(g, seeds)
|
|
429
|
+
fset = {u for u in seeds if _shown(g, u)}
|
|
430
|
+
for u in seeds: # a file named as the focus: what sits at its top is the focus
|
|
431
|
+
if g.kind[u] == "file":
|
|
432
|
+
fset |= {k for k, p in enumerate(g.parent) if p == u and _shown(g, k) and k in seeds}
|
|
433
|
+
names = {u: _short(g, u) for u in fset}
|
|
434
|
+
near = {v for u in seeds for v, _w in g.adj[u]}
|
|
435
|
+
|
|
436
|
+
def weight(u): # the walk's score with its boosts, and a hub's share of it unless it is linked to the focus
|
|
437
|
+
return scores.get(u, 0.0) * g.boost[u] * (1.0 if u in near else g.hub[u])
|
|
438
|
+
ranked = sorted((u for u in scores if _shown(g, u) and u not in fset), key=lambda u: (-weight(u), g.ids[u]))
|
|
439
|
+
order = sorted(fset, key=lambda u: -seeds.get(u, 0.0)) + ranked
|
|
440
|
+
t_rank = time.perf_counter() - t0 - t_load
|
|
441
|
+
|
|
442
|
+
sources = _Sources(con)
|
|
443
|
+
budget_chars = budget * 4
|
|
444
|
+
head_names = [f["focus"] for f in found if "ids" in f]
|
|
445
|
+
header = f"Code around {', '.join(head_names)[:200]}: declarations, the most related files first; > marks the focus."
|
|
446
|
+
# What the focus named that is not on the map is said at the end, and counts against the budget too.
|
|
447
|
+
missing = [f"Not found: {diagrams_cut(f['focus'], 60)!r} ({f['error']})." for f in found if "error" in f]
|
|
448
|
+
reserve = len(header) + 520 + sum(len(m) + 1 for m in missing) # the header, the summary and left-out lines
|
|
449
|
+
chosen: set[int] = set()
|
|
450
|
+
lines_of: dict[int, tuple] = {} # node -> (declaration, notes)
|
|
451
|
+
files: dict[str, float] = {} # path -> best score in it, for the order of files
|
|
452
|
+
used, misses, left = reserve, 0, []
|
|
453
|
+
shown_path: dict[str, str] = {} # a path shortened to fit the focus into a small budget
|
|
454
|
+
|
|
455
|
+
def cost(u):
|
|
456
|
+
decl, note = lines_of.get(u) or (_declaration(g, u, sources), _notes(g, u, fset, names))
|
|
457
|
+
lines_of[u] = (decl, note)
|
|
458
|
+
depth = len(_ancestors(g, u))
|
|
459
|
+
return 2 + 2 * depth + len(decl) + 1 + ((6 + 2 * depth + len(note) + 1) if note else 0)
|
|
460
|
+
|
|
461
|
+
for pos, u in enumerate(order):
|
|
462
|
+
need = [a for a in _ancestors(g, u) if a not in chosen] + [u]
|
|
463
|
+
c = sum(cost(x) for x in need) + (0 if g.path[u] in files else len(g.path[u]) + 2)
|
|
464
|
+
if used + c > budget_chars and u in fset and not chosen:
|
|
465
|
+
# The focus is always shown: when it does not fit, shorten its file's path (keeping the end, which names
|
|
466
|
+
# the file) and its lines to what the budget has left.
|
|
467
|
+
room = budget_chars - used - sum(cost(x) - len(lines_of[x][0]) - len(lines_of[x][1]) for x in need) - 2
|
|
468
|
+
plen = min(len(g.path[u]), max(24, room // 3))
|
|
469
|
+
shown_path[g.path[u]] = g.path[u] if plen >= len(g.path[u]) else "..." + g.path[u][-(plen - 3):]
|
|
470
|
+
per = max(16, (room - plen) // sum(1 + bool(lines_of[x][1]) for x in need))
|
|
471
|
+
for x in need:
|
|
472
|
+
d, note = lines_of[x]
|
|
473
|
+
lines_of[x] = (diagrams_cut(d, per), diagrams_cut(note, per) if note else note)
|
|
474
|
+
c = sum(cost(x) for x in need) + len(shown_path[g.path[u]]) + 2
|
|
475
|
+
if used + c > budget_chars and not (u in fset and not chosen):
|
|
476
|
+
left.append(u)
|
|
477
|
+
misses += 1
|
|
478
|
+
if misses > 40: # nothing small enough is coming: stop looking
|
|
479
|
+
left += [x for x in order[pos + 1:]]
|
|
480
|
+
break
|
|
481
|
+
continue
|
|
482
|
+
used += c
|
|
483
|
+
chosen.update(need)
|
|
484
|
+
files[g.path[u]] = max(files.get(g.path[u], 0.0), 10.0 if u in fset else weight(u))
|
|
485
|
+
|
|
486
|
+
# Render: files by their best score, inside each by line, members under their type.
|
|
487
|
+
out = [header, ""]
|
|
488
|
+
for path in sorted(files, key=lambda p: (-files[p], p)):
|
|
489
|
+
out.append(shown_path.get(path, path))
|
|
490
|
+
for u in sorted((x for x in chosen if g.path[x] == path), key=lambda x: (g.line[x], len(_ancestors(g, x)))):
|
|
491
|
+
depth = len(_ancestors(g, u))
|
|
492
|
+
decl, note = lines_of.get(u) or (_declaration(g, u, sources), _notes(g, u, fset, names))
|
|
493
|
+
out.append(("> " if u in fset else " ") + " " * depth + decl)
|
|
494
|
+
if note:
|
|
495
|
+
out.append(" " + " " * depth + " -- " + note)
|
|
496
|
+
out.append("")
|
|
497
|
+
near = _hops(g, set(seeds), 2)
|
|
498
|
+
related = [u for u in left if u in near]
|
|
499
|
+
direct = sum(near[u] == 1 for u in related)
|
|
500
|
+
left_files = sorted({g.path[u] for u in related})
|
|
501
|
+
out.append(f"Shown: {len(chosen)} symbol{'s' * (len(chosen) != 1)} in {len(files)} file{'s' * (len(files) != 1)}.")
|
|
502
|
+
if related:
|
|
503
|
+
top = diagrams_cut(", ".join(f"{_short(g, u)} ({g.path[u]})" for u in related[:3]), 260)
|
|
504
|
+
out.append(f"Left out: {direct} more symbol{'s' * (direct != 1)} linked directly to the focus and"
|
|
505
|
+
f" {len(related) - direct} two links away, in {len(left_files)} file{'s' * (len(left_files) != 1)};"
|
|
506
|
+
f" the nearest: {top}. Raise the budget to see more.")
|
|
507
|
+
else:
|
|
508
|
+
out.append("Left out: nothing within two links of the focus.")
|
|
509
|
+
out += missing
|
|
510
|
+
text = "\n".join(out).rstrip() + "\n"
|
|
511
|
+
return {"focus": found, "budget_tokens": budget, "tokens": len(text) // 4, "text": text,
|
|
512
|
+
"shown": {"symbols": len(chosen), "files": len(files)},
|
|
513
|
+
"left_out": {"direct": direct, "two_links_away": len(related) - direct, "files": len(left_files),
|
|
514
|
+
"nearest": [{"id": g.ids[u], "name": _short(g, u), "path": g.path[u]} for u in related[:10]]},
|
|
515
|
+
"seconds": {"load": round(t_load, 3), "rank": round(t_rank, 3),
|
|
516
|
+
"total": round(time.perf_counter() - t0, 3)}}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _hops(g: Graph, start: set, most: int) -> dict[int, int]:
|
|
520
|
+
"""How many links each node is from the nearest of `start`, up to `most`."""
|
|
521
|
+
dist = {u: 0 for u in start}
|
|
522
|
+
frontier = list(start)
|
|
523
|
+
for d in range(1, most + 1):
|
|
524
|
+
nxt = []
|
|
525
|
+
for u in frontier:
|
|
526
|
+
for v, _w in g.adj[u]:
|
|
527
|
+
if v not in dist:
|
|
528
|
+
dist[v] = d
|
|
529
|
+
nxt.append(v)
|
|
530
|
+
frontier = nxt
|
|
531
|
+
return dist
|