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/fncoupling.py ADDED
@@ -0,0 +1,328 @@
1
+ """Change coupling by function, for a few functions at a time.
2
+
3
+ leyline.coupling counts files that change in the same commits. Two functions in two files can be the pair that
4
+ really moves together (a validator rule and its twin in another language; a reader and the writer of one format)
5
+ while the rest of each file does not. Telling that needs, for each commit, which functions its diff touched, and a
6
+ commit's line numbers say that only against the file as it was at that commit. So this parses old versions of
7
+ files, which is too slow to do for a whole history, and is done only for the functions a plan or a pull request is
8
+ about:
9
+
10
+ 1. the last MAX_COMMITS commits that changed the function's lines (`git log -L`, which follows the lines back through
11
+ each diff), merges and bulk commits left out;
12
+ 2. at each, the function's file as it was, parsed with the same adapter the map uses, and the commit's hunks (`-U0`,
13
+ line ranges of the new version) placed in the innermost function around them, so a commit counts for the function
14
+ only where it changed the function's own lines (not only a function nested in it); a hunk that only deletes lines
15
+ is placed at the line it deleted after;
16
+ 3. the MAX_FILES other files changed in most of those commits (each in at least MIN_TOGETHER of them and in at least
17
+ half) are read the same way at those commits, and nothing else is read.
18
+
19
+ `partner` changed in `together` of the `changes` commits that changed `function`: it is reported when that is at
20
+ least MIN_TOGETHER commits and at least MIN_CONFIDENCE of them. Which functions one commit changed in one file is
21
+ kept in the store (coupling_functions), so a second plan reads no old file again.
22
+
23
+ What it cannot see: a function renamed or moved to another file has a new id, so its older commits count under the
24
+ old one; a file renamed counts only from its new name (no --follow).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ import time
32
+ from collections import Counter
33
+ from pathlib import Path
34
+ from typing import Iterable, Optional
35
+
36
+ from .coupling import BULK, MIN_CONFIDENCE, MIN_TOGETHER, _git
37
+
38
+ MAX_COMMITS = 20 # commits read per function
39
+ MAX_FILES = 4 # other files read per function: those changed in most of its commits
40
+ POOL_FROM = 6 # parses that are worth a second process
41
+ KINDS = ("callable", "test")
42
+ TOP = ("<module>", "<top-level>")
43
+ HUNK = re.compile(rb"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
44
+
45
+ _TABLE = ("CREATE TABLE IF NOT EXISTS coupling_functions (repo_id TEXT, path TEXT, sha TEXT, version TEXT, ids TEXT,"
46
+ " PRIMARY KEY (repo_id, path, sha, version))")
47
+
48
+
49
+ def _adapter(path: str):
50
+ from .adapters import BY_EXTENSION
51
+ return BY_EXTENSION.get("." + path.rsplit(".", 1)[-1] if "." in path else "")
52
+
53
+
54
+ def _range_commits(root: Path, sha: str, path: str, start: int, end: int, n: int) -> list[str]:
55
+ """The last `n` commits before `sha` that changed lines `start`..`end` of `path` as it is at `sha`, newest first.
56
+ git follows the range back through each diff (`log -L`), so no older version is parsed to find them."""
57
+ out = _git(root, "log", "--no-merges", "-s", "--format=%x01%H", f"-n{n}", f"-L{start},{end}:{path}", sha)
58
+ return [c.strip().decode() for c in (out or b"").split(b"\x01")[1:] if c.strip()]
59
+
60
+
61
+ def _commit_files(root: Path, commits: list[str]) -> dict[str, set]:
62
+ """commit -> every file it changed, for the commits that changed no more than BULK files."""
63
+ if not commits:
64
+ return {}
65
+ out = _git(root, "-c", "core.quotePath=false", "log", "--no-walk=unsorted", "--no-renames", "--relative",
66
+ "--name-only", "-z", "--format=%x01%H", *commits)
67
+ found = {}
68
+ for chunk in (out or b"").split(b"\x01")[1:]:
69
+ tokens = chunk.split(b"\0") # the sha, then each file
70
+ files = {t.strip(b"\n").decode("utf-8", "replace") for t in tokens[1:] if t.strip(b"\n")}
71
+ if 0 < len(files) <= BULK:
72
+ found[tokens[0].decode().strip()] = files
73
+ return found
74
+
75
+
76
+ def _hunks(root: Path, commits: list[str], paths: list[str]) -> dict:
77
+ """(commit, path) -> the changed lines of the path as that commit left it, from one `git show -U0`."""
78
+ out = _git(root, "-c", "core.quotePath=false", "show", "--no-renames", "--relative", "-U0", "--no-color",
79
+ "--format=%x01%H", *commits, "--", *paths) or b""
80
+ found: dict = {}
81
+ cur = path = None
82
+ for ln in out.split(b"\n"):
83
+ if ln.startswith(b"\x01"):
84
+ cur, path = ln[1:].decode().strip(), None
85
+ elif ln.startswith(b"+++ "):
86
+ p = ln[4:].decode("utf-8", "replace").removesuffix("\t") # git ends a path with a space in it with a tab
87
+ path = p[2:] if p.startswith("b/") else None # /dev/null: the file was deleted
88
+ elif ln.startswith(b"@@") and cur and path:
89
+ m = HUNK.match(ln)
90
+ if m:
91
+ start, count = int(m.group(1)), int(m.group(2) if m.group(2) is not None else 1)
92
+ lines = found.setdefault((cur, path), set())
93
+ lines.update(range(start, start + count) if count else (max(start, 1),))
94
+ return found
95
+
96
+
97
+ def _blobs(root: Path, wanted: list[tuple[str, str]]) -> dict:
98
+ """(commit, path) -> the file's bytes at that commit, from one `git cat-file --batch`."""
99
+ import subprocess
100
+ if not wanted:
101
+ return {}
102
+ try:
103
+ proc = subprocess.run(["git", "-C", str(root), "cat-file", "--batch"], capture_output=True, timeout=120,
104
+ input=b"".join(f"{c}:./{p}\n".encode() for c, p in wanted))
105
+ except (OSError, subprocess.SubprocessError):
106
+ return {}
107
+ data, pos, out = proc.stdout, 0, {}
108
+ for key in wanted:
109
+ nl = data.find(b"\n", pos)
110
+ if nl < 0:
111
+ break
112
+ head = data[pos:nl].split()
113
+ pos = nl + 1
114
+ if len(head) == 3 and head[1] == b"blob":
115
+ size = int(head[2])
116
+ out[key] = data[pos:pos + size]
117
+ pos += size + 1
118
+ return out
119
+
120
+
121
+ def _nodes(adapter, repo: str, path: str, data: bytes, mod_dir: str):
122
+ """The adapter's parse of one file, without the channel pass where the adapter has a walker of its own (C#,
123
+ Python, TypeScript): the channel pass reads the nodes and adds none, and is about half the time."""
124
+ walker = getattr(adapter, "_Walker", None)
125
+ if walker is not None and callable(getattr(walker, "run", None)):
126
+ import inspect
127
+ args = [repo, path, f"{repo}:file:{path}", data]
128
+ if len(inspect.signature(walker).parameters) >= 5:
129
+ args.append(mod_dir)
130
+ return walker(*args).run()
131
+ return adapter.parse(repo, path, f"{repo}:file:{path}", data, mod_dir)
132
+
133
+
134
+ def _spans(data: bytes, path: str, repo: str, mod_dir: str) -> dict:
135
+ """id -> (first line, last line) of each function in one version of a file; empty when it cannot be read."""
136
+ adapter = _adapter(path)
137
+ if adapter is None:
138
+ return {}
139
+ try:
140
+ res = _nodes(adapter, repo, path, data, mod_dir or ".")
141
+ except Exception: # RecursionError, a grammar that fails on an old version: that version is not read
142
+ return {}
143
+ return {n.id: (n.span_start, n.span_end or n.span_start) for n in res.nodes
144
+ if n.kind in KINDS and n.span_start and n.name not in TOP}
145
+
146
+
147
+ def _changed(data: bytes, lines: set, path: str, repo: str, mod_dir: str) -> Optional[list[str]]:
148
+ """The functions of one version of a file that hold a changed line (the innermost one around each line)."""
149
+ spans = [(a, b, i) for i, (a, b) in _spans(data, path, repo, mod_dir).items()]
150
+ if not spans:
151
+ return None
152
+ out = set()
153
+ for ln in lines:
154
+ around = [s for s in spans if s[0] <= ln <= s[1]]
155
+ if around:
156
+ out.add(min(around, key=lambda s: (s[1] - s[0], -s[0]))[2])
157
+ return sorted(out)
158
+
159
+
160
+ def _job(args):
161
+ return _changed(*args)
162
+
163
+
164
+ def _run(jobs: list) -> list:
165
+ """_changed() for each job, on two processes when there are enough of them (the parse is the cost)."""
166
+ import os
167
+ workers = min(4, os.cpu_count() or 1)
168
+ if len(jobs) < POOL_FROM or workers < 2:
169
+ return [_job(j) for j in jobs]
170
+ import multiprocessing
171
+ from concurrent.futures import ProcessPoolExecutor
172
+ try:
173
+ ctx = multiprocessing.get_context("fork")
174
+ except ValueError:
175
+ return [_job(j) for j in jobs]
176
+ try:
177
+ with ProcessPoolExecutor(workers, mp_context=ctx) as pool:
178
+ return list(pool.map(_job, jobs, chunksize=max(1, len(jobs) // (workers * 4))))
179
+ except Exception: # a pool that will not start (a sandbox, a broken worker): one process
180
+ return [_job(j) for j in jobs]
181
+
182
+
183
+ class _Reader:
184
+ """Which functions each commit changed in a file, read once and kept in the store."""
185
+
186
+ def __init__(self, con, repo: str, root: Path):
187
+ self.con, self.repo, self.root = con, repo, Path(root)
188
+ con.execute(_TABLE)
189
+ self.mods: dict = {}
190
+ self.parsed = 0
191
+
192
+ def mod_dir(self, path: str) -> Optional[str]:
193
+ if path not in self.mods:
194
+ r = self.con.execute("SELECT m.path FROM ancestry a JOIN nodes m ON m.id = a.module_id WHERE a.node_id = ?",
195
+ (f"{self.repo}:file:{path}",)).fetchone()
196
+ self.mods[path] = r[0] if r else None
197
+ return self.mods[path]
198
+
199
+ def spans_at(self, path: str, data: bytes) -> dict:
200
+ """The functions of one version of a file and their lines: from the map when it read these same bytes, else
201
+ parsed."""
202
+ import hashlib
203
+ r = self.con.execute("SELECT content_hash FROM nodes WHERE id = ?", (f"{self.repo}:file:{path}",)).fetchone()
204
+ if r is not None and r[0] == hashlib.sha1(data).hexdigest():
205
+ return {i: (a, b or a) for i, a, b, name in self.con.execute(
206
+ f"SELECT id, span_start, span_end, name FROM nodes WHERE repo_id = ? AND path = ? AND layer = 'fact'"
207
+ f" AND kind IN ({','.join('?' * len(KINDS))}) AND span_start IS NOT NULL", (self.repo, path, *KINDS))
208
+ if name not in TOP}
209
+ self.parsed += 1
210
+ return _spans(data, path, self.repo, self.mod_dir(path))
211
+
212
+ def read(self, wanted: dict[str, set]) -> dict:
213
+ """path -> commit -> the ids of the functions it changed there (None: not readable)."""
214
+ out: dict = {p: {} for p in wanted}
215
+ todo: list = []
216
+ for path, commits in wanted.items():
217
+ adapter = _adapter(path)
218
+ if adapter is None or self.mod_dir(path) is None: # not code the map reads
219
+ continue
220
+ version = f"{adapter.NAME}/{adapter.VERSION}"
221
+ for c in commits:
222
+ r = self.con.execute("SELECT ids FROM coupling_functions WHERE repo_id = ? AND path = ? AND sha = ? AND version = ?",
223
+ (self.repo, path, c, version)).fetchone()
224
+ if r is not None:
225
+ out[path][c] = json.loads(r[0]) if r[0] is not None else None
226
+ else:
227
+ todo.append((c, path, version))
228
+ if not todo:
229
+ return out
230
+ hunks = _hunks(self.root, sorted({c for c, _, _ in todo}), sorted({p for _, p, _ in todo}))
231
+ blobs = _blobs(self.root, [(c, p) for c, p, _ in todo if (c, p) in hunks])
232
+ jobs = [(blobs[(c, p)], hunks[(c, p)], p, self.repo, self.mod_dir(p)) for c, p, _ in todo if (c, p) in blobs]
233
+ self.parsed += len(jobs)
234
+ done = iter(_run(jobs))
235
+ rows = []
236
+ for c, path, version in todo:
237
+ # no hunk: the file was deleted at that commit, so no function of it changed there
238
+ ids = next(done) if (c, path) in blobs else []
239
+ out[path][c] = ids
240
+ rows.append((self.repo, path, c, version, json.dumps(ids) if ids is not None else None))
241
+ with self.con:
242
+ self.con.executemany("INSERT OR REPLACE INTO coupling_functions VALUES (?,?,?,?,?)", rows)
243
+ return out
244
+
245
+
246
+ def _inside(a: str, b: str) -> bool:
247
+ return a.startswith(b + ".") or a.startswith(b + "/") or b.startswith(a + ".") or b.startswith(a + "/")
248
+
249
+
250
+ def compute(con, repo: str, root: Path, sha: str, ids: Iterable[str], max_commits: int = MAX_COMMITS,
251
+ min_together: int = MIN_TOGETHER, min_confidence: float = MIN_CONFIDENCE) -> dict:
252
+ """For each function in `ids` (ids on the map now), the functions that changed in most of the commits that changed
253
+ it: {"functions": [{function_id, partner_id, path, together, changes, confidence}], "commits", "parsed", "seconds"}."""
254
+ t0 = time.perf_counter()
255
+ reader = _Reader(con, repo, root)
256
+ targets: dict[str, list] = {}
257
+ for i in dict.fromkeys(ids):
258
+ r = con.execute("SELECT kind, path, name FROM nodes WHERE id = ?", (i,)).fetchone()
259
+ if r is not None and r[0] in KINDS and r[1] and r[2] not in TOP:
260
+ targets.setdefault(r[1], []).append(i)
261
+ found, read = [], 0
262
+ for path, fids in targets.items():
263
+ # Where each function is at `sha` (that version parsed), then the commits that changed those lines.
264
+ mod = reader.mod_dir(path)
265
+ now = _blobs(reader.root, [(sha, path)]).get((sha, path)) if mod is not None else None
266
+ spans = reader.spans_at(path, now) if now is not None else {}
267
+ cands = {f: _range_commits(reader.root, sha, path, *spans[f], max_commits) for f in fids if f in spans}
268
+ files_of = _commit_files(reader.root, sorted({c for cs in cands.values() for c in cs}))
269
+ read = max([read] + [len(cs) for cs in cands.values()])
270
+ # git's range takes in the functions nested in a function; a commit counts for it only where the parse of
271
+ # that version puts a changed line in the function itself, as it does for every partner.
272
+ own = reader.read({path: set(files_of)})[path]
273
+ need: dict[str, set] = {}
274
+ mine: dict[str, list] = {}
275
+ for f, cs in cands.items():
276
+ cf = [c for c in cs if c in files_of and f in (own.get(c) or [])]
277
+ if len(cf) < min_together:
278
+ continue
279
+ mine[f] = cf
280
+ seen = Counter(x for c in cf for x in files_of[c] if x != path and reader.mod_dir(x) is not None
281
+ and _adapter(x) is not None)
282
+ for x, k in sorted(seen.items(), key=lambda kv: (-kv[1], kv[0]))[:MAX_FILES]:
283
+ if k >= min_together and k / len(cf) >= min_confidence:
284
+ need.setdefault(x, set()).update(c for c in cf if x in files_of[c])
285
+ others = reader.read(need) if need else {}
286
+ for f, cf in mine.items():
287
+ together: Counter = Counter()
288
+ for c in cf:
289
+ ids_at = set(own.get(c) or [])
290
+ for x in files_of[c]:
291
+ if x in others:
292
+ ids_at |= set(others[x].get(c) or [])
293
+ together.update(ids_at)
294
+ for g, k in together.items():
295
+ if g == f or _inside(f, g) or k < min_together or k / len(cf) < min_confidence:
296
+ continue
297
+ r = con.execute("SELECT path FROM nodes WHERE id = ?", (g,)).fetchone()
298
+ if r is None: # gone from the code now
299
+ continue
300
+ found.append({"function_id": f, "partner_id": g, "path": r[0], "together": k, "changes": len(cf),
301
+ "confidence": round(k / len(cf), 2)})
302
+ found.sort(key=lambda x: (-x["confidence"], -x["together"], x["function_id"], x["partner_id"]))
303
+ return {"functions": found, "commits": read, "parsed": reader.parsed, "seconds": round(time.perf_counter() - t0, 2)}
304
+
305
+
306
+ def missed(con, repo: str, root: Path, sha: str, ids: Iterable[str], covered, limit: int = 0) -> dict:
307
+ """compute(), keeping the partners `covered(id)` says nothing covers (no task names them; the branch did not change
308
+ them), each partner once (with the function it most often changed with), labelled as the pages say them."""
309
+ from .diagrams import _Nodes
310
+ try:
311
+ r = compute(con, repo, root, sha, ids)
312
+ except Exception: # history is a lead, never a reason for a page to fail
313
+ return {}
314
+ nodes = _Nodes(con)
315
+ best: dict = {}
316
+ for x in r["functions"]:
317
+ g = x["partner_id"]
318
+ if covered(g) or g in best:
319
+ continue
320
+ best[g] = {**x, "function": nodes.label(x["function_id"]), "partner": nodes.label(g)}
321
+ out = list(best.values())
322
+ return {"functions": out[:limit] if limit else out, "total": len(out), "commits": r["commits"], "seconds": r["seconds"]}
323
+
324
+
325
+ def line(x: dict, tail: str = "") -> str:
326
+ """`B.g` changed in 5 of the 6 commits that changed `A.f`; <tail>."""
327
+ return (f"`{x['partner']}` changed in {x['together']} of the {x['changes']} commits that changed `{x['function']}`"
328
+ + (f"; {tail}" if tail else ""))