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/cluster.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Propose systems: groups of types inside a large module, found by Louvain community detection.
|
|
2
|
+
|
|
3
|
+
The proposal is deterministic. Naming and explaining each system is an inferred-layer job, done
|
|
4
|
+
through `annotate`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from . import store
|
|
14
|
+
|
|
15
|
+
SOURCE = "leyline-cluster/louvain-0.1"
|
|
16
|
+
WEIGHTS = {"calls": 1.0, "uses_type": 1.0, "instantiates": 1.0, "extends": 3.0, "implements": 3.0}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _units(con, repo_id: str, only: str = "") -> tuple[dict, dict]:
|
|
20
|
+
"""Map every node to its unit (outermost type, or file for loose functions) and unit to module. `only` limits
|
|
21
|
+
it to the nodes in some modules (SQL naming their ids)."""
|
|
22
|
+
nodes = {r["id"]: r for r in con.execute(
|
|
23
|
+
f"SELECT id, kind, parent_id, name FROM nodes WHERE repo_id = ? AND layer = 'fact'{only}", (repo_id,))}
|
|
24
|
+
unit: dict[str, str] = {}
|
|
25
|
+
for i, n in nodes.items():
|
|
26
|
+
if n["kind"] in ("module", "repo", "external", "entry_point"):
|
|
27
|
+
continue
|
|
28
|
+
if n["kind"] == "file":
|
|
29
|
+
unit[i] = i
|
|
30
|
+
continue
|
|
31
|
+
cur = i
|
|
32
|
+
while nodes[cur]["parent_id"] in nodes and nodes[nodes[cur]["parent_id"]]["kind"] != "file":
|
|
33
|
+
cur = nodes[cur]["parent_id"]
|
|
34
|
+
unit[i] = cur if nodes[cur]["kind"] == "type" else nodes[cur]["parent_id"]
|
|
35
|
+
module = {r["node_id"]: r["module_id"] for r in con.execute(
|
|
36
|
+
"SELECT node_id, module_id FROM ancestry" + (f" WHERE node_id IN (SELECT id FROM nodes WHERE 1{only})" if only else ""))}
|
|
37
|
+
return unit, module
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def propose(con, repo_id: str, min_units: int = 12, resolution: float = 1.0, seed: int = 7,
|
|
41
|
+
min_modularity: float = 0.3, modules: Optional[set] = None) -> dict:
|
|
42
|
+
"""Replace the proposed systems of a repo. Returns counts per module. With `modules`, only those modules are
|
|
43
|
+
clustered again (each module is clustered on its own, so the others would come out as they are) and the
|
|
44
|
+
counts kept for the rest are carried over."""
|
|
45
|
+
try:
|
|
46
|
+
import networkx as nx
|
|
47
|
+
except ImportError: # clustering is optional
|
|
48
|
+
return {"skipped": "networkx is not installed"}
|
|
49
|
+
only, src_only = "", ""
|
|
50
|
+
if modules is not None:
|
|
51
|
+
con.execute("CREATE TEMP TABLE IF NOT EXISTS cluster_mods (id TEXT PRIMARY KEY)")
|
|
52
|
+
con.execute("DELETE FROM cluster_mods")
|
|
53
|
+
con.executemany("INSERT INTO cluster_mods VALUES (?)", [(m,) for m in sorted(modules)])
|
|
54
|
+
only = " AND id IN (SELECT node_id FROM ancestry WHERE module_id IN (SELECT id FROM cluster_mods))"
|
|
55
|
+
src_only = " AND src_id IN (SELECT node_id FROM ancestry WHERE module_id IN (SELECT id FROM cluster_mods))"
|
|
56
|
+
unit, module = _units(con, repo_id, only)
|
|
57
|
+
names = {r["id"]: r["name"] for r in con.execute(f"SELECT id, name FROM nodes WHERE repo_id = ?{only}", (repo_id,))}
|
|
58
|
+
kinds = {r["id"]: r["kind"] for r in con.execute(f"SELECT id, kind FROM nodes WHERE repo_id = ?{only}", (repo_id,))}
|
|
59
|
+
has_code = {u for i, u in unit.items() if kinds.get(i) in ("callable", "type")}
|
|
60
|
+
by_module: dict[str, set] = defaultdict(set)
|
|
61
|
+
for u in set(unit.values()):
|
|
62
|
+
if u in has_code and module.get(u):
|
|
63
|
+
by_module[module[u]].add(u)
|
|
64
|
+
weights: dict[tuple, float] = defaultdict(float)
|
|
65
|
+
|
|
66
|
+
def add(src: str, dst: str, w: float) -> None:
|
|
67
|
+
a, b = unit.get(src), unit.get(dst)
|
|
68
|
+
if a and b and a != b and module.get(a) == module.get(b):
|
|
69
|
+
weights[(a, b) if a < b else (b, a)] += w
|
|
70
|
+
|
|
71
|
+
for r in con.execute(f"SELECT src_id, dst_id, COUNT(*) AS n FROM calls WHERE 1{src_only} GROUP BY src_id, dst_id ORDER BY dst_id, src_id"):
|
|
72
|
+
add(r["src_id"], r["dst_id"], WEIGHTS["calls"] * r["n"])
|
|
73
|
+
for r in con.execute("SELECT src_id, dst_id, kind FROM edges WHERE kind IN ('uses_type','instantiates','extends','implements')"
|
|
74
|
+
+ src_only):
|
|
75
|
+
add(r["src_id"], r["dst_id"], WEIGHTS[r["kind"]])
|
|
76
|
+
|
|
77
|
+
# Both ends of a weighted pair share a module, so each module's pairs can be found without scanning them all.
|
|
78
|
+
pairs_of: dict[str, list] = defaultdict(list)
|
|
79
|
+
for (a, b), w in weights.items():
|
|
80
|
+
pairs_of[module.get(a)].append((a, b, w))
|
|
81
|
+
|
|
82
|
+
mine = " AND parent_id IN (SELECT id FROM cluster_mods)" if modules is not None else ""
|
|
83
|
+
old = [r[0] for r in con.execute(f"SELECT id FROM nodes WHERE repo_id = ? AND kind = 'system' AND source = ?{mine}",
|
|
84
|
+
(repo_id, SOURCE))]
|
|
85
|
+
with con:
|
|
86
|
+
for sid in old:
|
|
87
|
+
con.execute("DELETE FROM links WHERE kind = 'groups' AND src IN (SELECT k FROM keys WHERE id = ?)", (sid,))
|
|
88
|
+
con.execute(f"DELETE FROM nodes WHERE repo_id = ? AND kind = 'system' AND source = ?{mine}", (repo_id, SOURCE))
|
|
89
|
+
result = {}
|
|
90
|
+
for mod, members in sorted(by_module.items()):
|
|
91
|
+
if len(members) < min_units:
|
|
92
|
+
continue
|
|
93
|
+
g = nx.Graph()
|
|
94
|
+
g.add_nodes_from(sorted(members))
|
|
95
|
+
for a, b, w in sorted(pairs_of.get(mod, ())): # sorted: the communities found depend on the order edges are added
|
|
96
|
+
if a in members and b in members:
|
|
97
|
+
g.add_edge(a, b, weight=w)
|
|
98
|
+
communities = nx.community.louvain_communities(g, weight="weight", seed=seed, resolution=resolution)
|
|
99
|
+
modularity = nx.community.modularity(g, communities, weight="weight") if g.number_of_edges() else 0.0
|
|
100
|
+
made, made_ids, anchors = 0, set(), []
|
|
101
|
+
if modularity < min_modularity:
|
|
102
|
+
# The module does not split cleanly; proposing groups would be noise.
|
|
103
|
+
result[mod.split(":module:")[-1]] = {"units": len(members), "systems": 0,
|
|
104
|
+
"modularity": round(modularity, 3), "note": "no clear split"}
|
|
105
|
+
continue
|
|
106
|
+
for community in sorted(communities, key=lambda c: (-len(c), sorted(c)[0])):
|
|
107
|
+
if len(community) < 2:
|
|
108
|
+
continue # a type with no links stays ungrouped
|
|
109
|
+
ranked = sorted(community, key=lambda v: (-g.degree(v, weight="weight"), v))
|
|
110
|
+
anchor = ranked[0]
|
|
111
|
+
internal = sum(d["weight"] for a, b, d in g.subgraph(community).edges(data=True))
|
|
112
|
+
boundary = sum(d["weight"] for a, b, d in g.edges(community, data=True)
|
|
113
|
+
if (a in community) != (b in community))
|
|
114
|
+
sid = f"{mod}/system:{names[anchor]}"
|
|
115
|
+
if sid in made_ids:
|
|
116
|
+
# another group here is anchored on a type of the same name: named by the anchor's full name
|
|
117
|
+
sid = f"{mod}/system:{anchor.split(':', 2)[-1]}"
|
|
118
|
+
made_ids.add(sid)
|
|
119
|
+
con.execute(
|
|
120
|
+
"INSERT OR REPLACE INTO nodes (id, kind, name, parent_id, repo_id, layer, source, attrs)"
|
|
121
|
+
" VALUES (?, 'system', ?, ?, ?, 'inferred', ?, ?)",
|
|
122
|
+
(sid, f"{names[anchor]} group", mod, repo_id, SOURCE, json.dumps({
|
|
123
|
+
"anchor": anchor, "members": len(community), "modularity": round(modularity, 3),
|
|
124
|
+
"cohesion": round(internal / (internal + boundary), 3) if internal + boundary else 0.0,
|
|
125
|
+
"top": [names[v] for v in ranked[:6]], "method": "louvain"})))
|
|
126
|
+
store.insert_edges(con, [(None, "groups", sid, u, "heuristic", "inferred", SOURCE, None, None)
|
|
127
|
+
for u in sorted(community)])
|
|
128
|
+
made += 1
|
|
129
|
+
anchors.append((sid, anchor))
|
|
130
|
+
# Groups anchored on types of the same name (a _Walker in each of three files): named with the anchor's file.
|
|
131
|
+
seen = defaultdict(list)
|
|
132
|
+
for sid, anchor in anchors:
|
|
133
|
+
seen[names[anchor]].append((sid, anchor))
|
|
134
|
+
for same in (v for v in seen.values() if len(v) > 1):
|
|
135
|
+
for sid, anchor in same:
|
|
136
|
+
row = con.execute("SELECT path FROM nodes WHERE id = ?", (anchor,)).fetchone()
|
|
137
|
+
if row and row[0]:
|
|
138
|
+
con.execute("UPDATE nodes SET name = ? WHERE id = ?", (f"{names[anchor]} group ({row[0]})", sid))
|
|
139
|
+
result[mod.split(":module:")[-1]] = {"units": len(members), "systems": made,
|
|
140
|
+
"modularity": round(modularity, 3)}
|
|
141
|
+
if modules is not None:
|
|
142
|
+
row = con.execute("SELECT stats FROM extractor_coverage WHERE repo_id = ? AND extractor = 'systems:louvain'",
|
|
143
|
+
(repo_id,)).fetchone()
|
|
144
|
+
kept = json.loads(row[0]) if row and row[0] else {}
|
|
145
|
+
redone = {m.split(":module:")[-1] for m in modules}
|
|
146
|
+
result = dict(sorted({**{k: v for k, v in kept.items() if k not in redone}, **result}.items()))
|
|
147
|
+
con.execute("INSERT OR REPLACE INTO extractor_coverage VALUES (?,?,?,?,?,?)",
|
|
148
|
+
(repo_id, "systems:louvain", "0.1", "ok", None, json.dumps(result)))
|
|
149
|
+
return result
|