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/quick.py ADDED
@@ -0,0 +1,853 @@
1
+ """A small change with no spec folder: the plan's three answers for a one-line fix.
2
+
3
+ A spec is the right weight for a change that needs design. "Make the retry count 3" does not: writing a proposal,
4
+ spec deltas and a task list for it costs more than the change. `leyline quick` asks the same three questions with
5
+ no folder behind it:
6
+
7
+ leyline quick "make the retry count 3" --about RETRIES fetch # before: what it touches, what it reaches,
8
+ <your test command> | leyline quick "..." --tests - # the tests that run it (and records them)
9
+ <your test command> | leyline quick --done quick-make-the-retry-count-3 --tests - # after: one verdict
10
+
11
+ Before, the named code is looked up on the map as a task's names are (a name in backticks in the sentence counts
12
+ too), its impact is assessed, the test run is recorded, and the baseline is kept. After, the code is mapped again
13
+ and compared with that baseline the way `leyline pr` compares a branch with its base. The change is stored as
14
+ `quick-<slug>`, so the review steps take it as they take `pr-<id>`: `leyline spec facts quick-<slug>`, findings,
15
+ `leyline spec forget quick-<slug>`. When a change grows past quick (several functions, a channel crossed, a caller
16
+ left), the page says so and gives the way into a spec, keeping the baseline (`--to-spec`).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ import shutil
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ from . import change, diff, spec, store, verdicts
28
+
29
+ PREFIX = "quick-"
30
+ GROWN_FUNCTIONS = 3 # more functions edited than this, and the change wants a spec
31
+
32
+
33
+ # -- ids and names ----------------------------------------------------------------------------------
34
+ def slug(text: str) -> str:
35
+ """quick-<the first words of the sentence>: `make the retry count 3` -> quick-make-the-retry-count-3."""
36
+ words = re.findall(r"[a-z0-9]+", re.sub(r"`[^`]*`", lambda m: m.group(0).strip("`"), text.lower()))
37
+ out = ""
38
+ for w in words:
39
+ if len(out) + len(w) + 1 > 40:
40
+ break
41
+ out = f"{out}-{w}" if out else w
42
+ return PREFIX + (out or "change")
43
+
44
+
45
+ def change_id(about: str, given: Optional[str] = None) -> str:
46
+ if given:
47
+ return PREFIX + re.sub(r"[^A-Za-z0-9._-]+", "-", given.removeprefix(PREFIX)).strip("-")
48
+ return slug(about)
49
+
50
+
51
+ def names_in(about: str) -> list[str]:
52
+ """Code named in backticks in the sentence."""
53
+ return list(dict.fromkeys(w.strip() for w in spec.CODE.findall(about) if w.strip()))
54
+
55
+
56
+ def _action(about: str) -> str:
57
+ lower = re.sub(r"`", "", about.lower()).strip()
58
+ return spec.action_of(lower)
59
+
60
+
61
+ def _assignment(name: str):
62
+ """A line that sets `name` at the top of a file or a type, or inside a function: `RETRIES = 3`, `const MAX = 3`,
63
+ `public const int Max = 3;`."""
64
+ mods = r"(?:(?:export|public|private|internal|protected|static|readonly|const|let|var|final|pub|default)\s+)*"
65
+ return re.compile(r"^(\s{0,8})" + mods + r"(?:[\w<>\[\],.?]+\s+)?" + re.escape(name) + r"\s*(?::[^=\n]+)?=(?!=)")
66
+
67
+
68
+ def _statement(lines: list[str], at: int) -> tuple[int, int]:
69
+ """The lines (from 1) of the statement that starts at line `at`: on to where brackets close."""
70
+ depth, end = 0, at
71
+ for k in range(at - 1, min(len(lines), at + 200)):
72
+ depth += sum(lines[k].count(c) for c in "([{") - sum(lines[k].count(c) for c in ")]}")
73
+ end = k + 1
74
+ if depth <= 0 and not lines[k].rstrip().endswith(("\\", ",")):
75
+ break
76
+ return at, end
77
+
78
+
79
+ def _find_value(con, names: spec._Names, name: str) -> dict:
80
+ """A name the map has no node for, looked up in the source: a constant or variable set at the top of a file
81
+ or a type, or inside one function. {"value": {...}} | {"ambiguous": [...]} | {}"""
82
+ if not re.fullmatch(r"[A-Za-z_$][\w$]*", name):
83
+ return {}
84
+ pattern, word = _assignment(name), re.compile(r"(?<![\w$])" + re.escape(name) + r"(?![\w$])")
85
+ root_of = diff.roots(con)
86
+ found, users = [], set()
87
+ for f in [r for r in names.rows if r["kind"] == "file"]:
88
+ data = diff.source(con, f["id"].split(":", 1)[0], f["path"], root_of)
89
+ if not data or name.encode() not in data:
90
+ continue
91
+ lines = data.decode("utf-8", errors="replace").split("\n")
92
+ inner = con.execute("SELECT id, kind, span_start, span_end FROM nodes WHERE path = ? AND repo_id = ? AND layer = 'fact'"
93
+ " AND kind IN ('callable', 'test', 'field', 'type') AND span_start IS NOT NULL"
94
+ " AND name NOT IN ('<module>', '<top-level>')", (f["path"], f["id"].split(":", 1)[0])).fetchall()
95
+ for k, line in enumerate(lines, 1):
96
+ if pattern.match(line):
97
+ around = [r for r in inner if r["span_start"] <= k <= (r["span_end"] or r["span_start"])]
98
+ host = min(around, key=lambda r: (r["span_end"] or r["span_start"]) - r["span_start"]) if around else None
99
+ found.append({"name": name, "path": f["path"], "file": f["id"], "repo": f["id"].split(":", 1)[0], "line": k,
100
+ "in": host["id"] if host is not None and host["kind"] != "type" else None,
101
+ "type": host["id"] if host is not None and host["kind"] == "type" else None,
102
+ "test": names.in_tests(f["id"])})
103
+ for r in inner: # functions whose text names it: what runs the value
104
+ if r["kind"] in ("callable", "test") and any(word.search(lines[k - 1]) for k in
105
+ range(r["span_start"], min(len(lines), r["span_end"] or r["span_start"]) + 1)):
106
+ users.add(r["id"])
107
+ product = [x for x in found if not x["test"]] or found
108
+ if not product:
109
+ return {}
110
+ if len({(x["path"], x["in"]) for x in product}) > 1:
111
+ return {"ambiguous": [f"{x['path']}:{x['line']}" for x in product][:8]}
112
+ v = product[0]
113
+ if v["in"]: # a local inside one function: the function is what changes
114
+ return {"ids": [v["in"]]}
115
+ def nested(u): # a function defined inside another reader runs as part of it
116
+ p = (names.by_id.get(u) or {"parent_id": None})["parent_id"]
117
+ while p in names.by_id:
118
+ if p in users:
119
+ return True
120
+ p = names.by_id[p]["parent_id"]
121
+ return False
122
+ v["users"] = sorted(u for u in users if not nested(u))
123
+ return {"value": v}
124
+
125
+
126
+ def resolve(con, names: spec._Names, written: list[str], about: str) -> dict:
127
+ """The named code, as a task's names are read: targets for the impact assessment, the ids each name stands for,
128
+ values (constants) the map has no node for, and the names it could not place."""
129
+ action = _action(about)
130
+ named, values, unplaced, new = [], [], [], []
131
+ ids = [w for w in written if w in names.by_id] # a node id, as `search` gives it, is taken as it is
132
+ targets = [{"id": i, "action": action if action in change.ACTIONS and action != "add" else "behavior", "note": "named"}
133
+ for i in ids]
134
+ named += [{"written": i, "ids": [i], "labels": [spec._label(names, i)]} for i in ids]
135
+ written = [w for w in written if w not in names.by_id]
136
+ parsed = {"tasks": [{"key": str(k), "text": f"`{w}`", "action": action, "names": [w]} for k, w in enumerate(written, 1)],
137
+ "scenarios": []}
138
+ more, links = spec._targets(names, parsed)
139
+ targets += more
140
+ for w, l in zip(written, links):
141
+ ids = l["nodes"] + l["into"]
142
+ if l["into"]: # "add `Foo`" where Foo exists: Foo is what changes
143
+ targets += [{"id": i, "action": "behavior", "note": f"named: {w}"} for i in l["into"]
144
+ if names.by_id[i]["kind"] != "module" and not any(t.get("id") == i for t in targets)]
145
+ if ids:
146
+ named += [{"written": w, "ids": ids, "labels": [spec._label(names, i) for i in ids]}]
147
+ continue
148
+ if l["new"] and any(n.get("parent") for n in l["new"]):
149
+ new += [{"written": w, **n} for n in l["new"]]
150
+ continue
151
+ found = _find_value(con, names, w.split(".")[-1]) if not l["ambiguous"] else {}
152
+ if found.get("ids"):
153
+ targets += [{"id": i, "action": "behavior", "note": f"sets {w}"} for i in found["ids"]]
154
+ named.append({"written": w, "ids": found["ids"], "labels": [spec._label(names, i) for i in found["ids"]],
155
+ "local": True})
156
+ elif found.get("value"):
157
+ v = found["value"]
158
+ values.append(v)
159
+ targets += [{"id": u, "action": "behavior", "note": f"reads {w}"} for u in v["users"]
160
+ if not any(t.get("id") == u for t in targets)]
161
+ else:
162
+ could = l["ambiguous"][0]["could_be"] if l["ambiguous"] else found.get("ambiguous", [])
163
+ unplaced.append({"written": w, "could_be": could})
164
+ return {"action": action, "targets": targets, "named": named, "values": values, "new": new, "unplaced": unplaced}
165
+
166
+
167
+ # -- the baseline's source text, to read what a changed declaration was --------------------------------
168
+ def _src_path(con, cid: str) -> Path:
169
+ return diff.snapshot_path(con, cid).with_suffix(".src.json")
170
+
171
+
172
+ def _git(root: Path, *args) -> Optional[str]:
173
+ from . import pr
174
+ try:
175
+ return pr._git(root, *args)
176
+ except pr.GitError:
177
+ return None
178
+
179
+
180
+ def _keep_source(con, cid: str, files: set) -> dict:
181
+ """The text of the files the change is likely to edit, and the commit each repository was at with the files that
182
+ differed from it: enough to say later what a changed declaration was."""
183
+ root_of = diff.roots(con)
184
+ texts, repos = {}, {}
185
+ for repo, root in root_of.items():
186
+ head = _git(root, "rev-parse", "HEAD")
187
+ dirty = (_git(root, "status", "--porcelain", "--untracked-files=all") or "").splitlines() if head else []
188
+ repos[repo] = {"head": head, "dirty": sorted({d[3:].strip().split(" -> ")[-1].strip('"') for d in dirty})}
189
+ files |= {(repo, p) for p in repos[repo]["dirty"]}
190
+ for repo, path in files:
191
+ data = diff.source(con, repo, path, root_of)
192
+ if data is not None and len(data) < 2_000_000:
193
+ texts[f"{repo}:{path}"] = data.decode("utf-8", errors="replace")
194
+ out = {"texts": texts, "repos": repos}
195
+ p = _src_path(con, cid)
196
+ p.parent.mkdir(parents=True, exist_ok=True)
197
+ store.write_file(p, json.dumps(out))
198
+ return out
199
+
200
+
201
+ def _old_source(con, cid: str):
202
+ """path -> the file's text when the baseline was taken, or None: the copy kept then, else git's copy at the commit
203
+ the repository was at, for a file that did not differ from it."""
204
+ p = _src_path(con, cid)
205
+ try:
206
+ kept = json.loads(p.read_text(encoding="utf-8")) if p.is_file() else {"texts": {}, "repos": {}}
207
+ except (OSError, ValueError, RecursionError):
208
+ kept = {"texts": {}, "repos": {}}
209
+ root_of = diff.roots(con)
210
+
211
+ def read(path: str) -> Optional[str]:
212
+ for repo, root in root_of.items():
213
+ if f"{repo}:{path}" in kept["texts"]:
214
+ return kept["texts"][f"{repo}:{path}"]
215
+ for repo, root in root_of.items():
216
+ r = kept["repos"].get(repo) or {}
217
+ if r.get("head") and path not in r.get("dirty", []) and (root / path).exists():
218
+ return _git(root, "show", f"{r['head']}:{path}")
219
+ return None
220
+ return read
221
+
222
+
223
+ # -- before ---------------------------------------------------------------------------------------
224
+ def start(db: str | Path, about: str, written: Optional[list[str]] = None, results: Optional[list[dict]] = None,
225
+ path: str | Path = ".", given_id: Optional[str] = None, new_baseline: bool = False) -> dict:
226
+ """Look up the named code, assess what it reaches, record the tests, and keep the baseline."""
227
+ from . import loop
228
+ written = list(dict.fromkeys([*(written or []), *names_in(about)]))
229
+ if not written:
230
+ return {"error": "name the code the change touches: in backticks in the sentence (\"make `RETRIES` 3\"), or after"
231
+ " --about (`leyline quick \"make the retry count 3\" --about RETRIES fetch`)."}
232
+ db = Path(db)
233
+ if not db.exists():
234
+ loop.map_repos([str(Path(path).resolve())], db, page=False)
235
+ reindexed = True
236
+ else:
237
+ reindexed = bool(loop.refresh(db))
238
+ cid = change_id(about, given_id)
239
+ con = store.connect(db)
240
+ try:
241
+ names = spec._Names(con)
242
+ r = resolve(con, names, written, about)
243
+ if not r["targets"]:
244
+ return {"error": "none of the names is code on the map: " + ", ".join(f"`{u['written']}`" for u in r["unplaced"])
245
+ + ". Write each as it is in the code (`Owner.name`, `module.func`, `path/to/file.py: func`);"
246
+ " `leyline search <word>` finds them.", "unplaced": r["unplaced"]}
247
+ kept = not new_baseline and diff.snapshot_path(con, cid).exists() and diff.moved_on(con, cid)
248
+ recorded = None
249
+ if results is not None and not kept:
250
+ from .loop import _record
251
+ recorded = _record(con, spec.run_label(cid, "before"), results)
252
+ title = about.strip().split("\n")[0][:120]
253
+ earlier = stored(con, cid) if kept else None # the same change, started again: names added since stay named
254
+ if earlier:
255
+ for key in ("named", "values", "new"):
256
+ have = {json.dumps(x, sort_keys=True) for x in r[key]}
257
+ r[key] = r[key] + [x for x in earlier.get(key) or [] if json.dumps(x, sort_keys=True) not in have]
258
+ written = list(dict.fromkeys(written + (earlier.get("names") or [])))
259
+ report = change.propose(con, about, r["targets"], title, source="quick", change_id=cid, keep_baseline=not new_baseline)
260
+ if "error" in report:
261
+ return report
262
+ named_ids = [i for n in r["named"] for i in n["ids"]]
263
+ links = [{"key": "1", "action": r["action"], "nodes": named_ids + [u for v in r["values"] for u in v["users"]],
264
+ "new": [{"name": n["name"], "parent": n["parent"], "label": n.get("label") or n["name"]} for n in r["new"]],
265
+ "into": [], "mention_ids": [], "scenarios": [], "notes": [], "ambiguous": []}]
266
+ crossings, agree = spec._crossings(con, names, links)
267
+ files = {(names.by_id[m["id"]]["id"].split(":", 1)[0], names.by_id[m["id"]]["path"]) for m in report["marks"]
268
+ if m["id"] in names.by_id and names.by_id[m["id"]]["path"] and m["role"] in ("changed", "must_edit", "contract", "direct")}
269
+ files |= {(v["repo"], v["path"]) for v in r["values"]}
270
+ if report.get("snapshot") in ("new", "same") or not _src_path(con, cid).is_file():
271
+ _keep_source(con, cid, files)
272
+ roots = store.roots(con)
273
+ with con:
274
+ row = con.execute("SELECT attrs FROM change_proposals WHERE id = ?", (cid,)).fetchone()
275
+ attrs = json.loads(row[0] or "{}") if row else {}
276
+ attrs.update({"kind": "quick", "about": about, "names": written, "action": r["action"],
277
+ "named": r["named"], "values": r["values"], "new": r["new"], "unplaced": r["unplaced"],
278
+ "must_edit_ids": [m["id"] for m in report["must_edit"]],
279
+ "root": str(next(iter(roots.values()))) if roots else str(Path(path).resolve())})
280
+ con.execute("UPDATE change_proposals SET attrs = ? WHERE id = ?", (json.dumps(attrs), cid))
281
+ from . import affected
282
+ picked = affected.select(con, cid)
283
+ out = {"change_id": cid, "title": title, "about": about, **r, "reindexed": reindexed,
284
+ "must_edit": report["must_edit"], "reached": report["summary"]["reached"],
285
+ "callers": [spec._label(names, m["id"]) for m in report["marks"] if m["role"] == "direct"],
286
+ "reached_across": _across(names, report["channels"]),
287
+ # the modules the code that runs into it is in
288
+ "modules": sum(1 for g in report["by_module"] if g["reached"]) or report["summary"]["modules"],
289
+ "channels": crossings, "must_agree": agree,
290
+ "tests_to_run": [{**t, "name": spec._label(names, t["id"]) if t["id"] in names.by_id else t["name"]}
291
+ for t in report["tests_to_run"]],
292
+ "untested": report["untested"], "risks": report["risks"],
293
+ "commands": [c for c in (picked.get("commands") or []) if c.get("command")] if "error" not in picked else [],
294
+ "baseline": "kept" if kept else report.get("snapshot"), "tests_recorded": recorded,
295
+ "baseline_tests": con.execute("SELECT 1 FROM test_results WHERE run = ? LIMIT 1",
296
+ (spec.run_label(cid, "before"),)).fetchone() is not None}
297
+ out["grown"] = _grown_before(out)
298
+ return out
299
+ finally:
300
+ con.close()
301
+
302
+
303
+ def _grown_before(b: dict) -> list[str]:
304
+ why = []
305
+ fns = len({i for t in b["targets"] if t.get("id") for i in [t["id"]]}) + len(b["new"])
306
+ if fns > GROWN_FUNCTIONS:
307
+ why.append(f"it names {fns} pieces of code")
308
+ if b["must_edit"]:
309
+ why.append(f"{spec._n(len(b['must_edit']), 'caller')} must be edited with it")
310
+ if b["channels"]:
311
+ why.append("it crosses " + _channels(b["channels"]))
312
+ return why
313
+
314
+
315
+ def _across(names: spec._Names, links: list[dict]) -> list[dict]:
316
+ """Code that reaches the change from the other side of a channel (a program that starts the one that runs it, a
317
+ request to a route that runs it): it sees any change in behavior, with no compile-time link."""
318
+ groups: dict = {}
319
+ for c in links:
320
+ key = (c["channel"], c.get("address") or c["to_name"])
321
+ g = groups.setdefault(key, {"channel": c["channel"], "address": key[1], "to": c["to_name"], "from": [],
322
+ "data": bool(c.get("data"))})
323
+ g["from"].append(spec._label(names, c["from"]) if c["from"] in names.by_id else c["from_name"])
324
+ return [{**g, "from": list(dict.fromkeys(g["from"]))} for g in groups.values()]
325
+
326
+
327
+ def _channels(cs: list[dict]) -> str:
328
+ """`the http GET /api/x and the db orders`: each channel once."""
329
+ said = list(dict.fromkeys(f"the {c['channel']} {c.get('program') or c.get('address') or ''}".strip() for c in cs))
330
+ return spec._and(said, 2)
331
+
332
+
333
+ def _value_label(v: dict) -> str:
334
+ return f"the value `{v['name']}` ({v['path']}, line {v['line']})"
335
+
336
+
337
+ def start_text(b: dict) -> str:
338
+ L = [f"# Quick change: {b['title']}", ""]
339
+ touch = [_names([l for n in b["named"] for l in n["labels"]])] if b["named"] else []
340
+ touch += [_value_label(v) + (f", read by {_names([_label_id(b, u) for u in v['users']], 4)}" if v["users"] else "")
341
+ for v in b["values"]]
342
+ touch += [f"new `{n.get('label') or n['name']}`" for n in b["new"]]
343
+ L.append("Will touch: " + "; ".join(touch) + ".")
344
+ for u in b["unplaced"]:
345
+ L.append(f"Not placed: `{u['written']}`" + (f" could be {len(u['could_be'])} things ({', '.join(u['could_be'][:3])});"
346
+ " write it as `Owner.name` or `path/to/file: name`." if u["could_be"]
347
+ else " is not on the map, so it is left out."))
348
+ if b["must_edit"]:
349
+ L.append("Must edit with it: " + "; ".join(f"`{m['name']}` ({m['note']})" for m in b["must_edit"][:5])
350
+ + (f"; and {len(b['must_edit']) - 5} more" if len(b["must_edit"]) > 5 else "") + ".")
351
+ else:
352
+ L.append("Must edit with it: nothing, as far as the map sees.")
353
+ L.append(f"Runs into it: {spec._n(b['reached'], 'place')} in {spec._n(b['modules'], 'module')}"
354
+ + (f"; the nearest {_nearest(b['callers'])}" if b["callers"] else "") + "." if b["reached"]
355
+ else "Runs into it: nothing else on the map calls it.")
356
+ if b["channels"]:
357
+ L += ["Channels: " + spec._crossing_line(c) for c in b["channels"][:3]]
358
+ L += [f" Must agree: `{a['name']}`, which {a['why']}." for a in b["must_agree"][:3]]
359
+ else:
360
+ L.append("Channels: none touched.")
361
+ for g in (b.get("reached_across") or [])[:3]:
362
+ many = len(g["from"]) > 1
363
+ verb = (f"{'read' if many else 'reads'} what `{g['to']}` writes" if g.get("data") else
364
+ f"{'run' if many else 'runs'} `{g['to']}`")
365
+ L.append(f"Reached across the {g['channel']} {g['address']}: {_names(g['from'], 3)} {verb} with no compile-time link,"
366
+ f" so {'they see' if many else 'it sees'} any change in what it does.")
367
+ tests = [t["name"] for t in b["tests_to_run"]]
368
+ L.append(f"Tests that run it: {_names(tests, 5)}." if tests else "Tests that run it: none on the map. Write one, or say"
369
+ " how you will know it works.")
370
+ if b["untested"] and tests:
371
+ L.append("No test reaches " + _names([u["name"] for u in b["untested"]], 4) + ".")
372
+ for c in b["commands"][:2]:
373
+ L.append(f" Run them: cd {c['cwd']} && {c['command']}" if len(c["command"]) <= 160 else
374
+ f" Run them: `leyline affected-tests {b['change_id']}` prints the command ({spec._n(c['tests'], 'test')}).")
375
+ t = b.get("tests_recorded")
376
+ if t:
377
+ L.append(f"Recorded the tests as they are now: {diff.recorded_text(t)}.")
378
+ elif b["baseline"] == "kept":
379
+ L.append("The code has changed since this change was first started; `--done` still compares with the code as it was"
380
+ " then (`--new-baseline` starts over).")
381
+ if b["grown"]:
382
+ L.append("This is more than a quick change (" + "; ".join(b["grown"]) + "): consider a spec, `leyline plan`.")
383
+ L += ["", *next_after_start(b)]
384
+ return "\n".join(L) + "\n"
385
+
386
+
387
+ def next_after_start(b: dict, for_agent: bool = False) -> list[str]:
388
+ cid = b["change_id"]
389
+ if not b["baseline_tests"]:
390
+ if for_agent:
391
+ return [f"Before editing, run the tests and call `quick` again with the same sentence and their output as"
392
+ f" test_output. Then make the change, run the tests, and call `quick` with done={cid!r} and their output."]
393
+ return [f"Next: before editing, record how the tests pass now: `<your test command> | leyline quick \"{b['about']}\""
394
+ f" --tests -`. Then make the change and run `<your test command> | leyline quick --done {cid} --tests -`."]
395
+ if for_agent:
396
+ return [f"Make the change, run the tests, and call `quick` with done={cid!r} and their output as test_output."]
397
+ return [f"Next: make the change, then run `<your test command> | leyline quick --done {cid} --tests -`."]
398
+
399
+
400
+ def _nearest(callers: list[str]) -> str:
401
+ """`is `build`, which calls the changed code` / `are `build` and `cost`, which call the changed code`."""
402
+ xs = list(dict.fromkeys(callers))
403
+ if len(xs) == 1:
404
+ return f"is `{xs[0]}`, which calls the changed code"
405
+ said = _names(xs, 4) if len(xs) > 4 else ", ".join(f"`{x}`" for x in xs[:-1]) + f" and `{xs[-1]}`"
406
+ return f"are {said}, which call the changed code"
407
+
408
+
409
+ def _names(xs: list[str], k: int = 4) -> str:
410
+ xs = list(dict.fromkeys(xs))
411
+ return ", ".join(f"`{x}`" for x in xs[:k]) + (f" and {len(xs) - k} more" if len(xs) > k else "")
412
+
413
+
414
+ def _label_id(b: dict, i: str) -> str:
415
+ return i.split(":")[-1].rsplit(".", 1)[-1].split("(")[0]
416
+
417
+
418
+ # -- after ----------------------------------------------------------------------------------------
419
+ def stored(con, cid: str) -> Optional[dict]:
420
+ row = con.execute("SELECT intent, attrs FROM change_proposals WHERE id = ?", (cid,)).fetchone()
421
+ if row is None:
422
+ return None
423
+ a = json.loads(row["attrs"] or "{}")
424
+ return {**a, "intent": row["intent"]} if a.get("kind") == "quick" else None
425
+
426
+
427
+ def _inside(i: str, named: set) -> bool:
428
+ from .pr import _inside as inside
429
+ return inside(i, named) or inside(diff._base(i), {diff._base(n) for n in named})
430
+
431
+
432
+ TOP_ASSIGN = re.compile(r"^(?:(?:export|const|let|var|final|static|pub)\s+)*([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=(?!=)")
433
+
434
+
435
+ def _top_values(con, path: str, lines: list[int]) -> dict[str, list[int]]:
436
+ """The values set at the top of a file (no indentation: `LINE_MAX = 200`, `export const MAX = 3`) whose
437
+ statements hold some of `lines`: name -> those lines."""
438
+ if not lines:
439
+ return {}
440
+ row = con.execute("SELECT repo_id FROM nodes WHERE path = ? AND kind = 'file' LIMIT 1", (path,)).fetchone()
441
+ data = diff.source(con, row[0], path) if row else None
442
+ if data is None:
443
+ return {}
444
+ text = data.decode("utf-8", errors="replace").split("\n")
445
+ out: dict[str, list[int]] = {}
446
+ for k, line in enumerate(text, 1):
447
+ m = TOP_ASSIGN.match(line)
448
+ if not m:
449
+ continue
450
+ s, e = _statement(text, k)
451
+ held = [ln for ln in lines if s <= ln <= e]
452
+ if held:
453
+ out.setdefault(m.group(1), []).extend(held)
454
+ return out
455
+
456
+
457
+ def _value_span(con, v: dict) -> Optional[tuple[int, int]]:
458
+ """Where a value is set now: the lines of its statement in its file."""
459
+ data = diff.source(con, v["repo"], v["path"])
460
+ if data is None:
461
+ return None
462
+ lines = data.decode("utf-8", errors="replace").split("\n")
463
+ pattern = _assignment(v["name"])
464
+ hits = [k for k, line in enumerate(lines, 1) if pattern.match(line)]
465
+ if not hits:
466
+ return None
467
+ return _statement(lines, min(hits, key=lambda k: abs(k - v["line"])))
468
+
469
+
470
+ def judge(con, cid: str, a: dict, facts: dict, before_run: Optional[str], after_run: Optional[str],
471
+ old_top: dict) -> dict:
472
+ """The few items a quick change is judged on, each with a verdict, and the lists behind them."""
473
+ names = spec._Names(con)
474
+ named = {i for n in a.get("named") or [] for i in n["ids"]}
475
+ new_named = {(n.get("parent"), n["name"]) for n in a.get("new") or []}
476
+ c = facts["changed"]
477
+
478
+ def is_test(x):
479
+ return bool(x.get("test")) or names.in_tests(x["id"])
480
+
481
+ def inside(x): # named code, something nested in it, or the new code the sentence named, where it said
482
+ if _inside(x["id"], named):
483
+ return True
484
+ row = names.by_id.get(x["id"])
485
+ return any(row is not None and row["parent_id"] == p and row["name"] == n for p, n in new_named)
486
+ # The top of a file is judged by its lines (old_top), not as one function.
487
+ top = lambda x: (names.by_id.get(x["id"]) or {"name": ""})["name"] in ("<module>", "<top-level>")
488
+ items = [x for x in c["edited"] + c["types"] + c["added"] + c["removed"] if not top(x)]
489
+ product = [x for x in items if not is_test(x)]
490
+ ours = [x for x in product if inside(x)]
491
+ # A new function only the named code calls is a helper of it, not an edit elsewhere.
492
+ added_ids = {x["id"] for x in c["added"]}
493
+ changed_inside = {x["id"] for x in ours} | named
494
+ helpers = []
495
+ for x in product:
496
+ if x["id"] in added_ids and not inside(x):
497
+ callers = {r[0] for r in con.execute("SELECT DISTINCT src_id FROM calls WHERE dst_id = ?", (x["id"],))}
498
+ if callers and all(_inside(s, changed_inside) for s in callers):
499
+ helpers.append(x)
500
+ # A caller edited to match a named function whose signature changed, or one the start said must change with it,
501
+ # is part of the change, not an edit elsewhere.
502
+ resigned = {x["id"] for x in ours if x.get("signature")}
503
+ must = set(a.get("must_edit_ids") or [])
504
+ follows = []
505
+ for x in product:
506
+ if not inside(x) and x not in helpers and x["id"] in names.by_id:
507
+ calls = {r[0] for r in con.execute("SELECT DISTINCT dst_id FROM calls WHERE src_id = ?", (x["id"],))}
508
+ if x["id"] in must or calls & resigned:
509
+ follows.append(x)
510
+ outside = [x for x in product if not inside(x) and x not in helpers and x not in follows]
511
+ # Edits at the top of a file: inside the named code when they are the lines that set a named value.
512
+ spans = {}
513
+ for v in a.get("values") or []:
514
+ s = _value_span(con, v)
515
+ if s:
516
+ spans.setdefault(v["path"], []).append(s)
517
+ value_edited, top_outside = [], []
518
+ for path, lines in old_top.items():
519
+ if TEST.search(path):
520
+ continue
521
+ mine = [ln for ln in lines if any(s <= ln <= e for s, e in spans.get(path, []))]
522
+ if mine:
523
+ value_edited.append(path)
524
+ rest = [ln for ln in lines if ln not in mine]
525
+ by_name = _top_values(con, path, rest) # a constant set at the top of the file is named by its name
526
+ for name, lns in by_name.items():
527
+ top_outside.append({"name": name, "path": path, "lines": len(lns), "about": name})
528
+ left = [ln for ln in rest if not any(ln in lns for lns in by_name.values())]
529
+ if left:
530
+ top_outside.append({"name": f"the top level of {path}", "path": path, "lines": len(left)})
531
+ tests_edited = [x["name"] for x in items if is_test(x) and x["name"] not in ("<module>", "<top-level>")]
532
+ r = facts["reaches"]
533
+ label = lambda i, fallback: spec._label(names, i) if i in names.by_id else fallback
534
+ broken = ([{"name": label(m["id"], m["name"]), "why": "calls code whose signature changed" if m.get("note") in (None, "", "its call must change")
535
+ else m["note"], "test": m.get("test")} for m in r["signature_changed_callers_not_edited"]]
536
+ + [{"name": label(i, cl), "why": f"calls {x['removed']}, which was removed", "test": cl in x["test_callers"]}
537
+ for x in r["removed_but_still_called"] for cl, i in zip(x["callers"], x["caller_ids"])])
538
+ def written(x): # how to name it with --about: its name when that finds it alone, else its id
539
+ return x["name"] if names.resolve(x["name"]).get("ids") == [x["id"]] else x["id"]
540
+ out_list = [{"name": x["name"], "path": x["path"], "id": x["id"], "about": written(x),
541
+ "kind": "removed" if x in c["removed"] else "added" if x["id"] in added_ids else "edited"}
542
+ for x in outside] + top_outside
543
+ anything = bool(product or old_top or tests_edited or facts["tests"]["test_files_changed"])
544
+
545
+ # 1. scope
546
+ did = bool(ours or value_edited)
547
+ if not anything:
548
+ scope = (verdicts.INCONCLUSIVE, "nothing on the map changed since the baseline; was the edit saved?")
549
+ elif did and not out_list:
550
+ scope = (verdicts.PROVEN, "only the named code changed" + (f", with {spec._n(len(helpers), 'new helper')} it calls"
551
+ if helpers else ""))
552
+ elif did:
553
+ scope = (verdicts.PARTIAL, f"the named code changed, and so did {spec._n(len(out_list), 'piece')} of code it did not name")
554
+ elif product or top_outside:
555
+ scope = (verdicts.CONTRADICTED, "the named code did not change, though other code did")
556
+ else:
557
+ scope = (verdicts.INCONCLUSIVE, "only tests changed")
558
+ # 2. callers
559
+ callers = ((verdicts.CONTRADICTED, f"{spec._n(len(broken), 'caller')} of changed code {'was' if len(broken) == 1 else 'were'}"
560
+ f" left as {'it was' if len(broken) == 1 else 'they were'} and will break") if broken else
561
+ (verdicts.PROVEN, "no caller of a changed signature or of removed code was left as it was"))
562
+ # 3. tests, against the run from before
563
+ after_rows = con.execute("SELECT name, status, message FROM test_results WHERE run = ?", (after_run,)).fetchall() if after_run else []
564
+ delta = diff.test_delta(con, before_run, after_run) if before_run and after_run else None
565
+ broke = (delta["newly_failing"] + delta["new_failing"]) if delta else []
566
+ failing_now = [x["name"] for x in after_rows if x["status"] == "fail"]
567
+ if not after_rows:
568
+ tests = (verdicts.INCONCLUSIVE, "no test results from after the change were passed")
569
+ elif delta is not None:
570
+ tests = ((verdicts.CONTRADICTED, f"{spec._n(len(broke), 'test')} {'fails' if len(broke) == 1 else 'fail'} that"
571
+ " passed before, or are new and fail") if broke else
572
+ (verdicts.PROVEN, diff.passed_pair_text(delta["before"], delta["after"], now_first=True)))
573
+ elif failing_now:
574
+ one = len(failing_now) == 1
575
+ tests = (verdicts.INCONCLUSIVE, f"{spec._n(len(failing_now), 'test')} {'fails' if one else 'fail'}, and no run from"
576
+ f" before was recorded to tell whether the change broke {'it' if one else 'them'}")
577
+ else:
578
+ tests = (verdicts.PROVEN, f"all {len(after_rows)} pass (no run from before was recorded)")
579
+ # 4. whether a test ran the changed code
580
+ t = facts["tests"]
581
+ ix = spec._results_index(after_rows)
582
+ short = lambda x: (names.by_id.get(x.get("id") or "") or {"name": x.get("name")})["name"]
583
+ to_run = list(t.get("tests_to_run") or [])
584
+ readers = [u for v in a.get("values") or [] if v["path"] in value_edited for u in v.get("users") or [] if u in names.by_id]
585
+ if readers: # a changed constant runs wherever it is read
586
+ to_run += change.assess(con, "", [{"id": u, "action": "behavior"} for u in readers]).get("tests_to_run") or []
587
+ candidates = [(x.get("name"), "measured coverage shows it running the changed code")
588
+ for x in t.get("measured_running_the_change") or []]
589
+ candidates += [(short(x), "the map shows it running the changed code") for x in to_run]
590
+ candidates += [(n, "it was edited with the change") for n in tests_edited]
591
+ ran_pass, ran_fail = [], []
592
+ for name, how in candidates:
593
+ got = spec._scenario_results(ix, name.split("::")[-1]) if name else []
594
+ if got and all(g["status"] == "pass" for g in got):
595
+ ran_pass.append((name, how))
596
+ elif got:
597
+ ran_fail.append(name)
598
+ ran_pass = list(dict.fromkeys(ran_pass))
599
+ if not anything:
600
+ ran = (verdicts.INCONCLUSIVE, "nothing changed, so no test could run a change")
601
+ elif not after_rows:
602
+ ran = (verdicts.INCONCLUSIVE, "no test results from after the change were passed")
603
+ elif ran_pass:
604
+ many = len(ran_pass) > 1
605
+ how = ran_pass[0][1].replace("shows it", "shows them").replace("it was", "they were") if many else ran_pass[0][1]
606
+ ran = (verdicts.PROVEN, f"{_names([n for n, _ in ran_pass], 2)} passed, and {how}")
607
+ elif ran_fail:
608
+ ran = (verdicts.CONTRADICTED, f"every test that runs it fails: {_names(ran_fail, 3)}")
609
+ else:
610
+ ran = (verdicts.PERSON, "no test that passed runs the changed code, as far as the map can tell")
611
+ items_out = [{"key": "scope", "what": "Edits stayed in the named code", "verdict": scope[0], "why": scope[1]},
612
+ {"key": "callers", "what": "No caller left broken", "verdict": callers[0], "why": callers[1]},
613
+ {"key": "tests", "what": "No test broke", "verdict": tests[0], "why": tests[1]},
614
+ {"key": "ran", "what": "A test ran the change", "verdict": ran[0], "why": ran[1]}]
615
+ edited_fns = len([x for x in product if x["kind"] in ("callable", "test")])
616
+ grown = []
617
+ if edited_fns > GROWN_FUNCTIONS:
618
+ grown.append(f"{edited_fns} functions edited")
619
+ # A channel whose answer or data the edit changed, or whose request it changed; a read of data alone is not one.
620
+ crossed = [x for x in r["channels_crossed"] if x["hub_touched"] or not x["data"]]
621
+ if crossed:
622
+ grown.append("it crosses " + _channels(crossed))
623
+ if broken:
624
+ grown.append(f"{spec._n(len(broken), 'caller')} that must change {'was' if len(broken) == 1 else 'were'} left")
625
+ return {"items": items_out, "outside": out_list, "helpers": [x["name"] for x in helpers],
626
+ "followed": [label(x["id"], x["name"]) for x in follows], "broken": broken,
627
+ "tests_broke": [x["name"] for x in broke], "failing_now": failing_now, "tests_edited": tests_edited,
628
+ "ran": [{"name": n, "how": how} for n, how in ran_pass], "grown": grown,
629
+ "test_delta": delta}
630
+
631
+
632
+ TEST = re.compile(r"(^|/)(tests?|__tests__|specs?)(/|$)|[._-](test|spec)s?\.[A-Za-z]+$|(^|/)test_[^/]+$|Tests?\.cs$")
633
+
634
+
635
+ def done(db: str | Path, cid: str, results: Optional[list[dict]] = None, coverage_file: Optional[str | Path] = None,
636
+ more: Optional[list[str]] = None) -> dict:
637
+ """After the change: map again, record the tests, compare with the baseline, and judge it. `more` names code the
638
+ change touched that the start did not name: it is added to the named code, and the baseline stays."""
639
+ from . import loop, pr
640
+ db = Path(db)
641
+ if not db.exists():
642
+ return {"error": "no map of this code yet; start the change with `leyline quick \"<what>\" --about <names>`"}
643
+ reindexed = bool(loop.refresh(db))
644
+ con = store.connect(db)
645
+ try:
646
+ a = stored(con, cid)
647
+ snap = diff.snapshot_path(con, cid)
648
+ if a is None or not snap.exists():
649
+ begun = con.execute("SELECT 1 FROM change_proposals WHERE id = ?", (cid,)).fetchone()
650
+ if a is None and cid.startswith(PREFIX) and begun:
651
+ return {"error": f"the start of {cid} did not finish; start it again with the same words (`leyline quick"
652
+ " \"<what>\" --about <names>`)" + ("; the baseline it took is kept." if snap.exists() else
653
+ ", before editing.")}
654
+ known = [r[0] for r in con.execute("SELECT id FROM change_proposals WHERE id LIKE 'quick-%' ORDER BY id")
655
+ if stored(con, r[0]) is not None and diff.snapshot_path(con, r[0]).exists()]
656
+ return {"error": f"no quick change {cid!r} started here" + (f" (there are: {', '.join(known[:5])})" if known else "")
657
+ + "; start it with `leyline quick \"<what>\" --about <names>` before editing."}
658
+ if more:
659
+ a = _name_more(con, cid, a, more)
660
+ if "error" in a:
661
+ return a
662
+ before_run, after_run = spec.run_label(cid, "before"), spec.run_label(cid, "after")
663
+ if coverage_file is not None:
664
+ from . import coverage as measured
665
+ imported = measured.import_file(con, coverage_file, run=after_run)
666
+ if "error" in imported:
667
+ return {"error": f"cannot import the coverage file: {imported['error']}"}
668
+ recorded = loop._record(con, after_run, results) if results is not None else None
669
+ has = lambda run: con.execute("SELECT 1 FROM test_results WHERE run = ? LIMIT 1", (run,)).fetchone() is not None
670
+ facts = pr.analyse(con, snap, a.get("about") or a["intent"], _old_source(con, cid))
671
+ facts.pop("marks", None)
672
+ before = diff._open(snap)
673
+ try: # the changed lines at the top of each file, outside any function: where a constant is set
674
+ rows = con.execute("SELECT id, path FROM nodes WHERE layer = 'fact' AND (kind = 'file' OR name IN ('<module>',"
675
+ " '<top-level>')) AND path IN (SELECT value FROM json_each(?))",
676
+ (json.dumps(facts["changed"]["files"]),)).fetchall()
677
+ own = diff.own_changes(before, con, [r[0] for r in rows]) or {}
678
+ finally:
679
+ before.close()
680
+ old_top: dict = {}
681
+ for i, path in rows:
682
+ old_top.setdefault(path, []).extend(ln for ln, text in own.get(i) or [] if text.strip())
683
+ old_top = {p: sorted(set(lines)) for p, lines in old_top.items() if lines}
684
+ j = judge(con, cid, a, facts, before_run if has(before_run) else None, after_run if has(after_run) else None, old_top)
685
+ config = verdicts.load_config(Path(a.get("root") or ".") / "openspec" / "changes" / cid)
686
+ blocking = [x for x in j["items"] if x["verdict"] in config["blocking"]]
687
+ out = {"change_id": cid, "title": a.get("title") or a["intent"], "about": a.get("about") or a["intent"],
688
+ "done": not blocking, "blocking": config["blocking"], "config": config["file"], **j,
689
+ "changed": facts["changed"], "size": facts["size"], "reindexed": reindexed, "tests_recorded": recorded,
690
+ "findings": spec.findings(con, cid)["findings"]}
691
+ with con:
692
+ row = con.execute("SELECT attrs FROM change_proposals WHERE id = ?", (cid,)).fetchone()
693
+ attrs = json.loads(row[0] or "{}")
694
+ attrs["last_done"] = {"done": out["done"], "items": {x["key"]: x["verdict"] for x in j["items"]}}
695
+ con.execute("UPDATE change_proposals SET attrs = ? WHERE id = ?", (json.dumps(attrs), cid))
696
+ return out
697
+ finally:
698
+ con.close()
699
+
700
+
701
+ def _name_more(con, cid: str, a: dict, more: list[str]) -> dict:
702
+ """Add names to a started change's named code, keeping its baseline."""
703
+ r = resolve(con, spec._Names(con), more, a.get("about") or a["intent"])
704
+ if not (r["named"] or r["values"] or r["new"]):
705
+ return {"error": "none of those names is code on the map: " + ", ".join(
706
+ f"`{u['written']}`" + (f" could be {len(u['could_be'])} things (give one of these ids: {', '.join(u['could_be'][:3])})"
707
+ if u["could_be"] else "") for u in r["unplaced"])}
708
+ for key in ("named", "values", "new", "unplaced"):
709
+ a[key] = (a.get(key) or []) + r[key]
710
+ a["names"] = list(dict.fromkeys((a.get("names") or []) + more))
711
+ with con:
712
+ row = con.execute("SELECT attrs FROM change_proposals WHERE id = ?", (cid,)).fetchone()
713
+ attrs = json.loads(row[0] or "{}")
714
+ attrs.update({k: a[k] for k in ("named", "values", "new", "unplaced", "names")})
715
+ con.execute("UPDATE change_proposals SET attrs = ? WHERE id = ?", (json.dumps(attrs), cid))
716
+ return a
717
+
718
+
719
+ def verdict_line(v: dict) -> str:
720
+ counts = {k: sum(x["verdict"] == k for x in v["items"]) for k in verdicts.VERDICTS}
721
+ if v["done"]:
722
+ let = [x for x in v["items"] if x["verdict"] != verdicts.PROVEN]
723
+ if not let:
724
+ return ("**Done.** The edits stayed in the named code, no caller was left broken, no test broke, and a test ran"
725
+ " the change.")
726
+ return "**Done**, with this left to you: " + "; ".join(f"{x['why']} ({x['verdict']})" for x in let) + "."
727
+ stop = [x for x in v["items"] if x["verdict"] in v["blocking"]]
728
+ return f"**Not done** ({verdicts.summary_line(counts)}): " + "; ".join(x["why"] for x in stop) + "."
729
+
730
+
731
+ def done_text(v: dict) -> str:
732
+ L = [f"# Quick change: {v['title']}", "", verdict_line(v), ""]
733
+ L += [f"- {x['what']}: {x['verdict']}. {x['why'][0].upper() + x['why'][1:]}." for x in v["items"]]
734
+ if v.get("followed"):
735
+ L += ["", "Callers edited to match the named code: " + _names(v["followed"], 6) + "."]
736
+ if v["outside"]:
737
+ L += ["", "Edits outside the named code:"]
738
+ L += [f"- `{x['name']}` ({x.get('kind', 'edited')}, {x['path']})" for x in v["outside"][:6]]
739
+ if len(v["outside"]) > 6:
740
+ L.append(f"- and {len(v['outside']) - 6} more")
741
+ if v["broken"]:
742
+ L += ["", "Callers left broken:"] + [f"- `{x['name']}`: {x['why']}" for x in v["broken"][:6]]
743
+ if v["tests_broke"]:
744
+ L += ["", "Tests that broke: " + _names(v["tests_broke"], 6) + "."]
745
+ elif v["failing_now"] and not v["test_delta"]:
746
+ L += ["", "Failing now: " + _names(v["failing_now"], 6) + "."]
747
+ open_ = [f for f in v.get("findings") or [] if f["status"] == "open"]
748
+ if open_:
749
+ L += ["", f"Open review findings: {', '.join(f['id'] for f in open_)} (`leyline spec findings {v['change_id']}`)."]
750
+ if v["grown"]:
751
+ L += ["", "This has grown past a quick change (" + "; ".join(v["grown"]) + "). Write it as a spec instead:",
752
+ " 1. Have your agent write openspec/changes/<id>/ with tasks naming this code (the leyline-spec skill says how).",
753
+ f" 2. `leyline quick --to-spec <id> {v['change_id']}` hands this baseline and test run to the spec.",
754
+ " 3. `leyline plan <id>`, then `<your test command> | leyline check <id> --tests -`."]
755
+ L += ["", *next_after_done(v)]
756
+ return "\n".join(L) + "\n"
757
+
758
+
759
+ def next_after_done(v: dict, for_agent: bool = False) -> list[str]:
760
+ cid = v["change_id"]
761
+ if v["done"]:
762
+ unseen = any(x["key"] == "ran" and x["verdict"] != verdicts.PROVEN for x in v["items"])
763
+ if for_agent:
764
+ return ["Next: show the person the verdict and the diff" + (", and say that no test seen running the change"
765
+ " passed, so it needs checking by hand." if unseen else ".")]
766
+ return [("Next: check by hand that the change does what you meant (no passing test was seen running it), then"
767
+ " review the diff and commit it." if unseen else "Next: review the diff and commit it.")
768
+ + f" `leyline spec forget {cid}` deletes the baseline."]
769
+ out = []
770
+ by = {x["key"]: x for x in v["items"]}
771
+ if by["tests"]["verdict"] == verdicts.INCONCLUSIVE and not v["failing_now"]:
772
+ out.append("run the tests and pass their output (" + ("test_output" if for_agent else "`--tests -`") + ").")
773
+ if v["outside"]:
774
+ import shlex
775
+ more = " ".join(shlex.quote(x["about"]) for x in v["outside"][:3] if x.get("about"))
776
+ out.append(f"undo the edits outside the named code, or, if they belong, name them: `leyline quick --done {cid}"
777
+ f" --about {more or '<names>'} --tests -`." if not for_agent else
778
+ "undo the edits outside the named code, or, if the person says they belong, call `quick` again with"
779
+ " done and those names in `names`.")
780
+ if v["broken"]:
781
+ out.append("update the callers left broken: " + _names([x["name"] for x in v["broken"]], 3) + ".")
782
+ if v["tests_broke"]:
783
+ out.append("fix what broke " + _names(v["tests_broke"], 3) + ".")
784
+ if by["scope"]["verdict"] in (verdicts.CONTRADICTED, verdicts.INCONCLUSIVE) and not v["outside"]:
785
+ out.append("make the change (nothing named has changed yet).")
786
+ again = ("call `quick` again with done=" + repr(cid) + " and the tests' output." if for_agent
787
+ else f"run `<your test command> | leyline quick --done {cid} --tests -` again.")
788
+ if not out:
789
+ out.append("fix what the verdict lists.")
790
+ return ["Next: " + out[0], *("Also: " + x for x in out[1:]),
791
+ *([] if len(out) == 1 and "--done" in out[0] else ["Then: " + again])]
792
+
793
+
794
+ # -- growing into a spec, and the review steps ------------------------------------------------------
795
+ def to_spec(con, cid: str, spec_id: str) -> dict:
796
+ """Hand a quick change's baseline and its test run from before to a spec, so `plan` and `check` compare with the
797
+ code as it was when the quick change started, not as it is now."""
798
+ if stored(con, cid) is None or not diff.snapshot_path(con, cid).exists():
799
+ return {"error": f"no quick change {cid!r} with a baseline here"}
800
+ sid = "spec-" + spec_id.removeprefix("spec-")
801
+ target = diff.snapshot_path(con, sid)
802
+ if target.exists():
803
+ return {"error": f"{sid} already has a baseline; `leyline spec forget {spec_id}` first if it should start from {cid}"}
804
+ shutil.copyfile(diff.snapshot_path(con, cid), target)
805
+ with con:
806
+ rows = [(spec.run_label(sid, "before"), *tuple(r)[1:]) for r in con.execute(
807
+ "SELECT * FROM test_results WHERE run = ?", (spec.run_label(cid, "before"),))]
808
+ con.execute("DELETE FROM test_results WHERE run = ?", (spec.run_label(sid, "before"),))
809
+ if rows:
810
+ con.executemany(f"INSERT INTO test_results VALUES ({','.join('?' * len(rows[0]))})", rows)
811
+ n = len(rows)
812
+ return {"change_id": sid, "from": cid, "baseline": str(target), "tests": n}
813
+
814
+
815
+ def forget(con, cid: str) -> bool:
816
+ gone = diff.drop_snapshot(con, cid)
817
+ p = _src_path(con, cid)
818
+ if p.exists():
819
+ p.unlink()
820
+ gone = True
821
+ return gone
822
+
823
+
824
+ def review_facts(con, cid: str, reviewer: Optional[str] = None) -> dict:
825
+ """The facts behind a quick change, arranged for the adversarial reviewers as a pull request's are."""
826
+ from . import learnings, pr
827
+ a = stored(con, cid)
828
+ snap = diff.snapshot_path(con, cid)
829
+ if a is None or not snap.exists():
830
+ return {"error": f"no quick change {cid!r} started here; run `leyline quick` first"}
831
+ f = pr.analyse(con, snap, a.get("about") or "", _old_source(con, cid))
832
+ if reviewer:
833
+ spec.record_review(con, cid, reviewer)
834
+ r, t = f["reaches"], f["tests"]
835
+ return {
836
+ "change_id": cid, "title": a.get("title"), "what_it_says_it_does": a.get("about") or a["intent"],
837
+ "named_code": [l for n in a.get("named") or [] for l in n["labels"]] + [v["name"] for v in a.get("values") or []],
838
+ "changed": f["changed"], "size": f["size"],
839
+ "house_rules_to_read_first": pr._house_rules(Path(a.get("root") or "."), f["changed"]["files"]),
840
+ "learnings_that_apply": learnings.applying(con, cid),
841
+ "learnings_unreadable": learnings.unreadable(con),
842
+ "logic": {**{k: r[k] for k in ("signature_changed_callers_not_edited", "removed_but_still_called", "channels_crossed",
843
+ "callers_left_alone", "state_shared_with_unchanged_code")},
844
+ "other_ends_of_those_channels_not_edited": r["other_ends_not_edited"],
845
+ "changed_code_no_test_reaches": t["changed_code_no_test_reaches"],
846
+ "ask": "Does the code do what the sentence says, and nothing else? Then, for each list: is the change"
847
+ " wrong, or is the map? Read the code before filing."},
848
+ "performance": {**f["performance"], "ask": "For each changed function on a hot path: does the change add work per"
849
+ " call? Name the test that would show it."},
850
+ "how_sure": f["how_sure"],
851
+ "how_to_file": f"leyline spec finding {cid} --reviewer <logic|performance> --severity <high|medium|low>"
852
+ " --claim \"...\" --evidence <node id> --proposal \"...\" (or the spec_finding tool with this id).",
853
+ }