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/packing.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Keep a large repository's parse output small while names are resolved.
|
|
2
|
+
|
|
3
|
+
A file's calls and field uses (and the variable declarations the parse read for the generic resolver) are most of
|
|
4
|
+
what an adapter returns: on a repository of millions of lines they were gigabytes as objects, and every one of them
|
|
5
|
+
was held from the parse to the end of resolving. They are only read a file at a time, in a few passes. So the parse
|
|
6
|
+
worker packs them, pickled and compressed, beside the rest of the file's result, and on a large repository they stay
|
|
7
|
+
packed: the indexer unpacks one file's calls when a pass reaches that file, and lets them go when it moves on.
|
|
8
|
+
|
|
9
|
+
A small repository unpacks everything at once, as before: its output is small and the passes then pay nothing.
|
|
10
|
+
LEYLINE_PACKED=1 packs every repository and LEYLINE_PACKED=0 none (the tests use both).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import pickle
|
|
17
|
+
import zlib
|
|
18
|
+
|
|
19
|
+
from .model import CallSite
|
|
20
|
+
|
|
21
|
+
PACKED_MIN_FILES = 2000 # files to parse in a repository before its calls are kept packed
|
|
22
|
+
_P = pickle.HIGHEST_PROTOCOL
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def packs(files: int) -> bool:
|
|
26
|
+
env = os.environ.get("LEYLINE_PACKED", "")
|
|
27
|
+
if env in ("0", "1"):
|
|
28
|
+
return env == "1"
|
|
29
|
+
return files >= PACKED_MIN_FILES
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pack(res, decls) -> bytes:
|
|
33
|
+
"""One file's parse output as the parse worker hands it back (and the parse cache keeps it): the result without
|
|
34
|
+
its calls and field uses, then those two packed together (a call can be the receiver of a field use, and the
|
|
35
|
+
two must stay one object), then the declarations packed."""
|
|
36
|
+
calls, uses = res.calls, res.field_uses
|
|
37
|
+
res.calls = res.field_uses = None
|
|
38
|
+
try:
|
|
39
|
+
return pickle.dumps((res, pack_heavy(calls, uses), None if decls is None else zlib.compress(pickle.dumps(decls, _P), 1)), _P)
|
|
40
|
+
finally:
|
|
41
|
+
res.calls, res.field_uses = calls, uses
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def unpack(blob: bytes) -> tuple:
|
|
45
|
+
"""(result without calls and field uses, the packed calls and field uses, the packed declarations or None)."""
|
|
46
|
+
return pickle.loads(blob)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def pack_heavy(calls, uses) -> bytes:
|
|
50
|
+
return zlib.compress(pickle.dumps((calls, uses), _P), 1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def unpack_heavy(packed: bytes) -> tuple:
|
|
54
|
+
return pickle.loads(zlib.decompress(packed))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def unpack_decls(packed):
|
|
58
|
+
return None if packed is None else pickle.loads(zlib.decompress(packed))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def call_keys(res, index: int) -> dict:
|
|
62
|
+
"""id of every call site in a file's unpacked calls and field uses -> a key that names the same call each time
|
|
63
|
+
the file is unpacked: its place in a walk of the calls (receivers and argument calls first), counted down from
|
|
64
|
+
a range of negative numbers kept for the file. Results remembered about a call (Indexer._chain_memo) are kept
|
|
65
|
+
under this key, so they outlive one unpacking as they outlived nothing before; a negative key is never an id."""
|
|
66
|
+
out: dict = {}
|
|
67
|
+
base = -1 - (index << 24)
|
|
68
|
+
stack: list = []
|
|
69
|
+
for c in reversed(res.calls):
|
|
70
|
+
stack.append(c)
|
|
71
|
+
roots = [u.chain for u in res.field_uses if u.chain is not None]
|
|
72
|
+
n = 0
|
|
73
|
+
for group in (None, roots):
|
|
74
|
+
if group is not None:
|
|
75
|
+
stack.extend(reversed(group))
|
|
76
|
+
while stack:
|
|
77
|
+
c = stack.pop()
|
|
78
|
+
if id(c) in out:
|
|
79
|
+
continue
|
|
80
|
+
out[id(c)] = base - n
|
|
81
|
+
n += 1
|
|
82
|
+
more = [a for a in c.args if type(a) is CallSite]
|
|
83
|
+
if c.chain is not None:
|
|
84
|
+
more.insert(0, c.chain)
|
|
85
|
+
stack.extend(reversed(more))
|
|
86
|
+
return out
|
leyline/patterns.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Label design patterns by their shape in the graph.
|
|
2
|
+
|
|
3
|
+
Each matcher looks for a structure, not a name: a strategy is an abstraction with several
|
|
4
|
+
implementations that some other type holds and calls. A match says the code has that shape; it does
|
|
5
|
+
not say the author meant the pattern. Matches are written to the inferred layer with the nodes that
|
|
6
|
+
play each role, a sentence saying why, and a fixed confidence per matcher. An agent can add labels the
|
|
7
|
+
matchers cannot see through `label`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from . import store
|
|
19
|
+
|
|
20
|
+
MATCHER = "leyline-patterns/0.1"
|
|
21
|
+
SIDE_DIRS = ("sample", "samples", "example", "examples", "bench", "benchmarks", "docs", "doc", "demo", "demos", "snippets")
|
|
22
|
+
|
|
23
|
+
# How far each shape is from proof of intent. Shapes that also occur by accident score lower.
|
|
24
|
+
CONFIDENCE = {"strategy": 0.8, "decorator": 0.75, "composite": 0.75, "template method": 0.7, "observer": 0.85,
|
|
25
|
+
"factory": 0.6, "builder": 0.6, "singleton": 0.7, "process boundary": 0.9}
|
|
26
|
+
|
|
27
|
+
ABOUT = {
|
|
28
|
+
"strategy": "An abstraction with interchangeable implementations, chosen and held by another type.",
|
|
29
|
+
"decorator": "A type that implements an abstraction and wraps another object of the same abstraction.",
|
|
30
|
+
"composite": "A type that implements an abstraction and holds a collection of that same abstraction.",
|
|
31
|
+
"template method": "A base method that fixes the steps and leaves some of them to subclasses.",
|
|
32
|
+
"observer": "A type raises an event; other code subscribes and is called back.",
|
|
33
|
+
"factory": "One function decides which of several related types to create.",
|
|
34
|
+
"builder": "A type that collects settings step by step and then produces another object.",
|
|
35
|
+
"singleton": "A type that keeps one shared instance of itself.",
|
|
36
|
+
"process boundary": "Two programs that share no code and talk over a pipe.",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Parsed(dict):
|
|
41
|
+
"""key -> attrs, read from their JSON text the first time they are asked for. The matchers look at the attrs
|
|
42
|
+
of a few thousand nodes and edges; parsing all of them up front was most of the time on a large repository."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, text: dict):
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.text = text
|
|
47
|
+
|
|
48
|
+
def __missing__(self, key):
|
|
49
|
+
got = self[key] = json.loads(self.text[key] or "{}") # KeyError when the key is not there at all
|
|
50
|
+
return got
|
|
51
|
+
|
|
52
|
+
def get(self, key, default=None):
|
|
53
|
+
return self[key] if key in self.text else default
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# A node as _Graph keeps it: a tuple, these fields.
|
|
57
|
+
_ID, _KIND, _NAME, _PARENT, _LANG, _PATH = range(6)
|
|
58
|
+
_ATTR_EDGES = ("uses_type", "communicates") # the edge kinds whose attrs a matcher reads
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Graph:
|
|
62
|
+
def __init__(self, con):
|
|
63
|
+
# Each id is one string, used everywhere the node is named, and the few kinds, languages, paths and modules
|
|
64
|
+
# are one string each: every row read gives its own copies, and on a large repository the copies were
|
|
65
|
+
# most of what this held (GBs).
|
|
66
|
+
share = {}
|
|
67
|
+
share = share.setdefault
|
|
68
|
+
nodes = self.nodes = {}
|
|
69
|
+
text = {}
|
|
70
|
+
for r in con.execute("SELECT id, kind, name, parent_id, language, path, attrs FROM nodes WHERE layer = 'fact'"):
|
|
71
|
+
i = r[0]
|
|
72
|
+
nodes[i] = (i, share(r[1], r[1]), r[2], r[3], share(r[4], r[4]), share(r[5], r[5]))
|
|
73
|
+
text[i] = r[6]
|
|
74
|
+
for i, n in nodes.items():
|
|
75
|
+
p = nodes.get(n[_PARENT])
|
|
76
|
+
if p is not None and p[_ID] is not n[_PARENT]:
|
|
77
|
+
nodes[i] = (n[0], n[1], n[2], p[_ID], n[4], n[5])
|
|
78
|
+
self.attrs = _Parsed(text)
|
|
79
|
+
self.children = defaultdict(list)
|
|
80
|
+
for i, n in nodes.items():
|
|
81
|
+
self.children[n[_PARENT]].append(i)
|
|
82
|
+
self.out = defaultdict(lambda: defaultdict(set))
|
|
83
|
+
self.inn = defaultdict(lambda: defaultdict(set))
|
|
84
|
+
edge_attrs = {}
|
|
85
|
+
for e in con.execute("SELECT kind, src_id, dst_id, attrs FROM edges WHERE kind != 'contains'"):
|
|
86
|
+
kind, src, dst = share(e[0], e[0]), e[1], e[2]
|
|
87
|
+
n = nodes.get(src)
|
|
88
|
+
src = n[_ID] if n is not None else src
|
|
89
|
+
n = nodes.get(dst)
|
|
90
|
+
dst = n[_ID] if n is not None else dst
|
|
91
|
+
self.out[kind][src].add(dst)
|
|
92
|
+
self.inn[kind][dst].add(src)
|
|
93
|
+
if kind in _ATTR_EDGES:
|
|
94
|
+
edge_attrs[(kind, src, dst)] = e[3]
|
|
95
|
+
self.edge_attrs = _Parsed(edge_attrs)
|
|
96
|
+
self.calls = defaultdict(set)
|
|
97
|
+
for c in con.execute("SELECT DISTINCT src_id, dst_id FROM calls"):
|
|
98
|
+
src, dst = c[0], c[1]
|
|
99
|
+
n = nodes.get(src)
|
|
100
|
+
src = n[_ID] if n is not None else src
|
|
101
|
+
n = nodes.get(dst)
|
|
102
|
+
self.calls[src].add(n[_ID] if n is not None else dst)
|
|
103
|
+
self.module = {}
|
|
104
|
+
for r in con.execute("SELECT node_id, module_id FROM ancestry"):
|
|
105
|
+
n = nodes.get(r[0])
|
|
106
|
+
self.module[n[_ID] if n is not None else r[0]] = share(r[1], r[1])
|
|
107
|
+
self.test_files, self.test_modules = store.test_places(con)
|
|
108
|
+
|
|
109
|
+
def kind(self, i):
|
|
110
|
+
return self.nodes[i][_KIND] if i in self.nodes else None
|
|
111
|
+
|
|
112
|
+
def name(self, i):
|
|
113
|
+
if i not in self.nodes:
|
|
114
|
+
return i
|
|
115
|
+
n = self.nodes[i]
|
|
116
|
+
if n[_KIND] == "type" and "`" in i.rsplit(".", 1)[-1]:
|
|
117
|
+
return n[_NAME] + "<T>" # tell Foo<T> from Foo
|
|
118
|
+
return n[_NAME]
|
|
119
|
+
|
|
120
|
+
def qual(self, i):
|
|
121
|
+
"""A member with its owner: Simulation.Step."""
|
|
122
|
+
owner = self.owner(i) if self.kind(i) in ("callable", "field") else None
|
|
123
|
+
return f"{self.name(owner)}.{self.name(i)}" if owner else self.name(i)
|
|
124
|
+
|
|
125
|
+
def subtypes(self, t) -> set:
|
|
126
|
+
return self.inn["implements"][t] | self.inn["extends"][t]
|
|
127
|
+
|
|
128
|
+
def supertypes(self, t, seen=None) -> set:
|
|
129
|
+
seen = seen if seen is not None else set()
|
|
130
|
+
for b in self.out["implements"][t] | self.out["extends"][t]:
|
|
131
|
+
if b not in seen:
|
|
132
|
+
seen.add(b)
|
|
133
|
+
self.supertypes(b, seen)
|
|
134
|
+
return seen
|
|
135
|
+
|
|
136
|
+
def owner(self, i) -> Optional[str]:
|
|
137
|
+
"""The type a member belongs to."""
|
|
138
|
+
cur = self.nodes[i][_PARENT] if i in self.nodes else None
|
|
139
|
+
while cur in self.nodes and self.nodes[cur][_KIND] != "type":
|
|
140
|
+
cur = self.nodes[cur][_PARENT]
|
|
141
|
+
return cur if cur in self.nodes else None
|
|
142
|
+
|
|
143
|
+
def fields(self, t):
|
|
144
|
+
return [c for c in self.children[t] if self.kind(c) == "field"]
|
|
145
|
+
|
|
146
|
+
def methods(self, t):
|
|
147
|
+
return [c for c in self.children[t] if self.kind(c) == "callable"]
|
|
148
|
+
|
|
149
|
+
def is_abstraction(self, t) -> bool:
|
|
150
|
+
a = self.attrs.get(t, {})
|
|
151
|
+
return a.get("native_kind") == "interface" or bool(a.get("is_abstract")) or (
|
|
152
|
+
self.nodes[t][_LANG] == "python" and len(self.subtypes(t)) >= 2)
|
|
153
|
+
|
|
154
|
+
def in_tests(self, i) -> bool:
|
|
155
|
+
"""Test code, and code beside the product: samples, benchmarks, docs."""
|
|
156
|
+
path = (self.nodes[i][_PATH] or "") if i in self.nodes else ""
|
|
157
|
+
parts = [p.lower() for p in path.split("/")[:-1]]
|
|
158
|
+
return (self.module.get(i) in self.test_modules or path in self.test_files
|
|
159
|
+
or any(p.startswith("test") or p in SIDE_DIRS for p in parts))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _held(g: _Graph, abstraction: str):
|
|
163
|
+
"""Fields whose type is the abstraction itself (not a collection of it), by owning type."""
|
|
164
|
+
out = defaultdict(list)
|
|
165
|
+
for f in sorted(g.inn["uses_type"][abstraction]): # sorted: the rationale names them in this order
|
|
166
|
+
if g.kind(f) == "field" and g.edge_attrs.get(("uses_type", f, abstraction), {}).get("role") == "field_type":
|
|
167
|
+
owner = g.owner(f)
|
|
168
|
+
if owner:
|
|
169
|
+
out[owner].append(f)
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _collections(g: _Graph, abstraction: str):
|
|
174
|
+
"""Fields that hold many of the abstraction (List<I>, I[], dict of I), by owning type."""
|
|
175
|
+
out = defaultdict(list)
|
|
176
|
+
name = g.name(abstraction)
|
|
177
|
+
for f in sorted(g.inn["uses_type"][abstraction]):
|
|
178
|
+
if g.kind(f) != "field":
|
|
179
|
+
continue
|
|
180
|
+
role = g.edge_attrs.get(("uses_type", f, abstraction), {}).get("role")
|
|
181
|
+
declared = g.attrs[f].get("declared_type") or ""
|
|
182
|
+
if role == "generic_arg" or declared.replace(" ", "").endswith(name + "[]"):
|
|
183
|
+
owner = g.owner(f)
|
|
184
|
+
if owner:
|
|
185
|
+
out[owner].append(f)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# What makes a function choose between the types it creates: a branch, a ternary, or a lookup by key.
|
|
190
|
+
_BRANCH = re.compile(r"\b(?:if|elif|else|switch|case|match|when|default)\b|\?(?![?.\[])|=>\s*$")
|
|
191
|
+
_LOOKUP = re.compile(r"[\]})]\s*\[\s*[A-Za-z_][\w.]*\s*\]|\bTryGetValue\b|\.get\(|\[\s*[A-Za-z_][\w.]*\s*\]\s*\(")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _builds_all(con, f: str, names: list[str], sources: dict) -> bool:
|
|
195
|
+
"""Whether a function creates all of these types together (`new IRule[] { new A(), new B() }`, `[Box(), Disc()]`)
|
|
196
|
+
rather than one of them by a branch, a ternary or a lookup by key. False when its source cannot be read."""
|
|
197
|
+
r = con.execute("SELECT repo_id, path, span_start, span_end FROM nodes WHERE id = ?", (f,)).fetchone()
|
|
198
|
+
if r is None or not r[1] or not r[2]:
|
|
199
|
+
return False
|
|
200
|
+
key = (r[0], r[1])
|
|
201
|
+
if key not in sources:
|
|
202
|
+
from . import diff
|
|
203
|
+
try:
|
|
204
|
+
data = diff.source(con, *key)
|
|
205
|
+
except Exception:
|
|
206
|
+
data = None
|
|
207
|
+
sources[key] = data.decode("utf-8", errors="replace").split("\n") if data else None
|
|
208
|
+
lines = sources[key]
|
|
209
|
+
if lines is None:
|
|
210
|
+
return False
|
|
211
|
+
body = lines[r[2] - 1:(r[3] or r[2])]
|
|
212
|
+
made = re.compile(r"(?<![\w.])(?:new\s+)?(?:" + "|".join(re.escape(n) for n in names) + r")\s*[({]")
|
|
213
|
+
at = [k for k, ln in enumerate(body) if made.search(ln)]
|
|
214
|
+
if len(at) < 2 and not (at and len(made.findall(body[at[0]])) >= 2):
|
|
215
|
+
return False
|
|
216
|
+
span = body[max(at[0] - 1, 0):at[-1] + 1] # the line before the first: `if kind == "box":` above `return Box()`
|
|
217
|
+
code = [re.sub(r"(\"[^\"]*\"|'[^']*')", '""', ln.split("//")[0].split(" #")[0]) for ln in span]
|
|
218
|
+
if any(_BRANCH.search(ln) or re.match(r'\s*(?:""|[\w.]+)\s*=>', ln) for ln in code) or sum(bool(re.search(r"\breturn\b", ln)) for ln in code) >= 2:
|
|
219
|
+
return False
|
|
220
|
+
after = " ".join(body[at[-1]:at[-1] + 4])
|
|
221
|
+
return not _LOOKUP.search(after) and not any(_LOOKUP.search(ln) for ln in body[at[-1] + 1:])
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def detect(con) -> list[dict]:
|
|
225
|
+
g = _Graph(con)
|
|
226
|
+
found: list[dict] = []
|
|
227
|
+
|
|
228
|
+
def add(pattern, anchor, roles, rationale, confidence=None, peripheral=None):
|
|
229
|
+
roles = {k: sorted(set(v)) for k, v in roles.items() if v}
|
|
230
|
+
found.append({"id": "pat:" + hashlib.sha1(f"{pattern}|{anchor}".encode()).hexdigest()[:10],
|
|
231
|
+
"pattern": pattern, "anchor": anchor, "roles": roles, "rationale": rationale,
|
|
232
|
+
"confidence": confidence or CONFIDENCE[pattern],
|
|
233
|
+
"in_tests": g.in_tests(anchor.split("|")[0]) if peripheral is None else peripheral})
|
|
234
|
+
|
|
235
|
+
types = [i for i, r in g.nodes.items() if r[_KIND] == "type"]
|
|
236
|
+
wrappers: dict[str, set] = defaultdict(set) # abstraction -> types already explained as decorator/composite
|
|
237
|
+
|
|
238
|
+
# Decorator and composite first: a wrapper also holds the abstraction, and should not count as a strategy's context.
|
|
239
|
+
for t in types:
|
|
240
|
+
supers = g.supertypes(t)
|
|
241
|
+
for a in supers:
|
|
242
|
+
if not g.is_abstraction(a):
|
|
243
|
+
continue
|
|
244
|
+
many = _collections(g, a).get(t)
|
|
245
|
+
one = _held(g, a).get(t)
|
|
246
|
+
if many:
|
|
247
|
+
wrappers[a].add(t)
|
|
248
|
+
add("composite", f"{t}|{a}", {"component": [a], "composite": [t], "children": many},
|
|
249
|
+
f"{g.name(t)} implements {g.name(a)} and holds a collection of {g.name(a)} in "
|
|
250
|
+
f"{', '.join(g.name(f) for f in many)}, so one object can stand for many.")
|
|
251
|
+
elif one:
|
|
252
|
+
wrappers[a].add(t)
|
|
253
|
+
add("decorator", f"{t}|{a}", {"component": [a], "decorator": [t], "wrapped": one},
|
|
254
|
+
f"{g.name(t)} implements {g.name(a)} and keeps another {g.name(a)} in "
|
|
255
|
+
f"{', '.join(g.name(f) for f in one)}, so it can add behavior around the one it wraps.")
|
|
256
|
+
|
|
257
|
+
for a in types:
|
|
258
|
+
if not g.is_abstraction(a):
|
|
259
|
+
continue
|
|
260
|
+
impls = sorted(s for s in g.subtypes(a) if g.kind(s) == "type" and not g.attrs[s].get("native_kind") == "interface")
|
|
261
|
+
real = [s for s in impls if not g.in_tests(s)]
|
|
262
|
+
if len(real) < 2:
|
|
263
|
+
continue
|
|
264
|
+
holders = {t: fs for t, fs in _held(g, a).items() if t not in wrappers[a] and t not in impls and t != a}
|
|
265
|
+
abstract_methods = set(g.methods(a))
|
|
266
|
+
using = {}
|
|
267
|
+
for t, fs in holders.items():
|
|
268
|
+
callers = [m for m in g.methods(t) if g.calls[m] & abstract_methods]
|
|
269
|
+
if callers:
|
|
270
|
+
using[t] = (fs, callers)
|
|
271
|
+
if any(not g.in_tests(t) for t in using):
|
|
272
|
+
using = {t: v for t, v in using.items() if not g.in_tests(t)}
|
|
273
|
+
if using:
|
|
274
|
+
contexts = sorted(using)
|
|
275
|
+
add("strategy", a, {"strategy": [a], "implementation": real, "context": contexts,
|
|
276
|
+
"held in": [f for t in contexts for f in using[t][0]]},
|
|
277
|
+
f"{g.name(a)} has {len(real)} implementations ({', '.join(g.name(s) for s in real[:4])}"
|
|
278
|
+
f"{', ...' if len(real) > 4 else ''}). {', '.join(g.name(t) for t in contexts[:3])} "
|
|
279
|
+
f"{'holds' if len(contexts) == 1 else 'each hold'} one and "
|
|
280
|
+
f"{'calls' if len(contexts) == 1 else 'call'} it without knowing which.",
|
|
281
|
+
peripheral=all(g.in_tests(t) for t in contexts))
|
|
282
|
+
|
|
283
|
+
# Template method: a concrete method on a base type calls that type's own abstract or virtual methods,
|
|
284
|
+
# and at least one subclass overrides one of them.
|
|
285
|
+
for t in types:
|
|
286
|
+
subs = g.subtypes(t)
|
|
287
|
+
if not subs or g.attrs[t].get("native_kind") == "interface":
|
|
288
|
+
continue
|
|
289
|
+
own = g.methods(t)
|
|
290
|
+
overridden = {m for m in own if g.inn["overrides"][m]}
|
|
291
|
+
hooks_all = {m for m in overridden if g.attrs[m].get("is_virtual") or g.nodes[m][_LANG] == "python"}
|
|
292
|
+
if not hooks_all:
|
|
293
|
+
continue
|
|
294
|
+
for m in own:
|
|
295
|
+
if m in hooks_all or g.name(m) in (".ctor", "__init__"):
|
|
296
|
+
continue
|
|
297
|
+
hooks = g.calls[m] & hooks_all
|
|
298
|
+
sig = g.attrs[m].get("signature") or ""
|
|
299
|
+
if hooks and " abstract " not in f" {sig} ":
|
|
300
|
+
overriders = {g.owner(o) for h in hooks for o in g.inn["overrides"][h]} - {None}
|
|
301
|
+
add("template method", m, {"template": [m], "step": list(hooks), "base": [t], "subclass": list(overriders)},
|
|
302
|
+
f"{g.name(t)}.{g.name(m)} is written once on the base and calls {', '.join(g.name(h) for h in sorted(hooks)[:4])}, "
|
|
303
|
+
f"which {len(overriders)} subclass{'es supply' if len(overriders) != 1 else ' supplies'}.")
|
|
304
|
+
|
|
305
|
+
# Observer: event channels found by the indexer.
|
|
306
|
+
by_subject = defaultdict(lambda: {"raiser": set(), "observer": set(), "event": set()})
|
|
307
|
+
for src, dsts in g.out["communicates"].items():
|
|
308
|
+
for dst in dsts:
|
|
309
|
+
a = g.edge_attrs.get(("communicates", src, dst), {})
|
|
310
|
+
if a.get("channel") != "event":
|
|
311
|
+
continue
|
|
312
|
+
subject = g.owner(src) or src
|
|
313
|
+
key = (subject, a.get("address") or "")
|
|
314
|
+
by_subject[key]["raiser"].add(src)
|
|
315
|
+
by_subject[key]["observer"].add(dst)
|
|
316
|
+
for (subject, address), r in by_subject.items():
|
|
317
|
+
add("observer", f"{subject}|{address}", {"subject": [subject], "raised in": list(r["raiser"]), "observer": list(r["observer"])},
|
|
318
|
+
f"{g.name(subject)} raises {address.split('.')[-1] or 'an event'}; {len(r['observer'])} handler"
|
|
319
|
+
f"{'s elsewhere subscribe' if len(r['observer']) != 1 else ' elsewhere subscribes'} to it, so the raiser does not know who listens.")
|
|
320
|
+
|
|
321
|
+
# Process boundary.
|
|
322
|
+
for src, dsts in g.out["communicates"].items():
|
|
323
|
+
for dst in dsts:
|
|
324
|
+
a = g.edge_attrs.get(("communicates", src, dst), {})
|
|
325
|
+
if a.get("channel") == "process":
|
|
326
|
+
add("process boundary", f"{src}|{dst}", {"launcher": [src], "program": [dst]},
|
|
327
|
+
f"{g.qual(src)} starts {g.name(g.module.get(dst) or dst)} as a separate program"
|
|
328
|
+
f"{' and talks to it over its standard input and output' if a.get('pipes') else ''}. "
|
|
329
|
+
f"Nothing in the type system ties the two sides together.")
|
|
330
|
+
|
|
331
|
+
# Factory: one function creates two or more types that share a supertype.
|
|
332
|
+
sources: dict = {}
|
|
333
|
+
for f, made in g.out["instantiates"].items():
|
|
334
|
+
if g.kind(f) != "callable" or g.name(f) in (".ctor", "__init__") or g.in_tests(f):
|
|
335
|
+
continue
|
|
336
|
+
made = {m for m in made if g.kind(m) == "type"}
|
|
337
|
+
if len(made) < 2:
|
|
338
|
+
continue
|
|
339
|
+
common = defaultdict(set)
|
|
340
|
+
for m in made:
|
|
341
|
+
for s in g.supertypes(m):
|
|
342
|
+
common[s].add(m)
|
|
343
|
+
best = max(common.items(), key=lambda kv: (len(kv[1]), kv[0]), default=None)
|
|
344
|
+
if best and len(best[1]) >= 2:
|
|
345
|
+
base, products = best
|
|
346
|
+
if not g.methods(base):
|
|
347
|
+
continue # a marker type with no behavior: creating several of them is not a choice between implementations
|
|
348
|
+
a = g.attrs[f]
|
|
349
|
+
rets = [n.split("`")[0] for n in (a.get("returns_names") or ([a["returns"]] if a.get("returns") else []))]
|
|
350
|
+
returns_base = bool(rets) and rets[0] == g.nodes[base][_NAME]
|
|
351
|
+
kinds = f"{len(products)} kinds of {g.name(base)} ({', '.join(g.name(p) for p in sorted(products)[:4])})"
|
|
352
|
+
if _builds_all(con, f, [g.name(p).split("<")[0] for p in products], sources):
|
|
353
|
+
add("factory", f, {"factory": [f], "product": list(products), "product type": [base]},
|
|
354
|
+
f"{g.qual(f)} creates every one of {kinds} together, as a list or a registry: it does not choose "
|
|
355
|
+
f"between them.", 0.4)
|
|
356
|
+
elif returns_base:
|
|
357
|
+
add("factory", f, {"factory": [f], "product": list(products), "product type": [base]},
|
|
358
|
+
f"{g.qual(f)} creates {kinds} and returns it as {g.name(base)}, so callers do not name the concrete type.", 0.8)
|
|
359
|
+
else:
|
|
360
|
+
add("factory", f, {"factory": [f], "product": list(products), "product type": [base]},
|
|
361
|
+
f"{g.qual(f)} decides which of {kinds} to create. It does not return one, so this is where the "
|
|
362
|
+
f"choice is wired in, not a factory others call.", 0.5)
|
|
363
|
+
|
|
364
|
+
# Builder: several methods return the type itself (chaining) and one produces a different type.
|
|
365
|
+
for t in types:
|
|
366
|
+
if g.in_tests(t):
|
|
367
|
+
continue
|
|
368
|
+
tname = g.name(t)
|
|
369
|
+
fluent, produce = [], []
|
|
370
|
+
for m in g.methods(t):
|
|
371
|
+
a = g.attrs[m]
|
|
372
|
+
rets = [n.split("`")[0] for n in (a.get("returns_names") or ([a["returns"]] if a.get("returns") else []))]
|
|
373
|
+
if g.name(m) in (".ctor", "__init__") or a.get("visibility") == "private":
|
|
374
|
+
continue
|
|
375
|
+
if rets and rets[0] == tname:
|
|
376
|
+
fluent.append(m)
|
|
377
|
+
elif g.name(m).lower().startswith(("build", "create")) and rets:
|
|
378
|
+
produce.append((m, rets[0]))
|
|
379
|
+
if len(fluent) >= 2 and produce:
|
|
380
|
+
add("builder", t, {"builder": [t], "step": fluent, "build": [m for m, _ in produce]},
|
|
381
|
+
f"{tname} has {len(fluent)} methods that return the {tname} itself, so calls chain, and "
|
|
382
|
+
f"{g.name(produce[0][0])} turns what was collected into a {produce[0][1]}.")
|
|
383
|
+
|
|
384
|
+
# Singleton: a static field of the type's own type, and no public constructor.
|
|
385
|
+
for t in types:
|
|
386
|
+
own_static = [f for f in g.fields(t) if g.attrs[f].get("is_static") and (g.attrs[f].get("type_name") or "").split("`")[0] == g.name(t)]
|
|
387
|
+
ctors = [m for m in g.methods(t) if g.name(m) == ".ctor"]
|
|
388
|
+
if own_static and ctors and all(g.attrs[c].get("visibility") in ("private", "protected") for c in ctors):
|
|
389
|
+
add("singleton", t, {"singleton": [t], "instance": own_static},
|
|
390
|
+
f"{g.name(t)} keeps an instance of itself in the static {g.name(own_static[0])} and its constructors "
|
|
391
|
+
f"are not public, so every caller shares that one object.")
|
|
392
|
+
return found
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def run(con, repo_id: Optional[str] = None) -> dict:
|
|
396
|
+
"""Replace the matcher's labels with a fresh pass. Labels written by others are kept and checked for staleness."""
|
|
397
|
+
found = detect(con)
|
|
398
|
+
with con:
|
|
399
|
+
old = [r[0] for r in con.execute("SELECT id FROM pattern_instances WHERE matcher = ?", (MATCHER,))]
|
|
400
|
+
con.executemany("DELETE FROM pattern_roles WHERE instance_id = ?", [(i,) for i in old])
|
|
401
|
+
con.execute("DELETE FROM pattern_instances WHERE matcher = ?", (MATCHER,))
|
|
402
|
+
for p in found:
|
|
403
|
+
evidence = [i for ids in p["roles"].values() for i in ids]
|
|
404
|
+
con.execute("INSERT OR REPLACE INTO pattern_instances (id, pattern, matcher, rationale, confidence, evidence_hash, stale, attrs)"
|
|
405
|
+
" VALUES (?,?,?,?,?,?,0,?)",
|
|
406
|
+
(p["id"], p["pattern"], MATCHER, p["rationale"], p["confidence"], store.evidence_hash(con, evidence),
|
|
407
|
+
json.dumps({"peripheral": bool(p["in_tests"])})))
|
|
408
|
+
con.executemany("INSERT INTO pattern_roles VALUES (?,?,?)",
|
|
409
|
+
[(p["id"], role, i) for role, ids in p["roles"].items() for i in ids])
|
|
410
|
+
# Labels from agents: stale when the code behind them changed, dropped roles when a node is gone.
|
|
411
|
+
for row in con.execute("SELECT id, evidence_hash FROM pattern_instances WHERE matcher != ?", (MATCHER,)).fetchall():
|
|
412
|
+
ids = [r[0] for r in con.execute("SELECT node_id FROM pattern_roles WHERE instance_id = ?", (row["id"],))]
|
|
413
|
+
con.execute("UPDATE pattern_instances SET stale = ? WHERE id = ?",
|
|
414
|
+
(int(store.evidence_hash(con, ids) != row["evidence_hash"]), row["id"]))
|
|
415
|
+
counts = defaultdict(int)
|
|
416
|
+
for p in found:
|
|
417
|
+
counts[p["pattern"]] += 1
|
|
418
|
+
if repo_id:
|
|
419
|
+
con.execute("INSERT OR REPLACE INTO extractor_coverage VALUES (?,?,?,?,?,?)",
|
|
420
|
+
(repo_id, "patterns:structural", "0.1", "ok", None, json.dumps(dict(counts))))
|
|
421
|
+
return dict(counts)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def label(con, pattern: str, roles: dict, rationale: str, confidence: float = 0.6, source: str = "mcp") -> dict:
|
|
425
|
+
"""Record a pattern the matchers did not find. `roles` maps a role name to node ids."""
|
|
426
|
+
if not rationale.strip():
|
|
427
|
+
return {"error": "a pattern label needs a rationale: say what in the code makes it this pattern"}
|
|
428
|
+
known = {r[0] for r in con.execute("SELECT id FROM nodes")}
|
|
429
|
+
clean = {role: [i for i in ids if i in known] for role, ids in roles.items()}
|
|
430
|
+
missing = [i for ids in roles.values() for i in ids if i not in known]
|
|
431
|
+
clean = {k: v for k, v in clean.items() if v}
|
|
432
|
+
if not clean:
|
|
433
|
+
return {"error": "none of the node ids exist in the store", "missing": missing[:5]}
|
|
434
|
+
evidence = [i for ids in clean.values() for i in ids]
|
|
435
|
+
pid = "pat:" + hashlib.sha1(f"{pattern}|{sorted(evidence)}|{source}".encode()).hexdigest()[:10]
|
|
436
|
+
with con:
|
|
437
|
+
con.execute("DELETE FROM pattern_roles WHERE instance_id = ?", (pid,))
|
|
438
|
+
con.execute("INSERT OR REPLACE INTO pattern_instances (id, pattern, matcher, rationale, confidence, evidence_hash, stale)"
|
|
439
|
+
" VALUES (?,?,?,?,?,?,0)",
|
|
440
|
+
(pid, pattern.strip().lower(), source, rationale, max(0.0, min(1.0, confidence)), store.evidence_hash(con, evidence)))
|
|
441
|
+
con.executemany("INSERT INTO pattern_roles VALUES (?,?,?)", [(pid, role, i) for role, ids in clean.items() for i in ids])
|
|
442
|
+
return {"id": pid, "pattern": pattern.strip().lower(), "missing": missing[:10]}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def listing(con, pattern: Optional[str] = None, node_id: Optional[str] = None, include_tests: bool = False,
|
|
446
|
+
limit: int = 100) -> dict:
|
|
447
|
+
"""Pattern labels, optionally of one kind or touching one node (or anything inside it)."""
|
|
448
|
+
share = {} # one string for each kind, path and module, not one per row (hundreds of MB on a large repository)
|
|
449
|
+
share = share.setdefault
|
|
450
|
+
names = {r[0]: (r[1], share(r[2], r[2]), share(r[3], r[3])) for r in con.execute("SELECT id, name, kind, path FROM nodes")}
|
|
451
|
+
test_mods = {r[0] for r in con.execute(
|
|
452
|
+
"SELECT DISTINCT a.module_id FROM nodes n JOIN ancestry a ON a.node_id = n.id WHERE n.kind = 'test'")}
|
|
453
|
+
module = {r[0]: share(r[1], r[1]) for r in con.execute("SELECT node_id, module_id FROM ancestry")}
|
|
454
|
+
out, counts = [], defaultdict(int)
|
|
455
|
+
for p in con.execute("SELECT * FROM pattern_instances ORDER BY confidence DESC, pattern, id"):
|
|
456
|
+
roles = defaultdict(list)
|
|
457
|
+
for r in con.execute("SELECT role, node_id FROM pattern_roles WHERE instance_id = ? ORDER BY role, node_id", (p["id"],)):
|
|
458
|
+
roles[r["role"]].append(r["node_id"])
|
|
459
|
+
ids = [i for v in roles.values() for i in v]
|
|
460
|
+
if not ids:
|
|
461
|
+
continue
|
|
462
|
+
stored = json.loads(p["attrs"] or "{}").get("peripheral")
|
|
463
|
+
in_tests = stored if stored is not None else sum(1 for i in ids if module.get(i) in test_mods) * 2 > len(ids)
|
|
464
|
+
if in_tests and not include_tests and not node_id:
|
|
465
|
+
continue
|
|
466
|
+
if pattern and p["pattern"] != pattern.strip().lower():
|
|
467
|
+
continue
|
|
468
|
+
if node_id and not any(i == node_id or i.startswith(node_id + ".") or i.startswith(node_id + "/") for i in ids):
|
|
469
|
+
continue
|
|
470
|
+
counts[p["pattern"]] += 1
|
|
471
|
+
if len(out) < limit:
|
|
472
|
+
out.append({"id": p["id"], "pattern": p["pattern"], "about": ABOUT.get(p["pattern"], ""),
|
|
473
|
+
"rationale": p["rationale"], "confidence": p["confidence"],
|
|
474
|
+
"source": "structural matcher" if p["matcher"] == MATCHER else p["matcher"],
|
|
475
|
+
"stale": bool(p["stale"]), "in_tests": in_tests,
|
|
476
|
+
"roles": {role: [{"id": i, "name": names.get(i, (i,))[0]} for i in v] for role, v in roles.items()}})
|
|
477
|
+
return {"total": sum(counts.values()), "by_pattern": dict(counts), "patterns": out,
|
|
478
|
+
"note": "A label says the code has this shape. It does not say the author intended the pattern."}
|