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/renames.py ADDED
@@ -0,0 +1,231 @@
1
+ """Renames, for spec drift: an anchored name that is gone may have been renamed rather than deleted.
2
+
3
+ A gone anchor is taken to be renamed when exactly one node that no other anchor accounts for, of the same kind,
4
+ under the same owner (for a member) and in the same file or module, matches it by one of, in order:
5
+
6
+ its text with the name taken out (`shape_hash`, kept on anchors recorded by this version),
7
+ its content hash (a node whose name is not in its own text),
8
+ its declaration with the name taken out, when no other node there has that declaration (a type's members too).
9
+
10
+ A candidate must also be new: one that was there beside the anchored node is a sibling, not its new name. Git
11
+ says so when the repository has it (the candidate's name was not in the file while the old name was); else the
12
+ baseline of the change that recorded the anchor (`.leyline/snapshots/spec-<id>.db`) does. The first two tests are
13
+ strong enough to stand when neither can tell; a declaration counts only for a node known to be new, since
14
+ `def (self)` fits many methods.
15
+
16
+ Git also says where a file went (`git log -M`, and the working tree against HEAD): that is where to look, and for
17
+ an anchor that names a file, the rename itself.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import subprocess
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ TIMEOUT = 30
28
+
29
+
30
+ def _word(name: str) -> re.Pattern:
31
+ return re.compile(r"(?<![\w$])" + re.escape(name) + r"(?![\w$])")
32
+
33
+
34
+ def _git(root: Path, *args: str) -> Optional[str]:
35
+ try:
36
+ p = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=TIMEOUT)
37
+ except (OSError, subprocess.TimeoutExpired):
38
+ return None
39
+ return p.stdout if p.returncode == 0 else None
40
+
41
+
42
+ class Git:
43
+ """What git says about the mapped repositories, asked once each."""
44
+
45
+ def __init__(self, roots: list[Path]):
46
+ self.repos = []
47
+ for root in roots:
48
+ prefix = _git(root, "rev-parse", "--show-prefix")
49
+ if prefix is not None:
50
+ self.repos.append((root, prefix.strip()))
51
+ self._renames: Optional[dict] = None
52
+ self._shown: dict = {}
53
+
54
+ def show(self, root: Path, spec: str) -> Optional[str]:
55
+ if (root, spec) not in self._shown:
56
+ self._shown[(root, spec)] = _git(root, "show", spec)
57
+ return self._shown[(root, spec)]
58
+
59
+ def renames(self) -> dict[str, str]:
60
+ """old path -> the path it has now, from renames in history and in the working tree, followed in order.
61
+ Paths are relative to the mapped directory, as the map writes them."""
62
+ if self._renames is not None:
63
+ return self._renames
64
+ out: dict[str, str] = {}
65
+ for root, prefix in self.repos:
66
+ pairs = []
67
+ for text in (_git(root, "log", "-M", "--diff-filter=R", "--name-status", "--format=", "--reverse",
68
+ "--max-count=2000", "HEAD"),
69
+ _git(root, "diff", "-M", "--diff-filter=R", "--name-status", "HEAD")):
70
+ for line in (text or "").splitlines():
71
+ bits = line.split("\t")
72
+ if len(bits) == 3 and bits[0].startswith("R") and bits[1].startswith(prefix) and bits[2].startswith(prefix):
73
+ pairs.append((bits[1][len(prefix):], bits[2][len(prefix):]))
74
+ for old, new in pairs:
75
+ for k, v in list(out.items()):
76
+ if v == old:
77
+ out[k] = new
78
+ out[old] = new
79
+ self._renames = {k: v for k, v in out.items() if k != v}
80
+ return self._renames
81
+
82
+ def was_new(self, path: str, old: str, new: str) -> Optional[bool]:
83
+ """Whether `new` came into the file while `old` was in it. True when HEAD does not have it yet (the rename
84
+ is not committed); else read from the parent of the commit that first wrote it, following the file across
85
+ renames: the old name there and the new one not. None when git cannot tell."""
86
+ back = {v: k for k, v in self.renames().items()}
87
+ for root, prefix in self.repos:
88
+ head = self.show(root, f"HEAD:{prefix}{path}")
89
+ if head is None and path in back:
90
+ head = self.show(root, f"HEAD:{prefix}{back[path]}")
91
+ if head is None:
92
+ continue
93
+ if not _word(new).search(head):
94
+ return True
95
+ # Each commit opens with a NUL, which no path has (a scoped package's path has `@`).
96
+ log = _git(root, "log", "--follow", "-M", "-S", new, "--format=%x00%H", "--name-status", "HEAD", "--",
97
+ f"{prefix}{path}") or ""
98
+ blocks = [b.strip().splitlines() for b in log.split("\0") if b.strip()]
99
+ if not blocks:
100
+ return None
101
+ first = blocks[-1] # the oldest commit that changed how often it is written
102
+ status = next((ln.split("\t") for ln in first[1:] if "\t" in ln), None)
103
+ if status is None:
104
+ return None
105
+ was = status[1] if status[0].startswith("R") and len(status) == 3 else status[-1]
106
+ before = self.show(root, f"{first[0]}^:{was}")
107
+ if before is None:
108
+ return None
109
+ return bool(_word(old).search(before)) and not _word(new).search(before)
110
+ return None
111
+
112
+
113
+ def _minus_name(text: Optional[str], name: str) -> str:
114
+ return " ".join(_word(name).sub("", text or "", count=1).split()) if text else ""
115
+
116
+
117
+ CTORS = ("__init__", "__new__", "constructor", ".ctor", ".cctor", "new", "init", "setUp", "tearDown")
118
+
119
+
120
+ def _baseline_has(m, key: Optional[str], node_id: str) -> Optional[bool]:
121
+ """Whether the baseline of the change that recorded the anchor (the code before that change) holds a node. None
122
+ when there is no such baseline (a living spec's anchors, or a change that was forgotten)."""
123
+ if not key or key.startswith("specs/"):
124
+ return None
125
+ snaps = m._snaps # closed with the map (drift._Map.close)
126
+ if key not in snaps:
127
+ from . import diff
128
+ p = diff.snapshot_path(m.con, "spec-" + key)
129
+ snaps[key] = diff._open(p) if p.exists() else None
130
+ db = snaps[key]
131
+ if db is None:
132
+ return None
133
+ try:
134
+ return db.execute("SELECT 1 FROM nodes WHERE id = ?", (node_id,)).fetchone() is not None
135
+ except Exception:
136
+ return None
137
+
138
+
139
+ def _is_new(m, key: Optional[str], a: dict, r) -> Optional[bool]:
140
+ """Whether a candidate is new since the anchor was right: git first, else the recording change's baseline (a node
141
+ there existed before that change, so after it too, beside the anchored one). None when neither can tell."""
142
+ git = m.git()
143
+ if git and a.get("name"):
144
+ v = git.was_new(r["path"], a["name"], r["name"])
145
+ if v is not None:
146
+ return v
147
+ had = _baseline_has(m, key, r["id"])
148
+ return None if had is None else not had
149
+
150
+
151
+ def find(m, a: dict, key: Optional[str] = None) -> Optional[dict]:
152
+ """What a gone anchor was most likely renamed to: {"id", "to", "to_path", "how"}, or None. `key` is the change
153
+ (or specs/<capability>) the anchor belongs to."""
154
+ from . import drift
155
+ names = m.names
156
+ git = m.git()
157
+ moved = git.renames().get(a.get("path") or "") if git else None
158
+ if a["kind"] in ("file", "module"):
159
+ if moved and names.file(moved):
160
+ f = names.file(moved)[0]
161
+ return {"id": f["id"], "to": moved, "to_path": moved, "how": "git records the file as renamed"}
162
+ return None
163
+ paths = {p for p in (a.get("path"), moved) if p}
164
+ files = [r for p in paths for r in names.file(p) if r["kind"] == "file" and r["path"] == p]
165
+ modules = {names.module.get(f["id"]) for f in files} - {None}
166
+ if not modules: # the file is gone: the module is the folder it was in
167
+ folder = str(Path(a.get("path") or "").parent).replace("\\", "/")
168
+ modules = {r["id"] for r in names.rows if r["kind"] == "module" and (r["path"] or ".") == folder}
169
+ label = a.get("label") or ""
170
+ owner = label.rsplit(".", 1)[0] if "." in label and a["kind"] in ("callable", "field", "test") and label != a.get("path") else None
171
+
172
+ def placed(r) -> bool:
173
+ parent = names.by_id.get(r["parent_id"])
174
+ if owner is not None:
175
+ if parent is None or parent["kind"] != "type" or parent["name"] != owner.rsplit(".", 1)[-1]:
176
+ return False
177
+ elif a["kind"] in ("callable", "field") and parent is not None and parent["kind"] == "type":
178
+ return False # a top-level function is not renamed into a method
179
+ return r["path"] in paths or names.module.get(r["id"]) in modules
180
+
181
+ region = [r for r in names.rows if r["kind"] == a["kind"] and r["name"] != a.get("name") and r["name"] not in CTORS
182
+ and placed(r)]
183
+ if not region:
184
+ return None
185
+ fps = {r["id"]: drift.fingerprint(m.con, names, r["id"]) for r in region}
186
+ fresh = [r for r in region if r["id"] not in m.claimed and fps[r["id"]]]
187
+
188
+ def new(rs, sure=False): # leave out a candidate that was there before; `sure`: keep only one known to be new
189
+ out = []
190
+ for r in rs:
191
+ v = _is_new(m, key, a, r)
192
+ if v is True or (v is None and not sure):
193
+ out.append(r)
194
+ return out
195
+ tiers = [("has the same body", lambda fp: a.get("shape_hash") and fp.get("shape_hash") == a["shape_hash"]),
196
+ ("has the same body", lambda fp: a.get("body_hash") and fp["body_hash"] == a["body_hash"])]
197
+ for how, same in tiers:
198
+ hits = new([r for r in fresh if same(fps[r["id"]])])
199
+ if len(hits) == 1:
200
+ return _found(m, hits[0], how)
201
+ if len(hits) > 1:
202
+ return None
203
+ shape = _minus_name(a.get("decl"), a.get("name") or "")
204
+ if not shape or (a["kind"] == "type" and not a.get("members")):
205
+ return None
206
+
207
+ def same_decl(r) -> bool:
208
+ fp = fps[r["id"]]
209
+ if not fp or _minus_name(fp.get("decl"), r["name"]) != shape:
210
+ return False
211
+ return a["kind"] != "type" or sorted(fp.get("members") or []) == sorted(a.get("members") or [])
212
+ every = [r for r in region if same_decl(r)] # the declaration must say which one: no other node there has it
213
+ # A declaration is weak evidence (`def (self)` fits many methods): it counts only for a node known to be new.
214
+ if len(every) == 1 and every[0] in fresh and new(every, sure=True):
215
+ return _found(m, every[0], "has the same declaration")
216
+ return None
217
+
218
+
219
+ def _found(m, r, how: str) -> dict:
220
+ return {"id": r["id"], "to": m.label(r["id"]), "to_path": r["path"], "how": how}
221
+
222
+
223
+ def for_file(m, written: str) -> Optional[dict]:
224
+ """A file path a spec names that is gone: where git says it went, when that file is on the map or on disk."""
225
+ git = m.git()
226
+ if not git:
227
+ return None
228
+ moved = git.renames().get(written.strip().lstrip("./"))
229
+ if moved and (m.names.file(moved) or m.names.on_disk(moved)):
230
+ return {"id": None, "to": moved, "to_path": moved, "how": "git records the file as renamed"}
231
+ return None
leyline/rereview.py ADDED
@@ -0,0 +1,375 @@
1
+ """Reviewing a pull request again: what changed since the last review, and which findings it may have fixed.
2
+
3
+ A pull request is reviewed more than once. After the first review its author pushes commits, and a second review
4
+ that starts from scratch files the same findings again, beside the ones already open: long threads of repeated
5
+ comments are the usual result. So each run of `leyline pr` is kept as a record (the head commit, when, a short form
6
+ of the page's facts) with a slim snapshot of the code at that head (see diff.snapshot), and the next run says, on
7
+ the page and in the reviewers' facts:
8
+
9
+ - what changed between the two heads (functions edited, added and removed since the last review, not since the
10
+ base), by comparing the snapshot with the store as it is now (diff.compare);
11
+ - which facts are new since then (a caller newly broken) and which are gone (fixed, or no longer true);
12
+ - for each open finding, whether the code its evidence names changed since it was filed: "may be fixed" asks the
13
+ reviewer to re-check it, "still applies" asks them not to file it again.
14
+
15
+ A run is a new record only when the code changed: running `leyline pr` again on the same code updates the last
16
+ record, so the page says the same thing however often it is made. Snapshots of the last few runs are kept, and of
17
+ any run an open finding was filed against; the rest are deleted, their records kept.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime
23
+ import json
24
+ import sqlite3
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ from . import diff
29
+
30
+ KEEP = 3 # snapshots kept per review, besides those of runs an open finding was filed against
31
+
32
+ _TABLE = """CREATE TABLE IF NOT EXISTS review_runs (
33
+ change_id TEXT NOT NULL, seq INTEGER NOT NULL, head_sha TEXT, dirty INTEGER, fingerprint TEXT, created TEXT,
34
+ seen TEXT, snapshot TEXT, facts TEXT, PRIMARY KEY (change_id, seq))"""
35
+
36
+
37
+ def ensure(con) -> None:
38
+ with con:
39
+ con.execute(_TABLE)
40
+
41
+
42
+ def now() -> str:
43
+ return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
44
+
45
+
46
+ def when(iso: Optional[str]) -> str:
47
+ """2026-10-07 14:03 UTC"""
48
+ try:
49
+ t = datetime.datetime.fromisoformat(iso or "")
50
+ except ValueError:
51
+ return iso or "?"
52
+ if t.tzinfo is not None:
53
+ t = t.astimezone(datetime.timezone.utc)
54
+ return t.strftime("%Y-%m-%d %H:%M UTC")
55
+
56
+
57
+ def runs(con, cid: str) -> list[dict]:
58
+ ensure(con)
59
+ out = []
60
+ for r in con.execute("SELECT * FROM review_runs WHERE change_id = ? ORDER BY seq", (cid,)):
61
+ out.append({k: r[k] for k in r.keys()} | {"facts": json.loads(r["facts"] or "[]")})
62
+ return out
63
+
64
+
65
+ def last_reviewed(con, cid: str) -> Optional[str]:
66
+ """When the review was last run, for the list of earlier changes."""
67
+ rs = runs(con, cid)
68
+ return rs[-1]["seen"] or rs[-1]["created"] if rs else None
69
+
70
+
71
+ # -- the facts, in short ----------------------------------------------------------------------------------
72
+ def summarize(f: dict) -> list[dict]:
73
+ """The facts on a pull request's page that say something may be wrong, each with a key that stays the same from
74
+ one run to the next, one plain line, and the nodes it is about (to match a finding's evidence)."""
75
+ reach, tests, st = f.get("reaches") or {}, f.get("tests") or {}, f.get("structure") or {}
76
+ out = []
77
+
78
+ def add(key, line, ids=(), **extra):
79
+ out.append({"key": "|".join(str(k) for k in key), "line": line, "ids": [i for i in ids if i], **extra})
80
+ for m in reach.get("signature_changed_callers_not_edited") or []:
81
+ add(("caller", m["id"]), f"`{m['name']}` calls code whose signature changed, and was not edited", [m["id"]])
82
+ for x in reach.get("removed_but_still_called") or []:
83
+ add(("removed", x["id"]), f"`{x['removed']}` was removed and is still called by {_names(x['callers'])}",
84
+ [x["id"], *x.get("caller_ids", [])])
85
+ for a in reach.get("other_ends_not_edited") or []:
86
+ add(("agree", a["id"], a["channel"], a.get("address") or ""), f"`{a['name']}` must agree with the change: {a['why']}",
87
+ [a["id"]], group=f"{a['channel']} {a.get('address') or ''}".strip(), name=a["name"])
88
+ for a in reach.get("reads_or_calls_across_a_channel") or []:
89
+ add(("across", a["id"], a["channel"], a.get("address") or ""), f"`{a['name']}` {a['why']}", [a["id"]])
90
+ for c in reach.get("channels_crossed") or []:
91
+ what = f"{c['channel']} {c.get('program') or c.get('address') or ''}".strip()
92
+ add(("channel", c.get("hub"), c["channel"], c.get("address") or ""), f"The edit touches the {what}", [c.get("hub")])
93
+ for r in st.get("rules_now_failing") or []:
94
+ add(("rule", r.get("id")), f"A rule now fails: {r['kind']} {r['from']}" + (f" -> {r['to']}" if r.get("to") else ""))
95
+ for d in st.get("new_dependencies") or []:
96
+ add(("dep", d.get("from_id") or d["from"], d.get("to_id") or d["to"]), f"New dependency: {d['from']} now uses {d['to']}")
97
+ for t in tests.get("likely_to_fail_unedited") or []:
98
+ add(("fails", t), f"Test `{t}` is likely to fail")
99
+ for u in tests.get("changed_code_no_test_reaches") or []:
100
+ add(("untested", u.get("id") or u["name"]), f"No test on the map reaches `{u['name']}`", [u.get("id")])
101
+ seen, kept = set(), []
102
+ for x in out:
103
+ if x["key"] not in seen:
104
+ seen.add(x["key"])
105
+ kept.append(x)
106
+ return kept
107
+
108
+
109
+ def _names(xs: list[str], k: int = 4) -> str:
110
+ xs = list(dict.fromkeys(xs))
111
+ return ", ".join(f"`{x}`" for x in xs[:k]) + (f" and {len(xs) - k} more" if len(xs) > k else "")
112
+
113
+
114
+ # -- comparing with the last review -----------------------------------------------------------------------
115
+ def _snap(con, name: Optional[str]) -> Optional[Path]:
116
+ if not name:
117
+ return None
118
+ p = diff.snapshot_path(con, name)
119
+ return p if p.exists() else None
120
+
121
+
122
+ def _hashes(con, ids: list[str]) -> dict:
123
+ out = {}
124
+ for i in ids:
125
+ r = con.execute("SELECT content_hash FROM nodes WHERE id = ?", (i,)).fetchone()
126
+ out[i] = r[0] if r else None
127
+ return out
128
+
129
+
130
+ def _label(con, i: str) -> str:
131
+ r = con.execute("SELECT n.name, p.name, p.kind FROM nodes n LEFT JOIN nodes p ON p.id = n.parent_id WHERE n.id = ?",
132
+ (i,)).fetchone()
133
+ if r is None:
134
+ return i.rsplit(":", 1)[-1]
135
+ return f"{r[1]}.{r[0]}" if r[2] == "type" else r[0]
136
+
137
+
138
+ def since(con, cid: str, facts_now: list[dict], fingerprint: Optional[str] = None) -> Optional[dict]:
139
+ """What changed since the last review of this pull request whose code differs from the code now. None on a
140
+ first review."""
141
+ fp = fingerprint or diff._fingerprint(con)
142
+ rs = runs(con, cid)
143
+ prev = next((r for r in reversed(rs) if r["fingerprint"] != fp), None)
144
+ if prev is None:
145
+ return None
146
+ out: dict = {"previous": {"head_sha": prev["head_sha"], "dirty": bool(prev["dirty"]), "when": prev["created"]}}
147
+ snap = _snap(con, prev["snapshot"])
148
+ d = None
149
+ if snap is not None:
150
+ try:
151
+ before = diff._open(snap)
152
+ try:
153
+ d = diff.compare(before, con)
154
+ # A function whose text changed only inside one nested in it (a registrar around an edited route
155
+ # handler) is not an edit of its own, as on the pull request page.
156
+ ids = [n["id"] for n in d["nodes"]["edited"] + d["nodes"]["resigned"]]
157
+ own = diff.own_changes(before, con, ids)
158
+ if own is not None:
159
+ for key in ("edited", "resigned"):
160
+ d["nodes"][key] = [n for n in d["nodes"][key] if own.get(n["id"])]
161
+ finally:
162
+ before.close()
163
+ except sqlite3.Error:
164
+ d = None
165
+ if d is None:
166
+ out["code"] = None
167
+ out["code_note"] = "The map of the code at the last review is no longer kept, so what changed since cannot be listed."
168
+ else:
169
+ nd = d["nodes"]
170
+ pick = lambda xs: [{"id": n["id"], "name": n["name"], "kind": n["kind"], "path": n["path"]} for n in xs
171
+ if n["kind"] in ("callable", "test", "type")]
172
+ outer = lambda xs: [n for n in xs if not any(n["id"].startswith(o["id"] + ".") for o in xs if o is not n)]
173
+ out["code"] = {"edited": pick(nd["edited"] + nd["resigned"]), "types": pick(nd["types_edited"]),
174
+ "added": pick(outer(nd["added"])), "removed": pick(outer(nd["removed"]))}
175
+ before_keys = {x["key"]: x for x in prev["facts"]}
176
+ now_keys = {x["key"]: x for x in facts_now}
177
+ out["new_facts"] = [x for k, x in now_keys.items() if k not in before_keys]
178
+ out["gone_facts"] = [x for k, x in before_keys.items() if k not in now_keys]
179
+ out["findings"] = _findings(con, cid, rs, out["gone_facts"])
180
+ return out
181
+
182
+
183
+ def _findings(con, cid: str, rs: list[dict], gone: list[dict]) -> dict:
184
+ """Each open finding: did the code its evidence names change since it was filed? Compared with the snapshot of
185
+ the last run before the finding was filed."""
186
+ out = {"may_be_fixed": [], "still_applies": [], "cannot_tell": []}
187
+ gone_ids = {i for x in gone for i in x["ids"]}
188
+ for f in con.execute("SELECT id, reviewer, severity, claim, evidence, created FROM findings WHERE change_id = ?"
189
+ " AND status = 'open' ORDER BY created", (cid,)).fetchall():
190
+ ev = json.loads(f["evidence"] or "[]")
191
+ item = {"id": f["id"], "reviewer": f["reviewer"], "severity": f["severity"], "claim": f["claim"],
192
+ "evidence": [_label(con, e) for e in ev]}
193
+ filed = [r for r in rs if (r["created"] or "") <= (f["created"] or "")]
194
+ snap = _snap(con, filed[-1]["snapshot"]) if filed else None
195
+ if snap is None:
196
+ item["why"] = ("it was filed before the first review Leyline kept" if not filed
197
+ else "the map of the code it was filed against is no longer kept")
198
+ out["cannot_tell"].append(item)
199
+ continue
200
+ item["filed_at"] = filed[-1]["head_sha"]
201
+ try:
202
+ before = diff._open(snap)
203
+ try:
204
+ was = _hashes(before, ev)
205
+ finally:
206
+ before.close()
207
+ except sqlite3.Error:
208
+ item["why"] = "the map of the code it was filed against could not be read"
209
+ out["cannot_tell"].append(item)
210
+ continue
211
+ is_ = _hashes(con, ev)
212
+ changed = [e for e in ev if was.get(e) != is_.get(e)]
213
+ facts_gone = [e for e in ev if e in gone_ids]
214
+ if changed or facts_gone:
215
+ item["changed"] = [_label(con, e) for e in changed]
216
+ item["why"] = (f"its code changed since it was filed ({_names(item['changed'], 3)})" if changed else
217
+ "the map no longer says what it said about its code when it was filed")
218
+ out["may_be_fixed"].append(item)
219
+ else:
220
+ item["why"] = "its code did not change since it was filed"
221
+ out["still_applies"].append(item)
222
+ return out
223
+
224
+
225
+ def mark(found: list[dict], s: Optional[dict]) -> None:
226
+ """Flag, on the findings listed on a page, those whose code changed since they were filed."""
227
+ if not s:
228
+ return
229
+ maybe = {x["id"] for x in s["findings"]["may_be_fixed"]}
230
+ for f in found:
231
+ if f["id"] in maybe:
232
+ f["code_changed_since"] = True
233
+
234
+
235
+ # -- keeping a record ---------------------------------------------------------------------------------------
236
+ def record(con, cid: str, head_sha: str, dirty: bool, facts_now: list[dict], fingerprint: Optional[str] = None) -> dict:
237
+ """Keep this run: a new record, with a snapshot of the code, when the code differs from the last run's; else the
238
+ last record, brought up to date."""
239
+ ensure(con)
240
+ fp = fingerprint or diff._fingerprint(con)
241
+ rs = runs(con, cid)
242
+ t = now()
243
+ if rs and rs[-1]["fingerprint"] == fp:
244
+ last = rs[-1]
245
+ snap = last["snapshot"] if _snap(con, last["snapshot"]) else None
246
+ if snap is None:
247
+ snap = _take(con, f"{cid}.head-{last['seq']}")
248
+ with con:
249
+ con.execute("UPDATE review_runs SET head_sha = ?, dirty = ?, seen = ?, facts = ?, snapshot = ? WHERE change_id = ?"
250
+ " AND seq = ?", (head_sha, int(dirty), t, json.dumps(facts_now), snap, cid, last["seq"]))
251
+ return {"seq": last["seq"], "new": False}
252
+ seq = (rs[-1]["seq"] + 1) if rs else 1
253
+ name = _take(con, f"{cid}.head-{seq}")
254
+ with con:
255
+ con.execute("INSERT INTO review_runs VALUES (?,?,?,?,?,?,?,?,?)",
256
+ (cid, seq, head_sha, int(dirty), fp, t, t, name, json.dumps(facts_now)))
257
+ _prune(con, cid)
258
+ return {"seq": seq, "new": True}
259
+
260
+
261
+ def _take(con, name: str) -> Optional[str]:
262
+ """A slim snapshot of the store as it is now, or None when it cannot be written (the record is kept without)."""
263
+ try:
264
+ diff.snapshot(con, name)
265
+ return name
266
+ except (OSError, sqlite3.Error):
267
+ return None
268
+
269
+
270
+ def _prune(con, cid: str) -> None:
271
+ rs = runs(con, cid)
272
+ keep = {r["seq"] for r in rs[-KEEP:]}
273
+ for f in con.execute("SELECT created FROM findings WHERE change_id = ? AND status = 'open'", (cid,)).fetchall():
274
+ filed = [r for r in rs if (r["created"] or "") <= (f[0] or "")]
275
+ if filed:
276
+ keep.add(filed[-1]["seq"])
277
+ for r in rs:
278
+ if r["seq"] not in keep and r["snapshot"]:
279
+ diff.drop_snapshot(con, r["snapshot"])
280
+ with con:
281
+ con.execute("UPDATE review_runs SET snapshot = NULL WHERE change_id = ? AND seq = ?", (cid, r["seq"]))
282
+
283
+
284
+ def forget(con, cid: str) -> int:
285
+ """Delete a review's head snapshots and its records (with `leyline spec forget pr-<id>`)."""
286
+ n = 0
287
+ for r in runs(con, cid):
288
+ if r["snapshot"] and diff.drop_snapshot(con, r["snapshot"]):
289
+ n += 1
290
+ with con:
291
+ con.execute("DELETE FROM review_runs WHERE change_id = ?", (cid,))
292
+ return n
293
+
294
+
295
+ # -- the page -----------------------------------------------------------------------------------------------
296
+ def lines(s: Optional[dict], head: str) -> list[str]:
297
+ """The page's section "Since the last review"; nothing on a first review."""
298
+ if not s:
299
+ return []
300
+ p = s["previous"]
301
+ was = f"`{(p['head_sha'] or '')[:7]}`" + (" with uncommitted edits" if p["dirty"] else "")
302
+ L = ["", f"## Since the last review ({was}, {when(p['when'])})", ""]
303
+ c = s.get("code")
304
+ if c is None:
305
+ L.append(s.get("code_note") or "")
306
+ else:
307
+ parts = []
308
+ fns = [x for x in c["edited"] if x["kind"] != "type"]
309
+ if fns:
310
+ parts.append(f"{len(fns)} function{'s' if len(fns) != 1 else ''} edited ({_names([x['name'] for x in fns], 6)})")
311
+ if c["types"] and not fns:
312
+ parts.append(f"{len(c['types'])} type{'s' if len(c['types']) != 1 else ''} changed ({_names([x['name'] for x in c['types']], 4)})")
313
+ if c["added"]:
314
+ parts.append(f"{len(c['added'])} added ({_names([x['name'] for x in c['added']], 6)})")
315
+ if c["removed"]:
316
+ parts.append(f"{len(c['removed'])} removed ({_names([x['name'] for x in c['removed']], 6)})")
317
+ L.append(f"Between {was} and {head}: " + ("; ".join(parts) if parts else "nothing on the map changed") + ".")
318
+ if s["new_facts"] or s["gone_facts"]:
319
+ L.append("")
320
+ for facts, lead, agree in ((s["new_facts"], "**New:**", "must agree with the change"),
321
+ (s["gone_facts"], "Gone (fixed, or no longer true):", "no longer need to agree with it")):
322
+ said = _grouped(facts, agree)
323
+ L += [f"- {lead} {x}." for x in said[:8]]
324
+ if len(said) > 8:
325
+ L.append(f"- and {len(said) - 8} more {'new' if lead.startswith('**') else 'gone'}.")
326
+ fs = s["findings"]
327
+ if any(fs.values()):
328
+ L += ["", "Open findings:"]
329
+ for x in fs["may_be_fixed"]:
330
+ L.append(f"- {x['id']} ({x['severity']}, {x['reviewer']}): {_said(x['claim'])} **May be fixed:** {x['why']}."
331
+ " Re-check it rather than filing it again.")
332
+ for x in fs["still_applies"]:
333
+ L.append(f"- {x['id']} ({x['severity']}, {x['reviewer']}): {_said(x['claim'])} **Still applies:** {x['why']}."
334
+ " Do not file it again.")
335
+ for x in fs["cannot_tell"]:
336
+ L.append(f"- {x['id']} ({x['severity']}, {x['reviewer']}): {_said(x['claim'])} Cannot tell whether its code"
337
+ f" changed: {x['why']}.")
338
+ return L
339
+
340
+
341
+ def _grouped(facts: list[dict], agree: str) -> list[str]:
342
+ """The facts' lines, with four or more other ends of one channel said once."""
343
+ facts = [x | {"group": " ".join(x["key"].split("|")[2:]).strip(), "name": x["line"].split("`")[1]}
344
+ if x["key"].startswith("agree|") and not x.get("group") and x["line"].count("`") >= 2 else x
345
+ for x in facts] # a run kept before facts carried their group
346
+ groups: dict = {}
347
+ for x in facts:
348
+ if x.get("group"):
349
+ groups.setdefault(x["group"], []).append(x)
350
+ out, done = [], set()
351
+ for x in facts:
352
+ g = groups.get(x.get("group") or "")
353
+ if g and len(g) > 3:
354
+ if x["group"] not in done:
355
+ done.add(x["group"])
356
+ out.append(f"{len(g)} other ends of the {x['group']} {agree} ({_names([y['name'] for y in g], 3)})")
357
+ continue
358
+ out.append(x["line"])
359
+ return out
360
+
361
+
362
+ def _said(text: str) -> str:
363
+ text = " ".join(text.split())
364
+ if len(text) > 160:
365
+ text = text[:160].rsplit(" ", 1)[0] + "..."
366
+ return text if text.endswith((".", "!", "?")) else text + "."
367
+
368
+
369
+ def for_facts(s: Optional[dict]) -> dict:
370
+ """since_last_review, as the reviewers' facts carry it."""
371
+ if not s:
372
+ return {"first_review": True}
373
+ return {**s, "ask": "Start here. Re-check each finding under may_be_fixed: settle it with the person if the code now"
374
+ " does the right thing, and do not file it again. Do not refile findings under still_applies."
375
+ " Review new_facts and the code changed since the last review first."}