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/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."}