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/export.py ADDED
@@ -0,0 +1,673 @@
1
+ """Export a store as one self-contained viewer page (or as the JSON the viewer reads)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import gzip
7
+ import html
8
+ import json
9
+ import re
10
+ from importlib import resources
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from . import store
15
+
16
+ KEEP_ATTRS = ("framework", "runner", "native_kind", "visibility", "signature", "declared_type", "trigger", "is_static",
17
+ "is_abstract", "marker", "ecosystem", "category", "also_in", "namespace", "version",
18
+ "target_framework", "url", "is_test")
19
+
20
+ # The role that names a pattern instance: a strategy is named after its abstraction, not its context.
21
+ PRIMARY_ROLE = {"strategy": "strategy", "decorator": "decorator", "composite": "composite", "template method": "template",
22
+ "observer": "subject", "factory": "factory", "builder": "builder", "singleton": "singleton",
23
+ "process boundary": "launcher"}
24
+
25
+ PAGE = """<!doctype html>
26
+ <html lang="en">
27
+ <head>
28
+ <meta charset="utf-8">
29
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
30
+ <style>html{{color-scheme:light}}body{{margin:0;font:14px system-ui,sans-serif}}[hidden]{{display:none!important}}</style>
31
+ </head>
32
+ <body>
33
+ {fragment}
34
+ </body>
35
+ </html>
36
+ """
37
+
38
+
39
+ def remote_url(url: Optional[str]) -> Optional[str]:
40
+ """A git remote that is a URL (https://, ssh://, git@host:path), or None for a path on this machine."""
41
+ ok = url and not url.lower().startswith("file:") and re.match(r"^(?:[a-z][a-z0-9+.-]*://|[\w.-]+@[\w.-]+:)", url, re.I)
42
+ return url if ok else None
43
+
44
+
45
+ def graph(con, with_sources: bool = True, memory: Optional[Path] = None) -> dict:
46
+ """The whole store in the compact shape the viewer loads. `memory` is the file that remembers where boxes
47
+ were drawn last time (see `layout`); without it the layout is worked out afresh, the same way each time."""
48
+ rows = con.execute("SELECT * FROM nodes ORDER BY kind = 'repo' DESC, id").fetchall()
49
+ index = {r["id"]: i for i, r in enumerate(rows)}
50
+ nodes = []
51
+ module_names: dict[str, int] = {}
52
+ for r in rows:
53
+ if r["kind"] == "module":
54
+ module_names[r["name"]] = module_names.get(r["name"], 0) + 1
55
+ for r in rows:
56
+ attrs = json.loads(r["attrs"]) if r["attrs"] else {}
57
+ n = {"i": r["id"], "k": r["kind"], "n": r["name"]}
58
+ if r["kind"] == "module" and module_names[r["name"]] > 1:
59
+ # Two modules with one name (src/Shared, test/Shared): show the path to tell them apart.
60
+ n["n"] = r["path"] if r["path"] not in (None, "", ".") else "(repository root)"
61
+ if r["parent_id"] in index:
62
+ n["p"] = index[r["parent_id"]]
63
+ if r["language"]:
64
+ n["l"] = r["language"]
65
+ if r["path"] is not None and r["kind"] != "repo":
66
+ n["f"] = r["path"]
67
+ if r["span_start"]:
68
+ n["a"], n["b"] = r["span_start"], r["span_end"]
69
+ x = {k: attrs[k] for k in KEEP_ATTRS if attrs.get(k) not in (None, "", [], False)}
70
+ if r["kind"] == "system":
71
+ x.update({k: attrs[k] for k in ("members", "cohesion", "top") if k in attrs})
72
+ x["layer"] = r["layer"]
73
+ if x:
74
+ n["x"] = x
75
+ nodes.append(n)
76
+ rank = {"exact": 2, "observed": 2, "heuristic": 1, "guess": 0}
77
+ edges = []
78
+ access = [] # [function, field, 1 for an assignment, times, line, 1 if only while creating, 1 if guessed]
79
+ for e in con.execute("SELECT kind, src_id, dst_id, precision, attrs FROM edges WHERE kind != 'contains'"):
80
+ if e["src_id"] in index and e["dst_id"] in index:
81
+ attrs = json.loads(e["attrs"]) if e["attrs"] else {}
82
+ if e["kind"] in ("reads", "writes"):
83
+ access.append([index[e["src_id"]], index[e["dst_id"]], int(e["kind"] == "writes"), attrs.get("n", 1),
84
+ attrs.get("line") or 0, int(bool(attrs.get("init"))), int(e["precision"] == "guess")])
85
+ continue
86
+ extra = attrs.get("role") or ""
87
+ if e["kind"] == "communicates":
88
+ extra = {"channel": attrs.get("channel"), "address": attrs.get("address"),
89
+ "handler": attrs.get("handler"), "pipes": attrs.get("pipes")}
90
+ edges.append([e["kind"], index[e["src_id"]], index[e["dst_id"]], rank.get(e["precision"], 1), extra])
91
+ calls = []
92
+ for c in con.execute(
93
+ "SELECT src_id, dst_id, COUNT(*) AS n, MIN(site_start) AS line,"
94
+ " MIN(CASE precision WHEN 'exact' THEN 2 WHEN 'guess' THEN 0 ELSE 1 END) AS rank"
95
+ " FROM calls GROUP BY src_id, dst_id ORDER BY dst_id, src_id"):
96
+ if c["src_id"] in index and c["dst_id"] in index:
97
+ calls.append([index[c["src_id"]], index[c["dst_id"]], c["n"], c["line"], c["rank"]])
98
+ flows = []
99
+ for f in con.execute("SELECT * FROM flows ORDER BY name"):
100
+ a = json.loads(f["attrs"]) if f["attrs"] else {}
101
+ # A step is [node, depth] or [node, depth, how it was reached] when that is not a plain call.
102
+ steps = [[index[s["callable_id"]], s["depth"]] + ([s["via"]] if s["via"] not in (None, "calls") else [])
103
+ for s in con.execute("SELECT callable_id, depth, via FROM flow_steps WHERE flow_id = ? ORDER BY seq", (f["id"],))
104
+ if s["callable_id"] in index]
105
+ if f["entry_id"] in index:
106
+ flows.append({"id": f["id"], "name": f["name"], "kind": a.get("kind"), "detail": a.get("detail"),
107
+ "truncated": a.get("truncated", False), "entry": index[f["entry_id"]], "steps": steps})
108
+ coverage = [{"repo": r["repo_id"], "extractor": r["extractor"], "status": r["status"],
109
+ "stats": json.loads(r["stats"]) if r["stats"] else {}}
110
+ for r in con.execute("SELECT * FROM extractor_coverage ORDER BY extractor")]
111
+ notes: dict[int, dict] = {}
112
+ for a in con.execute("SELECT * FROM annotations ORDER BY layer = 'intent'"):
113
+ if a["node_id"] in index:
114
+ notes.setdefault(index[a["node_id"]], {})[a["key"]] = {
115
+ "v": a["value"], "layer": a["layer"], "c": a["confidence"], "stale": bool(a["stale"])}
116
+ views = []
117
+ try:
118
+ for v in con.execute("SELECT * FROM views ORDER BY created DESC"):
119
+ spec = json.loads(v["spec"])
120
+ spec["marks"] = [{**m, "i": index[m["id"]]} for m in spec.get("marks", []) if m["id"] in index]
121
+ for key in ("tests_to_run", "entry_points_affected", "untested"):
122
+ for item in spec.get(key, []):
123
+ item["i"] = index.get(item.get("id"))
124
+ for c in spec.get("channels", []):
125
+ c["fi"], c["ti"] = index.get(c.get("from")), index.get(c.get("to"))
126
+ for g in spec.get("by_module", []) + spec.get("by_system", []):
127
+ g["i"] = index.get(g.get("id"))
128
+ for nn in spec.get("new_nodes", []):
129
+ nn["pi"] = index.get(nn.get("parent"))
130
+ def attach(x):
131
+ # Give every record in a review that names a node the node's index.
132
+ if isinstance(x, dict):
133
+ for key, out in (("id", "i"), ("from_id", "fi"), ("to_id", "ti")):
134
+ if isinstance(x.get(key), str):
135
+ x[out] = index.get(x[key])
136
+ for value in x.values():
137
+ attach(value)
138
+ elif isinstance(x, list):
139
+ for value in x:
140
+ attach(value)
141
+ attach(spec.get("review"))
142
+ views.append({"id": v["id"], "title": v["title"], "kind": v["kind"], "created": v["created"],
143
+ "change_id": v["change_id"], **spec})
144
+ except Exception: # a store written before views existed
145
+ views = []
146
+ pattern_list = []
147
+ try:
148
+ from . import patterns
149
+ for x in patterns.listing(con, include_tests=True, limit=2000)["patterns"]:
150
+ lead = PRIMARY_ROLE.get(x["pattern"])
151
+ marks = [{"i": index[n["id"]], "role": role}
152
+ for role, ns in sorted(x["roles"].items(), key=lambda kv: (kv[0] != lead, kv[0]))
153
+ for n in ns if n["id"] in index]
154
+ if marks:
155
+ pattern_list.append({"id": x["id"], "pattern": x["pattern"], "about": x["about"], "rationale": x["rationale"],
156
+ "c": x["confidence"], "source": x["source"], "stale": x["stale"], "tests": x["in_tests"],
157
+ "marks": marks})
158
+ except Exception: # a store written before patterns existed
159
+ pattern_list = []
160
+ from . import query
161
+ state = [{"i": index[f["id"]], "from": f["written_from"], "w": f["writers"], "r": f["readers"]}
162
+ for f in query.shared_state(con, limit=60)["fields"] if f["id"] in index]
163
+ measured = {"t": {}, "any": [], "watched": []}
164
+ try:
165
+ from . import coverage as cov_store
166
+ if cov_store.has(con):
167
+ per = {}
168
+ for r in con.execute("SELECT DISTINCT test_id, node_id FROM covered WHERE test_id IS NOT NULL"):
169
+ if r["test_id"] in index and r["node_id"] in index:
170
+ per.setdefault(index[r["test_id"]], []).append(index[r["node_id"]])
171
+ measured["t"] = per
172
+ measured["any"] = sorted(index[i] for i in cov_store.ran(con) if i in index)
173
+ measured["watched"] = sorted(index[r[0]] for r in con.execute(
174
+ "SELECT DISTINCT a.file_id FROM covered c JOIN ancestry a ON a.node_id = c.node_id") if r[0] in index)
175
+ except Exception:
176
+ pass
177
+ tour_list = []
178
+ try:
179
+ from . import tours
180
+ for t in tours.listing(con)["tours"]:
181
+ full = tours.get(con, t["id"])
182
+ tour_list.append({"id": t["id"], "title": t["title"], "audience": t["audience"], "source": t["source"],
183
+ "stops": [{"title": st["title"], "kind": st["kind"], "ref": st["ref"],
184
+ "i": index.get(st["ref"]), "text": st["narrative"]} for st in full["stops"] if st["exists"]]})
185
+ except Exception: # a store written before tours existed
186
+ tour_list = []
187
+ sources = {}
188
+ roots = {k: str(v) for k, v in store.roots(con).items()}
189
+ # The folder mapped is where the code is; git's origin is shown apart, and only when it is a URL (a clone of a
190
+ # local folder has that folder as its origin, which is not this one).
191
+ repos = [{"id": r["id"], "commit": r["commit_sha"], **(json.loads(r["attrs"]) if r["attrs"] else {}), "folder": roots.get(r["id"], "")}
192
+ for r in rows if r["kind"] == "repo"]
193
+ for r in repos:
194
+ if not remote_url(r.get("url")):
195
+ r.pop("url", None)
196
+ if with_sources:
197
+ from .indexer import source_lines
198
+ # Keyed by path; in a workspace by repo/path, since two repositories can hold the same path.
199
+ several = len(repos) > 1
200
+ for r in rows:
201
+ if r["kind"] == "file" and r["repo_id"] in roots:
202
+ p = Path(roots[r["repo_id"]]) / r["path"]
203
+ try:
204
+ # numbered as the parsers number lines, so a span points at the same text in the page
205
+ text = "\n".join(source_lines(p)) if p.is_file() and store.inside(p, roots[r["repo_id"]]) else None
206
+ except OSError:
207
+ text = None
208
+ if text is not None:
209
+ sources[f"{r['repo_id']}/{r['path']}" if several else r["path"]] = text
210
+ out = {"version": 3, "repos": repos, "nodes": nodes, "edges": edges, "calls": calls, "flows": flows, "notes": notes, "views": views, "patterns": pattern_list, "tours": tour_list, "access": access, "state": state, "measured": measured,
211
+ "coverage": coverage, "changes": changes(con, index, roots), "sources": sources}
212
+ out["layout"] = layout(out, memory)
213
+ out["parts"] = parts(con, index, nodes)
214
+ return out
215
+
216
+
217
+ def parts(con, index: dict, nodes: list) -> list:
218
+ """Large modules drawn as their parts, as `leyline outline` splits them (two levels): each part is added to `nodes`
219
+ as a node of kind "part" (after the layout, which does not draw them), and listed with the files it holds."""
220
+ from . import outline
221
+ try:
222
+ found = outline.map_parts(con)
223
+ except Exception: # the page is still worth having without them
224
+ return []
225
+ out = []
226
+
227
+ def add(x, parent):
228
+ k = len(nodes)
229
+ extra = {key: x[key] for key in ("kind", "files", "functions", "lines", "summary", "stale", "named")
230
+ if x.get(key) not in (None, "", False)}
231
+ extra["files"] = len(x["files"])
232
+ if x["label"] != x["name"]:
233
+ extra["label"] = x["label"]
234
+ nodes.append({"i": "part:" + x["id"], "k": "part", "n": x["name"], "p": parent, "x": extra})
235
+ out.append({"i": k, "id": x["id"], "files": [index[f] for f in x["files"] if f in index]})
236
+ for s in x.get("parts", ()):
237
+ add(s, k)
238
+ for mp in found:
239
+ if mp["module"] in index:
240
+ for x in mp["parts"]:
241
+ add(x, index[mp["module"]])
242
+ return out
243
+
244
+
245
+ # -- changes ---------------------------------------------------------------------------------------
246
+ # After `leyline check`, the page names what was done in the words of leyline.md's last section.
247
+ AFTER_ROLE = {"edited as predicted": "done", "new, as declared": "done_new", "edited, not predicted": "drift",
248
+ "predicted, not edited": "missed"}
249
+
250
+
251
+ def changes(con, index: dict, roots: dict) -> list[dict]:
252
+ """Each planned change: its page (leyline.md), its tasks and scenarios tied to nodes, and the views that
253
+ hold what it was expected to touch and, after a check, what it did touch."""
254
+ try:
255
+ props = con.execute("SELECT id, intent, status, attrs FROM change_proposals").fetchall()
256
+ views = {r["id"]: r for r in con.execute("SELECT id, kind, created, spec FROM views WHERE change_id IS NOT NULL")}
257
+ except Exception: # a store written before changes existed
258
+ return []
259
+ tests = set()
260
+ try:
261
+ from . import spec
262
+ tests = set(spec._tests(con).values())
263
+ except Exception:
264
+ pass
265
+ out = []
266
+ for p in props:
267
+ cid = p["id"]
268
+ attrs = json.loads(p["attrs"] or "{}")
269
+ name = cid[len("spec-"):] if cid.startswith("spec-") else cid
270
+ plan, review = views.get("view-" + cid), views.get("view-review-" + cid)
271
+ if plan is None:
272
+ continue
273
+ c = {"id": cid, "name": name, "title": attrs.get("title") or p["intent"] or name, "status": p["status"],
274
+ "planned": plan["created"], "plan_view": plan["id"], "review_view": review["id"] if review else None,
275
+ "checked": review["created"] if review else None, "tasks": [], "scenarios": [], "page": None, "folder": None}
276
+ for root in roots.values(): # the change folder sits in one of the mapped repositories
277
+ folder = Path(root) / "openspec" / "changes" / name
278
+ if (folder / "leyline.md").is_file() and store.inside(folder / "leyline.md", root):
279
+ c["page"] = (folder / "leyline.md").read_text(encoding="utf-8", errors="replace")
280
+ c["folder"] = f"openspec/changes/{name}/"
281
+ break
282
+ try:
283
+ for r in con.execute("SELECT * FROM spec_items WHERE change_id = ? ORDER BY kind, key", (cid,)):
284
+ ids = [index[i] for i in json.loads(r["nodes"] or "[]") if i in index]
285
+ extra = json.loads(r["attrs"] or "{}")
286
+ if r["kind"] == "task":
287
+ c["tasks"].append({"key": r["key"], "text": r["text"], "action": r["action"], "nodes": ids,
288
+ "new": [{"name": n.get("label") or n["name"], "pi": index.get(n.get("parent"))}
289
+ for n in extra.get("new", [])],
290
+ "into": [index[i] for i in extra.get("into", []) if i in index]})
291
+ else:
292
+ c["scenarios"].append({"key": r["key"], "name": r["text"], "test": ids[0] if ids else None})
293
+ except Exception:
294
+ pass
295
+ if review:
296
+ # leyline.md does not count a test the change edits (that is how a scenario gets proven), a new
297
+ # function only the planned code calls (a helper), or new code inside a container a task names, as
298
+ # outside the spec. The map says the same.
299
+ ids = {k: nid for nid, k in index.items()}
300
+ marks = [m for m in json.loads(review["spec"]).get("marks", []) if m["id"] in index]
301
+ named = {ids[i] for t in c["tasks"] for i in t["nodes"]} | {
302
+ m["id"] for m in marks if m["role"] in ("edited as predicted", "new, as declared")}
303
+ into = [ids[i] for t in c["tasks"] for i in t["into"]]
304
+ after = []
305
+ for m in marks:
306
+ role = AFTER_ROLE.get(m["role"], m["role"])
307
+ if role == "drift":
308
+ if m["id"] in tests or "/test:" in m["id"]:
309
+ role = "test_changed"
310
+ elif any(m["id"].startswith(x + ".") or m["id"].startswith(x + "/") for x in into):
311
+ role = "done"
312
+ elif str(m.get("note", "")).startswith("new") and _only_called_from(con, m["id"], named):
313
+ role = "helper"
314
+ after.append({"i": index[m["id"]], "role": role, "note": m.get("note") or ""})
315
+ c["after"] = after
316
+ out.append(c)
317
+ out.sort(key=lambda c: c["checked"] or c["planned"] or "", reverse=True)
318
+ return out
319
+
320
+
321
+ def _only_called_from(con, node_id: str, named: set) -> bool:
322
+ """True when something calls the node and every caller is named code (or inside it)."""
323
+ callers = {r[0] for r in con.execute("SELECT DISTINCT src_id FROM calls WHERE dst_id = ?", (node_id,))} - {node_id}
324
+ return bool(callers) and all(c in named or any(c.startswith(n + ".") or c.split("(")[0] == n.split("(")[0] for n in named)
325
+ for c in callers)
326
+
327
+
328
+ # -- a layout that stays put -----------------------------------------------------------------------
329
+ # The page draws a level of the map in rows, what depends on something above it. For the levels a person
330
+ # comes back to (the repositories of a workspace, the modules of a repository, the parts of a module) the
331
+ # rows are worked out here and remembered in a file beside the store, so a re-index keeps the map familiar:
332
+ # a box that was there before keeps its row and its place, a new box goes next to what it links to, and a
333
+ # box that went away leaves its neighbours where they were. Delete the file to lay the map out afresh.
334
+ LINK_KINDS = {"calls": "calls", "uses_type": "types", "instantiates": "types", "extends": "inherit", "implements": "inherit",
335
+ "imports": "imports", "depends_on": "imports", "communicates": "channels"}
336
+ ROW_WIDTH = 1300 # px; wider rows wrap
337
+ GAP_X, GAP_X_THIN = 26, 12
338
+
339
+
340
+ def memory_path(con) -> Optional[Path]:
341
+ """Where a store's remembered layout lives: beside it, as <store>.layout.json."""
342
+ try:
343
+ for row in con.execute("PRAGMA database_list"):
344
+ if row[1] == "main" and row[2]:
345
+ return Path(row[2]).with_suffix(".layout.json")
346
+ except Exception:
347
+ pass
348
+ return None
349
+
350
+
351
+ def layout(g: dict, memory: Optional[Path] = None) -> dict:
352
+ """{level: {node index: [row, x]}} for the workspace, each repository and each module. x is the centre
353
+ of the box in px; the page keeps that order and moves a box right only as far as needed to fit."""
354
+ N = g["nodes"]
355
+ unit, module, repo, system = _ownership(N, g["edges"])
356
+ links = [(k, s, d, (x or {}).get("channel") if k == "communicates" and isinstance(x, dict) else None)
357
+ for k, s, d, _, x in g["edges"] if k in LINK_KINDS] + [("calls", s, d, None) for s, d, *_ in g["calls"]]
358
+ kids: dict[int, list[int]] = {}
359
+ for i, n in enumerate(N):
360
+ if n.get("p") is not None:
361
+ kids.setdefault(n["p"], []).append(i)
362
+ levels: dict[str, tuple[list[int], set]] = {}
363
+ repos = [i for i, n in enumerate(N) if n["k"] == "repo"]
364
+ if len(repos) > 1:
365
+ pairs = {(repo[s], repo[d]) for _, s, d, ch in links if repo[s] is not None and repo[d] is not None and repo[s] != repo[d]}
366
+ levels["ws"] = (repos, pairs)
367
+ mods = {}
368
+ for i, n in enumerate(N):
369
+ if n["k"] == "module" and repo[i] is not None:
370
+ mods.setdefault(repo[i], []).append(i)
371
+ for r in repos:
372
+ own = set(mods.get(r, []))
373
+ pairs = set()
374
+ for _, s, d, ch in links:
375
+ a, b = module[s], module[d]
376
+ if a in own and b in own and a != b:
377
+ pairs.add((b, a) if ch == "event" else (a, b))
378
+ levels["repo:" + N[r]["i"]] = (sorted(own), pairs)
379
+ inner = {"calls", "inherit", "channels"} # what the page shows inside a module until a person asks for more
380
+ in_sys = {d: s for k, s, d, *_ in g["edges"] if k == "groups"}
381
+ grp = lambda u: in_sys.get(u, u)
382
+ # The links inside each module, found in one pass: going over every link for every module took minutes and
383
+ # most of the memory on a repository of thousands of modules.
384
+ within: dict[int, list] = {}
385
+ for k, s, d, ch in links:
386
+ if LINK_KINDS[k] in inner and module[s] is not None and module[s] == module[d] \
387
+ and unit[s] is not None and unit[d] is not None:
388
+ within.setdefault(module[s], []).append((s, d, ch))
389
+ for m in (i for i, n in enumerate(N) if n["k"] == "module"):
390
+ nodes = [k for k in kids.get(m, []) if N[k]["k"] == "system"]
391
+ stack = [k for k in kids.get(m, []) if N[k]["k"] == "file"]
392
+ while stack:
393
+ f = stack.pop()
394
+ for k in [f] + kids.get(f, []):
395
+ if unit[k] == k and k not in in_sys and (N[k]["k"] == "type" or any(N[c]["k"] == "callable" for c in kids.get(k, []))):
396
+ nodes.append(k)
397
+ if len(nodes) < 2:
398
+ continue
399
+ own = set(nodes)
400
+ pairs = set()
401
+ for s, d, ch in within.get(m, ()):
402
+ a, b = grp(unit[s]), grp(unit[d])
403
+ if a in own and b in own and a != b:
404
+ pairs.add((b, a) if ch == "event" else (a, b))
405
+ levels["module:" + N[m]["i"]] = (sorted(own), pairs)
406
+
407
+ old = {}
408
+ if memory and memory.is_file():
409
+ try:
410
+ old = json.loads(memory.read_text(encoding="utf-8")).get("levels", {})
411
+ except (OSError, ValueError, AttributeError, RecursionError):
412
+ old = {}
413
+ width = lambda i: _box_width(N, i, kids)
414
+ out, keep = {}, {}
415
+ for key, (nodes, pairs) in levels.items():
416
+ fresh = _layered(nodes, sorted(pairs), width)
417
+ placed = _remembered(fresh, {N[i]["i"]: i for i in nodes}, old.get(key), pairs, width)
418
+ out[key] = {str(i): v for i, v in placed.items()}
419
+ keep[key] = {N[i]["i"]: v for i, v in placed.items()}
420
+ if memory:
421
+ try:
422
+ body = json.dumps({"version": 1, "levels": keep}, separators=(",", ":"), sort_keys=True)
423
+ if not memory.is_file() or memory.read_text(encoding="utf-8") != body:
424
+ store.write_file(memory, body)
425
+ except OSError:
426
+ pass
427
+ return out
428
+
429
+
430
+ def _ownership(N: list[dict], edges: list) -> tuple[list, list, list, list]:
431
+ """For each node: its unit (outermost type, or its file), module, repository and system, as the page has them."""
432
+ unit, module, repo, system = [None] * len(N), [None] * len(N), [None] * len(N), [None] * len(N)
433
+ for i, n in enumerate(N): # parents come before children only by accident, so climb
434
+ c, f, top = i, None, None
435
+ while c is not None:
436
+ k = N[c]["k"]
437
+ if k == "file" and f is None:
438
+ f = c
439
+ if k == "module" and module[i] is None:
440
+ module[i] = c
441
+ if k == "repo":
442
+ repo[i] = c
443
+ c = N[c].get("p")
444
+ if n["k"] in ("module", "repo", "external", "system"):
445
+ continue
446
+ if n["k"] == "file":
447
+ unit[i] = i
448
+ continue
449
+ c = i
450
+ while N[c].get("p") is not None and N[N[c]["p"]]["k"] != "file":
451
+ c = N[c]["p"]
452
+ unit[i] = c if N[c]["k"] == "type" else f
453
+ for k, s, d, *_ in edges:
454
+ if k == "groups":
455
+ system[d] = s
456
+ return unit, module, repo, system
457
+
458
+
459
+ def _box_width(N: list[dict], i: int, kids: dict) -> int:
460
+ """About as wide as the page will draw the box: the label in a 12.5px monospace face, or the line under it."""
461
+ n = N[i]
462
+ meta = 30 if n["k"] in ("module", "repo", "system") else 0
463
+ return int(max(len(n["n"]) * 7.6 + 34, meta * 6.2 + 32, 70))
464
+
465
+
466
+ WAYPOINTS = 200_000
467
+
468
+
469
+ def _layered(nodes: list[int], pairs: list[tuple], width) -> dict[int, list]:
470
+ """The page's layered layout: break cycles, layer by longest path, order by barycentre, wrap wide rows,
471
+ route long edges through waypoints so they pass between boxes. Same input, same answer."""
472
+ out = {v: [] for v in nodes}
473
+ inn = {v: [] for v in nodes}
474
+ for a, b in pairs:
475
+ if b not in out[a]:
476
+ out[a].append(b)
477
+ inn[b].append(a)
478
+ linked = [v for v in nodes if out[v] or inn[v]]
479
+ loose = [v for v in nodes if not out[v] and not inn[v]]
480
+ back, state = set(), {}
481
+ for root in sorted(linked, key=lambda v: (len(inn[v]), -len(out[v]), v)):
482
+ if root in state:
483
+ continue
484
+ state[root] = 1
485
+ stack = [(root, iter(out[root]))]
486
+ while stack:
487
+ v, it = stack[-1]
488
+ w = next(it, None)
489
+ if w is None:
490
+ state[v] = 2
491
+ stack.pop()
492
+ elif state.get(w) == 1:
493
+ back.add((v, w))
494
+ elif w not in state:
495
+ state[w] = 1
496
+ stack.append((w, iter(out[w])))
497
+ layer: dict[int, int] = {}
498
+ pending = {v: sum(1 for u in inn[v] if (u, v) not in back) for v in linked}
499
+ ready = [v for v in linked if not pending[v]]
500
+ while ready:
501
+ v = ready.pop()
502
+ layer.setdefault(v, 0)
503
+ for w in out[v]:
504
+ if (v, w) in back:
505
+ continue
506
+ layer[w] = max(layer.get(w, 0), layer[v] + 1)
507
+ pending[w] -= 1
508
+ if not pending[w]:
509
+ ready.append(w)
510
+ rows: list[list[int]] = []
511
+ for v in linked:
512
+ while len(rows) <= layer.get(v, 0):
513
+ rows.append([])
514
+ rows[layer.get(v, 0)].append(v)
515
+ rows = [r for r in rows if r]
516
+ pos: dict = {}
517
+
518
+ def sweep(rs, up, down, times):
519
+ for r in rs:
520
+ for j, v in enumerate(r):
521
+ pos[v] = j
522
+ for k in range(times):
523
+ for r in (reversed(range(len(rs))) if k % 2 else range(len(rs))):
524
+ nb = down if k % 2 else up
525
+ bary = {}
526
+ for v in rs[r]:
527
+ ns = [u for u in nb.get(v, []) if u in pos]
528
+ bary[v] = sum(pos[u] for u in ns) / len(ns) if ns else pos[v]
529
+ rs[r].sort(key=lambda v: bary[v])
530
+ for j, v in enumerate(rs[r]):
531
+ pos[v] = j
532
+
533
+ at = {v: k for k, r in enumerate(rows) for v in r}
534
+ up = {v: [u for u in inn[v] + out[v] if at[u] < at[v]] for v in linked}
535
+ down = {v: [u for u in inn[v] + out[v] if at[u] > at[v]] for v in linked}
536
+ sweep(rows, up, down, 4)
537
+ ranks: list[list] = []
538
+ for r in rows:
539
+ line, lw = [], 0
540
+ for v in r:
541
+ if line and lw + width(v) > ROW_WIDTH:
542
+ ranks.append(line)
543
+ line, lw = [], 0
544
+ line.append(v)
545
+ lw += width(v) + GAP_X
546
+ if line:
547
+ ranks.append(line)
548
+ rank = {v: k for k, r in enumerate(ranks) for v in r}
549
+ up2: dict = {}
550
+ down2: dict = {}
551
+
552
+ def link(a, b):
553
+ down2.setdefault(a, []).append(b)
554
+ up2.setdefault(b, []).append(a)
555
+ # A waypoint per row a long edge crosses: on a level of thousands of boxes in hundreds of rows that is tens of
556
+ # millions of them (GBs), so past a budget long edges are ordered by their ends alone.
557
+ route = sum(max(0, abs(rank[a] - rank[b]) - 1) for a, b in pairs) <= WAYPOINTS
558
+ for a, b in pairs:
559
+ ra, rb = rank[a], rank[b]
560
+ if ra == rb:
561
+ continue
562
+ top, bottom = (a, b) if ra < rb else (b, a)
563
+ prev = top
564
+ for r in range(min(ra, rb) + 1, max(ra, rb)) if route else ():
565
+ d = ("~", a, b, r)
566
+ ranks[r].append(d)
567
+ rank[d] = r
568
+ link(prev, d)
569
+ prev = d
570
+ link(prev, bottom)
571
+ sweep(ranks, up2, down2, 8)
572
+ w = lambda v: 2 if isinstance(v, tuple) else width(v)
573
+
574
+ def gap(a, b):
575
+ da, db = isinstance(a, tuple), isinstance(b, tuple)
576
+ return 5 if da and db else GAP_X_THIN if da or db else GAP_X
577
+ placed: dict[int, list] = {}
578
+
579
+ def place(line, row):
580
+ total = sum(w(v) for v in line) + sum(gap(line[j], line[j + 1]) for j in range(len(line) - 1))
581
+ x = -total / 2
582
+ for j, v in enumerate(line):
583
+ if not isinstance(v, tuple):
584
+ placed[v] = [row, round(x + w(v) / 2)]
585
+ x += w(v) + (gap(v, line[j + 1]) if j + 1 < len(line) else 0)
586
+ for k, r in enumerate(ranks):
587
+ place(r, k)
588
+ row = len(ranks)
589
+ line, lw = [], 0
590
+ for v in sorted(loose, key=lambda v: v):
591
+ if line and lw + width(v) > ROW_WIDTH:
592
+ place(line, row)
593
+ row += 1
594
+ line, lw = [], 0
595
+ line.append(v)
596
+ lw += width(v) + GAP_X
597
+ if line:
598
+ place(line, row)
599
+ return placed
600
+
601
+
602
+ def _remembered(fresh: dict, ids: dict, old: Optional[dict], pairs: set, width) -> dict:
603
+ """Keep the boxes the remembered layout knows where they were, and fit new ones in beside their links.
604
+ When most of the boxes are new, the remembered layout is for other code: start again."""
605
+ if not old:
606
+ return fresh
607
+ at = {i: old[nid] for nid, i in ids.items() if nid in old}
608
+ if len(at) * 2 < len(ids):
609
+ return fresh
610
+ rows = [r for r, _ in at.values()]
611
+ first, last = min(rows), max(rows)
612
+ ins: dict = {}
613
+ outs: dict = {}
614
+ for a, b in pairs: # looked up per box below; scanning every pair for every new box was quadratic
615
+ outs.setdefault(a, []).append(b)
616
+ ins.setdefault(b, []).append(a)
617
+ in_row: dict = {}
618
+ for j, (r, _) in at.items():
619
+ in_row.setdefault(r, []).append(j)
620
+ for nid, i in sorted(ids.items()):
621
+ if i in at:
622
+ continue
623
+ above = [at[a][0] for a in ins.get(i, ()) if a in at] # what depends on it
624
+ below = [at[b][0] for b in outs.get(i, ()) if b in at] # what it depends on
625
+ row = max(above) + 1 if above else min(below) - 1 if below else last
626
+ near = [at[j][1] for j in ins.get(i, []) + outs.get(i, []) if j in at]
627
+ here = in_row.get(row, [])
628
+ if near:
629
+ x = sum(near) / len(near)
630
+ else: # at the right-hand end of its row
631
+ x = max([at[j][1] + width(j) / 2 for j in here] or [0]) + GAP_X + width(i) / 2
632
+ at[i] = [row, round(_free(x, width(i), [(at[j][1], width(j)) for j in here]))]
633
+ in_row.setdefault(row, []).append(i)
634
+ return at
635
+
636
+
637
+ def _free(x: float, w: float, row: list[tuple]) -> float:
638
+ """The nearest centre to x where a box w wide overlaps none in the row, so a new box never takes an old one's
639
+ place (the page would then push the old one aside)."""
640
+ clear = lambda c: all(abs(c - cx) >= (w + cw) / 2 + GAP_X for cx, cw in row)
641
+ if clear(x):
642
+ return x
643
+ spots = [cx + s * ((w + cw) / 2 + GAP_X) for cx, cw in row for s in (-1, 1)]
644
+ return min((c for c in spots if clear(c)), key=lambda c: (abs(c - x), c), default=x)
645
+
646
+
647
+ def fragment(con, with_sources: bool = True, open_change: Optional[str] = None, memory: Optional[Path] = None) -> str:
648
+ """The viewer with data embedded, without an html/head/body wrapper. `open_change` names a change the page
649
+ opens on; `memory` defaults to the layout file beside the store."""
650
+ template = resources.files("leyline").joinpath("viewer/viewer.html").read_text(encoding="utf-8")
651
+ g = graph(con, with_sources, memory if memory is not None else memory_path(con))
652
+ if open_change:
653
+ g["start"] = {"change": open_change}
654
+ data = json.dumps(g, separators=(",", ":"))
655
+ kind = "application/json"
656
+ if len(data) > COMPRESS_OVER:
657
+ # A big map goes in gzipped: the page unpacks it with the browser's own DecompressionStream.
658
+ data, kind = base64.b64encode(gzip.compress(data.encode(), 6, mtime=0)).decode(), "application/gzip+base64"
659
+ else:
660
+ # No `<` at all inside the script element: `</script>` would end it, and `<!--` then `<script` in some
661
+ # source text would make the browser read past its real end tag and into the viewer's code.
662
+ data = data.replace("<", "\\u003c")
663
+ repos = [r["id"] for r in g["repos"]] or ["repo"]
664
+ name = repos[0] if len(repos) == 1 else " + ".join(repos[:3]) + (f" and {len(repos) - 3} more" if len(repos) > 3 else "")
665
+ return (template.replace("__LEYLINE_REPO__", html.escape(name))
666
+ .replace('type="application/json">__LEYLINE_DATA__', f'type="{kind}">' + data))
667
+
668
+
669
+ COMPRESS_OVER = 2_000_000 # characters of JSON; smaller pages stay readable as plain JSON
670
+
671
+
672
+ def page(con, with_sources: bool = True, open_change: Optional[str] = None, memory: Optional[Path] = None) -> str:
673
+ return PAGE.format(fragment=fragment(con, with_sources, open_change, memory))