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/related.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Earlier changes to the same code: what was decided there is worth reading before changing it again.
|
|
2
|
+
|
|
3
|
+
`leyline plan` and `leyline pr` list up to five earlier changes that touched the code a change touches, newest first,
|
|
4
|
+
each with its id, title, date and the names they share. Two kinds of earlier change are indexed by the nodes they
|
|
5
|
+
touched:
|
|
6
|
+
|
|
7
|
+
- OpenSpec changes that are finished: archived (`openspec/changes/archive/<date>-<id>/`), or checked (`leyline check`
|
|
8
|
+
ran on them). Their nodes come from the store (the tasks' code, `spec_items`; the code their check found edited,
|
|
9
|
+
the review view's marks; the code they planned to change, the plan view's marks), from the anchors `check` keeps
|
|
10
|
+
in `openspec/leyline-anchors.json`, and, for a folder the store knows nothing of, from the code its tasks and
|
|
11
|
+
spec deltas name.
|
|
12
|
+
- Pull requests reviewed with `leyline pr`: the code each one edited and added (its view's marks).
|
|
13
|
+
|
|
14
|
+
A repository with neither falls back on git history, the cheap way: the commits before the change (at most 500, merges
|
|
15
|
+
and bulk commits left out) that changed the files the change touches, the most such files first. A commit's line
|
|
16
|
+
numbers are those of its own time, so placing its hunks in today's functions would mean parsing each file as it
|
|
17
|
+
was; files are what it can say for sure.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from . import store
|
|
29
|
+
|
|
30
|
+
LIMIT = 5
|
|
31
|
+
MAX_COMMITS = 500
|
|
32
|
+
BULK = 50 # a commit that changed more files than this is a bulk change (a formatter, a rename), left out
|
|
33
|
+
CHANGED_ROLES = ("changed", "new", "edited as predicted", "new, as declared", "edited, not predicted")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _near(a: str, b: str) -> bool:
|
|
37
|
+
return a == b or b.startswith((a + ".", a + "(", a + "/")) or a.startswith((b + ".", b + "(", b + "/"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _marks(con, view_id: str) -> list[str]:
|
|
41
|
+
row = con.execute("SELECT spec FROM views WHERE id = ?", (view_id,)).fetchone()
|
|
42
|
+
if not row:
|
|
43
|
+
return []
|
|
44
|
+
try:
|
|
45
|
+
return [m["id"] for m in json.loads(row[0] or "{}").get("marks", []) if m.get("role") in CHANGED_ROLES]
|
|
46
|
+
except (ValueError, TypeError, KeyError):
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _view_date(con, view_id: str) -> str:
|
|
51
|
+
row = con.execute("SELECT created FROM views WHERE id = ?", (view_id,)).fetchone()
|
|
52
|
+
return (row[0] or "")[:10] if row else ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _stored_nodes(con, cid: str) -> set:
|
|
56
|
+
"""What the store says a spec change touched: its tasks' code, and the code its plan and check marked."""
|
|
57
|
+
out = set()
|
|
58
|
+
for r in con.execute("SELECT nodes, attrs FROM spec_items WHERE change_id = ? AND kind = 'task'", (cid,)):
|
|
59
|
+
try:
|
|
60
|
+
out |= set(json.loads(r[0] or "[]"))
|
|
61
|
+
out |= set((json.loads(r[1] or "{}") or {}).get("into") or [])
|
|
62
|
+
except ValueError:
|
|
63
|
+
continue
|
|
64
|
+
out |= set(_marks(con, "view-review-" + cid)) | set(_marks(con, "view-" + cid))
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _full(names, repos: list[str], local: str) -> Optional[str]:
|
|
69
|
+
"""An anchor's node id, written without the repository's id, as an id on this map."""
|
|
70
|
+
if local in names.by_id:
|
|
71
|
+
return local
|
|
72
|
+
return next((f"{r}:{local}" for r in repos if f"{r}:{local}" in names.by_id), None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _named(names, folder: Path) -> set:
|
|
76
|
+
"""The code a change folder's tasks and spec deltas name, resolved on the map as it is now."""
|
|
77
|
+
from . import drift
|
|
78
|
+
out = set()
|
|
79
|
+
try:
|
|
80
|
+
for w in drift._change_names(folder):
|
|
81
|
+
out |= set(names.resolve(w).get("ids") or [])
|
|
82
|
+
except (OSError, ValueError):
|
|
83
|
+
pass
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _verdict(folder: Path) -> Optional[str]:
|
|
88
|
+
"""The date `leyline check` last found this folder's change done, from its page, or None."""
|
|
89
|
+
page = folder / "leyline.md"
|
|
90
|
+
try:
|
|
91
|
+
text = page.read_text(encoding="utf-8", errors="replace")
|
|
92
|
+
verdict = text.split("## 4. Was it done as agreed", 1)[1] if "## 4. Was it done as agreed" in text else ""
|
|
93
|
+
if re.search(r"^\*\*Yes\.\*\*", verdict, re.M):
|
|
94
|
+
return datetime.date.fromtimestamp(page.stat().st_mtime).isoformat()
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def index(con, names) -> list[dict]:
|
|
101
|
+
"""Every finished OpenSpec change and every reviewed pull request, with the nodes each touched."""
|
|
102
|
+
from . import drift, spec
|
|
103
|
+
repos = list(store.roots(con))
|
|
104
|
+
out: dict[str, dict] = {}
|
|
105
|
+
for osp in drift.places(con):
|
|
106
|
+
anchors, _ = drift.load(con, osp)
|
|
107
|
+
changes = osp / "changes"
|
|
108
|
+
folders = []
|
|
109
|
+
if changes.is_dir():
|
|
110
|
+
folders += [(f, "checked") for f in sorted(changes.iterdir()) if f.is_dir() and f.name != "archive"]
|
|
111
|
+
if (changes / "archive").is_dir():
|
|
112
|
+
folders += [(f, "archived") for f in sorted((changes / "archive").iterdir()) if f.is_dir()]
|
|
113
|
+
for folder, state in folders:
|
|
114
|
+
key = drift.DATED.sub("", folder.name)
|
|
115
|
+
cid = "spec-" + key
|
|
116
|
+
row = con.execute("SELECT attrs, intent, status FROM change_proposals WHERE id = ?", (cid,)).fetchone()
|
|
117
|
+
attrs = json.loads(row[0] or "{}") if row else {}
|
|
118
|
+
if state == "archived":
|
|
119
|
+
date = folder.name[:10] if drift.DATED.match(folder.name) else (attrs.get("verified") or "")[:10]
|
|
120
|
+
else:
|
|
121
|
+
# Finished while its last check found it done: one that failed since, or was planned again, is not.
|
|
122
|
+
# A folder the store does not know is read from its page.
|
|
123
|
+
date = ((attrs.get("verified") or "")[:10] if row[2] == "verified" else "") if row else _verdict(folder)
|
|
124
|
+
if not date:
|
|
125
|
+
continue # still being worked on: not an earlier change
|
|
126
|
+
nodes = _stored_nodes(con, cid)
|
|
127
|
+
nodes |= {i for a in (anchors.get(key) or {}).get("anchors", []) if (i := _full(names, repos, a.get("node", "")))}
|
|
128
|
+
if not nodes:
|
|
129
|
+
nodes = _named(names, folder)
|
|
130
|
+
try:
|
|
131
|
+
title = spec.parse(folder).get("title") or key
|
|
132
|
+
except OSError:
|
|
133
|
+
title = attrs.get("title") or key
|
|
134
|
+
out[cid] = {"id": key, "change_id": cid, "kind": state, "title": title, "date": date or "", "nodes": nodes,
|
|
135
|
+
"where": str(folder)}
|
|
136
|
+
for r in con.execute("SELECT id, intent, attrs FROM change_proposals"):
|
|
137
|
+
attrs = json.loads(r["attrs"] or "{}")
|
|
138
|
+
if attrs.get("kind") != "pr" or r["id"] in out:
|
|
139
|
+
continue
|
|
140
|
+
from . import rereview
|
|
141
|
+
try:
|
|
142
|
+
date = (rereview.last_reviewed(con, r["id"]) or "")[:10]
|
|
143
|
+
except Exception:
|
|
144
|
+
date = ""
|
|
145
|
+
out[r["id"]] = {"id": r["id"], "change_id": r["id"], "kind": "review", "title": attrs.get("title") or (r["intent"] or "")[:80],
|
|
146
|
+
"date": date or _view_date(con, "view-" + r["id"]), "nodes": set(_marks(con, "view-" + r["id"]))}
|
|
147
|
+
return [x for x in out.values() if x["nodes"]]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _label(names, i: str) -> str:
|
|
151
|
+
from . import spec
|
|
152
|
+
return spec._label(names, i) if i in names.by_id else i.rsplit(":", 1)[-1]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def overlap(names, current: list[str], past: set) -> list[str]:
|
|
156
|
+
"""The names of the code a change touches that an earlier change touched too: the same node, one inside the
|
|
157
|
+
other (a method of a type it changed), or a function in a file it changed."""
|
|
158
|
+
files = {p for p in past if (names.by_id.get(p) or {"kind": ""})["kind"] == "file"}
|
|
159
|
+
hits = []
|
|
160
|
+
for c in current:
|
|
161
|
+
row = names.by_id.get(c)
|
|
162
|
+
if any(_near(c, p) for p in past) or (row is not None and files and
|
|
163
|
+
any(f for f in files if names.by_id[f]["path"] == row["path"])):
|
|
164
|
+
hits.append(_label(names, c))
|
|
165
|
+
return list(dict.fromkeys(hits))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def find(con, names, current: list[str], exclude: Optional[str] = None, since: Optional[str] = None,
|
|
169
|
+
limit: int = LIMIT) -> dict:
|
|
170
|
+
"""Up to `limit` earlier changes to the code a change touches. {"source", "about", "items", "total"}, or {}."""
|
|
171
|
+
current = [c for c in dict.fromkeys(current) if c]
|
|
172
|
+
if not current:
|
|
173
|
+
return {}
|
|
174
|
+
try:
|
|
175
|
+
found = []
|
|
176
|
+
for x in index(con, names):
|
|
177
|
+
if x["change_id"] == exclude:
|
|
178
|
+
continue
|
|
179
|
+
shared = overlap(names, current, x["nodes"])
|
|
180
|
+
if shared:
|
|
181
|
+
found.append({k: v for k, v in x.items() if k not in ("nodes", "change_id")} | {"overlap": shared})
|
|
182
|
+
if found:
|
|
183
|
+
found.sort(key=lambda x: (x["date"], len(x["overlap"])), reverse=True)
|
|
184
|
+
return {"source": "openspec", "about": "finished OpenSpec changes and earlier reviews that touched the same code,"
|
|
185
|
+
" newest first", "items": found[:limit], "total": len(found)}
|
|
186
|
+
return from_git(con, names, current, since, limit)
|
|
187
|
+
except Exception: # a lead, never a reason for the plan or the review to fail
|
|
188
|
+
return {}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _ere(name: str) -> str:
|
|
192
|
+
"""A name as a whole word in a POSIX extended regular expression (what `git log -G` reads)."""
|
|
193
|
+
body = "".join("\\" + ch if ch in ".^$*+?()[]{}|\\" else ch for ch in name)
|
|
194
|
+
return f"(^|[^A-Za-z0-9_$]){body}([^A-Za-z0-9_$]|$)"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _log(root: Path, since: Optional[str], paths: list[str], *extra: str) -> list[tuple[str, str, str, list[str]]]:
|
|
198
|
+
"""(sha, date, subject, files) of the commits before `since` that changed `paths`, newest first; bulk commits
|
|
199
|
+
left out."""
|
|
200
|
+
from .coupling import _git
|
|
201
|
+
out = _git(root, "-c", "core.quotePath=false", "log", "--no-merges", "--full-diff", "--relative", "--name-only",
|
|
202
|
+
f"-n{MAX_COMMITS}", "--format=%x01%H%x02%cs%x02%s", *extra, since or "HEAD", "--", *paths, timeout=60)
|
|
203
|
+
found = []
|
|
204
|
+
for chunk in (out or b"").decode("utf-8", "replace").split("\x01")[1:]:
|
|
205
|
+
head, _, body = chunk.partition("\n")
|
|
206
|
+
sha, date, subject = (head.split("\x02") + ["", ""])[:3]
|
|
207
|
+
files = [f for f in body.splitlines() if f.strip()]
|
|
208
|
+
if len(files) <= BULK:
|
|
209
|
+
found.append((sha, date, subject.strip(), files))
|
|
210
|
+
return found
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def from_git(con, names, current: list[str], since: Optional[str] = None, limit: int = LIMIT) -> dict:
|
|
214
|
+
"""The commits before the change that changed the code it touches: first those whose diff, in a changed
|
|
215
|
+
function's file, adds or removes a line naming that function (`git log -G`), then those that changed its files."""
|
|
216
|
+
by_repo: dict[str, set] = {}
|
|
217
|
+
fns: dict[str, list] = {}
|
|
218
|
+
for c in current:
|
|
219
|
+
row = names.by_id.get(c)
|
|
220
|
+
if row is None or not row["path"]:
|
|
221
|
+
continue
|
|
222
|
+
repo = c.split(":", 1)[0]
|
|
223
|
+
by_repo.setdefault(repo, set()).add(row["path"])
|
|
224
|
+
if row["kind"] in ("callable", "test", "type") and row["name"] not in ("<module>", "<top-level>") and len(row["name"]) >= 3:
|
|
225
|
+
fns.setdefault(repo, []).append((row["path"], row["name"], _label(names, c)))
|
|
226
|
+
commits: dict = {}
|
|
227
|
+
for repo, root in store.roots(con).items():
|
|
228
|
+
paths = sorted(by_repo.get(repo, set()))[:100]
|
|
229
|
+
if not paths or not Path(root).is_dir():
|
|
230
|
+
continue
|
|
231
|
+
mine = set(paths)
|
|
232
|
+
for sha, date, subject, files in _log(Path(root), since, paths):
|
|
233
|
+
shared = [f for f in files if f in mine]
|
|
234
|
+
if shared:
|
|
235
|
+
commits[(repo, sha)] = {"id": sha[:7], "kind": "commit", "title": subject, "date": date, "files": shared,
|
|
236
|
+
"names": [], **({"repo": repo} if len(by_repo) > 1 else {})}
|
|
237
|
+
for path, name, label in list(dict.fromkeys(fns.get(repo, [])))[:20]: # a few calls, each on one file's history
|
|
238
|
+
for sha, *_ in _log(Path(root), since, [path], "-G", _ere(name)):
|
|
239
|
+
if (repo, sha) in commits and label not in commits[(repo, sha)]["names"]:
|
|
240
|
+
commits[(repo, sha)]["names"].append(label)
|
|
241
|
+
if not commits:
|
|
242
|
+
return {}
|
|
243
|
+
ranked = sorted(commits.values(), key=lambda x: (len(x["names"]), len(x["files"]), x["date"]), reverse=True)
|
|
244
|
+
found = [{k: v for k, v in x.items() if k not in ("files", "names")}
|
|
245
|
+
| {"overlap": x["names"] or x["files"], "level": "function" if x["names"] else "file"} for x in ranked]
|
|
246
|
+
return {"source": "git", "about": "no OpenSpec history on this code; from git history: commits before it whose diff"
|
|
247
|
+
" names the same functions, then those that changed the same files (of the last"
|
|
248
|
+
f" {MAX_COMMITS} that touched them)",
|
|
249
|
+
"items": found[:limit], "total": len(found)}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def lines(r: Optional[dict], header: str = "Earlier changes to this code") -> list[str]:
|
|
253
|
+
"""The page's list, empty when there is nothing to say."""
|
|
254
|
+
if not r or not r.get("items"):
|
|
255
|
+
return []
|
|
256
|
+
L = ["", f"{header} ({r['about']}):", ""]
|
|
257
|
+
for x in r["items"]:
|
|
258
|
+
when = {"archived": "archived ", "checked": "checked ", "review": "reviewed "}.get(x["kind"], "") + (x["date"] or "undated")
|
|
259
|
+
what = ("Changed the same file:" if len(x["overlap"]) == 1 else "Changed the same files:") if x.get("level") == "file" \
|
|
260
|
+
else "Changed" if x["kind"] == "commit" else "Touched"
|
|
261
|
+
shared = ", ".join(f"`{n}`" for n in x["overlap"][:4]) + (f" and {len(x['overlap']) - 4} more" if len(x["overlap"]) > 4 else "")
|
|
262
|
+
title = x["title"].rstrip(".") + "." if x["title"] else ""
|
|
263
|
+
L.append(f"- `{x['id']}` ({when}): {title} {what} {shared}.".replace(" ", " "))
|
|
264
|
+
if r["total"] > len(r["items"]):
|
|
265
|
+
L.append(f"- and {r['total'] - len(r['items'])} more.")
|
|
266
|
+
return L
|
leyline/removal.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Tasks that take code away: "Remove `X`" and "Rename `X` to `Y`".
|
|
2
|
+
|
|
3
|
+
`check` counts a task done when the code it names changed. For a removal that is too weak: `X` edited is not `X`
|
|
4
|
+
removed, and `X` deleted while something still calls it is half a removal (the build or the run breaks there).
|
|
5
|
+
So these two tasks are judged by what is left:
|
|
6
|
+
|
|
7
|
+
Remove `X` proven when `X` is gone and nothing that called it still names it;
|
|
8
|
+
partial when `X` is gone but a caller still calls it; contradicted when `X` still exists.
|
|
9
|
+
Rename `X` to `Y` the same for `X`, and `Y` must be there too.
|
|
10
|
+
|
|
11
|
+
Callers are those the baseline (the code as it was planned) linked to `X`: calls, and for a type or a file its
|
|
12
|
+
uses and imports. A caller still calls `X` when it is still on the map and its text still has `X`'s name outside a
|
|
13
|
+
comment and outside a string that is only the name (a key such as `row["X"]`). Only tasks written that way are judged so: "Remove a parameter from `X`" removes no `X`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from . import diff, verdicts
|
|
24
|
+
|
|
25
|
+
KIND_WORDS = (r"(?:the|an?|unused|dead|old|obsolete|legacy|deprecated|stale|now[- ]unused|function|method|class|type|"
|
|
26
|
+
r"field|property|constant|helper|file|module|interface|enum|struct|variable|member|attribute|route|"
|
|
27
|
+
r"endpoint|test)")
|
|
28
|
+
REMOVE = re.compile(r"^\s*(?:remove|delete|drop)\s+(?:" + KIND_WORDS + r"\s+)*`", re.I)
|
|
29
|
+
RENAME = re.compile(r"^\s*rename\s+(?:" + KIND_WORDS + r"\s+)*`([^`]+)`\s+(?:to|as|into)\s+(?:the\s+)?`([^`]+)`", re.I)
|
|
30
|
+
USES = ("instantiates", "uses_type", "extends", "implements", "imports", "reads", "writes", "overrides")
|
|
31
|
+
COMMENT = re.compile(r"^\s*(#|//|/\*|\*|--|')")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_path(written: str) -> bool:
|
|
35
|
+
from . import spec
|
|
36
|
+
return "/" in written or bool(spec.FILE_NAME.fullmatch(written))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _in_file(written: str) -> Optional[str]:
|
|
40
|
+
"""`path/to/file.py: name` (or `file.py::name`): the name, without the file it is in."""
|
|
41
|
+
from . import spec
|
|
42
|
+
m = re.fullmatch(r"(?:" + spec.FILE_NAME.pattern + r")\s*::?\s*([A-Za-z_$][\w$.]*)(?:\(.*\))?", written.strip())
|
|
43
|
+
return m.group(1) if m else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _leaf(written: str) -> str:
|
|
47
|
+
"""The name a node of this written name has: `Engine.start` -> start; a file's path stays whole."""
|
|
48
|
+
w = re.sub(r"\(.*\)$", "", _in_file(written) or written.strip())
|
|
49
|
+
return w if _is_path(w) else w.split(".")[-1]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _word(name: str) -> re.Pattern:
|
|
53
|
+
return re.compile(r"(?<![\w$])" + re.escape(name) + r"(?![\w$])")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _targets(text: str) -> Optional[tuple[str, list[str], Optional[str]]]:
|
|
57
|
+
"""("remove", [X, ...], None) or ("rename", [X], Y) for a task written as a removal or a rename, else None."""
|
|
58
|
+
m = RENAME.match(text)
|
|
59
|
+
if m:
|
|
60
|
+
return "rename", [m.group(1).strip()], m.group(2).strip()
|
|
61
|
+
if not REMOVE.match(text):
|
|
62
|
+
return None
|
|
63
|
+
from . import spec
|
|
64
|
+
cut = next((c.start() for c in spec.CLAUSE.finditer(text)), len(text))
|
|
65
|
+
head = text[:cut]
|
|
66
|
+
first = head.index("`")
|
|
67
|
+
close = head.find("`", first + 1)
|
|
68
|
+
if close < 0 or re.match(r"\s*['’]s\b", head[close + 1:]): # "Remove `X`'s parameter": not X
|
|
69
|
+
return None
|
|
70
|
+
leads = [w for w, role in spec._roles(head) if role == "lead"]
|
|
71
|
+
return ("remove", leads, None) if leads else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Baseline:
|
|
75
|
+
"""The code as it was planned, as kept in the change's snapshot."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, con, cid: str):
|
|
78
|
+
snap = diff.snapshot_path(con, cid)
|
|
79
|
+
self.db = diff._open(snap) if snap.exists() else None
|
|
80
|
+
|
|
81
|
+
def row(self, i: str):
|
|
82
|
+
return self.db.execute("SELECT id, kind, name, parent_id, path FROM nodes WHERE id = ?", (i,)).fetchone()
|
|
83
|
+
|
|
84
|
+
def users(self, i: str) -> list[tuple[str, str]]:
|
|
85
|
+
"""(user, how) for everything the baseline links to `i` or to something inside it."""
|
|
86
|
+
n = len(i) + 1
|
|
87
|
+
out = [(r[0], "calls") for r in self.db.execute(
|
|
88
|
+
"SELECT DISTINCT src_id FROM calls WHERE dst_id = ? OR substr(dst_id, 1, ?) IN (?, ?)", (i, n, i + ".", i + "("))]
|
|
89
|
+
out += [(r[0], "imports" if r[1] == "imports" else "uses") for r in self.db.execute(
|
|
90
|
+
f"SELECT DISTINCT src_id, kind FROM edges WHERE kind IN ({','.join('?' * len(USES))})"
|
|
91
|
+
f" AND (dst_id = ? OR substr(dst_id, 1, ?) IN (?, ?))", (*USES, i, n, i + ".", i + "("))]
|
|
92
|
+
return list(dict.fromkeys(out))
|
|
93
|
+
|
|
94
|
+
def close(self):
|
|
95
|
+
if self.db is not None:
|
|
96
|
+
self.db.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _text(con, i: str, cache: dict) -> Optional[str]:
|
|
100
|
+
"""A node's own text as it is now, comment lines left out."""
|
|
101
|
+
r = con.execute("SELECT repo_id, path, kind, span_start, span_end FROM nodes WHERE id = ?", (i,)).fetchone()
|
|
102
|
+
if r is None or not r["path"]:
|
|
103
|
+
return None
|
|
104
|
+
key = (r["repo_id"], r["path"])
|
|
105
|
+
if key not in cache:
|
|
106
|
+
data = diff.source(con, *key)
|
|
107
|
+
cache[key] = data.decode("utf-8", errors="replace").split("\n") if data else None
|
|
108
|
+
lines = cache[key]
|
|
109
|
+
if lines is None:
|
|
110
|
+
return None
|
|
111
|
+
s, e = (1, len(lines)) if r["kind"] == "file" or not r["span_start"] else (r["span_start"], r["span_end"] or r["span_start"])
|
|
112
|
+
return "\n".join(ln for ln in lines[s - 1:e] if not COMMENT.match(ln))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _still_there(names, i: str, kind: str, bases: dict) -> bool:
|
|
116
|
+
"""Whether the node is on the map now: by id, or by owner and name when only its parameters changed."""
|
|
117
|
+
if i in names.by_id:
|
|
118
|
+
return True
|
|
119
|
+
if kind != "callable":
|
|
120
|
+
return False
|
|
121
|
+
if "set" not in bases:
|
|
122
|
+
bases["set"] = {diff._base(x) for x, r in names.by_id.items() if r["kind"] == "callable"}
|
|
123
|
+
return diff._base(i) in bases["set"]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _callers(con, names, base: _Baseline, i: str, leaf: str, removed: set, cache: dict) -> list[tuple[str, str, bool]]:
|
|
127
|
+
"""Code that used `i` before the change and still names it: (label, how, is a test), code first."""
|
|
128
|
+
from . import spec
|
|
129
|
+
name = Path(leaf).stem if _is_path(leaf) else leaf # a file is named by its stem where it is imported
|
|
130
|
+
word = _word(name)
|
|
131
|
+
# A caller of the same name (a pytest fixture `child` that calls `engine.child()`) declares it: not a use.
|
|
132
|
+
own = re.compile(r"\b(?:def|function|fn|func|class|interface|struct|enum|sub)\s+" + re.escape(name) + r"(?![\w$])")
|
|
133
|
+
# A string that is just the name (a key: `{"about": ...}`, `row["about"]`) is not a use of it.
|
|
134
|
+
quoted = re.compile(r"([\"'`])" + re.escape(name) + r"\1")
|
|
135
|
+
out = []
|
|
136
|
+
for src, how in base.users(i):
|
|
137
|
+
if src == i or src.startswith((i + ".", i + "/", i + "(")) or src in removed or src not in names.by_id:
|
|
138
|
+
continue
|
|
139
|
+
text = _text(con, src, cache)
|
|
140
|
+
if text and word.search(quoted.sub(" ", own.sub(" ", text))):
|
|
141
|
+
out.append((spec._label(names, src), how, names.in_tests(src)))
|
|
142
|
+
return sorted(dict.fromkeys(out), key=lambda c: c[2])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _present(con, names, x_row, y: str) -> list[str]:
|
|
146
|
+
"""The new name of a rename on the map, beside where the old one was: the node ids that answer to it."""
|
|
147
|
+
from . import spec
|
|
148
|
+
w = re.sub(r"\(.*\)$", "", _in_file(y) or y.strip())
|
|
149
|
+
if "/" in w or spec.FILE_NAME.fullmatch(w) and x_row["kind"] == "file":
|
|
150
|
+
return [f["id"] for f in names.file(w)]
|
|
151
|
+
parts = w.split(".")
|
|
152
|
+
owner = parts[-2] if len(parts) > 1 else None
|
|
153
|
+
out = []
|
|
154
|
+
for r in names.by_name.get(parts[-1], []):
|
|
155
|
+
if r["kind"] == "file" or (x_row["kind"] in ("callable", "field", "type") and r["kind"] != x_row["kind"]):
|
|
156
|
+
continue
|
|
157
|
+
parent = names.by_id.get(r["parent_id"])
|
|
158
|
+
if r["parent_id"] == x_row["parent_id"] or r["path"] == x_row["path"] or (owner and parent is not None and parent["name"] == owner):
|
|
159
|
+
out.append(r["id"])
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _who(callers: list[tuple[str, str, bool]]) -> str:
|
|
164
|
+
""""parse still calls it", "documentXml, OfferChanges and 3 tests still call it"."""
|
|
165
|
+
from . import spec
|
|
166
|
+
code = [c for c, _, test in callers if not test]
|
|
167
|
+
tests = [c for c, _, test in callers if test]
|
|
168
|
+
if not code and len(tests) <= 3: # only tests: name them
|
|
169
|
+
code, tests = tests, []
|
|
170
|
+
items = code[:3] + ([f"{len(code) - 3} more"] if len(code) > 3 else []) + ([spec._n(len(tests), "test")] if tests else [])
|
|
171
|
+
hows = {h for _, h, _ in callers}
|
|
172
|
+
verb = "calls" if hows == {"calls"} else "imports" if hows == {"imports"} else "uses"
|
|
173
|
+
one = len(callers) == 1
|
|
174
|
+
return f"{spec._and(items)} still {verb if one else verb[:-1]} it"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def check(con, cid: str, row, names, touched: set, removed: set) -> Optional[dict]:
|
|
178
|
+
"""A removal or rename task judged by what is left: {state, verdict, why, missing, made}, or None when the task is not
|
|
179
|
+
one, or when nothing changed at all (the usual reading says so)."""
|
|
180
|
+
if row is None or row["action"] not in ("remove", "rename") or not (touched or removed):
|
|
181
|
+
return None
|
|
182
|
+
found = _targets(row["text"])
|
|
183
|
+
ids = json.loads(row["nodes"] or "[]")
|
|
184
|
+
if not found or not ids:
|
|
185
|
+
return None
|
|
186
|
+
kind, written, new = found
|
|
187
|
+
base = _Baseline(con, cid)
|
|
188
|
+
if base.db is None:
|
|
189
|
+
return None
|
|
190
|
+
try:
|
|
191
|
+
rows = {i: base.row(i) for i in ids}
|
|
192
|
+
cache, bases, judged, made = {}, {}, [], []
|
|
193
|
+
for w in written:
|
|
194
|
+
leaf = _leaf(w)
|
|
195
|
+
mine = [i for i in ids if rows[i] is not None and (rows[i]["name"] == leaf or rows[i]["kind"] == "file"
|
|
196
|
+
and (rows[i]["path"] or "").endswith(leaf.lstrip("./")))]
|
|
197
|
+
if not mine:
|
|
198
|
+
continue
|
|
199
|
+
here = [i for i in mine if _still_there(names, i, rows[i]["kind"], bases)]
|
|
200
|
+
if here:
|
|
201
|
+
edited = any(diff._base(t) == diff._base(i) for i in here for t in touched)
|
|
202
|
+
judged.append((verdicts.CONTRADICTED, f"`{w}` still exists" + ("; it was edited, not removed" if edited and kind == "remove"
|
|
203
|
+
else ""), f"{w} still exists"))
|
|
204
|
+
continue
|
|
205
|
+
if new is not None:
|
|
206
|
+
there = _present(con, names, rows[mine[0]], new)
|
|
207
|
+
made += there
|
|
208
|
+
if not there:
|
|
209
|
+
judged.append((verdicts.CONTRADICTED, f"`{w}` is gone, but `{new}` is not on the map", f"{new}"))
|
|
210
|
+
continue
|
|
211
|
+
callers = [c for i in mine for c in _callers(con, names, base, i, leaf, removed, cache)]
|
|
212
|
+
callers = list(dict.fromkeys(callers))
|
|
213
|
+
if callers:
|
|
214
|
+
judged.append((verdicts.PARTIAL, f"`{w}` is gone but {_who(callers)}",
|
|
215
|
+
f"{callers[0][0]} still {'calls' if callers[0][1] == 'calls' else 'uses'} {w}"))
|
|
216
|
+
else:
|
|
217
|
+
judged.append((verdicts.PROVEN, f"`{w}` is gone" + (f" and `{new}` is there" if new else "")
|
|
218
|
+
+ ", and nothing still calls it", ""))
|
|
219
|
+
finally:
|
|
220
|
+
base.close()
|
|
221
|
+
if not judged:
|
|
222
|
+
return None
|
|
223
|
+
rank = (verdicts.CONTRADICTED, verdicts.PARTIAL, verdicts.PROVEN)
|
|
224
|
+
worst = min((v for v, _, _ in judged), key=rank.index)
|
|
225
|
+
if worst == verdicts.PROVEN:
|
|
226
|
+
why = "; ".join(w for _, w, _ in judged)
|
|
227
|
+
else:
|
|
228
|
+
why = "; ".join(w for v, w, _ in judged if v != verdicts.PROVEN)
|
|
229
|
+
return {"state": {verdicts.CONTRADICTED: "not done", verdicts.PARTIAL: "partly"}.get(worst, "done"),
|
|
230
|
+
"verdict": worst, "why": why, "missing": [m for _, _, m in judged if m],
|
|
231
|
+
"made": made} # the new name of a rename: what the task added, not an edit outside the spec
|