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/pr.py ADDED
@@ -0,0 +1,1249 @@
1
+ """Review a change someone else made: a branch or a pull request, with no spec behind it.
2
+
3
+ A spec says what a change will do before it is written, and `check` compares the code with it. A pull request
4
+ arrives the other way round: the code is written, and what it was meant to do is a title and a paragraph. This
5
+ module reads the change from the code itself, by mapping the commit the branch started from and comparing it with
6
+ the checkout, and then asks of the edits what `plan` asks of a spec's tasks: what they reach and did not change,
7
+ which channels they cross, which shared state they write, which tests run them. The result is a page a person can
8
+ read in a minute and the same facts arranged for the adversarial reviewers, whose findings are kept like a spec's.
9
+
10
+ leyline pr main # the checkout against where it left main
11
+ leyline pr main --about "Fix the retry" # with what the change says it does
12
+ leyline pr --github 123 # base, title and description from GitHub (needs gh, and the PR checked out)
13
+ leyline pr main --gate # exit 1 while something that blocks is left ([pr] in openspec/leyline.toml)
14
+
15
+ The base is mapped once per commit, from `git archive` into a temporary directory (the repository and its
16
+ worktrees are not touched), reusing the parse output of the checkout's map for every file that did not change.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import re
24
+ import shutil
25
+ import sqlite3
26
+ import subprocess
27
+ import tarfile
28
+ import tempfile
29
+ from collections import defaultdict
30
+ from pathlib import Path
31
+ from typing import Optional
32
+
33
+ from . import change, diff, rules, spec, store
34
+
35
+ HOUSE_RULES = ("AGENTS.md", "CLAUDE.md", "CONTRIBUTING.md", ".github/copilot-instructions.md", ".cursorrules",
36
+ "docs/CONTRIBUTING.md", "ARCHITECTURE.md", "docs/ARCHITECTURE.md")
37
+
38
+
39
+ CONTEXT = 12 # lines above an edited line that still count as what the edit touches
40
+ TEST_PATH = re.compile(r"(^|/)(tests?|__tests__|specs?)(/|$)|[._-](test|spec)s?\.[A-Za-z]+$|(^|/)test_[^/]+$|Tests?\.cs$")
41
+
42
+
43
+ class GitError(RuntimeError):
44
+ pass
45
+
46
+
47
+ def _git(root: Path, *args: str, binary: bool = False):
48
+ try:
49
+ out = subprocess.run(["git", *args], cwd=root, capture_output=True, check=False)
50
+ except FileNotFoundError:
51
+ raise GitError("git is not on the PATH")
52
+ if out.returncode != 0:
53
+ raise GitError(out.stderr.decode("utf-8", errors="replace").strip() or f"git {' '.join(args)} failed")
54
+ return out.stdout if binary else out.stdout.decode("utf-8", errors="replace").strip()
55
+
56
+
57
+ def git_root(path: Path) -> Path:
58
+ return Path(_git(path, "rev-parse", "--show-toplevel")).resolve()
59
+
60
+
61
+ def github_pr(root: Path, number: str) -> dict:
62
+ """Base branch, title, description and head commit of a GitHub pull request, through the gh command."""
63
+ try:
64
+ # after --: a number that starts with - (`--web`, `--repo=...`, passed by an agent) is not one of gh's options
65
+ out = subprocess.run(["gh", "pr", "view", "--json", "baseRefName,title,body,headRefOid,number,url", "--", str(number)],
66
+ cwd=root, capture_output=True, check=False)
67
+ except FileNotFoundError:
68
+ raise GitError("--github needs the gh command (https://cli.github.com), signed in")
69
+ if out.returncode != 0:
70
+ raise GitError(out.stderr.decode("utf-8", errors="replace").strip() or "gh pr view failed")
71
+ return json.loads(out.stdout)
72
+
73
+
74
+ def slug(text: str) -> str:
75
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-")[:60] or "change"
76
+
77
+
78
+ def change_id(root: Path, given: Optional[str] = None, number: Optional[str] = None) -> str:
79
+ """pr-<given>, pr-<number>, or pr-<branch name>; on a detached checkout, pr-<commit>."""
80
+ if given:
81
+ return "pr-" + slug(given.removeprefix("pr-"))
82
+ if number: # 123, #123 or a pull request's URL; the id names files under .leyline/, so it is a slug
83
+ m = re.fullmatch(r"#?(\d+)|https?://\S+/pull/(\d+)/?", str(number).strip())
84
+ return "pr-" + (m.group(1) or m.group(2) if m else slug(str(number)))
85
+ try:
86
+ branch = _git(root, "symbolic-ref", "--short", "-q", "HEAD")
87
+ except GitError:
88
+ branch = ""
89
+ return "pr-" + slug(branch or _git(root, "rev-parse", "--short", "HEAD"))
90
+
91
+
92
+ # -- the two maps ----------------------------------------------------------------------------------------
93
+ def _repo_of(con, root: Path) -> Optional[str]:
94
+ for rid, where in store.roots(con).items():
95
+ try:
96
+ if Path(where).resolve() == root:
97
+ return rid
98
+ except OSError:
99
+ continue
100
+ return None
101
+
102
+
103
+ def _safe_members(tar: tarfile.TarFile, into: Path):
104
+ """The archive's entries that stay inside `into`: a branch under review is someone else's code, and an entry
105
+ named ../x, or a link out of the tree followed by a file written through it, must not reach outside."""
106
+ import posixpath
107
+ top = into.resolve()
108
+ members = tar.getmembers()
109
+ # Each check below reads the path as written, before anything is extracted. A path that passes through a link
110
+ # in the archive (s -> ., then w -> s/.. and w/evil.txt) is checked as if the link were a folder, and is only
111
+ # where it seems once nothing follows it: Python without extraction filters would write it outside.
112
+ links = {posixpath.normpath(m.name) for m in members if m.issym()}
113
+
114
+ def through_link(path: str) -> bool:
115
+ cur: list[str] = []
116
+ parts = [p for p in path.split("/") if p not in ("", ".")]
117
+ for k, part in enumerate(parts):
118
+ if part == "..":
119
+ if not cur:
120
+ return True
121
+ cur.pop()
122
+ else:
123
+ cur.append(part)
124
+ if k < len(parts) - 1 and "/".join(cur) in links:
125
+ return True
126
+ return False
127
+ for m in members:
128
+ dest = (top / m.name).resolve()
129
+ if m.name.startswith("/") or (dest != top and top not in dest.parents) or through_link(m.name):
130
+ continue
131
+ if m.issym() or m.islnk():
132
+ target = (dest.parent / m.linkname).resolve() if m.issym() else (top / m.linkname).resolve()
133
+ if m.linkname.startswith("/") or (target != top and top not in target.parents):
134
+ continue
135
+ if through_link(posixpath.join(posixpath.dirname(m.name), m.linkname) if m.issym() else m.linkname):
136
+ continue
137
+ if not (m.isfile() or m.isdir() or m.issym() or m.islnk()):
138
+ continue
139
+ yield m
140
+
141
+
142
+ def _archive(root: Path, sha: str) -> bytes:
143
+ """`git archive` of a commit with every file in it as the checkout has it. The repository's `export-ignore` and
144
+ `export-subst` attributes shape release tarballs (tests left out, a version string filled in), so the archive is
145
+ made from an empty repository that borrows this one's objects and unsets both, at the highest precedence."""
146
+ objects = (root / _git(root, "rev-parse", "--git-path", "objects")).resolve()
147
+ # Line endings as the checkout has them (core.autocrlf=true writes CRLF), or every file would differ from it.
148
+ eol = []
149
+ for key in ("core.autocrlf", "core.eol"):
150
+ try:
151
+ eol += ["-c", f"{key}={_git(root, 'config', '--get', key)}"]
152
+ except GitError: # not set
153
+ pass
154
+ with tempfile.TemporaryDirectory(prefix="leyline-archive-") as tmp:
155
+ bare = Path(tmp)
156
+ _git(bare, "init", "-q", "--bare")
157
+ (bare / "objects" / "info").mkdir(parents=True, exist_ok=True)
158
+ # git reads these byte for byte: a "\r\n" ending, as Windows writes text, would make the objects path "...\r"
159
+ (bare / "objects" / "info" / "alternates").write_text(str(objects) + "\n", newline="\n")
160
+ (bare / "info").mkdir(exist_ok=True)
161
+ (bare / "info" / "attributes").write_text("* -export-ignore -export-subst\n", newline="\n")
162
+ return _git(bare, *eol, "archive", "--format=tar", sha, binary=True)
163
+
164
+
165
+ def _export(root: Path, sha: str, into: Path) -> list[str]:
166
+ """The files of a commit, as git keeps them, written under `into`; returns their paths, as the map lists them
167
+ (every file of the commit: a tracked file that a .gitignore pattern would match stays in). Nothing in the
168
+ repository changes."""
169
+ import io
170
+ with tarfile.open(fileobj=io.BytesIO(_archive(root, sha))) as tar:
171
+ members = list(_safe_members(tar, into))
172
+ try:
173
+ tar.extractall(into, members=members, filter="tar")
174
+ except TypeError: # no extraction filters before Python 3.12 (and 3.11.4, 3.10.12)
175
+ tar.extractall(into, members=members)
176
+ except tarfile.FilterError: # a link the filter refuses: take the entries one by one, leaving those out
177
+ for m in members:
178
+ try:
179
+ tar.extract(m, into, filter="tar")
180
+ except tarfile.FilterError:
181
+ pass
182
+ # A sparse checkout leaves some of the commit's files out of the working tree (git marks them skip-worktree), and
183
+ # its map leaves them out: the base leaves out the same, or each would read as removed by the change.
184
+ for entry in _git(root, "ls-files", "-z", "-t", binary=True).split(b"\0"):
185
+ if entry.startswith(b"S "):
186
+ path = entry[2:].decode("utf-8", "surrogateescape")
187
+ if not os.path.lexists(root / path) and (into / path).is_file():
188
+ (into / path).unlink()
189
+ # An empty repository of its own, so that git asked about the base never answers for a folder above it. The
190
+ # files are listed here rather than added to it: adding them hashed every file again (a third of the time).
191
+ _git(into, "init", "-q")
192
+ return [m.name for m in members if not m.isdir() and os.path.lexists(into / m.name)]
193
+
194
+
195
+ def base_snapshot(db: Path, root: Path, rid: str, base_sha: str, cid: str) -> Path:
196
+ """The map of the base commit, kept as the change's baseline (the snapshot `check` compares with). Made once
197
+ per base commit: a copy of the checkout's store is mapped again from the base's files, so every file that did
198
+ not change keeps its parse output, and the result is cut down to what a comparison reads."""
199
+ con = store.connect(db)
200
+ try:
201
+ target = diff.snapshot_path(con, cid)
202
+ finally:
203
+ con.close()
204
+ from .incremental import code_version
205
+ marker = target.with_suffix(".base")
206
+ stamp = f"{base_sha} {code_version()}" # a base mapped by another version of Leyline is mapped again
207
+ if target.exists() and marker.exists() and marker.read_text().strip() == stamp:
208
+ return target
209
+ from .incremental import cache_path
210
+ from .indexer import index
211
+ work = Path(tempfile.mkdtemp(prefix="leyline-pr-"))
212
+ try:
213
+ src = work / rid # the directory name is the repository's id in a workspace
214
+ src.mkdir()
215
+ names = _export(root, base_sha, src)
216
+ mapped = work / "map" / "leyline.db"
217
+ mapped.parent.mkdir()
218
+ live = store.connect(db) # a consistent copy of the store, even while something else reads it
219
+ try:
220
+ live.execute("VACUUM INTO ?", (str(mapped),))
221
+ exact = (live.execute("SELECT value FROM meta WHERE key = 'exact'").fetchone() or ["auto"])[0]
222
+ finally:
223
+ live.close()
224
+ if cache_path(db).exists():
225
+ shutil.copyfile(cache_path(db), cache_path(mapped))
226
+ con = store.connect(mapped)
227
+ try:
228
+ with con: # paths kept relative to the store would now point near the copy
229
+ con.execute("DELETE FROM meta WHERE key LIKE 'rel:%'")
230
+ finally:
231
+ con.close()
232
+ index(src, mapped, rid, exact, listed={rid: names})
233
+ con = store.connect(mapped)
234
+ try:
235
+ snap = diff.snapshot(con, "base")
236
+ finally:
237
+ con.close()
238
+ target.parent.mkdir(parents=True, exist_ok=True)
239
+ shutil.move(str(snap), str(target))
240
+ store.write_file(marker, stamp + "\n")
241
+ finally:
242
+ shutil.rmtree(work, ignore_errors=True)
243
+ return target
244
+
245
+
246
+ # -- what changed, and what it reaches --------------------------------------------------------------------
247
+ def _inside(i: str, others: set) -> bool:
248
+ """i is one of `others`, or nested in one (a method of a changed type)."""
249
+ if i in others:
250
+ return True
251
+ return any(i.startswith(o + ".") or i.startswith(o + "/") for o in others)
252
+
253
+
254
+ def _house_rules(root: Path, paths: list[str]) -> list[str]:
255
+ """Files a reviewer should read first: the repository's own rules for agents and contributors, at the top and
256
+ in the directories the change touches."""
257
+ out = [f for f in HOUSE_RULES if (root / f).is_file()]
258
+ for p in sorted({str(Path(p).parent) for p in paths if p}):
259
+ cur = Path(p)
260
+ while str(cur) not in ("", "."):
261
+ for name in ("AGENTS.md", "CLAUDE.md"):
262
+ f = cur / name
263
+ if (root / f).is_file() and str(f) not in out:
264
+ out.append(str(f))
265
+ cur = cur.parent
266
+ return out
267
+
268
+
269
+ def _address_words(address: str) -> set:
270
+ """What an edited line says when it touches a channel: the address, a route without its method, the route up to
271
+ its first parameter (`/api/entities` of `GET /api/entities/:type`), a file's name. A table, event or program
272
+ name is matched whole."""
273
+ words = {address}
274
+ if "/" in address:
275
+ path = address.split(" ", 1)[-1]
276
+ words |= {path, re.split(r"/[:{<*]", path, maxsplit=1)[0].rstrip("/")}
277
+ leaf = path.rstrip("/").rsplit("/", 1)[-1]
278
+ if "." in leaf:
279
+ words.add(leaf)
280
+ return {w for w in words if len(w) >= 3}
281
+
282
+
283
+ def _names_format(address: str, text: str) -> bool:
284
+ """An edited line touches a key format when it writes one of the format's fixed parts as a piece of a key
285
+ (`/text`, `nodes/`) or as a word the reader compares (`"text"`)."""
286
+ for w in re.split(r"[/:]", address):
287
+ if len(w) < 2 or w == "*":
288
+ continue
289
+ e = re.escape(w)
290
+ if re.search(rf"(?<![\w-]){e}(?=[/:])|[/:]{e}(?![\w-])|[\"'`]{e}[\"'`]", text):
291
+ return True
292
+ return False
293
+
294
+
295
+ def _params(text: str, start: int, name: str) -> Optional[str]:
296
+ """The parameter list of a function whose text starts at line `start` (from 1): the brackets after its name,
297
+ past any decorators, with the spacing made plain. None when it cannot be found."""
298
+ lines = text.split("\n")[start - 1:start + 40]
299
+ body = "\n".join(lines)
300
+ name = (name or "").rsplit(".", 1)[-1] # a method is named `Owner.name` on the map, and `name` in its text
301
+ at = body.find(name) if name and name not in ("<module>", "<top-level>") else -1
302
+ if at < 0:
303
+ return None
304
+ open_ = body.find("(", at)
305
+ if open_ < 0 or "\n" in body[at:open_].strip("\n ") and body[at:open_].count("\n") > 2:
306
+ return None
307
+ depth = 0
308
+ for k in range(open_, len(body)):
309
+ ch = body[k]
310
+ if ch in "([{<":
311
+ depth += 1
312
+ elif ch in ")]}>":
313
+ depth -= 1
314
+ if depth == 0:
315
+ return re.sub(r"\s+", " ", body[open_ + 1:k]).strip().rstrip(",")
316
+ return None
317
+
318
+
319
+ def _split_params(params: str) -> list[str]:
320
+ """The parameters of a list as `_params` gives it, one by one, with nested brackets and strings kept whole."""
321
+ out, depth, cur, quote = [], 0, "", ""
322
+ for ch in params:
323
+ if quote:
324
+ cur += ch
325
+ if ch == quote:
326
+ quote = ""
327
+ continue
328
+ if ch in "\"'":
329
+ quote = ch
330
+ elif ch in "([{<":
331
+ depth += 1
332
+ elif ch in ")]}>":
333
+ depth -= 1
334
+ if ch == "," and depth == 0:
335
+ out.append(cur.strip())
336
+ cur = ""
337
+ else:
338
+ cur += ch
339
+ if cur.strip():
340
+ out.append(cur.strip())
341
+ return out
342
+
343
+
344
+ def _compatible(was: str, now: str) -> bool:
345
+ """Whether every call that fit the signature as it was still fits it now: the parameters it had are still
346
+ there, in order, and each one added after them has a default (or gathers the rest: `*args`, `**kwargs`,
347
+ `...rest`, `params`). Such a change asks nothing of its callers."""
348
+ before, after = _split_params(was), _split_params(now)
349
+ if len(after) < len(before) or after[:len(before)] != before:
350
+ return False
351
+ return all("=" in p or p.startswith(("*", "...", "params ")) for p in after[len(before):])
352
+
353
+
354
+ def _touching(con, names, crossings: list, changed: set, own: Optional[dict], added: set):
355
+ """A pull request, unlike a spec, shows the edited lines, so a channel counts as crossed only when the edit
356
+ touches it: an edited line (or a new function's text) inside one of its ends names its address (the table, the
357
+ route, the event, the program). A route whose handler is a function of its own is touched by any edit inside
358
+ that function, and by no other. Of the other ends that must agree, a reader of data whose own read changed is
359
+ told apart: the writer of that data need not change, but the reader must read what it writes.
360
+ Returns the crossings kept, the other ends that must agree, and the cross-channel reads to check."""
361
+ root_of = diff.roots(con)
362
+ text_of: dict = {}
363
+
364
+ def text(i):
365
+ if i not in text_of:
366
+ if i in added:
367
+ row = con.execute("SELECT repo_id, path, span_start, span_end FROM nodes WHERE id = ?", (i,)).fetchone()
368
+ data = diff.source(con, row["repo_id"], row["path"], root_of) if row and row["path"] else None
369
+ lines = data.decode("utf-8", errors="replace").split("\n") if data else []
370
+ text_of[i] = "\n".join(lines[(row["span_start"] or 1) - 1:row["span_end"] or len(lines)]) if lines else ""
371
+ else: # the edited lines, with the few above each: a route's handler is edited below its path
372
+ changed_at = [ln for ln, _ in (own or {}).get(i) or []]
373
+ row = con.execute("SELECT repo_id, path, span_start, span_end FROM nodes WHERE id = ?", (i,)).fetchone()
374
+ data = diff.source(con, row["repo_id"], row["path"], root_of) if changed_at and row and row["path"] else None
375
+ lines = data.decode("utf-8", errors="replace").split("\n") if data else []
376
+ lo = (row["span_start"] or 1) if row else 1
377
+ keep = sorted({k for ln in changed_at for k in range(max(lo, ln - CONTEXT), ln + 1)})
378
+ text_of[i] = "\n".join(lines[k - 1] for k in keep if 0 < k <= len(lines))
379
+ return text_of[i]
380
+
381
+ def within(i, end):
382
+ while i:
383
+ if i == end:
384
+ return True
385
+ i = names.by_id[i]["parent_id"] if i in names.by_id else None
386
+ return False
387
+
388
+ def answers(end, address):
389
+ """`end` is the handler of this route: its own function (an inline handler, or one given by name), so every
390
+ line of it is what the route answers."""
391
+ return any(a.get("channel") == "http" and a.get("handler") is True and a.get("address") == address
392
+ for a in (json.loads(r[0] or "{}") for r in con.execute(
393
+ "SELECT attrs FROM edges WHERE kind = 'communicates' AND dst_id = ?", (end,))))
394
+
395
+ def touches(end, address, channel=""):
396
+ inside = [i for i in changed if within(i, end)]
397
+ if not inside:
398
+ return False
399
+ if not address or own is None or answers(end, address):
400
+ return True
401
+ if channel == "format":
402
+ return any(_names_format(address, text(i)) for i in inside)
403
+ words = _address_words(address)
404
+ return any(w and w in text(i) for i in inside for w in words)
405
+ kept, must, across = [], [], []
406
+ for c in crossings:
407
+ address = c.get("address") or ""
408
+ hub = touches(c["hub"], address, c["channel"])
409
+ moved = [sp for sp in c.get("spokes", []) if touches(sp["id"], address, c["channel"])]
410
+ if not hub and not moved:
411
+ continue
412
+ what = f"{c['channel']} {address}".strip()
413
+ guessed = bool(c.get("guessed"))
414
+ kept.append({**c, "hub_touched": hub, "spokes_touched": [sp["name"] for sp in moved]})
415
+ if hub: # what the hub writes, sends or answers changed: every other end must agree
416
+ for sp in c.get("spokes", []):
417
+ if sp["changed"]:
418
+ continue
419
+ must.append({"id": sp["id"], "name": sp["name"], "channel": c["channel"], "address": address,
420
+ "hub": c["hub_name"], "guessed": guessed, "why": (f"starts {c.get('program') or c['hub_name']}, whose input or output the edit changed"
421
+ if c["channel"] == "process"
422
+ else f"takes apart keys of the form {address}, which the edit to {c['hub_name']} changed"
423
+ if c["channel"] == "format"
424
+ else f"reads the {what} data, which the edit to {c['hub_name']} changed" if c["data"]
425
+ else f"calls {what}, whose answer the edit to {c['hub_name']} changed")})
426
+ elif c["data"]: # a read changed: the writer need not change, but the read must match what it writes
427
+ across.append({"id": c["hub"], "name": c["hub_name"], "channel": c["channel"], "address": address,
428
+ "why": f"writes the {what} data the change now reads in {', '.join(sp['name'] for sp in moved)}:"
429
+ " check it reads what is written"})
430
+ else: # a caller changed what it sends: the other end must accept it
431
+ must.append({"id": c["hub"], "name": c["hub_name"], "channel": c["channel"], "address": address,
432
+ "hub": c["hub_name"], "guessed": guessed, "why": f"answers {', '.join(sp['name'] for sp in moved)} over {what}"})
433
+ dedupe = lambda xs: list({x["id"]: x for x in xs}.values())
434
+ return kept, dedupe(must), dedupe(across)
435
+
436
+
437
+ def analyse(con, snap: Path, about: str = "", old_source=None) -> dict:
438
+ """The change between the base snapshot and the store as it is now, as facts a reviewer works from.
439
+ `old_source(path)` gives a file's text at the base, to read what a changed declaration was."""
440
+ before = diff._open(snap)
441
+ try:
442
+ d = diff.compare(before, con)
443
+ old_spans = {}
444
+ if old_source is not None:
445
+ for n in d["nodes"]["edited"]:
446
+ if n["kind"] == "callable":
447
+ r = before.execute("SELECT path, span_start FROM nodes WHERE id = ?", (n["id"],)).fetchone()
448
+ if r is not None:
449
+ old_spans[n["id"]] = (r[0], r[1])
450
+ changed_ids = [n["id"] for key in ("resigned", "edited", "types_edited") for n in d["nodes"][key]]
451
+ fb = {r[0]: r[1] for r in before.execute("SELECT id, content_hash FROM nodes WHERE kind = 'file' AND layer = 'fact'")}
452
+ files = [r for r in con.execute("SELECT id, name, path, content_hash, span_end FROM nodes WHERE kind = 'file' AND layer = 'fact'")
453
+ if fb.get(r["id"]) != r["content_hash"]]
454
+ gone_files = sorted({r[0] for r in before.execute("SELECT path FROM nodes WHERE kind = 'file' AND layer = 'fact'")}
455
+ - {r[0] for r in con.execute("SELECT path FROM nodes WHERE kind = 'file' AND layer = 'fact'")})
456
+ own = diff.own_changes(before, con, changed_ids + [f["id"] for f in files])
457
+ rules_before = rules.check(before, rules_from=con)
458
+ removed_callers = {}
459
+ for n in d["nodes"]["removed"]:
460
+ if n["kind"] in ("callable", "test", "type"):
461
+ # caller -> whether every call it made was a guess by name
462
+ removed_callers[n["id"]] = dict(sorted((r[0], bool(r[1])) for r in before.execute(
463
+ "SELECT src_id, MIN(precision = 'guess') FROM calls WHERE dst_id = ? OR dst_id LIKE ? GROUP BY src_id",
464
+ (n["id"], n["id"] + ".%"))))
465
+ finally:
466
+ before.close()
467
+ names = spec._Names(con)
468
+
469
+ def lines_of(i):
470
+ return len(own.get(i) or []) if own is not None else None
471
+ # A container whose text changed only inside something nested in it (a class around an edited method) is not
472
+ # an edit of its own.
473
+ edited = [n for n in d["nodes"]["edited"] + d["nodes"]["resigned"] if own is None or own.get(n["id"])]
474
+ resigned = {n["id"] for n in d["nodes"]["resigned"]}
475
+ # Where ids carry no parameter list (Python, TypeScript, Go ...), a changed signature is an edit like any other:
476
+ # read the declaration as it was and as it is.
477
+ root_of = diff.roots(con)
478
+ old_files: dict = {}
479
+ for n in edited:
480
+ i = n["id"]
481
+ if i in resigned or i not in old_spans or not n.get("line"):
482
+ continue
483
+ first = min((ln for ln, _ in (own or {}).get(i) or []), default=None)
484
+ if first is None or first > n["line"] + 40:
485
+ continue
486
+ path, old_line = old_spans[i]
487
+ if path not in old_files:
488
+ old_files[path] = old_source(path)
489
+ new_data = diff.source(con, i.split(":", 1)[0], n["path"], root_of)
490
+ was = _params(old_files[path], old_line or 1, n["name"]) if old_files[path] and old_line else None
491
+ now = _params(new_data.decode("utf-8", errors="replace"), n["line"], n["name"]) if new_data else None
492
+ if was is not None and now is not None and was != now:
493
+ n.update({"was": was, "now": now})
494
+ resigned.add(i)
495
+ # A parameter added with a default breaks no call: the change is listed as a signature change, but its callers
496
+ # are not told to update, do not block the gate and are not said to be likely to fail.
497
+ compatible = {n["id"] for n in edited if n["id"] in resigned and "was" in n and _compatible(n["was"], n["now"])}
498
+ sig_changed, resigned = resigned, resigned - compatible
499
+ types = [n for n in d["nodes"]["types_edited"] if own is None or own.get(n["id"])]
500
+ added = d["nodes"]["added"]
501
+ outer_added = [n for n in added if not any(n["id"].startswith(o["id"] + ".") for o in added if o is not n)]
502
+ from . import diagrams # the changed code as it runs now, and the calls and channel links it gained and lost
503
+ how_it_runs = diagrams.safe(diagrams.for_snapshot, snap, con, [n["id"] for n in edited + added] or [n["id"] for n in types],
504
+ [n["id"] for n in d["nodes"]["removed"]])
505
+ top = [{"id": f["id"], "path": f["path"], "lines": sum(1 for _, t in own[f["id"]] if diff._code_line(t))} for f in files
506
+ if own is not None and any(diff._code_line(t) for _, t in own.get(f["id"]) or [])]
507
+ changed = {n["id"] for n in edited + types} | {n["id"] for n in added}
508
+ removed = d["nodes"]["removed"]
509
+
510
+ targets = ([{"id": n["id"], "action": "signature" if n["id"] in resigned else "behavior"} for n in edited + types]
511
+ + [{"id": n["id"], "action": "behavior"} for n in outer_added if n["id"] in names.by_id])
512
+ report = change.assess(con, about or "a pull request", targets) if targets else {}
513
+ if "error" in report:
514
+ report = {}
515
+ must = [m for m in report.get("must_edit") or [] if not _inside(m["id"], changed)]
516
+ sig_targets = [i for i in resigned if i in names.by_id]
517
+ for m in must: # how many calls each must fix: a caller can call the changed code more than once
518
+ sure = 0
519
+ if sig_targets:
520
+ m["call_sites"], sure = con.execute(
521
+ f"SELECT COUNT(*), COALESCE(SUM(precision != 'guess'), 0) FROM calls WHERE src_id = ?"
522
+ f" AND dst_id IN ({','.join('?' * len(sig_targets))})", (m["id"], *sig_targets)).fetchone()
523
+ m["test"] = bool(names.in_tests(m["id"]))
524
+ # the map's only link to the changed code is a guess by name: worth a read, never a reason to block
525
+ m["guessed"] = "a guess" in (m.get("note") or "") and not sure
526
+
527
+ # Code that moved with its file keeps its name under a new id. A caller whose call now reaches it there is not
528
+ # still calling removed code.
529
+ moved_to = defaultdict(list)
530
+ for n in added:
531
+ moved_to[(n["kind"], n["name"])].append(n["id"])
532
+
533
+ def reaches_now(caller: str, rid_: str) -> bool:
534
+ gone = next(n for n in removed if n["id"] == rid_)
535
+ return any(con.execute("SELECT 1 FROM calls WHERE src_id = ? AND (dst_id = ? OR dst_id LIKE ?) LIMIT 1",
536
+ (caller, m, m + ".%")).fetchone() is not None
537
+ for m in moved_to.get((gone["kind"], gone["name"]), []))
538
+ still_called = []
539
+ for rid_, callers in removed_callers.items():
540
+ live = [c for c in callers if c in names.by_id and not _inside(c, changed) and not _inside(c, {rid_})
541
+ and not reaches_now(c, rid_)]
542
+ if live:
543
+ still_called.append({"id": rid_, "removed": next(n["name"] for n in removed if n["id"] == rid_),
544
+ "callers": [spec._label(names, c) for c in live], "caller_ids": live,
545
+ "test_callers": [spec._label(names, c) for c in live if names.in_tests(c)],
546
+ "guessed": all(callers[c] for c in live)})
547
+
548
+ flagged = {m["id"] for m in must} | {c for x in still_called for c in x["caller_ids"]}
549
+ existing = [i for i in dict.fromkeys([n["id"] for n in edited + types]) if i in names.by_id]
550
+ new_existing = [n["id"] for n in outer_added if n["id"] in names.by_id]
551
+ links = [{"key": "edits", "action": "behavior", "text": "", "nodes": existing, "new": [], "into": [], "mention_ids": [],
552
+ "scenarios": [], "notes": [], "ambiguous": []},
553
+ {"key": "added", "action": "add", "text": "", "nodes": new_existing, "new": [], "into": [], "mention_ids": [],
554
+ "scenarios": [], "notes": [], "ambiguous": []}]
555
+ crossings, agree = spec._crossings(con, names, links)
556
+ crossings, agree, across = _touching(con, names, crossings, changed, own, {n["id"] for n in added})
557
+ alone = spec._left_alone(con, names, links)
558
+ state = spec._shared_state_touched(con, set(existing) | set(new_existing))
559
+ now_rules = rules.check(con)
560
+ was_failing = {r["id"] for r in rules_before.get("rules", []) if not r["passes"]}
561
+ new_violations = [r for r in now_rules["rules"] if not r["passes"] and r["id"] not in was_failing]
562
+ if any(r["status"] == "confirmed" for r in new_violations): # does it fail on links the map is sure of?
563
+ sure_fail = {r["id"] for r in rules.check(con, sure_only=True)["rules"] if not r["passes"]}
564
+ for r in new_violations:
565
+ r["guessed"] = r["id"] not in sure_fail
566
+
567
+ def is_test(i):
568
+ n = names.by_id.get(i)
569
+ return bool(n) and (n["kind"] == "test" or bool(TEST_PATH.search(n["path"] or "")))
570
+ fns = [i for i in existing + new_existing if names.by_id[i]["kind"] in ("callable", "test") and not is_test(i)]
571
+ tests_touched = [spec._label(names, i) for i in existing + new_existing
572
+ if is_test(i) and names.by_id[i]["name"] not in ("<module>", "<top-level>")]
573
+ test_files = sorted({f["path"] for f in files if TEST_PATH.search(f["path"] or "")})
574
+ product = [n for n in edited + types + outer_added if not is_test(n["id"])]
575
+ lines = sum(lines_of(n["id"]) or 0 for n in edited + types) + sum(t["lines"] for t in top)
576
+ modules = sorted({names.module.get(n["id"]) for n in product if names.module.get(n["id"])})
577
+ guessed = sum(1 for r in report.get("risks") or [] if "guesses by name" in r["what"])
578
+
579
+ def item(n, **extra):
580
+ name = f"{n['path']} (top level)" if n["name"] in ("<module>", "<top-level>") else n["name"]
581
+ return {"id": n["id"], "name": name, "kind": n["kind"], "path": n["path"], "line": n.get("line"),
582
+ **({"test": True} if is_test(n["id"]) or TEST_PATH.search(n["path"] or "") else {}), **extra}
583
+ bodies = {n["path"] for n in edited if n["name"] in ("<module>", "<top-level>")}
584
+ top = [t for t in top if t["path"] not in bodies]
585
+ return {
586
+ "about": about,
587
+ "changed": {
588
+ "edited": [item(n, lines=lines_of(n["id"]),
589
+ **({"signature": f"({n['was']}) -> ({n['now']})", **({"compatible": True} if n["id"] in compatible else {})}
590
+ if n["id"] in sig_changed else {}))
591
+ for n in edited],
592
+ "types": [item(n, lines=lines_of(n["id"])) for n in types],
593
+ "added": [item(n) for n in outer_added],
594
+ "removed": [item(n) for n in removed if not any(n["id"].startswith(o["id"] + ".") for o in removed if o is not n)],
595
+ "top_level": top, "files": sorted(f["path"] for f in files), "files_removed": gone_files,
596
+ },
597
+ "size": {"functions": len([n for n in product if n["kind"] in ("callable", "test")]), "types": len(types),
598
+ "added": len(outer_added), "removed": len(removed), "lines": lines if own is not None else None,
599
+ "modules": len(modules), "files": len(files) + len(gone_files)},
600
+ "reaches": {
601
+ "signature_changed_callers_not_edited": must,
602
+ "removed_but_still_called": still_called,
603
+ "channels_crossed": crossings,
604
+ "other_ends_not_edited": agree,
605
+ "reads_or_calls_across_a_channel": across,
606
+ "callers_left_alone": [x for x in ({**c, "callers": [n for n, i in zip(c["callers"], c["caller_ids"]) if i not in flagged],
607
+ "caller_ids": [i for i in c["caller_ids"] if i not in flagged]}
608
+ for c in alone["callers"]) if x["caller_ids"]][:30],
609
+ "state_shared_with_unchanged_code": [x for x in alone["state"] if not x.get("quiet")][:30],
610
+ "fields_written_from_elsewhere_too": state,
611
+ "new_members_named_like_existing_ones": alone["beside"][:20],
612
+ "entry_points_affected": (report.get("entry_points_affected") or [])[:20],
613
+ "entry_points_affected_total": len(report.get("entry_points_affected") or []),
614
+ },
615
+ "tests": {
616
+ "likely_to_fail_unedited": sorted({m["name"] for m in must if m.get("test")}
617
+ | {t for x in still_called for t in x["test_callers"]}),
618
+ "touched_by_the_change": tests_touched, "test_files_changed": test_files,
619
+ "changed_code_no_test_reaches": [u for u in report.get("untested") or [] if not u["name"].endswith(("<module>", "<top-level>"))],
620
+ "tests_to_run": (report.get("tests_to_run") or [])[:30],
621
+ "tests_to_run_total": len(report.get("tests_to_run") or []),
622
+ **_measured(con, [n["id"] for n in edited + types + added]),
623
+ },
624
+ "structure": {"new_dependencies": d["structure"]["new_dependencies"],
625
+ "removed_dependencies": d["structure"]["removed_dependencies"],
626
+ "flows_changed": d["flows"]["changed"], "rules_now_failing": new_violations},
627
+ "performance": {"changed_functions_by_how_much_runs_through_them": spec.hot_functions(con, names, fns)[:25],
628
+ "tests_that_measure_speed": spec._speed_tests(con, set(fns))[:20]},
629
+ "how_it_runs": how_it_runs,
630
+ "how_sure": {"guessed_caller_links": guessed > 0,
631
+ "note": "Caller and channel links come from the map. Where the map guessed a link by name it says so;"
632
+ " read the code before filing anything on one."},
633
+ "marks": (
634
+ [{"id": n["id"], "role": "changed", "note": "signature changed" if n["id"] in sig_changed else "edited"} for n in edited + types
635
+ if n["id"] in names.by_id]
636
+ + [{"id": i, "role": "new", "note": "added"} for i in new_existing]
637
+ + [{"id": m["id"], "role": "must_edit", "note": "calls code whose signature changed; not edited"} for m in must]
638
+ + [{"id": c, "role": "must_edit", "note": f"calls {x['removed']}, which was removed"} for x in still_called for c in x["caller_ids"]]
639
+ + [{"id": a["id"], "role": "contract", "note": a["why"]} for a in agree if a["id"] in names.by_id]
640
+ + [{"id": a["id"], "role": "direct", "note": a["why"]} for a in across if a["id"] in names.by_id]
641
+ + [{"id": w, "role": "direct", "note": f"also calls {c['changed']}"} for c in alone["callers"][:30] for w in c["caller_ids"]]),
642
+ }
643
+
644
+
645
+ def _measured(con, ids: list[str]) -> dict:
646
+ """With per-test coverage imported: the tests measured running the changed code, and a command that runs them."""
647
+ from . import affected
648
+ ran = affected.measured_tests(con, ids)
649
+ return {"measured_running_the_change": ran[:40], "run_them": affected.commands(con, ran)} if ran else {}
650
+
651
+
652
+ # -- the whole step ---------------------------------------------------------------------------------------
653
+ def review(db: str | Path, path: str | Path = ".", base: Optional[str] = None, about: str = "", github: Optional[str] = None,
654
+ given_id: Optional[str] = None) -> dict:
655
+ """Map the checkout (again, if it changed) and the commit it branched from, compare them, store the change
656
+ under pr-<id> so reviewers can file findings on it, and write its page."""
657
+ from . import loop
658
+ root = git_root(Path(path).resolve())
659
+ title, url = "", ""
660
+ if github:
661
+ gh = github_pr(root, github)
662
+ base = base or gh["baseRefName"]
663
+ title, url = gh.get("title") or "", gh.get("url") or ""
664
+ about = about or ((gh.get("title") or "") + "\n\n" + (gh.get("body") or "")).strip()
665
+ head = _git(root, "rev-parse", "HEAD")
666
+ if gh.get("headRefOid") and gh["headRefOid"] != head:
667
+ return {"error": f"the checkout is at {head[:7]}, not at pull request {github}'s head"
668
+ f" ({gh['headRefOid'][:7]}): run `gh pr checkout {github}` first"}
669
+ try:
670
+ _git(root, "rev-parse", "--verify", "-q", "HEAD^{commit}")
671
+ except GitError:
672
+ return {"error": f"the repository at {root} has no commits yet: commit the base first, then branch from it"}
673
+ named = bool(base)
674
+ if not base:
675
+ base = _default_base(root)
676
+ # A branch this clone has only as origin's; for a GitHub pull request origin's first, as GitHub compares with it
677
+ # (a local branch of that name may have been left behind).
678
+ for name in (("origin/" + base, base) if github else (base, "origin/" + base)):
679
+ try:
680
+ _git(root, "rev-parse", "--verify", "-q", name + "^{commit}")
681
+ base = name
682
+ break
683
+ except GitError:
684
+ continue
685
+ else:
686
+ if not named: # main by default: a repository whose default branch has another name says nothing of it
687
+ return {"error": f"no main or master branch in {root}, and origin's default branch is not known: name the"
688
+ " base, `leyline pr <branch or commit>`"}
689
+ return {"error": f"no commit or branch {base!r} in {root}"}
690
+ try:
691
+ base_sha = _git(root, "merge-base", base, "HEAD")
692
+ except GitError:
693
+ if _git(root, "rev-parse", "--is-shallow-repository") == "true":
694
+ return {"error": f"this clone is shallow and holds no commit both {base} and HEAD come from: fetch more"
695
+ " history (`git fetch --unshallow`, or --deepen), then run it again"}
696
+ return {"error": f"{base} and HEAD share no history: name a base the branch was made from"}
697
+ head_sha = _git(root, "rev-parse", "HEAD")
698
+ dirty = bool(_git(root, "status", "--porcelain")) # a new file not yet added is mapped, so it counts too
699
+ given = about.strip() or None # said by the person (or the pull request); a description made here is not kept
700
+ if not given:
701
+ about = _described(root, base_sha, db, given_id, github)
702
+ db = Path(db)
703
+ if not db.exists():
704
+ loop.map_repos([str(root)], db, page=False)
705
+ else:
706
+ con = store.connect(db)
707
+ try:
708
+ known = _repo_of(con, root)
709
+ finally:
710
+ con.close()
711
+ if known is None:
712
+ return {"error": f"the store {db} does not hold {root}: map it first (`leyline map {root}`)"}
713
+ loop.refresh(db)
714
+ con = store.connect(db)
715
+ try:
716
+ rid = _repo_of(con, root)
717
+ cid = change_id(root, given_id, github)
718
+ finally:
719
+ con.close()
720
+ snap = base_snapshot(db, root, rid, base_sha, cid)
721
+ con = store.connect(db)
722
+ try:
723
+ facts = analyse(con, snap, about, _old_source(root, base_sha))
724
+ title = title or _title(about) or _untitled(root)
725
+ marks = facts.pop("marks")
726
+ with con:
727
+ row = con.execute("SELECT attrs FROM change_proposals WHERE id = ?", (cid,)).fetchone()
728
+ attrs = json.loads(row[0] or "{}") if row else {}
729
+ if given:
730
+ attrs["about_given"] = given
731
+ attrs.update({"title": title, "kind": "pr", "base": base, "base_sha": base_sha, "url": url, "dirty": dirty,
732
+ "root": str(root), "report": {k: facts[k] for k in ("size",)}})
733
+ con.execute("INSERT OR REPLACE INTO change_proposals (id, intent, status, base_commit, head_commit, attrs)"
734
+ " VALUES (?,?,?,?,?,?)", (cid, attrs.get("about_given") or about or title, "pr", base_sha, head_sha,
735
+ json.dumps(attrs)))
736
+ seen, kept = set(), []
737
+ for m in marks:
738
+ if m["id"] not in seen:
739
+ seen.add(m["id"])
740
+ kept.append(m)
741
+ if kept:
742
+ change.save_view(con, "Pull request: " + title, about or title, kept, kind="change", source="leyline-pr",
743
+ change_id=cid, view_id="view-" + cid,
744
+ legend={"changed": "edited by the change", "new": "added by the change",
745
+ "must_edit": "should have changed with it, and did not",
746
+ "contract": "the other end of a channel the change crosses",
747
+ "direct": "also calls what the change edited"})
748
+ out = {"change_id": cid, "title": title, "url": url, "base": base, "base_sha": base_sha, "head_sha": head_sha,
749
+ "dirty": dirty, "root": str(root), **facts,
750
+ "house_rules": _house_rules(root, facts["changed"]["files"]),
751
+ "other_files": _other_files(root, base_sha, facts["changed"]["files"] + facts["changed"]["files_removed"]),
752
+ "findings": spec.findings(con, cid)["findings"], "reviews": spec.reviews(con, cid),
753
+ "learnings_unreadable": spec._learnings_unreadable(con)}
754
+ from . import coupling # files that usually changed with what the branch changed, in the history before it
755
+ ch = facts["changed"]
756
+ out["usually_changes_with"] = coupling.for_pr(
757
+ con, rid, root, base_sha, functions=[x["id"] for x in ch["edited"] if not x.get("test")],
758
+ changed_ids=[x["id"] for k in ("edited", "types", "added", "removed") for x in ch[k]])
759
+ from . import related, rereview # earlier changes to this code; and, on a re-review, what changed since the last
760
+ out["related_changes"] = related.find(con, spec._Names(con), _touched(facts), exclude=cid, since=base_sha)
761
+ summary, fp = rereview.summarize(facts), diff._fingerprint(con)
762
+ out["since_last_review"] = rereview.since(con, cid, summary, fp)
763
+ rereview.mark(out["findings"], out["since_last_review"])
764
+ rereview.record(con, cid, head_sha, dirty, summary, fp)
765
+ from . import learnings
766
+ out["learnings_not_applied"] = learnings.not_applied(con, cid)
767
+ out["gate"] = gate(out, gate_config(root)) # judged from this run's facts and the findings as they are now
768
+ page =Path(db).parent / "reviews" / f"{cid}.md"
769
+ page.parent.mkdir(parents=True, exist_ok=True)
770
+ store.write_file(page, text(out))
771
+ out["page"] = str(page)
772
+ return out
773
+ finally:
774
+ con.close()
775
+
776
+
777
+ def _touched(f: dict) -> list[str]:
778
+ """The code a review's change edited or added, by id."""
779
+ c = f["changed"]
780
+ return [x["id"] for k in ("edited", "types", "added") for x in c[k]]
781
+
782
+
783
+ def _title(about: str) -> str:
784
+ """A title from a description: its first line, or the first commit's subject (the oldest: commits added later
785
+ leave it as it was), cut at a word near 80 characters."""
786
+ line = about.strip().split("\n")[0]
787
+ if line.startswith("From its commit messages: "):
788
+ line = line.removeprefix("From its commit messages: ").split(" / ")[-1]
789
+ line = line.strip()
790
+ if len(line) <= 80:
791
+ return line
792
+ cut = line[:80].rsplit(" ", 1)[0].rstrip(",;:")
793
+ return cut + "..."
794
+
795
+
796
+ def _untitled(root: Path) -> str:
797
+ """A title for a branch with no commits of its own and no description: its name, and that it is uncommitted."""
798
+ try:
799
+ branch = _git(root, "symbolic-ref", "--short", "-q", "HEAD")
800
+ except GitError:
801
+ branch = ""
802
+ return f"Uncommitted edits on {branch}" if branch else "Uncommitted edits"
803
+
804
+
805
+ def _described(root: Path, base_sha: str, db, given_id, github) -> str:
806
+ """What the change says it does when no one said: what an earlier review of it was told, else its commits'
807
+ messages."""
808
+ if Path(db).exists(): # what the person said at an earlier review of this change still holds
809
+ con = store.connect(db)
810
+ try:
811
+ row = con.execute("SELECT attrs FROM change_proposals WHERE id = ?", (change_id(root, given_id, github),)).fetchone()
812
+ finally:
813
+ con.close()
814
+ said = (json.loads(row[0] or "{}").get("about_given") if row else None)
815
+ if said:
816
+ return said
817
+ try:
818
+ log = _git(root, "log", "--no-merges", "--format=%s%n%n%b%x00", f"{base_sha}..HEAD")
819
+ except GitError:
820
+ return ""
821
+ msgs = [m.strip() for m in log.split("\0") if m.strip()][:20]
822
+ if not msgs:
823
+ return ""
824
+ subjects = " / ".join(m.split("\n")[0] for m in msgs)
825
+ # the whole messages only when one says more than its subject line: else the page reads each subject twice
826
+ return "From its commit messages: " + subjects + ("\n\n" + "\n\n".join(msgs) if any("\n" in m for m in msgs) else "")
827
+
828
+
829
+ def _other_files(root: Path, base_sha: str, mapped: list[str]) -> list[str]:
830
+ """Files the change touches that the map does not read (docs, styles, data, config): the page names them so a
831
+ reviewer knows the map's view of the change stops short of them."""
832
+ try:
833
+ # NUL-separated and unquoted: git otherwise quotes a path with a byte outside ASCII ("caf\303\251.py")
834
+ listed = _git(root, "-c", "core.quotePath=false", "diff", "--name-only", "-z", base_sha).split("\0")
835
+ listed += _git(root, "-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard").split("\0")
836
+ except GitError:
837
+ return []
838
+ seen = set(mapped)
839
+ return sorted({f for f in listed if f and f not in seen and not f.startswith(".leyline/")})
840
+
841
+
842
+ def _old_source(root: Path, sha: str):
843
+ """path -> the file's text at a commit, or None."""
844
+ def read(path: str) -> Optional[str]:
845
+ try:
846
+ return _git(root, "show", f"{sha}:{path}", binary=True).decode("utf-8", errors="replace")
847
+ except GitError:
848
+ return None
849
+ return read
850
+
851
+
852
+ def _default_base(root: Path) -> str:
853
+ """origin's default branch when it is known, else main, else master."""
854
+ try:
855
+ ref = _git(root, "symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD")
856
+ if ref:
857
+ return ref
858
+ except GitError:
859
+ pass
860
+ for b in ("main", "master", "origin/main", "origin/master"):
861
+ try:
862
+ _git(root, "rev-parse", "--verify", "-q", b + "^{commit}")
863
+ return b
864
+ except GitError:
865
+ continue
866
+ return "main"
867
+
868
+
869
+ def stored(con, cid: str) -> Optional[dict]:
870
+ row = con.execute("SELECT intent, base_commit, attrs FROM change_proposals WHERE id = ?", (cid,)).fetchone()
871
+ if row is None:
872
+ return None
873
+ a = json.loads(row["attrs"] or "{}")
874
+ return a if a.get("kind") == "pr" else None
875
+
876
+
877
+ def review_facts(con, cid: str, reviewer: Optional[str] = None) -> dict:
878
+ """The facts behind a pull request's page, arranged as the questions each reviewer answers, as
879
+ spec.review_facts does for a spec. Run `leyline pr` first: it makes the baseline these compare with."""
880
+ a = stored(con, cid)
881
+ snap = diff.snapshot_path(con, cid)
882
+ if a is None or not snap.exists():
883
+ return {"error": f"no pull request {cid!r} reviewed here; run `leyline pr` in its checkout first"}
884
+ about = con.execute("SELECT intent FROM change_proposals WHERE id = ?", (cid,)).fetchone()[0] or ""
885
+ root = Path(a.get("root") or ".")
886
+ f = analyse(con, snap, about, _old_source(root, a["base_sha"]) if a.get("base_sha") and root.is_dir() else None)
887
+ if reviewer:
888
+ spec.record_review(con, cid, reviewer)
889
+ r, t = f["reaches"], f["tests"]
890
+ from . import learnings, related, rereview
891
+ return {
892
+ "change_id": cid, "title": a.get("title"), "what_it_says_it_does": about or "(no description: judge it by the code)",
893
+ "since_last_review": rereview.for_facts(rereview.since(con, cid, rereview.summarize(f))), # a re-review starts here
894
+ "related_changes": related.find(con, spec._Names(con), _touched(f), exclude=cid, since=a.get("base_sha")),
895
+ "changed": f["changed"], "size": f["size"],
896
+ "house_rules_to_read_first": _house_rules(Path(a.get("root") or "."), f["changed"]["files"]),
897
+ "learnings_that_apply": learnings.applying(con, cid), # past decisions on this code: read these first
898
+ "learnings_unreadable": learnings.unreadable(con),
899
+ "learnings_not_applied": learnings.not_applied(con, cid), # what the pull request itself says was decided
900
+ "logic": {
901
+ "signature_changed_callers_not_edited": r["signature_changed_callers_not_edited"],
902
+ "removed_but_still_called": r["removed_but_still_called"],
903
+ "channels_crossed": r["channels_crossed"],
904
+ "other_ends_of_those_channels_not_edited": r["other_ends_not_edited"],
905
+ "callers_of_changed_functions_left_alone": r["callers_left_alone"],
906
+ "state_shared_with_unchanged_code": r["state_shared_with_unchanged_code"],
907
+ "fields_written_from_elsewhere_too": r["fields_written_from_elsewhere_too"],
908
+ "new_members_named_like_existing_ones": r["new_members_named_like_existing_ones"],
909
+ "usually_changes_with_not_changed": _usually(con, cid, a, root),
910
+ "changed_code_no_test_reaches": t["changed_code_no_test_reaches"],
911
+ "tests_touched": t["touched_by_the_change"], "test_files_changed": t["test_files_changed"],
912
+ "new_dependencies_between_modules": f["structure"]["new_dependencies"],
913
+ "rules_now_failing": f["structure"]["rules_now_failing"],
914
+ "ask": "First: does the code do what the description says, all of it and nothing else? Name each edit the"
915
+ " description does not explain. Then, for each list: is the change wrong, or is the map? Read the code"
916
+ " before filing. Then ask what the change leaves out: error paths, empty inputs, ordering, the second"
917
+ " caller, the other end of each channel, data written before the change.",
918
+ },
919
+ "performance": {**f["performance"],
920
+ "ask": "For each changed function on a hot path: does the change add work per call, allocation, I/O,"
921
+ " a lock or a process hop? Name the test that would show a regression, or say none exists."},
922
+ "how_sure": f["how_sure"],
923
+ "how_to_file": f"leyline spec finding {cid} --reviewer <logic|performance> --severity <high|medium|low>"
924
+ " --claim \"...\" --evidence <node id> --proposal \"...\" (or the spec_finding tool with this id).",
925
+ }
926
+
927
+
928
+ def _usually(con, cid: str, a: dict, root: Path) -> dict:
929
+ """What usually changed with the files the branch changed, and was not changed, from git history before it."""
930
+ from . import coupling
931
+ rid = _repo_of(con, root) if root.is_dir() else None
932
+ return coupling.for_pr(con, rid, root, a["base_sha"]) if rid and a.get("base_sha") else {}
933
+
934
+
935
+ # -- the gate ---------------------------------------------------------------------------------------------
936
+ # What can hold up a pull request under `leyline pr --gate`, by the name a project lists in openspec/leyline.toml:
937
+ #
938
+ # [pr]
939
+ # blocking = ["unedited-callers", "still-called", "failing-rules", "open-high-findings"]
940
+ #
941
+ # The default blocks only what the map shows is broken. A link the map guessed by name never blocks on its own.
942
+ GATE_KINDS = {
943
+ "unedited-callers": "callers of a changed signature that were not edited",
944
+ "still-called": "removed code that is still called",
945
+ "failing-rules": "confirmed error-level rules that now fail",
946
+ "open-high-findings": "open high findings",
947
+ "open-medium-findings": "open findings of medium severity or higher",
948
+ "open-findings": "open findings of any severity",
949
+ "other-ends": "other ends of a channel the edit changed, not edited",
950
+ "untested": "changed code no test reaches",
951
+ }
952
+ DEFAULT_GATE = ("unedited-callers", "still-called", "failing-rules", "open-high-findings")
953
+ _GATE_ALIASES = {"callers": "unedited-callers", "removed": "still-called", "rules": "failing-rules",
954
+ "high-findings": "open-high-findings", "medium-findings": "open-medium-findings",
955
+ "findings": "open-findings", "channels": "other-ends", "no-test": "untested"}
956
+ _AT_LEAST = {"open-high-findings": ("high",), "open-medium-findings": ("high", "medium"), "open-findings": None}
957
+
958
+
959
+ def gate_config(root: Path) -> dict:
960
+ """Which kinds block, from `[pr] blocking` in the repository's openspec/leyline.toml, else the default. Never
961
+ raises: a file that cannot be read leaves the default in place and says why in `notes`."""
962
+ from . import verdicts
963
+ out = {"blocking": list(DEFAULT_GATE), "file": None, "notes": []}
964
+ path = Path(root) / "openspec" / verdicts.CONFIG
965
+ if not path.is_file():
966
+ return out
967
+ shown = f"openspec/{verdicts.CONFIG}"
968
+ try:
969
+ data = verdicts._toml(path.read_text(encoding="utf-8"))
970
+ except (OSError, ValueError, UnicodeDecodeError) as e:
971
+ out["notes"].append(f"{shown} could not be read ({e}), so the default gate applies.")
972
+ return out
973
+ section = data.get("pr")
974
+ if not isinstance(section, dict) or "blocking" not in section:
975
+ return out
976
+ listed = section["blocking"]
977
+ if isinstance(listed, str):
978
+ listed = [listed]
979
+ if not isinstance(listed, list):
980
+ out["notes"].append(f"{shown}: `[pr] blocking` should be a list of kinds, so the default gate applies.")
981
+ return out
982
+ kinds = []
983
+ for w in listed:
984
+ k = re.sub(r"[_\s]+", "-", str(w).strip().lower())
985
+ k = _GATE_ALIASES.get(k, k)
986
+ if k not in GATE_KINDS:
987
+ out["notes"].append(f"{shown} names \"{w}\" under [pr], which is not a kind; it was left out."
988
+ f" The kinds are {', '.join(GATE_KINDS)}.")
989
+ elif k not in kinds:
990
+ kinds.append(k)
991
+ out["blocking"] = [k for k in GATE_KINDS if k in kinds]
992
+ out["file"] = shown
993
+ return out
994
+
995
+
996
+ def gate(r: dict, config: dict) -> dict:
997
+ """What is left that blocks, from the facts of this run and the findings as they stand now: one line each,
998
+ with what to do about it. Running `leyline pr` again after new commits judges it again from scratch."""
999
+ on = set(config["blocking"])
1000
+ reach, t = r["reaches"], r["tests"]
1001
+ out = [] # (reason, what to do)
1002
+ guessed = 0 # left out: the map's only link behind them is a guess by name
1003
+ if "unedited-callers" in on:
1004
+ for m in reach["signature_changed_callers_not_edited"]:
1005
+ guessed += bool(m.get("guessed"))
1006
+ if not m.get("guessed"):
1007
+ out.append((f"`{m['name']}` calls code whose signature changed, and was not edited",
1008
+ f"update the call in `{m['name']}` ({m.get('path') or 'see the page'}) to the new signature"))
1009
+ if "still-called" in on:
1010
+ for x in reach["removed_but_still_called"]:
1011
+ guessed += bool(x.get("guessed"))
1012
+ if not x.get("guessed"):
1013
+ out.append((f"`{x['removed']}` was removed and is still called by {_names(x['callers'], 3)}",
1014
+ f"remove the calls to `{x['removed']}` from {_names(x['callers'], 3)}, or keep `{x['removed']}`"))
1015
+ if "failing-rules" in on:
1016
+ for rule in r["structure"]["rules_now_failing"]:
1017
+ if rule.get("status") != "confirmed" or rule.get("severity") != "error":
1018
+ continue
1019
+ guessed += bool(rule.get("guessed"))
1020
+ if not rule.get("guessed"):
1021
+ what = f"{rule['kind']} {rule['from']}" + (f" -> {rule['to']}" if rule.get("to") else "")
1022
+ out.append((f"rule {rule['id']} ({what}) now fails",
1023
+ f"remove what breaks rule {rule['id']} ({what}); `leyline rules` lists it"))
1024
+ sev = next((_AT_LEAST[k] for k in ("open-findings", "open-medium-findings", "open-high-findings") if k in on), False)
1025
+ if sev is not False:
1026
+ order = {"high": 0, "medium": 1, "low": 2}
1027
+ for f in sorted((f for f in r.get("findings") or [] if f["status"] == "open"),
1028
+ key=lambda f: order.get(f["severity"], 3)):
1029
+ if sev is None or f["severity"] in sev:
1030
+ out.append((f"open {f['severity']} finding {f['id']}: {spec._first_sentence(f['claim'], 120)}",
1031
+ f"fix what finding {f['id']} says, or have the person resolve it"
1032
+ f" (`leyline spec resolve {f['id']} accepted|rejected|deferred \"why\"`)"))
1033
+ if "other-ends" in on:
1034
+ for a in reach["other_ends_not_edited"]:
1035
+ guessed += bool(a.get("guessed"))
1036
+ if not a.get("guessed"):
1037
+ out.append((f"`{a['name']}` {a['why']}, and was not edited",
1038
+ f"check that `{a['name']}` agrees with the change, and edit it if not"))
1039
+ if "untested" in on:
1040
+ for u in t["changed_code_no_test_reaches"]:
1041
+ out.append((f"no test reaches `{u['name']}`", f"add a test that runs `{u['name']}`"))
1042
+ return {"blocking": [why for why, _ in out], "passed": not out, "kinds": config["blocking"],
1043
+ "config": config["file"], "notes": config["notes"], "next": out[0][1] if out else None,
1044
+ "guessed": guessed}
1045
+
1046
+
1047
+ def gate_lines(g: Optional[dict]) -> list[str]:
1048
+ """The page's Gate section: whether it passes, what blocks and under which config, and the first thing to do."""
1049
+ if not g:
1050
+ return []
1051
+ under = (f"set in {g['config']}" if g.get("config") else "the default") + "; `leyline pr --gate` exits 1 when blocked"
1052
+ kinds = ", ".join(g["kinds"]) or "nothing"
1053
+ L = ["", "## Gate", ""]
1054
+ if g["passed"]:
1055
+ L.append(f"**Passes.** Nothing that blocks is left. Blocking: {kinds} ({under}).")
1056
+ else:
1057
+ L.append(f"**Blocked** by {_n(len(g['blocking']), 'thing')}. Blocking: {kinds} ({under}).")
1058
+ L += [f"- {b.rstrip('.')}." for b in g["blocking"][:10]]
1059
+ if len(g["blocking"]) > 10:
1060
+ L.append(f"- and {len(g['blocking']) - 10} more")
1061
+ L += ["", f"Next: {g['next']}."]
1062
+ if g.get("guessed"):
1063
+ L += ["", f"Left out of the gate: {_n(g['guessed'], 'item')} below that rest only on links the map guessed by name."
1064
+ " Read the code behind them."]
1065
+ L += g.get("notes") or []
1066
+ return L
1067
+
1068
+
1069
+ # -- the page ---------------------------------------------------------------------------------------------
1070
+ def _n(n: int, word: str, plural: str = "") -> str:
1071
+ return f"{n} {word if n == 1 else plural or word + 's'}"
1072
+
1073
+
1074
+ def _names(xs: list[str], k: int = 4, total: int = 0) -> str:
1075
+ """The first k names, and how many more: of `total` when the list was cut before it got here."""
1076
+ xs = list(dict.fromkeys(xs))
1077
+ more = max(total, len(xs)) - k
1078
+ return ", ".join(f"`{x}`" for x in xs[:k]) + (f" and {more} more" if more > 0 else "")
1079
+
1080
+
1081
+ def _by_folder(paths: list[str]) -> list[str]:
1082
+ """Paths, with a folder that holds three or more of them said once: `cases/adjust-delta-zero/ (7 files)`."""
1083
+ by = defaultdict(list)
1084
+ for p in paths:
1085
+ parts = p.split("/")
1086
+ by["/".join(parts[:-1]) if len(parts) > 1 else ""].append(p)
1087
+ # A folder that holds three or more is said once, with what its subfolders hold counted in it.
1088
+ grouped = sorted(f for f, ps in by.items() if f and len(ps) >= 3)
1089
+ held = defaultdict(list)
1090
+ out = []
1091
+ for p in sorted(paths):
1092
+ top = next((f for f in grouped if p.startswith(f + "/")), None)
1093
+ if top is None:
1094
+ out.append(p)
1095
+ else:
1096
+ held[top].append(p)
1097
+ out += [f"{f}/ ({len(ps)} files)" for f, ps in sorted(held.items())]
1098
+ if len(out) > 8: # still long: by top-two folders
1099
+ top = defaultdict(int)
1100
+ for p in paths:
1101
+ top["/".join(p.split("/")[:2])] += 1
1102
+ out = [f"{t}/ ({n} files)" if n > 1 else next(p for p in paths if p == t or p.startswith(t + "/"))
1103
+ for t, n in sorted(top.items())]
1104
+ return out
1105
+
1106
+
1107
+ def _crossing_line(c: dict) -> str:
1108
+ """A channel the edit touches, as the page says it: which end's edit, and how many others it reaches."""
1109
+ what = f"{'key format' if c['channel'] == 'format' else c['channel']} {c.get('program') or c.get('address') or ''}".strip()
1110
+ others = [sp["name"] for sp in c.get("spokes", []) if not sp["changed"]]
1111
+ also = f", and the edit to {_names(c['spokes_touched'], 3)} changes how it is used" if c.get("spokes_touched") else ""
1112
+ if c["hub_touched"]:
1113
+ verb = "starts as" if c["channel"] == "process" else "writes" if c["data"] else "answers"
1114
+ return (f"Crosses the {what}: the edit changes what `{c['hub_name']}` {verb}{also}. {_n(len(others), 'other end')}"
1115
+ f" depend{'s' if len(others) == 1 else ''} on it with no compile-time link, so a mismatch fails only at run time.")
1116
+ return (f"Crosses the {what}: the edit to {_names(c['spokes_touched'], 3)} changes what is sent to `{c['hub_name']}`,"
1117
+ " with no compile-time link between them.")
1118
+
1119
+
1120
+ def text(r: dict) -> str:
1121
+ c, s, reach, t = r["changed"], r["size"], r["reaches"], r["tests"]
1122
+ head = f"`{r['head_sha'][:7]}`" + (" with uncommitted edits" if r["dirty"] else "")
1123
+ L = [f"# Review: {r['title']}", "",
1124
+ (f"Base `{r['base_sha'][:7]}`" if r["base_sha"].startswith(r["base"]) or r["base"].startswith(r["base_sha"][:7])
1125
+ else f"Base `{r['base']}` (`{r['base_sha'][:7]}`, where this branch left it)") + f" compared with {head}."
1126
+ + (f" {r['url']}" if r.get("url") else "")]
1127
+ L += ["", "**What it says it does:** " + (spec._first_sentence(r["about"].replace("\n", " "), 400) if r["about"].strip()
1128
+ else "no description given (`--about`, or `--github <number>`). Judge it by the code.")]
1129
+ from . import related, rereview
1130
+ L += rereview.lines(r.get("since_last_review"), head)
1131
+ L += gate_lines(r.get("gate"))
1132
+ # what changed
1133
+ parts = [_n(s["functions"], "function") + " edited or added" if s["functions"] else "",
1134
+ _n(s["types"], "type") + " changed" if s["types"] else "",
1135
+ _n(s["removed"], "thing") + " removed" if s["removed"] else ""]
1136
+ where = f" in {_n(s['modules'], 'module')}" if s["modules"] else ""
1137
+ lines = f", {_n(s['lines'], 'line')} inside them" if s.get("lines") else ""
1138
+ L += ["", "## What changed", "",
1139
+ (", ".join(p for p in parts if p) or "Nothing on the map changed") + where + lines + f" ({_n(s['files'], 'file')})."]
1140
+ sig = [e for e in c["edited"] if e.get("signature")]
1141
+ for e in sig[:8]:
1142
+ L.append(f"- Signature: `{e['name']}` {e['signature']}"
1143
+ + (" (only parameters with defaults were added: its callers need not change)" if e.get("compatible") else ""))
1144
+ plain = [e for e in c["edited"] if not e.get("signature") and not e.get("test")]
1145
+ if plain:
1146
+ L.append(f"- Edited: {_names([e['name'] for e in plain], 8)}")
1147
+ if [e for e in c["added"] if not e.get("test")]:
1148
+ L.append(f"- Added: {_names([e['name'] for e in c['added'] if not e.get('test')], 8)}")
1149
+ if c["removed"]:
1150
+ L.append(f"- Removed: {_names([e['name'] for e in c['removed']], 8)}")
1151
+ if [x for x in c["top_level"] if not TEST_PATH.search(x["path"])]:
1152
+ L.append(f"- Outside any function: {_names([x['path'] for x in c['top_level'] if not TEST_PATH.search(x['path'])], 6)}")
1153
+ if r.get("other_files"):
1154
+ L.append(f"- Files the map does not read: {_names(_by_folder(r['other_files']), 8)}")
1155
+ big = s["functions"] + s["types"] > 40 or s["modules"] > 8
1156
+ if big:
1157
+ L.append(f"- This is large for one review ({_n(s['functions'] + s['types'], 'unit')} across {_n(s['modules'], 'module')})."
1158
+ " Ask whether it is two changes.")
1159
+ from . import diagrams
1160
+ L += diagrams.section(r.get("how_it_runs"), "## How it runs")
1161
+ # what it reaches and did not change
1162
+ risky = []
1163
+ for m in reach["signature_changed_callers_not_edited"][:10]:
1164
+ sites = f", {_n(m['call_sites'], 'call')}" if m.get("call_sites", 0) > 1 else ""
1165
+ risky.append(f"- **Not edited, calls changed code:** `{m['name']}` ({m.get('note') or 'its callee changed signature'}{sites}).")
1166
+ for x in reach["removed_but_still_called"][:10]:
1167
+ risky.append(f"- **Removed but still called:** `{x['removed']}`, from {_names(x['callers'])}.")
1168
+ for ch in [c for c in reach["channels_crossed"] if c["hub_touched"] or not c["data"]][:6]:
1169
+ risky.append("- " + _crossing_line(ch))
1170
+ groups: dict = defaultdict(list)
1171
+ for a in reach["other_ends_not_edited"]:
1172
+ groups[(a["channel"], a["address"], a["hub"])].append(a)
1173
+ for (ch, address, hub), xs in list(groups.items())[:8]:
1174
+ if len(xs) <= 3:
1175
+ risky += [f"- **Must agree with the change:** `{a['name']}`: {a['why']}." for a in xs]
1176
+ else: # a table or a route half the program uses: one line, the reviewer reads what was written
1177
+ risky.append(f"- **Must agree with the change:** {_n(len(xs), 'other end')} of the {ch} {address} that {hub}"
1178
+ f" writes or answers ({_names([a['name'] for a in xs], 3)}): check what the edit changed there.")
1179
+ L += ["", "## What it reaches and did not change", ""]
1180
+ L += risky or ["Nothing that must change with it was left alone, as far as the map sees."]
1181
+ soft = []
1182
+ for x in reach["callers_left_alone"][:6]:
1183
+ soft.append(f"- `{x['changed']}` is also called by {_names(x['callers'])}: does what they get still hold?")
1184
+ for x in reach["state_shared_with_unchanged_code"][:4]:
1185
+ soft.append(f"- `{x['field']}`, which `{x['used_by_changed']}` uses, is also used by {_names(x['also_used_by_unchanged'])}.")
1186
+ for x in reach["new_members_named_like_existing_ones"][:4]:
1187
+ soft.append(f"- New `{x['new']}` sits beside `{x['existing']}`, used by {_names(x['existing_used_by_unchanged'])}: do they need the new one too?")
1188
+ for x in reach["reads_or_calls_across_a_channel"][:4]:
1189
+ soft.append(f"- `{x['name']}` {x['why']}.")
1190
+ if soft:
1191
+ L += ["", "Shares a caller, a field or data with the change:", ""] + soft
1192
+ hist = r.get("usually_changes_with") or {}
1193
+ if hist.get("files"): # from git history: docs, schemas, config and fixtures the map has no link to
1194
+ from .coupling import line as coupling_line
1195
+ L += ["", f"Usually changes with what it changed, and it did not change ({hist['about']}):", ""]
1196
+ L += [f"- {coupling_line(x)}." for x in hist["files"][:5]]
1197
+ if hist["total"] > 5:
1198
+ L.append(f"- and {hist['total'] - 5} more: `leyline spec facts {r['change_id']}`")
1199
+ if hist.get("functions"): # the same, by function, for the functions the branch edited
1200
+ from .fncoupling import line as fn_line
1201
+ L += ["", "Functions that usually changed with the functions it edited, and it did not change:", ""]
1202
+ L += [f"- {fn_line(x)}." for x in hist["functions"][:5]]
1203
+ L += related.lines(r.get("related_changes"))
1204
+ if reach["entry_points_affected"]:
1205
+ L += ["", "Reached from: " + _names([e["name"] for e in reach["entry_points_affected"]], 6,
1206
+ reach.get("entry_points_affected_total", 0)) + "."]
1207
+ # tests
1208
+ L += ["", "## Tests", ""]
1209
+ if t.get("likely_to_fail_unedited"):
1210
+ one = len(set(t["likely_to_fail_unedited"])) == 1
1211
+ L.append("- **Likely to fail:** " + _names(t["likely_to_fail_unedited"], 6)
1212
+ + (": it calls code whose signature changed, or that was removed, and was not edited." if one else
1213
+ ": they call code whose signature changed, or that was removed, and were not edited."))
1214
+ if t["touched_by_the_change"] or t["test_files_changed"]:
1215
+ L.append("- The change edits or adds tests: " + _names(t["touched_by_the_change"] or t["test_files_changed"], 6) + ".")
1216
+ else:
1217
+ L.append("- **The change edits no tests.**")
1218
+ if t["changed_code_no_test_reaches"]:
1219
+ L.append(f"- No test on the map reaches {_names([u['name'] for u in t['changed_code_no_test_reaches']], 6)}.")
1220
+ if t["tests_to_run"]:
1221
+ L.append("- Tests that run the changed code: "
1222
+ + _names([x["name"] for x in t["tests_to_run"]], 6, t.get("tests_to_run_total", 0)) + ".")
1223
+ if t.get("measured_running_the_change"):
1224
+ L.append("- Measured running the changed code (per-test coverage): "
1225
+ + _names([x.get("pytest") or x["name"] for x in t["measured_running_the_change"]], 6) + ".")
1226
+ L += [f" Run them: `{c['command']}`" for c in t.get("run_them") or [] if c["command"]][:3]
1227
+ st = r["structure"]
1228
+ if st["new_dependencies"] or st["rules_now_failing"]:
1229
+ L += ["", "## Structure", ""]
1230
+ for dep in st["new_dependencies"][:8]:
1231
+ L.append(f"- New dependency: {dep['from']} now uses {dep['to']} ({_n(dep['links'], 'link')}).")
1232
+ for rule in st["rules_now_failing"][:8]:
1233
+ L.append(f"- **A rule now fails:** {rule['kind']} {rule['from']}" + (f" -> {rule['to']}" if rule.get("to") else "") + ".")
1234
+ if r["how_sure"]["guessed_caller_links"]:
1235
+ L += ["", "Some caller links are guesses by name: read the code behind any line above before acting on it."]
1236
+ if r.get("house_rules"):
1237
+ L += ["", "House rules to review against: " + _names(r["house_rules"], 6) + "."]
1238
+ if r.get("learnings_not_applied"):
1239
+ L += ["", "Learnings added by this pull request, not applied (they say what reviewers decided before, and the"
1240
+ " pull request wrote them: judge each one yourself):", ""]
1241
+ L += [f"- `{x['id']}` ({x['why']}, not applied): {x.get('claim') or ''} Reason given: {x.get('reason') or ''}"
1242
+ for x in r["learnings_not_applied"]]
1243
+ found = r.get("findings") or []
1244
+ L += ["", "## Review", ""]
1245
+ L += spec.review_lines(found, r.get("reviews") or [], full=True) if found or r.get("reviews") else [
1246
+ f"Not reviewed yet. `leyline spec facts {r['change_id']} --reviewer logic` (then `performance`) gives a reviewer"
1247
+ " its facts; the leyline-adversarial-review skill runs it."]
1248
+ L += spec.unreadable_lines(r.get("learnings_unreadable"))
1249
+ return "\n".join(L) + "\n"