leyline-code 0.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/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."}
|