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/grade.py ADDED
@@ -0,0 +1,207 @@
1
+ """Grade the call links an adapter found against a compiler's, read from a SCIP index.
2
+
3
+ A call in the compiler's index is a reference, at a place in the source where the name is followed
4
+ by `(` or opens a JSX tag, to a symbol whose definition lands on a function on the map. Both sides
5
+ are reduced to (calling function, called function) pairs and compared:
6
+
7
+ - precision: of the links the adapter made, the share the compiler also makes;
8
+ - recall: of the compiler's links, the share the adapter found.
9
+
10
+ Only files the compiler indexed count, and only links whose both ends are on the map, so a function
11
+ the adapter did not declare at all shows up as missed recall, not as a wrong link.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from collections import Counter, defaultdict
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from . import exact, store
22
+ from .indexer import Indexer, source_lines
23
+
24
+
25
+ CTOR_NAMES = ("constructor", ".ctor", "__init__", "new", "init")
26
+
27
+
28
+ def compiler_sites(ix, scip_path: str, prefix: str = "") -> tuple[dict, set, set]:
29
+ """What the compiler says at each call site.
30
+
31
+ Returns (sites, files, mentions): sites maps (calling function, line, name) to the called function's
32
+ id on the map, or to None when the compiler's target is not a function on the map (an interface
33
+ member, a library); files are the repo paths it indexed; mentions are (function, function) pairs
34
+ where one names the other without calling it."""
35
+ from . import scip_pb2
36
+ index = scip_pb2.Index()
37
+ index.ParseFromString(Path(scip_path).read_bytes())
38
+ loc = exact._Locator(ix)
39
+ pre = prefix.strip("/") + "/" if prefix.strip("/") else ""
40
+ types_at = defaultdict(list)
41
+ for n in ix.nodes.values():
42
+ if n.kind == "type" and n.path and n.span_start:
43
+ types_at[n.path].append(n)
44
+ members = defaultdict(list)
45
+ for n in ix.nodes.values():
46
+ if n.kind == "callable":
47
+ members[n.parent_id].append(n)
48
+ definition = {}
49
+ for doc in index.documents:
50
+ for occ in doc.occurrences:
51
+ if occ.symbol_roles & 1 and not occ.symbol.startswith("local ") and exact.occ_range(occ):
52
+ definition[occ.symbol] = (pre + doc.relative_path, exact.occ_range(occ)[0] + 1)
53
+ sites, files, mentions = {}, set(), set()
54
+ for doc in index.documents:
55
+ path = pre + doc.relative_path
56
+ files.add(path)
57
+ src = ix.root / path
58
+ try:
59
+ lines = source_lines(src) if src.is_file() else []
60
+ except OSError:
61
+ lines = []
62
+ for occ in doc.occurrences:
63
+ sym = occ.symbol
64
+ if occ.symbol_roles & 1:
65
+ continue
66
+ row, col, end = exact.occ_range(occ) or (len(lines), 0, 0)
67
+ if row >= len(lines):
68
+ continue
69
+ col, end = (exact._chars(lines[row], c, doc.position_encoding) for c in (col, end))
70
+ if sym.startswith("local "):
71
+ # A local the compiler could not tie to a declaration (often an import it failed to follow):
72
+ # a call through it is not judged either way.
73
+ src_node = loc.caller(path, row + 1)
74
+ word = lines[row][col:end].strip()
75
+ if src_node is not None and lines[row][end:].lstrip().startswith("("):
76
+ sites.setdefault((src_node.id, row + 1, word), None)
77
+ continue
78
+ text = lines[row]
79
+ after, before = text[end:].lstrip(), text[:col].rstrip()
80
+ called = after.startswith("(") or after[1:].lstrip()[:1] in ("(", "[", "{") and after[:1] == "!" or (after.startswith("<") and "(" in after) or before.endswith(("<", "new"))
81
+ if before.endswith("def") and after.startswith("("):
82
+ continue # `def f(`: an overload's implementation or a property setter names the symbol again
83
+ word = text[col:end].strip()
84
+ name = exact._scip_name(sym).strip("<>")
85
+ src_node = loc.caller(path, row + 1) if called else loc.enclosing(path, row + 1)
86
+ if src_node is None:
87
+ continue
88
+ dst = None
89
+ macro = after.startswith("!")
90
+ if macro:
91
+ word += "!" # a Rust macro: the map names it with its bang
92
+ if sym in definition and macro == name.endswith("!"):
93
+ tf, tl = definition[sym]
94
+ dst = loc.target(tf, tl, name, ("callable",))
95
+ if dst is None and called and (name in CTOR_NAMES or word[:1].isupper()):
96
+ # `new Foo()` points at the class or at its constructor: either way, the constructor.
97
+ t = next((n for n in types_at.get(tf, ()) if n.name in (word, name) and n.span_start <= tl <= (n.span_end or n.span_start)), None)
98
+ ctor = [m for m in members.get(t.id, ()) if m.name in CTOR_NAMES] if t is not None else []
99
+ dst = ctor[0] if ctor else None
100
+ if not called:
101
+ if dst is not None and src_node.id != dst.id and not before.lstrip().startswith(("import", "export", "from")):
102
+ mentions.add((src_node.id, dst.id))
103
+ continue
104
+ key = (src_node.id, row + 1, word or name)
105
+ if dst is not None and dst.id != src_node.id:
106
+ sites[key] = dst.id
107
+ else:
108
+ sites.setdefault(key, None)
109
+ return sites, files, mentions
110
+
111
+
112
+ def roslyn_sites(ix) -> tuple[dict, set, set]:
113
+ """The same as compiler_sites, from the C# compiler run directly (no SCIP indexer needed)."""
114
+ records, info = exact.roslyn(ix)
115
+ loc = exact._Locator(ix)
116
+ sites, files = {}, set()
117
+ for r in records:
118
+ if r["k"] == "file":
119
+ files.add(r["f"])
120
+ continue
121
+ if r["k"] != "call":
122
+ continue
123
+ src = loc.caller(r["f"], r["l"])
124
+ if src is None:
125
+ continue
126
+ name = r.get("tn") or r["n"]
127
+ dst = None
128
+ if r["s"] in ("ok", "candidate") and r.get("tf"):
129
+ for nm in ((name, r["n"]) if name != ".ctor" else (".ctor", r["n"], "constructor")):
130
+ dst = loc.target(r["tf"], r["tl"], nm, ("callable",))
131
+ if dst is not None:
132
+ break
133
+ key = (src.id, r["l"], ix.nodes[dst.parent_id].name if dst is not None and name == ".ctor" and dst.parent_id in ix.nodes else r["n"])
134
+ if dst is not None and dst.id != src.id:
135
+ sites[key] = dst.id
136
+ else:
137
+ sites.setdefault(key, None)
138
+ return sites, files, set()
139
+
140
+
141
+ def grade(root: str, scip_path: str, prefix: str = "", repo_id: Optional[str] = None, db: Optional[str] = None) -> dict:
142
+ root_p = Path(root).resolve()
143
+ ix = Indexer(root_p, repo_id)
144
+ started = time.perf_counter()
145
+ db = db or str(root_p / ".leyline" / "grade.db")
146
+ Path(db).parent.mkdir(parents=True, exist_ok=True)
147
+ con = store.connect(db)
148
+ ix.run(con)
149
+ con.close()
150
+ seconds = round(time.perf_counter() - started, 2)
151
+ sites, files, mentions = roslyn_sites(ix) if scip_path == "roslyn" else compiler_sites(ix, scip_path, prefix)
152
+ on_map = {n.id for n in ix.nodes.values() if n.kind in ("callable", "test")}
153
+ truth = {(k[0], v) for k, v in sites.items() if v and k[0] in on_map and v in on_map}
154
+ by_line = defaultdict(dict)
155
+ for (s_id, line, word), dst in sites.items():
156
+ by_line[(s_id, line)][word] = dst
157
+ judged, silent, offmap = defaultdict(set), defaultdict(set), 0
158
+ refs = set()
159
+ for src, dst, dispatch, precision, line in ix.calls:
160
+ node = ix.nodes.get(src)
161
+ if node is None or node.path not in files or src == dst:
162
+ continue
163
+ if dispatch in ("fixture", "registers"):
164
+ continue # pytest handing a test its fixture, or a router given a route's handler, is not a call written there
165
+ if dispatch == "reference":
166
+ refs.add((src, dst))
167
+ continue
168
+ name = ix.nodes[dst].name
169
+ verdicts = by_line.get((src, line), {})
170
+ if name in CTOR_NAMES:
171
+ # `new Foo()` is recorded under Foo; Rust's `Foo::new()` is an ordinary call recorded under new. Foo
172
+ # first: in `new Foo(new Bar { })` the compiler also records Bar's implicit constructor as .ctor.
173
+ owner = ix.nodes[ix.nodes[dst].parent_id].name if ix.nodes[dst].parent_id in ix.nodes else name
174
+ name = owner if owner in verdicts or name not in verdicts else name
175
+ if name in verdicts:
176
+ if verdicts[name] is None:
177
+ offmap += 1 # the compiler resolved it to something that is not a function on the map: not judged
178
+ continue
179
+ judged[precision].add((src, dst, verdicts[name] == dst))
180
+ else:
181
+ silent[precision].add((src, dst)) # the compiler saw no call of that name there
182
+ every = {(s_, d) for v in judged.values() for s_, d, _ in v} | {p for v in silent.values() for p in v}
183
+ right = {(s_, d) for v in judged.values() for s_, d, ok in v if ok}
184
+ out = {"seconds_to_index": seconds, "files_compared": len(files),
185
+ "functions_on_map": len(on_map), "compiler_links": len(truth), "adapter_links": len(every),
186
+ # Precision counts only links at sites the compiler resolved. A link at a site where the compiler
187
+ # recorded no call of that name is listed apart: there the compiler could not see either.
188
+ "precision": round(len(right) / max(1, sum(len(v) for v in judged.values())), 3) if judged else None,
189
+ "compiler_silent": sum(len(v) for v in silent.values()),
190
+ "recall": round(len(right & truth) / len(truth), 3) if truth else None,
191
+ "by_kind": {k: {"judged": len(judged[k]), "right": sum(ok for *_, ok in judged[k]), "compiler_silent": len(silent[k])}
192
+ for k in sorted(set(judged) | set(silent))},
193
+ "not_judged_compiler_target_off_map": offmap,
194
+ "references": {"links": len(refs), "precision": round(len(refs & (mentions | truth)) / len(refs), 3) if refs else None},
195
+ "adapters": sorted(k for k in ix.stats if k.startswith(("tree-sitter", "generic")))}
196
+ wrong_pairs = {(s_, d) for v in judged.values() for s_, d, ok in v if not ok} | {p for v in silent.values() for p in v}
197
+ every = wrong_pairs | right
198
+ missed = Counter()
199
+ for s, d in truth - right:
200
+ missed[ix.nodes[d].name] += 1
201
+ wrong = Counter()
202
+ for s, d in wrong_pairs:
203
+ wrong[ix.nodes[d].name] += 1
204
+ out["most_missed"] = missed.most_common(12)
205
+ out["most_wrong"] = wrong.most_common(12)
206
+ out["_samples"] = {"missed": sorted(truth - right)[:400], "wrong": sorted(wrong_pairs)[:400]}
207
+ return out