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/loop.py ADDED
@@ -0,0 +1,584 @@
1
+ """The short path: map the code, plan a change, check it was done as agreed.
2
+
3
+ Three verbs over the rest of Leyline, for a person who should not need to know about snapshots, run labels
4
+ or the order of the spec commands. The CLI and the MCP server both call these.
5
+
6
+ map index one or more repositories; a short overview and a page to browse
7
+ plan brief an OpenSpec change folder: leyline.md, what is still needed, what to do next
8
+ check after the change: re-index, read the test results, verify, and say what is left
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from . import diff, spec, store
20
+ from . import verdicts
21
+
22
+ STORE = Path(".leyline") / "leyline.db"
23
+
24
+
25
+ # -- finding things -------------------------------------------------------------------------------
26
+ def find_change(target: str | Path, db: Optional[str | Path] = None) -> Optional[Path]:
27
+ """A change folder, or a change id looked up under openspec/changes/ here, above here, or in a mapped repo."""
28
+ p = Path(target)
29
+ if p.is_dir():
30
+ return p
31
+ here = Path.cwd().resolve()
32
+ places = [here, *here.parents]
33
+ if db and Path(db).exists():
34
+ con = store.connect(db)
35
+ try:
36
+ places += list(store.roots(con).values())
37
+ finally:
38
+ con.close()
39
+ for d in places:
40
+ hit = d / "openspec" / "changes" / str(target)
41
+ if hit.is_dir():
42
+ return hit
43
+ return None
44
+
45
+
46
+ def find_store(start: Path) -> Optional[Path]:
47
+ """The store of the repository a change folder sits in: the nearest .leyline/leyline.db above it."""
48
+ for d in [start.resolve(), *start.resolve().parents]:
49
+ if (d / STORE).is_file():
50
+ return d / STORE
51
+ return None
52
+
53
+
54
+ def repository_of(folder: Path) -> Optional[Path]:
55
+ """The git repository `folder` is in (its top folder), when it is in one; None otherwise, or without git."""
56
+ import subprocess
57
+ try:
58
+ run = subprocess.run(["git", "-C", str(folder), "rev-parse", "--show-toplevel"], capture_output=True,
59
+ text=True, timeout=30, stdin=subprocess.DEVNULL)
60
+ except (OSError, subprocess.SubprocessError):
61
+ return None
62
+ top = run.stdout.strip()
63
+ return Path(top).resolve() if run.returncode == 0 and top else None
64
+
65
+
66
+ def _show(path: str | Path) -> str:
67
+ """A path as a person would type it: relative when it is under the current directory."""
68
+ p = Path(path).resolve()
69
+ try:
70
+ return p.relative_to(Path.cwd().resolve()).as_posix() # the same on every system
71
+ except ValueError:
72
+ return str(path)
73
+
74
+
75
+ def _roots(con) -> dict[str, Path]:
76
+ return store.roots(con)
77
+
78
+
79
+ def changed_files(db: str | Path) -> list[str]:
80
+ """Source files added, edited or deleted since the store was indexed. Cheap next to indexing: it only hashes."""
81
+ return _changes(db)[0]
82
+
83
+
84
+ def _changes(db: str | Path) -> tuple[list[str], dict]:
85
+ """changed_files, and each repository's listing it read (an index run right after takes it, not listing again)."""
86
+ from .adapters import BY_EXTENSION
87
+ from .indexer import read_source, scan
88
+
89
+ con = store.connect(db)
90
+ try:
91
+ roots = _roots(con)
92
+ known = {(r["repo_id"], r["path"]): r["content_hash"] for r in con.execute(
93
+ "SELECT repo_id, path, content_hash FROM nodes WHERE kind = 'file' AND layer = 'fact'")}
94
+ finally:
95
+ con.close()
96
+ out, listings = [], {}
97
+ for repo, root in roots.items():
98
+ if not root.is_dir():
99
+ continue
100
+ now = set()
101
+ listings[repo] = listing = scan(root)
102
+ for f in listing.files:
103
+ if "." + f.rsplit(".", 1)[-1] not in BY_EXTENSION:
104
+ continue
105
+ now.add(f)
106
+ try:
107
+ data = read_source(root / f)
108
+ except OSError:
109
+ data = None
110
+ if data is None or known.get((repo, f)) != hashlib.sha1(data).hexdigest():
111
+ out.append(f)
112
+ out += [p for (r, p) in known if r == repo and p not in now]
113
+ return sorted(set(out)), listings
114
+
115
+
116
+ def made_by_another_version(db: str | Path) -> bool:
117
+ """True when the map was made by another version of Leyline (or one that did not say), so what it found may
118
+ differ from what this version would find in the same files."""
119
+ from .incremental import code_version
120
+ con = store.connect(db)
121
+ try:
122
+ row = con.execute("SELECT value FROM meta WHERE key = 'made_by'").fetchone()
123
+ finally:
124
+ con.close()
125
+ return row is None or row[0] != code_version()
126
+
127
+
128
+ def refresh(db: str | Path, force: bool = False, full: bool = False) -> Optional[dict]:
129
+ """Re-index the store's repositories when their code has changed (only what changed is done again, see
130
+ leyline.incremental; `full` does everything). Returns the index stats, or None."""
131
+ from .indexer import index
132
+
133
+ changed, listed = _changes(db) if not force else ([], {})
134
+ if not force and not changed and not made_by_another_version(db):
135
+ return None
136
+ con = store.connect(db)
137
+ try:
138
+ roots = _roots(con)
139
+ row = con.execute("SELECT value FROM meta WHERE key = 'exact'").fetchone()
140
+ finally:
141
+ con.close()
142
+ exact = row[0] if row else "auto"
143
+ if not roots:
144
+ return None
145
+ if len(roots) == 1: # the repo id may not be the directory name (--repo), so pass it
146
+ (rid, root), = roots.items()
147
+ return index(root, db, rid, exact, full=full, listed=listed)
148
+ return index(list(roots.values()), db, None, exact, full=full, listed=listed)
149
+
150
+
151
+ # -- map ------------------------------------------------------------------------------------------
152
+ def forget(db: str | Path, ids: list[str]) -> dict:
153
+ """Drop repositories from the store: their facts, where they were, and their place in the workspace. What the
154
+ others know is kept; they are mapped again in full, so no link into a dropped one is left. Notes and decisions
155
+ written about a dropped one's code (inferred and intent rows) are kept, as when code is deleted."""
156
+ if not Path(db).is_file():
157
+ return {"error": f"no store at {db}: nothing to forget"}
158
+ con = store.connect(db)
159
+ try:
160
+ held = sorted(set(store.roots(con)) | {r[0] for r in con.execute("SELECT id FROM nodes WHERE kind = 'repo'")})
161
+ unknown = [i for i in ids if i not in held]
162
+ if unknown:
163
+ return {"error": f"no repository {unknown[0]!r} in the store (it holds {', '.join(held) or 'none'})"}
164
+ row = con.execute("SELECT value FROM meta WHERE key = 'workspace'").fetchone()
165
+ members = [m for m in (json.loads(row[0]) if row else []) if m not in ids]
166
+ with con:
167
+ for rid in ids:
168
+ store.clear_facts(con, rid)
169
+ for prefix in ("root", "rel", "left_out", "timing", "first_commit"):
170
+ con.execute("DELETE FROM meta WHERE key = ?", (f"{prefix}:{rid}",))
171
+ for table in ("coupling_runs", "coupling_files", "coupling_pairs", "coupling_dirs"):
172
+ con.execute(f"DELETE FROM {table} WHERE repo_id = ?", (rid,))
173
+ if len(members) > 1:
174
+ con.execute("INSERT OR REPLACE INTO meta VALUES ('workspace', ?)", (json.dumps(members),))
175
+ else:
176
+ con.execute("DELETE FROM meta WHERE key = 'workspace'")
177
+ left = sorted(store.roots(con))
178
+ finally:
179
+ con.close()
180
+ said = f"Forgot {', '.join(ids)}."
181
+ if not left:
182
+ con = store.connect(db)
183
+ try:
184
+ with con:
185
+ store.rebuild_derived(con)
186
+ finally:
187
+ con.close()
188
+ return {"said": said + " Nothing else is mapped in this store."}
189
+ out = map_repos(None, db, full=True)
190
+ return {**out, "said": said} if "error" not in out else out
191
+
192
+
193
+ def map_repos(paths: Optional[list[str]], db: str | Path, repo_id: Optional[str] = None, exact: str = "auto",
194
+ scip: Optional[list[str]] = None, page: bool = True, full: bool = False) -> dict:
195
+ """Index, write the browsable map page next to the store, and count what was found. With no paths, map
196
+ again the repositories the store already holds."""
197
+ from .indexer import index
198
+
199
+ began = time.perf_counter()
200
+ if paths and len(paths) == 1 and not repo_id and Path(db).exists():
201
+ # Mapping again a repository the store holds under another id (it was mapped with --repo) keeps that id,
202
+ # rather than adding the same files a second time as a new repository.
203
+ con = store.connect(db)
204
+ try:
205
+ held = {str(p.resolve()): r for r, p in _roots(con).items()}
206
+ finally:
207
+ con.close()
208
+ repo_id = held.get(str(Path(paths[0]).resolve()))
209
+ if paths:
210
+ stats = index(paths if len(paths) > 1 else paths[0], db, repo_id, exact, scip or [], full=full)
211
+ else: # map again what the store already holds, the way it was mapped
212
+ stats = refresh(db, force=True, full=full) if Path(db).exists() else None
213
+ if stats is None:
214
+ return {"error": "nothing is mapped in this store yet: name the repository directories"}
215
+ ignore = Path(db).parent / ".gitignore"
216
+ if Path(db).parent.name == ".leyline" and not ignore.exists():
217
+ store.write_file(ignore, "# Leyline's map and baselines: local, rebuilt by `leyline map`.\n*\n")
218
+ con = store.connect(db)
219
+ try:
220
+ prune_baselines(con)
221
+ if paths:
222
+ with con: # so plan and check re-index the same way
223
+ con.execute("INSERT OR REPLACE INTO meta VALUES ('exact', ?)", (exact,))
224
+ out = {"db": str(db), "seconds": round(time.perf_counter() - began, 1), **counts(con),
225
+ "timing": stats.get("timing", {}), "exact": {k: v for k, v in stats.items() if k.startswith("exact:")},
226
+ "left_out": _left_out(stats.get("left_out") or {})}
227
+ if page:
228
+ out["page"] = str(write_page(con, db, always=True))
229
+ finally:
230
+ con.close()
231
+ return out
232
+
233
+
234
+ def write_page(con, db: str | Path, open_change: Optional[str] = None, always: bool = False) -> Optional[Path]:
235
+ """The map page beside the store. `map` writes it; `plan` and `check` rewrite it when it is there, opening
236
+ on their change, so the page a person already has open shows the change after a reload."""
237
+ from . import export
238
+
239
+ path = Path(db).parent / "map.html"
240
+ if not always and not path.is_file():
241
+ return None
242
+ store.write_file(path, export.page(con, open_change=open_change))
243
+ return path
244
+
245
+
246
+ def counts(con) -> dict:
247
+ one = lambda sql: con.execute(sql).fetchone()[0]
248
+ modules = [{"name": r["path"] or r["name"], "repo": r["repo_id"], "files": r["n"]} for r in con.execute(
249
+ "SELECT m.path, m.name, m.repo_id, COUNT(f.id) AS n FROM nodes m LEFT JOIN nodes f ON f.parent_id = m.id AND f.kind = 'file'"
250
+ " WHERE m.kind = 'module' GROUP BY m.id ORDER BY n DESC, m.path")]
251
+ from . import patterns as found
252
+ patterns = dict(sorted(found.listing(con, limit=1)["by_pattern"].items()))
253
+ return {
254
+ "repos": [r[0] for r in con.execute("SELECT id FROM nodes WHERE kind = 'repo' ORDER BY id")],
255
+ "modules": modules,
256
+ "files": one("SELECT COUNT(*) FROM nodes WHERE kind = 'file'"),
257
+ "lines": one("SELECT COALESCE(SUM(span_end), 0) FROM nodes WHERE kind = 'file'"),
258
+ "types": one("SELECT COUNT(*) FROM nodes WHERE kind = 'type'"),
259
+ "functions": one("SELECT COUNT(*) FROM nodes WHERE kind = 'callable' AND COALESCE(json_extract(attrs, '$.is_test'), 0) = 0"),
260
+ "tests": one("SELECT COUNT(*) FROM nodes WHERE kind = 'test' OR json_extract(attrs, '$.is_test') = 1"),
261
+ "entry_points": one("SELECT COUNT(*) FROM nodes WHERE kind = 'entry_point'"),
262
+ "patterns": patterns,
263
+ }
264
+
265
+
266
+ # Left out as a matter of course; counted on stderr, not repeated in the summary.
267
+ QUIET_LEFT_OUT = ("dependency directory", "deleted (git still lists it)", "symlink to another listed file (indexed there)",
268
+ "symlink to a directory")
269
+
270
+
271
+ def _left_out(left: dict) -> dict:
272
+ """reason -> count, over every repository (a workspace's stats hold one summary per repository)."""
273
+ per_repo = left and all(isinstance(v, dict) and "count" not in v for v in left.values())
274
+ out: dict = {}
275
+ for summary in (left.values() if per_repo else [left]):
276
+ for why, v in summary.items():
277
+ out[why] = out.get(why, 0) + v["count"]
278
+ return out
279
+
280
+
281
+ def map_text(m: dict) -> str:
282
+ repos = m["repos"]
283
+ who = repos[0] if len(repos) == 1 else f"{len(repos)} repositories ({', '.join(repos)})"
284
+ mods = m["modules"]
285
+ n = lambda k, word: f"{k:,} {word}{'' if k == 1 else 's'}"
286
+ L = [f"Mapped {who} in {m['seconds']} s: {n(m['files'], 'file')}, {n(m['lines'], 'line')}, {n(len(mods), 'module')}.",
287
+ f"Found {spec._n(m['types'], 'type')}, {spec._n(m['functions'], 'function')}, {spec._n(m['tests'], 'test')} and "
288
+ f"{spec._n(m['entry_points'], 'entry point')} (where a program starts).",
289
+ ("Largest modules: " if len(mods) > 5 else "Modules: ") + ", ".join(f"{x['name']} ({n(x['files'], 'file')})" for x in mods[:5])
290
+ + (f" and {len(mods) - 5} more" if len(mods) > 5 else "")]
291
+ if not mods:
292
+ L.pop()
293
+ if not m["files"]:
294
+ from .adapters import ADAPTERS
295
+ langs = sorted({a.LANGUAGE for a in ADAPTERS})
296
+ L.insert(1, "No source files were found in a language Leyline reads (" + ", ".join(langs) + ").")
297
+ left = {k: v for k, v in m.get("left_out", {}).items() if k not in QUIET_LEFT_OUT}
298
+ if left:
299
+ gone = sum(left.values())
300
+ L.append(f"Not mapped: {gone:,} file{'s' * (gone != 1)} (named above): "
301
+ + ", ".join(f"{v} {k}" for k, v in sorted(left.items(), key=lambda kv: -kv[1])) + ".")
302
+ if m["patterns"]:
303
+ L.append("Design patterns found: " + ", ".join(f"{k} {v}" if v > 1 else k for k, v in m["patterns"].items()))
304
+ for k, v in m.get("exact", {}).items():
305
+ if isinstance(v, dict) and v.get("status") == "ok" and "calls_confirmed" in v:
306
+ L.append(f"Checked by a compiler ({k.split(':')[1]}): {n(v['calls_confirmed'], 'call')} confirmed, "
307
+ f"{v.get('calls_removed', 0)} removed, {v.get('calls_added', 0)} added.")
308
+ L.append(f"Store: {Path(m['db']).as_posix()}")
309
+ if m.get("page"):
310
+ L.append(f"Map page: {Path(m['page']).as_posix()} (open it in a browser)")
311
+ L.append("Next: write the change you want as an OpenSpec folder, openspec/changes/<id>/ (ask your agent; the "
312
+ "leyline-spec skill says how), then run `leyline plan <id>`.")
313
+ return "\n".join(L)
314
+
315
+
316
+ def _record(con, run: str, results: list[dict]) -> dict:
317
+ """Store a test run, and count how many results carry the name of a test on the map (how scenarios find them)."""
318
+ out = diff.record_tests(con, run, results)
319
+ out["on_map"] = out.pop("matched_to_test_nodes", 0)
320
+ return out
321
+
322
+
323
+ def prune_baselines(con) -> list[str]:
324
+ """Delete the baseline of each change whose folder is gone (archived or removed): nothing will check it again.
325
+ A change's baseline is otherwise kept, done or not, so `check` can always run again."""
326
+ gone = []
327
+ for r in con.execute("SELECT id FROM change_proposals WHERE id LIKE 'spec-%'").fetchall():
328
+ if spec.folder_gone(con, r[0]) and diff.drop_snapshot(con, r[0]):
329
+ gone.append(r[0])
330
+ return gone
331
+
332
+
333
+ def unmapped(con) -> Optional[dict]:
334
+ """An error when the store holds no repository (a first map stopped before it wrote anything, or a store a reader
335
+ created): planned against it, every name would read as new code and the baseline would be of nothing."""
336
+ if con.execute("SELECT 1 FROM nodes WHERE kind = 'repo' LIMIT 1").fetchone() is None:
337
+ return {"error": f"nothing is mapped in the store at {_show(diff.store_path(con))} yet: run `leyline map <repo>` first"}
338
+ return None
339
+
340
+
341
+ # -- plan -----------------------------------------------------------------------------------------
342
+ def plan(db: str | Path, change_dir: str | Path, results: Optional[list[dict]] = None, new_baseline: bool = False) -> dict:
343
+ """Bring the map up to date, write the brief, and record the tests as they pass before the change."""
344
+ reindexed = refresh(db)
345
+ con = store.connect(db)
346
+ try:
347
+ parsed = spec.parse(change_dir)
348
+ if "error" in parsed:
349
+ return parsed
350
+ if bad := unmapped(con):
351
+ return bad
352
+ cid = "spec-" + parsed["id"]
353
+ prune_baselines(con)
354
+ if not new_baseline and diff.lost(con, cid):
355
+ return {"error": f"the baseline of {parsed['id']} (.leyline/snapshots/{cid}.db) is gone, and was not forgotten"
356
+ " with `leyline spec forget`: planning now would take the code as it is as the start, and"
357
+ " check could no longer see what the change did. If the code is still as it was before the"
358
+ f" change, or you mean to start over, run `leyline plan {parsed['id']} --new-baseline`."}
359
+ # Results passed in are the start only while the code is as it was when first planned (or a new baseline is
360
+ # taken now). They are recorded before the brief, which reads them to find tests the map does not know.
361
+ kept = not new_baseline and diff.snapshot_path(con, cid).exists() and diff.moved_on(con, cid)
362
+ recorded = _record(con, spec.run_label(cid, "before"), results) if results is not None and not kept else None
363
+ b = spec.brief(con, change_dir, new_baseline=new_baseline)
364
+ if "error" in b:
365
+ return b
366
+ b["reindexed"] = bool(reindexed)
367
+ if results is not None:
368
+ # The code has moved on since the first plan: these results would describe the change, not the start.
369
+ b["tests_recorded"] = recorded or {"error": "The code has changed since the first plan, so these results are not"
370
+ " a baseline. Pass them to `leyline check` instead."}
371
+ page = write_page(con, db, b["change_id"]) # after leyline.md is final, since the page embeds it
372
+ if page:
373
+ b["page"] = str(page)
374
+ finally:
375
+ con.close()
376
+ return b
377
+
378
+
379
+ def plan_text(b: dict, name: str) -> str:
380
+ L = [spec.brief_text(b).rstrip(), ""]
381
+ t = b.get("tests_recorded")
382
+ if t:
383
+ L.append(t["error"] if "error" in t else
384
+ f"Recorded the tests as they are before the change: {diff.recorded_text(t)}"
385
+ f" ({t['on_map']} named like tests on the map).")
386
+ if b.get("reindexed"):
387
+ L.append("The code had changed since it was mapped, so it was mapped again first.")
388
+ if b.get("baseline") == "kept":
389
+ L.append("The code has changed since the first plan; `leyline check` still compares with the code as it was then.")
390
+ L.append(f"Written to {_show(b['written'])}" if b.get("written") else f"Not written: {b.get('not_written')}")
391
+ if b.get("page"):
392
+ L.append(f"Map page: {_show(b['page'])} (opens on this change)")
393
+ L += ["", *next_after_plan(b, name)]
394
+ return "\n".join(L)
395
+
396
+
397
+ def next_after_plan(b: dict, name: str, for_agent: bool = False) -> list[str]:
398
+ """What to do after a plan: commands to type, or (for_agent) the MCP tools to call."""
399
+ st = spec.brief_status(b)
400
+ if st["blocking"]:
401
+ decide = [x for x in st["blocking"] if x.startswith("decide")]
402
+ if decide and len(decide) == len(st["blocking"]):
403
+ ids = ", ".join(x.split()[4].rstrip(":") for x in decide)
404
+ if for_agent:
405
+ return [f"Next: ask the person to decide the open high {'finding' if len(decide) == 1 else 'findings'} {ids}."
406
+ " Record each decision with `spec_resolve` (accepted means the spec changes; edit it to match),"
407
+ " then call `plan` again."]
408
+ return [f"Next: decide the open high {'finding' if len(decide) == 1 else 'findings'} {ids}: `leyline spec resolve"
409
+ f" <finding id> accepted|rejected|deferred \"why\"` (accepted means the spec changes), then run"
410
+ f" `leyline plan {name}` again."]
411
+ first = st["blocking"][0]
412
+ more = f" (and {len(st['blocking']) - 1} more under \"Before implementation\")" if len(st["blocking"]) > 1 else ""
413
+ if for_agent:
414
+ return [f"Next: fix the spec in {b['dir']} for: {first}{more}. Then call `plan` again."]
415
+ return [f"Next: fix the spec for: {first}{more}. Edit the files in {_show(b['dir'])} (or ask your agent), then run"
416
+ f" `leyline plan {name}` again."]
417
+ out = []
418
+ opened = [f["id"] for f in b.get("findings") or [] if f["status"] == "open"]
419
+ if opened: # the person decides each finding before the code is written; nothing else says so
420
+ ids = ", ".join(opened)
421
+ out.append(f"ask the person to decide the open review finding{'s' if len(opened) > 1 else ''} {ids} and record each"
422
+ " with `spec_resolve` (accepted means the spec changes; edit it to match), then call `plan` again."
423
+ if for_agent else
424
+ f"decide the open review finding{'s' if len(opened) > 1 else ''} {ids}: `leyline spec resolve <finding id>"
425
+ f" accepted|rejected|deferred \"why\"` (accepted means the spec changes), then run `leyline plan {name}` again."
426
+ " `leyline spec findings " + name + "` shows them in full.")
427
+ if not b.get("baseline_tests") and b.get("baseline") != "kept":
428
+ out.append("while the code is unchanged, run the tests and call `plan` again with their output as test_output"
429
+ f" ({diff.READS})." if for_agent else
430
+ f"while the code is unchanged, record how the tests pass now: `<your test command> | leyline plan {name}"
431
+ f" --tests -`. It reads {diff.READS}.")
432
+ st_missing = [r for r in spec.REVIEWERS if r not in (b.get("reviews") or [])]
433
+ if not st["reviewed"] or st_missing:
434
+ which = " and ".join(st_missing)
435
+ out.append(f"have the plan reviewed before code is written: call `spec_review_facts` with reviewer="
436
+ f"{' and again with reviewer='.join(st_missing)} (ideally each in a fresh agent), file real problems with"
437
+ " `spec_finding`, then call `plan` again. Or, if the person accepts the plan as it is, implement it."
438
+ if for_agent else
439
+ f"have the plan reviewed ({which}) before code is written (ask your agent to run the"
440
+ f" leyline-adversarial-review skill), then run `leyline plan {name}` again. Or, if you accept the plan as"
441
+ " it is, implement it.")
442
+ else:
443
+ out.append("implement the tasks, then run the tests and call `check` with their output as test_output."
444
+ if for_agent else
445
+ f"implement it (ask your agent to do the tasks), then pipe the tests' output into"
446
+ f" `leyline check {name} --tests -`.")
447
+ return ["Next: " + out[0], *("Then: " + x for x in out[1:])]
448
+
449
+
450
+ # -- check ----------------------------------------------------------------------------------------
451
+ def check(db: str | Path, change_dir: str | Path, results: Optional[list[dict]] = None,
452
+ coverage_file: Optional[str | Path] = None) -> dict:
453
+ """After the change: re-index if the code moved, record the test results, and verify against the plan.
454
+ `coverage_file`, measured on the same run, is imported after the re-index so its lines land on the new code."""
455
+ reindexed = refresh(db)
456
+ con = store.connect(db)
457
+ try:
458
+ parsed = spec.parse(change_dir)
459
+ if "error" in parsed:
460
+ return parsed
461
+ if bad := unmapped(con):
462
+ return bad
463
+ cid = "spec-" + parsed["id"]
464
+ before, after = spec.run_label(cid, "before"), spec.run_label(cid, "after")
465
+ if coverage_file is not None:
466
+ from . import coverage as measured
467
+ imported = measured.import_file(con, coverage_file, run=after)
468
+ if "error" in imported:
469
+ return {"error": f"cannot import the coverage file: {imported['error']}"}
470
+ code = diff._fingerprint(con)
471
+ recorded = _record(con, after, results) if results is not None else None
472
+ if results is not None:
473
+ with con: # the code these results ran against
474
+ con.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", ("tested:" + after, code))
475
+ has = lambda run: con.execute("SELECT 1 FROM test_results WHERE run = ? LIMIT 1", (run,)).fetchone() is not None
476
+ prune_baselines(con)
477
+ # Results recorded earlier describe code that has since changed: they prove nothing about it.
478
+ tested = con.execute("SELECT value FROM meta WHERE key = ?", ("tested:" + after,)).fetchone()
479
+ old = results is None and has(after) and (tested[0] != code if tested else bool(reindexed))
480
+ v = spec.verify(con, change_dir, before if has(before) else None, after if has(after) and not old else None,
481
+ old_run=after if old else None)
482
+ if "error" in v:
483
+ return v
484
+ v["reindexed"] = bool(reindexed)
485
+ v["tests_recorded"] = recorded
486
+ v["tests_old"] = old
487
+ v["tests_missing"] = not has(after)
488
+ page = write_page(con, db, v["change_id"])
489
+ if page:
490
+ v["page"] = str(page)
491
+ finally:
492
+ con.close()
493
+ return v
494
+
495
+
496
+ def check_text(v: dict, name: str) -> str:
497
+ L = [f"Check of: {v['title']}", "", spec.verify_text(v).rstrip(), ""]
498
+ if v.get("tests_recorded"):
499
+ t = v["tests_recorded"]
500
+ L.append(f"Recorded the test results: {diff.recorded_text(t)}"
501
+ f" ({t['on_map']} named like tests on the map).")
502
+ if v.get("reindexed"):
503
+ L.append("The code had changed since it was mapped, so it was mapped again first.")
504
+ if v.get("written"):
505
+ L.append(f"Written to {_show(v['written'])}")
506
+ elif v.get("not_written"):
507
+ L.append(f"Not written: {v['not_written']}")
508
+ if (v.get("anchors") or {}).get("count"):
509
+ a = v["anchors"]
510
+ L.append(f"Recorded what the spec's {spec._n(a['count'], 'code name')} mean now"
511
+ + (f" in {_show(a['file'])} (commit it)" if a.get("file") else "") + ", so `leyline drift` can tell when the code moves on.")
512
+ if v.get("page"):
513
+ L.append(f"Map page: {_show(v['page'])} (opens on this change)")
514
+ L += ["", *next_after_check(v, name)]
515
+ return "\n".join(L)
516
+
517
+
518
+ def next_after_check(v: dict, name: str, for_agent: bool = False) -> list[str]:
519
+ """What to do after a check, named for what is left: commands to type, or (for_agent) the MCP tools to call."""
520
+ if v["done_as_agreed"]:
521
+ mine = [t["key"] for t in v["tasks"] if t["state"] == "checked by you"]
522
+ yours = (f" Check task{'s' if len(mine) > 1 else ''} {', '.join(mine)} by hand: {'they name' if len(mine) > 1 else 'it names'}"
523
+ " no code.") if mine else ""
524
+ yours += verdicts.next_note(v) # scenarios whose tests pass but may not run the change
525
+ return [("Next: nothing left to check; the change was done as agreed. Show the person the verdict and the diff."
526
+ if for_agent else "Next: nothing left to check; the change was done as agreed. Review the diff and commit it.")
527
+ + yours + (" The baseline is kept, so `check` can run again after later edits." if for_agent else
528
+ f" The baseline is kept, so a later edit can be checked the same way; `leyline spec forget {name}`"
529
+ " deletes it.")]
530
+ again = ("call `check` again with the tests' output" if for_agent else
531
+ f"run the tests again into `leyline check {name} --tests -`")
532
+ out = []
533
+ if v.get("tests_missing") or v.get("tests_old"):
534
+ out.append(("the test results on record are from before the code last changed. " if v.get("tests_old") else "")
535
+ + (f"run the tests and call `check` again with their output as test_output ({diff.READS})." if for_agent else
536
+ f"run the tests and pass the output: `<your test command> | leyline check {name} --tests -`"
537
+ f" ({diff.READS})."))
538
+ undone = [t for t in v["tasks"] if t["state"] in ("not done", "partly")]
539
+ if undone:
540
+ out.append(f"finish task{'s' if len(undone) > 1 else ''} " + ", ".join(
541
+ t["key"] + (f" (missing {', '.join(t['missing'][:3])})" if t["missing"] else "") for t in undone)
542
+ + f"{'' if for_agent else ' (ask your agent)'}, then {again}.")
543
+ if v["drift"]:
544
+ what = spec._some([n["name"] if n["name"] not in ("<module>", "<top-level>") else f"the top level of {n.get('path')}"
545
+ for n in v["drift"]], 3)
546
+ out.append(f"{what} changed outside the spec: ask the person to add a task for each (then call `plan`) or undo it."
547
+ if for_agent else
548
+ f"{what} changed outside the spec: add a task for each (then `leyline plan {name}`) or undo it.")
549
+ if not (v.get("tests_missing") or v.get("tests_old")):
550
+ fails = [s["name"] for s in v["scenarios"] if s["state"] == "fails"]
551
+ missing = [s["name"] for s in v["scenarios"] if s["state"] == "no test"]
552
+ unrun = [s["name"] for s in v["scenarios"] if s["state"] in ("test exists, not run", "skipped")]
553
+ broke = [x["name"] for x in (v["tests"] or {}).get("newly_failing", []) + (v["tests"] or {}).get("new_failing", [])]
554
+ if not v["tests"]:
555
+ broke = [x["name"] for x in v.get("after_failing", [])]
556
+ if fails:
557
+ out.append(f"make the failing scenario{'s' if len(fails) > 1 else ''} pass: " + spec._some([f'"{x}"' for x in fails], 3) + ".")
558
+ if broke:
559
+ out.append(f"{len(broke)} {'tests fail that did not before' if len(broke) > 1 else 'test fails that did not before'}: "
560
+ + spec._some(broke, 3) + ". Fix the code, or the test if the spec changed what it checks.")
561
+ if missing:
562
+ out.append("write a test named for each scenario that has none: " + spec._some([f'"{x}"' for x in missing], 3) + ".")
563
+ if unrun:
564
+ out.append("run the tests for " + spec._some([f'"{x}"' for x in unrun], 3)
565
+ + " too; the output passed in has no result with that name.")
566
+ weak = [s["name"] for s in v["scenarios"] if s.get("ran_changed_code") is False]
567
+ if weak:
568
+ out.append("the test for " + spec._some([f'"{x}"' for x in weak], 3) + " passed without running the changed"
569
+ " code: make it exercise the change, or say why it need not.")
570
+ if v["open_high_findings"]:
571
+ ids = ", ".join(f["id"] for f in v["open_high_findings"])
572
+ out.append(f"ask the person to decide the open high review finding {ids}, and record it with `spec_resolve`."
573
+ if for_agent else
574
+ f"decide the open high review finding {ids}: `leyline spec resolve <finding id> accepted|rejected|deferred \"why\"`.")
575
+ if v["new_dependencies"]:
576
+ out.append("new links between modules: " + spec._some([f"{d['from']} to {d['to']}" for d in v["new_dependencies"]], 3)
577
+ + ". Keep them in the spec, or undo them.")
578
+ if v["rules_newly_failing"]:
579
+ out.append(f"{spec._n(len(v['rules_newly_failing']), 'rule')} that held now {'fail' if len(v['rules_newly_failing']) > 1 else 'fails'}:"
580
+ " fix the code or change the rule.")
581
+ out += verdicts.waiting_lines(v) # when the project makes a person's check block
582
+ if not out:
583
+ out.append(f"fix what the verdict lists, then {again}.")
584
+ return ["Next: " + out[0], *("Also: " + x for x in out[1:])]