leyline-code 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/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