leyline-code 0.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/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"
|