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/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 ""))
|