leyline-code 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/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