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/query.py ADDED
@@ -0,0 +1,683 @@
1
+ """The query layer. The MCP server, the CLI and the viewer all call these functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter, defaultdict
7
+ import re
8
+ import sqlite3
9
+ from pathlib import Path
10
+ from typing import Optional
11
+ from . import store
12
+
13
+ ROLLUP_KINDS = ("imports", "uses_type", "extends", "implements", "instantiates")
14
+
15
+
16
+ def _attrs(row) -> dict:
17
+ return json.loads(row["attrs"]) if row["attrs"] else {}
18
+
19
+
20
+ def _brief(row) -> dict:
21
+ a = _attrs(row)
22
+ out = {"id": row["id"], "kind": row["kind"], "name": row["name"]}
23
+ if row["path"]:
24
+ out["path"] = row["path"]
25
+ if row["span_start"] and row["kind"] not in ("file", "module", "repo"):
26
+ out["lines"] = [row["span_start"], row["span_end"]]
27
+ for k in ("native_kind", "visibility", "signature", "declared_type", "trigger"):
28
+ if a.get(k) is not None:
29
+ out[k] = a[k]
30
+ return out
31
+
32
+
33
+ def _node(con, node_id: str):
34
+ return con.execute("SELECT * FROM nodes WHERE id = ?", (node_id,)).fetchone()
35
+
36
+
37
+ def _module_of(alias: str) -> str:
38
+ # A node's module: itself if it is a module, otherwise the cached ancestor.
39
+ return (f"COALESCE((SELECT module_id FROM ancestry WHERE node_id = {alias}),"
40
+ f" CASE WHEN (SELECT kind FROM nodes WHERE id = {alias}) = 'module' THEN {alias} END)")
41
+
42
+
43
+ def module_edges(con) -> list[dict]:
44
+ """Every edge rolled up to module level, with a count per edge kind."""
45
+ acc: dict[tuple, dict] = {}
46
+ q = (f"SELECT {_module_of('e.src_id')} AS s, {_module_of('e.dst_id')} AS d, e.kind AS k, COUNT(*) AS n"
47
+ f" FROM edges e WHERE e.kind IN ({','.join('?' * len(ROLLUP_KINDS))}) GROUP BY s, d, k")
48
+ for r in con.execute(q, ROLLUP_KINDS):
49
+ if r["s"] and r["d"] and r["s"] != r["d"]:
50
+ acc.setdefault((r["s"], r["d"]), {})[r["k"]] = r["n"]
51
+ q = (f"SELECT {_module_of('c.src_id')} AS s, {_module_of('c.dst_id')} AS d, COUNT(*) AS n"
52
+ " FROM calls c GROUP BY s, d")
53
+ for r in con.execute(q):
54
+ if r["s"] and r["d"] and r["s"] != r["d"]:
55
+ acc.setdefault((r["s"], r["d"]), {})["calls"] = r["n"]
56
+ out = [{"from": s, "to": d, **kinds, "total": sum(kinds.values())} for (s, d), kinds in acc.items()]
57
+ return sorted(out, key=lambda e: -e["total"])
58
+
59
+
60
+ def cross_repo(con, limit: int = 20) -> dict:
61
+ """Links between the repositories of a workspace: calls, imports, type use and inheritance whose two
62
+ ends are in different repos, the functions most called across, and the flows that cross."""
63
+ pairs: dict[tuple, dict] = {}
64
+ for r in con.execute("SELECT s.repo_id AS a, d.repo_id AS b, COUNT(*) AS n FROM calls c JOIN nodes s ON s.id = c.src_id"
65
+ " JOIN nodes d ON d.id = c.dst_id WHERE s.repo_id != d.repo_id GROUP BY a, b"):
66
+ pairs.setdefault((r["a"], r["b"]), {})["calls"] = r["n"]
67
+ for r in con.execute("SELECT s.repo_id AS a, d.repo_id AS b, e.kind AS k, COUNT(*) AS n FROM edges e"
68
+ " JOIN nodes s ON s.id = e.src_id JOIN nodes d ON d.id = e.dst_id"
69
+ " WHERE s.repo_id != d.repo_id AND e.kind != 'contains' GROUP BY a, b, k"):
70
+ pairs.setdefault((r["a"], r["b"]), {})[r["k"]] = r["n"]
71
+ called = [{"id": r["dst_id"], "name": r["name"], "repo": r["repo_id"], "callers": r["n"]} for r in con.execute(
72
+ "SELECT c.dst_id, d.name, d.repo_id, COUNT(DISTINCT c.src_id) AS n FROM calls c JOIN nodes s ON s.id = c.src_id"
73
+ " JOIN nodes d ON d.id = c.dst_id WHERE s.repo_id != d.repo_id GROUP BY c.dst_id ORDER BY n DESC, c.dst_id LIMIT ?", (limit,))]
74
+ # A flow crosses when a step is in another repo than its entry, and comes back when a step in the
75
+ # entry's repo was reached from a step in another (a framework calling the code that uses it).
76
+ crossing = returning = 0
77
+ if pairs:
78
+ rows = con.execute(
79
+ "SELECT f.id AS flow, e.repo_id AS home, s.seq, s.parent_seq, n.repo_id AS repo FROM flows f"
80
+ " JOIN nodes e ON e.id = f.entry_id JOIN flow_steps s ON s.flow_id = f.id JOIN nodes n ON n.id = s.callable_id"
81
+ " WHERE f.layer = 'fact' ORDER BY f.id, s.seq")
82
+ cur, repo_at, crossed, back = None, {}, False, False
83
+ for r in rows:
84
+ if r["flow"] != cur:
85
+ crossing, returning = crossing + crossed, returning + back
86
+ cur, repo_at, crossed, back = r["flow"], {}, False, False
87
+ repo_at[r["seq"]] = r["repo"]
88
+ crossed = crossed or r["repo"] != r["home"]
89
+ back = back or (r["repo"] == r["home"] and repo_at.get(r["parent_seq"], r["home"]) != r["home"])
90
+ crossing, returning = crossing + crossed, returning + back
91
+ return {"repos": [r[0] for r in con.execute("SELECT id FROM nodes WHERE kind = 'repo' ORDER BY id")],
92
+ "pairs": [{"from": a, "to": b, **kinds, "total": sum(kinds.values())} for (a, b), kinds in sorted(pairs.items())],
93
+ "most_called_across": called,
94
+ "flows_crossing": crossing, "flows_crossing_and_back": returning}
95
+
96
+
97
+ def overview(con) -> dict:
98
+ """The top-level map: repos, modules, how modules depend on each other, and what was analyzed."""
99
+ repos = []
100
+ for repo in con.execute("SELECT * FROM nodes WHERE kind = 'repo' ORDER BY id"):
101
+ modules = []
102
+ for m in con.execute("SELECT * FROM nodes WHERE kind = 'module' AND repo_id = ? ORDER BY path", (repo["id"],)):
103
+ counts = {r["kind"]: r["n"] for r in con.execute(
104
+ "SELECT n.kind, COUNT(*) AS n FROM ancestry a JOIN nodes n ON n.id = a.node_id"
105
+ " WHERE a.module_id = ? AND n.id != ? GROUP BY n.kind", (m["id"], m["id"]))}
106
+ # `+kind` keeps SQLite on the parent index: through the kind index it read every file of the repository
107
+ # for each module (70 s on Kubernetes).
108
+ files = con.execute(
109
+ "SELECT language, COUNT(*) AS n, SUM(span_end) AS loc FROM nodes"
110
+ " WHERE +kind = 'file' AND parent_id = ? GROUP BY language", (m["id"],)).fetchall()
111
+ modules.append({
112
+ "id": m["id"], "name": m["name"], "path": m["path"] or ".",
113
+ "languages": {f["language"]: f["n"] for f in files},
114
+ "files": sum(f["n"] for f in files), "loc": sum(f["loc"] or 0 for f in files),
115
+ "types": counts.get("type", 0), "callables": counts.get("callable", 0),
116
+ "entry_points": counts.get("entry_point", 0),
117
+ **({"marker": _attrs(m)["marker"]} if _attrs(m).get("marker") else {}),
118
+ })
119
+ repos.append({"id": repo["id"], "commit": repo["commit_sha"], **_attrs(repo), "modules": modules})
120
+ externals = []
121
+ for x in con.execute("SELECT * FROM nodes WHERE kind = 'external' ORDER BY name"):
122
+ users = [r[0] for r in con.execute(
123
+ f"SELECT DISTINCT {_module_of('e.src_id')} FROM edges e"
124
+ " WHERE e.dst_id = ? AND e.kind IN ('imports', 'depends_on')", (x["id"],)) if r[0]]
125
+ externals.append({"id": x["id"], "name": x["name"], **_attrs(x), "used_by": sorted(users)})
126
+ coverage = [{"repo": r["repo_id"], "extractor": r["extractor"], "status": r["status"],
127
+ **({"stats": json.loads(r["stats"])} if r["stats"] and r["stats"] != "{}" else {})}
128
+ for r in con.execute("SELECT * FROM extractor_coverage ORDER BY repo_id, status, extractor")]
129
+ systems = systems_list(con)
130
+ ws = con.execute("SELECT value FROM meta WHERE key = 'workspace'").fetchone()
131
+ from .outline import named_parts # names given to modules and their parts (module_outline, name_part)
132
+ named = named_parts(con)
133
+ for r in repos:
134
+ for m in r["modules"]:
135
+ if m["id"] in named:
136
+ m["title"] = named[m["id"]]["name"]
137
+ if named[m["id"]]["summary"]:
138
+ m["summary"] = named[m["id"]]["summary"]
139
+ return {
140
+ "repos": repos,
141
+ **({"named_parts": [x for k, x in named.items() if x["kind"] != "module"]} if named else {}),
142
+ # Repositories indexed together: names resolve across them, and these are the links between them.
143
+ **({"workspace": {"repos": json.loads(ws[0]), "links": cross_repo(con)["pairs"]}} if ws else {}),
144
+ "systems": systems,
145
+ "module_edges": module_edges(con),
146
+ "externals": externals,
147
+ "coverage": coverage,
148
+ "notes": ([] if systems else ["No systems: no module is large enough to be split."])
149
+ + ["Call, type-use and inheritance edges are heuristic (tree-sitter). Import and containment edges are exact."],
150
+ }
151
+
152
+
153
+ def _notes(con, node_id: str) -> dict:
154
+ """Annotations on a node, keyed by annotation key. Intent wins over inferred."""
155
+ out: dict = {}
156
+ for a in con.execute("SELECT * FROM annotations WHERE node_id = ? ORDER BY layer = 'intent'", (node_id,)):
157
+ out[a["key"]] = {"value": a["value"], "layer": a["layer"], "confidence": a["confidence"],
158
+ "stale": bool(a["stale"]), "source": a["source"]}
159
+ return out
160
+
161
+
162
+ def systems_list(con) -> list[dict]:
163
+ """Proposed and confirmed systems, with their members."""
164
+ out = []
165
+ for s in con.execute("SELECT * FROM nodes WHERE kind = 'system' ORDER BY parent_id, id"):
166
+ a, notes = _attrs(s), _notes(con, s["id"])
167
+ members = [r[0] for r in con.execute(
168
+ "SELECT n.name FROM edges e JOIN nodes n ON n.id = e.dst_id WHERE e.kind = 'groups' AND e.src_id = ?"
169
+ " ORDER BY n.name", (s["id"],))]
170
+ out.append({"id": s["id"], "name": notes.get("name", {}).get("value", s["name"]),
171
+ "named": "name" in notes, "module": (s["parent_id"] or "").split(":module:")[-1],
172
+ "layer": s["layer"], "responsibility": notes.get("responsibility", {}).get("value"),
173
+ "cohesion": a.get("cohesion"), "members": members,
174
+ "stale": any(n["stale"] for n in notes.values())})
175
+ return out
176
+
177
+
178
+ def annotate(con, node_id: str, key: str, value: str, evidence: Optional[list[str]] = None,
179
+ confidence: Optional[float] = None, layer: str = "inferred", source: str = "mcp") -> dict:
180
+ """Record an inferred or intent statement about a node. Inferred statements must cite evidence."""
181
+ from . import store
182
+ try:
183
+ return store.annotate(con, node_id, key, value, layer, source, confidence, evidence or [])
184
+ except ValueError as exc:
185
+ return {"error": str(exc)}
186
+
187
+
188
+ def _edge_rows(con, node_id: str, direction: str, limit: int) -> dict:
189
+ col, other = ("src_id", "dst_id") if direction == "out" else ("dst_id", "src_id")
190
+ out: dict[str, dict] = {}
191
+ for r in con.execute(
192
+ f"SELECT e.kind AS k, e.precision, e.attrs AS eattrs, n.* FROM edges e JOIN nodes n ON n.id = e.{other}"
193
+ f" WHERE e.{col} = ? AND e.kind != 'contains' ORDER BY e.kind, n.id", (node_id,)):
194
+ group = out.setdefault(r["k"], {"total": 0, "items": []})
195
+ group["total"] += 1
196
+ if len(group["items"]) < limit:
197
+ item = _brief(r)
198
+ item["precision"] = r["precision"]
199
+ if r["eattrs"]:
200
+ item["edge"] = json.loads(r["eattrs"])
201
+ group["items"].append(item)
202
+ return out
203
+
204
+
205
+ def _call_rows(con, node_id: str, direction: str, limit: int) -> dict:
206
+ col, other = ("src_id", "dst_id") if direction == "out" else ("dst_id", "src_id")
207
+ rows = con.execute(
208
+ f"SELECT n.*, COUNT(*) AS sites, MIN(c.site_start) AS first_line, c.dispatch, c.precision"
209
+ f" FROM calls c JOIN nodes n ON n.id = c.{other} WHERE c.{col} = ?"
210
+ f" GROUP BY n.id ORDER BY sites DESC, n.id", (node_id,)).fetchall()
211
+ items = []
212
+ for r in rows[:limit]:
213
+ item = _brief(r)
214
+ item.update({"sites": r["sites"], "dispatch": r["dispatch"], "precision": r["precision"]})
215
+ if direction == "out":
216
+ item["first_call_line"] = r["first_line"]
217
+ items.append(item)
218
+ return {"total": len(rows), "items": items}
219
+
220
+
221
+ def expand(con, node_id: str, limit: int = 50) -> dict:
222
+ """One node in detail: what it contains, what it depends on, and what depends on it."""
223
+ row = _node(con, node_id)
224
+ if row is None:
225
+ hits = search(con, node_id.split(":")[-1], limit=5)["results"]
226
+ return {"error": f"No node with id {node_id!r}.", "did_you_mean": hits}
227
+ out = _brief(row)
228
+ out.update({k: v for k, v in _attrs(row).items() if k not in out and v not in (None, [], "")})
229
+ if row["kind"] == "system":
230
+ out["members"] = [_brief(r) for r in con.execute(
231
+ "SELECT n.* FROM edges e JOIN nodes n ON n.id = e.dst_id WHERE e.kind = 'groups' AND e.src_id = ?"
232
+ " ORDER BY n.name", (node_id,))]
233
+ out["layer"], out["source"] = row["layer"], row["source"]
234
+ if row["parent_id"]:
235
+ p = _node(con, row["parent_id"])
236
+ out["parent"] = {"id": p["id"], "kind": p["kind"], "name": p["name"]} if p else row["parent_id"]
237
+ children = con.execute(
238
+ "SELECT * FROM nodes WHERE parent_id = ? ORDER BY kind, span_start, name", (node_id,)).fetchall()
239
+ kind = row["kind"]
240
+ if kind == "module":
241
+ files = [c for c in children if c["kind"] == "file"]
242
+ out["files"] = [{"id": f["id"], "name": f["name"], "language": f["language"], "loc": f["span_end"]}
243
+ for f in files[:limit]]
244
+ types = con.execute(
245
+ "SELECT n.* FROM ancestry a JOIN nodes n ON n.id = a.node_id"
246
+ " WHERE a.module_id = ? AND n.kind = 'type' ORDER BY n.path, n.span_start", (node_id,)).fetchall()
247
+ public = [t for t in types if "public" in (_attrs(t).get("visibility") or "")]
248
+ out["public_types"] = {"total": len(public), "items": [_brief(t) for t in public[:limit]]}
249
+ out["internal_types"] = len(types) - len(public)
250
+ out["entry_points"] = [_brief(e) for e in con.execute(
251
+ "SELECT n.* FROM ancestry a JOIN nodes n ON n.id = a.node_id"
252
+ " WHERE a.module_id = ? AND n.kind = 'entry_point' ORDER BY n.path", (node_id,))][:limit]
253
+ edges = module_edges(con)
254
+ out["depends_on"] = [e for e in edges if e["from"] == node_id]
255
+ out["depended_on_by"] = [e for e in edges if e["to"] == node_id]
256
+ out["externals"] = sorted({r[0] for r in con.execute(
257
+ "SELECT DISTINCT x.name FROM edges e JOIN nodes x ON x.id = e.dst_id AND x.kind = 'external'"
258
+ f" WHERE {_module_of('e.src_id')} = ?", (node_id,))})
259
+ else:
260
+ by_kind: dict[str, list] = {}
261
+ for c in children:
262
+ by_kind.setdefault(c["kind"], []).append(c)
263
+ if by_kind:
264
+ out["contains"] = {k: {"total": len(v), "items": [_brief(c) for c in v[:limit]]}
265
+ for k, v in by_kind.items()}
266
+ out["out"] = _edge_rows(con, node_id, "out", limit)
267
+ out["in"] = _edge_rows(con, node_id, "in", limit)
268
+ if kind in ("callable", "field"):
269
+ calls, callers = _call_rows(con, node_id, "out", limit), _call_rows(con, node_id, "in", limit)
270
+ if calls["total"]:
271
+ out["calls"] = calls
272
+ if callers["total"]:
273
+ out["called_by"] = callers
274
+ if kind == "type":
275
+ users: dict[str, int] = {}
276
+ for r in con.execute(
277
+ "SELECT a.module_id AS m, COUNT(*) AS n FROM edges e JOIN ancestry a ON a.node_id = e.src_id"
278
+ " WHERE e.dst_id = ? AND e.kind IN ('uses_type', 'instantiates', 'extends', 'implements')"
279
+ " GROUP BY a.module_id", (node_id,)):
280
+ if r["m"]:
281
+ users[r["m"]] = r["n"]
282
+ out["used_by_module"] = users
283
+ notes = [{"key": a["key"], "value": a["value"], "layer": a["layer"], "confidence": a["confidence"],
284
+ "stale": bool(a["stale"])}
285
+ for a in con.execute("SELECT * FROM annotations WHERE node_id = ?", (node_id,))]
286
+ if notes:
287
+ out["annotations"] = notes
288
+ data = _data_access(con, row)
289
+ if data:
290
+ out["data"] = data
291
+ if row["kind"] in ("type", "callable", "field"):
292
+ from . import patterns
293
+ try:
294
+ labels = patterns.listing(con, node_id=node_id, limit=10)["patterns"]
295
+ except Exception: # a store written before patterns existed
296
+ labels = []
297
+ if labels:
298
+ out["patterns"] = [{"pattern": x["pattern"], "confidence": x["confidence"], "rationale": x["rationale"],
299
+ "roles_here": sorted(role for role, ns in x["roles"].items()
300
+ if any(n["id"] == node_id or n["id"].startswith(node_id + ".") for n in ns))}
301
+ for x in labels]
302
+ for side in ("out", "in"):
303
+ if not out[side]:
304
+ del out[side]
305
+ return out
306
+
307
+
308
+ def search(con, query: str, kind: Optional[str] = None, limit: int = 20) -> dict:
309
+ """Find nodes by name, qualified name or path."""
310
+ tokens = re.findall(r"[A-Za-z0-9_]+", query)
311
+ rows = []
312
+ if tokens:
313
+ match = " ".join(f'"{t}"*' for t in tokens)
314
+ sql = ("SELECT n.* FROM search s JOIN nodes n ON n.id = s.node_id WHERE search MATCH ?"
315
+ + (" AND n.kind = ?" if kind else "")
316
+ + " ORDER BY bm25(search, 0, 10.0, 3.0, 1.0), length(n.id) LIMIT ?")
317
+ try:
318
+ rows = con.execute(sql, (match, kind, limit) if kind else (match, limit)).fetchall()
319
+ except sqlite3.OperationalError:
320
+ rows = []
321
+ if not rows:
322
+ like = f"%{query}%"
323
+ sql = ("SELECT * FROM nodes WHERE (name LIKE ? OR id LIKE ?)" + (" AND kind = ?" if kind else "")
324
+ + " ORDER BY length(id) LIMIT ?")
325
+ rows = con.execute(sql, (like, like, kind, limit) if kind else (like, like, limit)).fetchall()
326
+ # What the text names exactly comes first: the id, a file at that path (or ending in it), a node of that
327
+ # name, or Owner.name. Ranking by words alone put local.ts 19th behind files that only mention "local".
328
+ q = query.strip()
329
+ exact = []
330
+ if q:
331
+ exact = con.execute(
332
+ "SELECT * FROM nodes WHERE (id = ? OR name = ? OR (kind IN ('file', 'module') AND (path = ? OR path LIKE ? ESCAPE '\\')))"
333
+ + (" AND kind = ?" if kind else "") + " ORDER BY id = ? DESC, path = ? DESC, name = ? DESC,"
334
+ " kind IN ('file', 'type', 'callable') DESC, length(id) LIMIT ?",
335
+ (q, q, q, "%/" + re.sub(r"([\\%_])", r"\\\1", q), *([kind] if kind else []), q, q, q, limit)).fetchall()
336
+ if "." in q and not exact:
337
+ owner, _, leaf = q.rpartition(".")
338
+ exact = [r for r in con.execute("SELECT n.* FROM nodes n JOIN nodes p ON p.id = n.parent_id WHERE n.name = ?"
339
+ " AND p.name = ?" + (" AND n.kind = ?" if kind else "") + " LIMIT ?",
340
+ (leaf, owner.rsplit(".", 1)[-1], *([kind] if kind else []), limit))]
341
+ first = {r["id"] for r in exact}
342
+ rows = (exact + [r for r in rows if r["id"] not in first])[:limit]
343
+ return {"query": query, "results": [_brief(r) for r in rows]}
344
+
345
+
346
+ def resolve(con, text: str) -> dict:
347
+ """A node id, or the node a person means by a name: `Owner.method`, `method`, a file path. {"id": ...} when it is
348
+ one thing; otherwise {"error": ..., "candidates": [...]}."""
349
+ text = text.strip()
350
+ if _node(con, text) is not None:
351
+ return {"id": text}
352
+ if "/" in text or re.search(r"\.\w{1,5}$", text) and con.execute(
353
+ "SELECT 1 FROM nodes WHERE kind = 'file' AND (path = ? OR path LIKE ?) LIMIT 1", (text, "%/" + text)).fetchone():
354
+ rows = con.execute("SELECT * FROM nodes WHERE kind = 'file' AND (path = ? OR path LIKE ?) ORDER BY length(path)",
355
+ (text, "%/" + text)).fetchall()
356
+ else:
357
+ parts = text.split(".")
358
+ rows = [r for r in con.execute("SELECT * FROM nodes WHERE name = ? AND kind IN ('callable', 'type', 'field', 'test',"
359
+ " 'module', 'system', 'entry_point') ORDER BY length(id)", (parts[-1],))]
360
+ for depth, owner in enumerate(reversed(parts[:-1])): # each written owner must be the next one up
361
+ keep = []
362
+ for r in rows:
363
+ cur = r
364
+ for _ in range(depth + 1):
365
+ cur = _node(con, cur["parent_id"]) if cur and cur["parent_id"] else None
366
+ if cur is not None and cur["name"] == owner:
367
+ keep.append(r)
368
+ rows = keep
369
+ exact = [r for r in rows if r["path"] == text] or rows
370
+ if len(exact) == 1:
371
+ return {"id": exact[0]["id"]}
372
+ if not exact:
373
+ hits = search(con, text, limit=8)["results"]
374
+ return {"error": f"nothing on the map is called {text!r}", "candidates": hits}
375
+ return {"error": f"{text!r} could be {len(exact)} things; give the id, or write it as Owner.name",
376
+ "candidates": [_brief(r) for r in exact[:20]]}
377
+
378
+
379
+ def neighbors(con, node_id: str, direction: str = "both", kinds: Optional[list[str]] = None,
380
+ limit: int = 100) -> dict:
381
+ """Raw edges around a node, optionally filtered by edge kind. 'calls' is a kind here too."""
382
+ if _node(con, node_id) is None:
383
+ return {"error": f"No node with id {node_id!r}."}
384
+ out: dict = {"id": node_id}
385
+ for d in (("out", "in") if direction == "both" else (direction,)):
386
+ groups = _edge_rows(con, node_id, d, limit)
387
+ calls = _call_rows(con, node_id, d, limit)
388
+ if calls["total"]:
389
+ groups["calls"] = calls
390
+ if kinds:
391
+ groups = {k: v for k, v in groups.items() if k in kinds}
392
+ out[d] = groups
393
+ return out
394
+
395
+
396
+ def flows(con, kind: Optional[str] = None, through: Optional[str] = None) -> dict:
397
+ """Flows: paths walked from an entry point or a test. `through` keeps flows that pass a node."""
398
+ sql = "SELECT f.* FROM flows f"
399
+ args: list = []
400
+ if through:
401
+ sql += " WHERE f.id IN (SELECT flow_id FROM flow_steps WHERE callable_id = ?)"
402
+ args.append(through)
403
+ out = []
404
+ for f in con.execute(sql + " ORDER BY f.name", args):
405
+ a = json.loads(f["attrs"]) if f["attrs"] else {}
406
+ if kind and a.get("kind") != kind:
407
+ continue
408
+ out.append({"id": f["id"], "name": f["name"], "origin": f["origin"], "kind": a.get("kind"),
409
+ "trigger": a.get("detail"), "steps": a.get("steps"), "truncated": a.get("truncated", False),
410
+ "modules": [m.split(":module:")[-1] for m in a.get("modules", [])], "entry": f["entry_id"]})
411
+ return {"total": len(out), "flows": out,
412
+ "note": "Static flows list each function once, in source order. They show what can run, not what did."}
413
+
414
+
415
+ def flow(con, flow_id: str, max_steps: int = 400) -> dict:
416
+ """One flow, step by step. `depth` is the call depth and `parent` the step it was reached from."""
417
+ f = con.execute("SELECT * FROM flows WHERE id = ?", (flow_id,)).fetchone()
418
+ if f is None:
419
+ return {"error": f"No flow with id {flow_id!r}. Use `flows` to list them."}
420
+ steps = []
421
+ for s in con.execute(
422
+ "SELECT s.seq, s.depth, s.via, s.site_line, s.parent_seq, n.* FROM flow_steps s"
423
+ " JOIN nodes n ON n.id = s.callable_id WHERE s.flow_id = ? ORDER BY s.seq LIMIT ?", (flow_id, max_steps)):
424
+ a = _attrs(s)
425
+ steps.append({"seq": s["seq"], "depth": s["depth"], "parent": s["parent_seq"], "via": s["via"],
426
+ "call_line": s["site_line"], "id": s["id"], "name": s["name"], "path": s["path"],
427
+ "lines": [s["span_start"], s["span_end"]], "signature": a.get("signature")})
428
+ a = json.loads(f["attrs"]) if f["attrs"] else {}
429
+ return {"id": f["id"], "name": f["name"], "origin": f["origin"], "kind": a.get("kind"),
430
+ "truncated": a.get("truncated", False), "total_steps": a.get("steps"), "steps": steps}
431
+
432
+
433
+ def _adjacency(con, reverse: bool = False) -> dict:
434
+ adj: dict[str, list] = {}
435
+ a, b = ("dst_id", "src_id") if reverse else ("src_id", "dst_id")
436
+ for r in con.execute(f"SELECT DISTINCT {a}, {b}, 'calls' FROM calls UNION"
437
+ f" SELECT {a}, {b}, 'communicates' FROM edges WHERE kind = 'communicates' UNION"
438
+ f" SELECT {b}, {a}, 'dispatch' FROM edges WHERE kind = 'overrides'"):
439
+ adj.setdefault(r[0], []).append((r[1], r[2]))
440
+ return adj
441
+
442
+
443
+ def trace(con, from_id: str, to_id: str, max_depth: int = 12) -> dict:
444
+ """The shortest chain of calls (and channels) from one function to another."""
445
+ for i in (from_id, to_id):
446
+ if _node(con, i) is None:
447
+ return {"error": f"No node with id {i!r}."}
448
+ adj = _adjacency(con)
449
+ prev: dict[str, tuple] = {from_id: (None, None)}
450
+ frontier, depth = [from_id], 0
451
+ while frontier and to_id not in prev and depth < max_depth:
452
+ nxt = []
453
+ for cur in frontier:
454
+ for dst, via in adj.get(cur, ()):
455
+ if dst not in prev:
456
+ prev[dst] = (cur, via)
457
+ nxt.append(dst)
458
+ frontier, depth = nxt, depth + 1
459
+ if to_id not in prev:
460
+ return {"from": from_id, "to": to_id, "found": False,
461
+ "note": "No static path. The link may run through a channel that was not analyzed."}
462
+ path, cur = [], to_id
463
+ while cur is not None:
464
+ p, via = prev[cur]
465
+ row = _node(con, cur)
466
+ path.append({"id": cur, "name": row["name"], "path": row["path"], "via": via})
467
+ cur = p
468
+ return {"from": from_id, "to": to_id, "found": True, "hops": len(path) - 1, "path": path[::-1]}
469
+
470
+
471
+ def impact(con, node_id: str, max_depth: int = 6, flows_limit: int = 40) -> dict:
472
+ """Everything that can reach a node through calls and channels, grouped by module, plus the
473
+ flows that pass through it (the first `flows_limit`, and their total). Use it before changing the node."""
474
+ row = _node(con, node_id)
475
+ if row is None:
476
+ return {"error": f"No node with id {node_id!r}."}
477
+ targets = {node_id} | {r[0] for r in con.execute(
478
+ "WITH RECURSIVE d(id) AS (SELECT ? UNION SELECT n.id FROM nodes n JOIN d ON n.parent_id = d.id)"
479
+ " SELECT id FROM d", (node_id,))}
480
+ adj = _adjacency(con, reverse=True)
481
+ from .change import enclosing
482
+ shape = {r[0]: {"kind": r[1], "parent_id": r[2]} for r in con.execute("SELECT id, kind, parent_id FROM nodes")}
483
+ # A field is reached by the code that reads or writes it, and through that code by its callers.
484
+ fields = [t for t in targets if shape.get(t, {}).get("kind") == "field"]
485
+ for f in fields:
486
+ for (src,) in con.execute("SELECT DISTINCT src_id FROM edges WHERE kind IN ('reads', 'writes') AND dst_id = ?", (f,)):
487
+ adj.setdefault(f, []).append((src, "uses"))
488
+ dist: dict[str, int] = {t: 0 for t in targets}
489
+
490
+ def lift(found, d):
491
+ # A nested function runs when the function it is defined in runs, so that function's callers reach it.
492
+ more = []
493
+ for i in found:
494
+ for e in enclosing(shape, i):
495
+ if e in dist:
496
+ break
497
+ dist[e] = d
498
+ more.append(e)
499
+ return found + more
500
+
501
+ frontier, depth = lift(list(targets), 0), 0
502
+ while frontier and depth < max_depth:
503
+ nxt = []
504
+ for cur in frontier:
505
+ for src, _via in adj.get(cur, ()):
506
+ if src not in dist:
507
+ dist[src] = depth + 1
508
+ nxt.append(src)
509
+ frontier, depth = lift(nxt, depth + 1), depth + 1
510
+ reached = [i for i in dist if i not in targets]
511
+ by_module: dict[str, dict] = {}
512
+ home = con.execute("SELECT module_id FROM ancestry WHERE node_id = ?", (node_id,)).fetchone()
513
+ for i in reached:
514
+ m = con.execute("SELECT module_id FROM ancestry WHERE node_id = ?", (i,)).fetchone()
515
+ mod = (m[0] if m and m[0] else "?")
516
+ g = by_module.setdefault(mod, {"module": mod.split(":module:")[-1], "repo": mod.split(":", 1)[0], "count": 0,
517
+ "direct": [], "direct_total": 0})
518
+ g["count"] += 1
519
+ if dist[i] == 1:
520
+ g["direct_total"] += 1
521
+ if len(g["direct"]) < 15:
522
+ g["direct"].append(i)
523
+ marks = ",".join("?" * len(targets))
524
+ through = [{"id": r["id"], "name": r["name"]} for r in con.execute(
525
+ f"SELECT DISTINCT f.id, f.name FROM flows f JOIN flow_steps s ON s.flow_id = f.id"
526
+ f" WHERE s.callable_id IN ({marks}) ORDER BY f.name", list(targets))]
527
+ by_repo: dict[str, int] = defaultdict(int)
528
+ for g in by_module.values():
529
+ by_repo[g["repo"]] += g["count"]
530
+ return {"id": node_id, "reached_by": len(reached), "depth_limit": max_depth,
531
+ "crosses_module_boundary": any(m != (home[0] if home else None) for m in by_module),
532
+ "by_repo": dict(by_repo), "crosses_repo_boundary": any(r != row["repo_id"] for r in by_repo),
533
+ "by_module": sorted(by_module.values(), key=lambda g: -g["count"]),
534
+ "flows_through": {"total": len(through), "items": through[:flows_limit]},
535
+ "note": "Callers found from syntax. Code reached only through outside frameworks is not counted."}
536
+
537
+
538
+ def source(con, node_id: str, max_lines: int = 200) -> dict:
539
+ """The source text of a node, read from the working tree it was indexed from."""
540
+ row = _node(con, node_id)
541
+ if row is None:
542
+ return {"error": f"No node with id {node_id!r}."}
543
+ if not row["path"] or not row["span_start"]:
544
+ return {"error": f"{row['kind']} nodes have no source span."}
545
+ root = store.roots(con).get(row["repo_id"])
546
+ path = root / row["path"] if root else None
547
+ if path is None or not path.is_file() or not store.inside(path, root): # a link out, checked out since the map
548
+ return {"error": f"Source file not found for {row['path']}."}
549
+ from .indexer import source_lines
550
+ try:
551
+ lines = source_lines(path)
552
+ except OSError as e:
553
+ return {"error": f"Cannot read {row['path']}: {e.strerror or e}."}
554
+ start, end = row["span_start"], row["span_end"] or row["span_start"]
555
+ truncated = end - start + 1 > max_lines
556
+ shown_end = start + max_lines - 1 if truncated else end
557
+ return {"id": node_id, "path": row["path"], "lines": [start, end], "truncated": truncated,
558
+ "text": "\n".join(lines[start - 1:shown_end])}
559
+
560
+
561
+ def _owner_type(con, node_id: str) -> Optional[str]:
562
+ row = con.execute("SELECT parent_id FROM nodes WHERE id = ?", (node_id,)).fetchone()
563
+ while row and row[0]:
564
+ p = con.execute("SELECT id, kind, parent_id FROM nodes WHERE id = ?", (row[0],)).fetchone()
565
+ if p is None:
566
+ return None
567
+ if p["kind"] == "type":
568
+ return p["id"]
569
+ row = (p["parent_id"],)
570
+ return None
571
+
572
+
573
+ def _data_access(con, row) -> dict:
574
+ """Which functions read and assign a field; which fields a function touches; a type's fields at a glance."""
575
+ def users(field_id):
576
+ r, w = [], []
577
+ for e in con.execute("SELECT e.kind, e.src_id, e.precision, e.attrs, n.name FROM edges e JOIN nodes n ON n.id = e.src_id"
578
+ " WHERE e.dst_id = ? AND e.kind IN ('reads', 'writes') ORDER BY e.src_id", (field_id,)):
579
+ a = json.loads(e["attrs"] or "{}")
580
+ item = {"id": e["src_id"], "name": e["name"], "times": a.get("n", 1), "line": a.get("line"),
581
+ **({"guessed": True} if e["precision"] == "guess" else {}),
582
+ **({"only_when_creating": True} if a.get("init") else {})}
583
+ (w if e["kind"] == "writes" else r).append(item)
584
+ return r, w
585
+ if row["kind"] == "field":
586
+ r, w = users(row["id"])
587
+ own = _owner_type(con, row["id"])
588
+ outside = [x for x in w if _owner_type(con, x["id"]) != own]
589
+ return {"read_by": r, "written_by": w, "written_outside_its_type": len(outside)} if (r or w) else {}
590
+ if row["kind"] in ("callable", "test"):
591
+ out = {"reads": [], "writes": []}
592
+ for e in con.execute("SELECT e.kind, e.dst_id, e.precision, n.name, p.name AS owner FROM edges e JOIN nodes n ON n.id = e.dst_id"
593
+ " LEFT JOIN nodes p ON p.id = n.parent_id WHERE e.src_id = ? AND e.kind IN ('reads', 'writes')"
594
+ " ORDER BY e.dst_id", (row["id"],)):
595
+ out[e["kind"]].append({"id": e["dst_id"], "name": f"{e['owner']}.{e['name']}" if e["owner"] else e["name"],
596
+ **({"guessed": True} if e["precision"] == "guess" else {})})
597
+ return out if (out["reads"] or out["writes"]) else {}
598
+ if row["kind"] == "type":
599
+ fields = []
600
+ for f in con.execute("SELECT id, name FROM nodes WHERE parent_id = ? AND kind = 'field' ORDER BY name", (row["id"],)):
601
+ r, w = users(f["id"])
602
+ if not (r or w):
603
+ continue
604
+ outside = sorted({_owner_type(con, x["id"]) or x["id"] for x in w} - {row["id"]})
605
+ fields.append({"id": f["id"], "name": f["name"], "readers": len(r), "writers": len(w),
606
+ "written_from": [o.split("::")[-1].split(":")[-1] for o in outside]})
607
+ return {"fields": fields} if fields else {}
608
+ return {}
609
+
610
+
611
+ def _told_apart(con, names: dict, ids: list[str]) -> list[str]:
612
+ """Names of nodes, with the file after each name that two of them share: `_Walker (src/adapters/python.py)`."""
613
+ seen = Counter(names.get(i, i) for i in ids)
614
+ out = []
615
+ for i in ids:
616
+ n = names.get(i, i)
617
+ if seen[n] > 1:
618
+ row = con.execute("SELECT path FROM nodes WHERE id = ?", (i,)).fetchone()
619
+ n += f" ({row[0]})" if row and row[0] else ""
620
+ out.append(n)
621
+ return out
622
+
623
+
624
+ def shared_state(con, scope: Optional[str] = None, limit: int = 40, guesses: bool = True) -> dict:
625
+ """Fields assigned from outside the type that declares them, most widely written first.
626
+ `scope` narrows to a module, type or path prefix of the field's id. `guesses=False` leaves out links found only
627
+ by a unique field name."""
628
+ share = {} # one string for each kind and module, not one per row (hundreds of MB on a large repository)
629
+ share = share.setdefault
630
+ parent = {r[0]: (r[1], share(r[2], r[2])) for r in con.execute("SELECT id, parent_id, kind FROM nodes")}
631
+ module = {r[0]: share(r[1], r[1]) for r in con.execute("SELECT node_id, module_id FROM ancestry")}
632
+ names = {r["id"]: r["name"] for r in con.execute("SELECT id, name FROM nodes")}
633
+ # Test code: a file that holds tests, or a test project. A test kept beside the code it tests (grocery.test.ts
634
+ # next to grocery.ts) does not make that code test code.
635
+ test_files, test_mods = store.test_places(con)
636
+ path = {r[0]: r[1] for r in con.execute("SELECT id, path FROM nodes WHERE kind IN ('callable', 'test')")}
637
+
638
+ def owner(i):
639
+ cur = parent.get(i, (None, None))[0]
640
+ while cur and parent.get(cur, (None, None))[1] != "type":
641
+ cur = parent.get(cur, (None, None))[0]
642
+ return cur
643
+ supers = defaultdict(set)
644
+ for e in con.execute("SELECT src_id, dst_id FROM edges WHERE kind IN ('extends', 'implements')"):
645
+ supers[e["src_id"]].add(e["dst_id"])
646
+
647
+ def is_a(t, base, seen=None): # a subclass assigning an inherited field is still inside the type
648
+ seen = seen or set()
649
+ return t == base or any(b not in seen and not seen.add(b) and is_a(b, base, seen) for b in supers.get(t, ()))
650
+ writers, readers = defaultdict(set), defaultdict(set)
651
+ for e in con.execute("SELECT kind, src_id, dst_id, attrs FROM edges WHERE kind IN ('reads', 'writes')"
652
+ + ("" if guesses else " AND COALESCE(precision, '') != 'guess'")):
653
+ if e["kind"] == "writes" and json.loads(e["attrs"] or "{}").get("init"):
654
+ continue # filling in a new object is construction, not a change to shared state
655
+ if e["kind"] == "writes" and names.get(e["src_id"]) in (".ctor", "__init__") and owner(e["src_id"]) == owner(e["dst_id"]):
656
+ continue
657
+ (writers if e["kind"] == "writes" else readers)[e["dst_id"]].add(e["src_id"])
658
+ rows = []
659
+ for f, ws in writers.items():
660
+ if scope and not (f.startswith(scope) or module.get(f) == scope):
661
+ continue
662
+ own = owner(f)
663
+ product = {w for w in ws if module.get(w) not in test_mods and path.get(w) not in test_files}
664
+ outside_types = sorted(t for t in {owner(w) or w for w in product} if not is_a(t, own))
665
+ if not outside_types:
666
+ continue
667
+ rows.append({"id": f, "name": f"{names.get(own, '?')}.{names.get(f, f)}", "module": names.get(module.get(f), ""),
668
+ "writers": len(product), "written_from": _told_apart(con, names, outside_types),
669
+ "writer_modules": sorted({names.get(module.get(w), "") for w in product}),
670
+ "readers": len(readers.get(f, ()))})
671
+ # Two fields can read the same (a Builder class in each of four scripts): name the file of each such one.
672
+ seen = Counter(r["name"] for r in rows)
673
+ for r in rows:
674
+ if seen[r["name"]] > 1:
675
+ path = con.execute("SELECT path FROM nodes WHERE id = ?", (r["id"],)).fetchone()
676
+ r["path"] = path[0] if path else ""
677
+ r["name"] += f" ({r['path']})"
678
+ rows.sort(key=lambda r: (-len(r["written_from"]), -len(r["writer_modules"]), -r["writers"], r["name"]))
679
+ return {"total": len(rows), "fields": rows[:limit],
680
+ "note": "A field many types assign has no single place that keeps it valid. Test code, constructors and values set "
681
+ "while creating an object (new Foo { a = 1 }) are not counted. "
682
+ "A call that changes a collection in place (list.Add, items.append) counts as an assignment; any "
683
+ "other method called on the field does not, since the map cannot tell what it does."}