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/drift.py ADDED
@@ -0,0 +1,747 @@
1
+ """Spec drift: code a spec names that has since moved, changed or gone.
2
+
3
+ OpenSpec keeps living specs in openspec/specs/<capability>/spec.md and archives finished changes under
4
+ openspec/changes/archive/<date>-<id>/. Both name code in backticks, and nothing tells a reader when that code
5
+ is renamed, moved, given new parameters or deleted. This module keeps a record of what each name meant when it
6
+ was last known to be right, and compares the map with it.
7
+
8
+ An anchor is that record for one name: the name as written, the node it resolved to (id, kind, path), and two
9
+ fingerprints: of its declaration (a function's signature line, a type's declaration line and member names, a
10
+ field's declared type) and of its body (the node's content hash). `check` records one for every code name of a
11
+ change it finds done as agreed; `leyline drift --accept` records them for the living specs, after a person says
12
+ the specs and the code agree.
13
+
14
+ Anchors are kept twice: in the store (table `spec_anchors`), and in `openspec/leyline-anchors.json` beside the
15
+ specs, to be committed with them, so they outlive a fresh map and travel with the repository. That file is
16
+ canonical JSON (sorted keys, two-space indent, a newline at the end), keyed by the change id, or by
17
+ `specs/<capability>` for a living spec, and its node ids are written without the repository's id (the name of
18
+ the directory it was mapped from), so another clone reads them the same. The file wins over the store.
19
+
20
+ Drift is reported per name:
21
+ gone nothing on the map answers to the name or its anchor any more
22
+ renamed gone, but one new node beside where it was has its body or declaration under another name, or git
23
+ records its file as renamed (leyline.renames)
24
+ moved found, but in another file or under another owner
25
+ signature its declaration differs from the anchor (or a type lost members)
26
+ ambiguous the name now resolves to several things
27
+ body changed inside, same declaration: worth a read, not drift by itself
28
+ A name with no anchor is checked by name only: gone when its owner or file is there without it, unclear when it
29
+ could be several things (it never named one, so that is not drift). Without an anchor, a changed signature
30
+ cannot be seen. Only gone, renamed and signature make `leyline drift` exit 1.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import datetime
36
+ import hashlib
37
+ import json
38
+ import re
39
+ from collections import defaultdict
40
+ from pathlib import Path
41
+ from typing import Optional
42
+
43
+ from . import spec, store
44
+
45
+ ANCHOR_FILE = "leyline-anchors.json"
46
+ DATED = re.compile(r"^\d{4}-\d{2}-\d{2}-")
47
+ ORDER = ("gone", "renamed", "signature", "moved", "ambiguous", "unclear", "body", "ok")
48
+ DRIFTED = ("gone", "renamed", "signature", "moved", "ambiguous") # what a plan warns about
49
+ FAILS = ("gone", "renamed", "signature") # what makes `leyline drift` exit 1
50
+ SHOWN = DRIFTED + ("unclear",) # a name with no anchor that could be several things
51
+
52
+
53
+ # -- where the specs are --------------------------------------------------------------------------
54
+ def openspec_of(path: str | Path) -> Optional[Path]:
55
+ """The openspec/ folder a path is in, or the one at or above it."""
56
+ p = Path(path).resolve()
57
+ for d in [p, *p.parents]:
58
+ if d.name == "openspec" and d.is_dir():
59
+ return d
60
+ if (d / "openspec").is_dir():
61
+ return d / "openspec"
62
+ return None
63
+
64
+
65
+ def places(con, path: Optional[str | Path] = None) -> list[Path]:
66
+ """Every openspec/ folder to check: the one at or above `path`, and one in each mapped repository."""
67
+ found = [openspec_of(path)] if path is not None else []
68
+ found += [root / "openspec" for root in store.roots(con).values() if (root / "openspec").is_dir()]
69
+ return list(dict.fromkeys(p.resolve() for p in found if p is not None))
70
+
71
+
72
+ def _code_names(text: str) -> list[str]:
73
+ """Backticked names in Markdown, leaving out fenced code and REMOVED sections (those name what is meant to go)."""
74
+ out, fenced, removed = [], False, False
75
+ for line in text.splitlines():
76
+ if line.lstrip().startswith(("```", "~~~")):
77
+ fenced = not fenced
78
+ continue
79
+ if fenced:
80
+ continue
81
+ if line.startswith("## "):
82
+ removed = line[3:].strip().upper().startswith("REMOVED")
83
+ if not removed:
84
+ out += [w.strip() for w in spec.CODE.findall(line)]
85
+ return list(dict.fromkeys(w for w in out if w))
86
+
87
+
88
+ def _change_names(folder: Path) -> dict[str, list[str]]:
89
+ """written name -> where in a change folder it is named: tasks.md, or specs/<capability>/spec.md. A task that
90
+ removes or renames code names something meant to be gone, so the code right after its verb is left out (only that:
91
+ "Remove `X` and update `Y`" keeps `Y`)."""
92
+ from . import removal
93
+ out: dict[str, list[str]] = defaultdict(list)
94
+ parsed = spec.parse(folder)
95
+ for t in parsed.get("tasks", []):
96
+ found = removal._targets(t["text"]) if t["action"] in ("remove", "rename") else None
97
+ gone = set(found[1]) if found else set()
98
+ for w, role in spec._roles(t["text"]):
99
+ if not (role == "lead" and w in gone) and "tasks.md" not in out[w.strip()]:
100
+ out[w.strip()].append("tasks.md")
101
+ specs = folder / "specs"
102
+ for f in sorted(specs.rglob("spec.md")) if specs.is_dir() else []:
103
+ rel = f.relative_to(folder).as_posix()
104
+ for w in _code_names(f.read_text(encoding="utf-8", errors="replace")):
105
+ if rel not in out[w]:
106
+ out[w].append(rel)
107
+ return dict(out)
108
+
109
+
110
+ # -- fingerprints -----------------------------------------------------------------------------------
111
+ def _h(text: str) -> str:
112
+ return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16]
113
+
114
+
115
+ def _local(node_id: str, repo: Optional[str]) -> str:
116
+ """A node id without its repository's id, which is only the name of the directory it was mapped from."""
117
+ if repo and node_id.startswith(repo + ":"):
118
+ return node_id[len(repo) + 1:]
119
+ return node_id.split(":", 1)[1] if ":" in node_id else node_id
120
+
121
+
122
+ def _row(con, node_id: str):
123
+ return con.execute("SELECT id, kind, name, parent_id, repo_id, path, content_hash, attrs, span_start, span_end"
124
+ " FROM nodes WHERE id = ?", (node_id,)).fetchone()
125
+
126
+
127
+ def _shape(con, r) -> Optional[str]:
128
+ """A hash of a node's text with its name taken out where it is declared (lines stripped, as the content hash
129
+ strips them), so a function renamed and otherwise untouched hashes the same. None for a file or module, or
130
+ text not on disk."""
131
+ if r["kind"] in ("file", "module", "repo") or not r["span_start"] or not r["path"]:
132
+ return None
133
+ root = store.roots(con).get(r["repo_id"])
134
+ if root is None:
135
+ return None
136
+ f = root / r["path"]
137
+ try:
138
+ st = f.stat()
139
+ key = (str(f), st.st_mtime_ns, st.st_size)
140
+ if _TEXT.get(str(f), (None,))[0] != key:
141
+ if len(_TEXT) > 4000:
142
+ _TEXT.clear()
143
+ _TEXT[str(f)] = (key, f.read_bytes().decode("utf-8", errors="replace").split("\n"))
144
+ except OSError:
145
+ return None
146
+ # Only where it is declared: the same word elsewhere (`JSON.parse` in `parse`) is other code, and stays.
147
+ word = re.compile(r"(?<![\w$])" + re.escape(r["name"]) + r"(?![\w$])")
148
+ lines = _TEXT[str(f)][1][r["span_start"] - 1:r["span_end"] or r["span_start"]]
149
+ return _h(word.sub("\0", "\n".join(ln.strip() for ln in lines), count=1))
150
+
151
+
152
+ _TEXT: dict = {} # path -> ((path, mtime, size), lines): a file read once while it stays the same
153
+
154
+
155
+ def _declaration(con, r) -> tuple[Optional[str], Optional[list[str]]]:
156
+ """What a node promises to the code that uses it: its declaration line, and for a type its member names."""
157
+ if r["kind"] in ("file", "module", "repo"):
158
+ return None, None
159
+ a = json.loads(r["attrs"] or "{}")
160
+ text = a.get("signature")
161
+ if not text and r["kind"] in ("callable", "test"):
162
+ text = json.dumps([a.get("params"), a.get("param_types"), a.get("returns")])
163
+ if not text and r["kind"] == "field":
164
+ text = a.get("declared_type") or ""
165
+ members = None
166
+ if r["kind"] == "type":
167
+ members = sorted({m[0] for m in con.execute(
168
+ "SELECT name FROM nodes WHERE parent_id = ? AND kind IN ('callable', 'field')", (r["id"],))})
169
+ return " ".join((text or r["name"]).split()), members
170
+
171
+
172
+ def fingerprint(con, names, node_id: str) -> Optional[dict]:
173
+ r = _row(con, node_id)
174
+ if r is None:
175
+ return None
176
+ decl, members = _declaration(con, r)
177
+ out = {"id": node_id, "node": _local(node_id, r["repo_id"]), "kind": r["kind"], "name": r["name"],
178
+ "label": spec._label(names, node_id), "path": r["path"], "decl": decl,
179
+ "decl_hash": _h(decl) if decl is not None else None, "body_hash": r["content_hash"]}
180
+ if members is not None:
181
+ out["members"] = members
182
+ shape = _shape(con, r) # the body without the name: a rename keeps it (see renamed())
183
+ if shape is not None:
184
+ out["shape_hash"] = shape
185
+ return out
186
+
187
+
188
+ def _anchor(written: str, sources: list[str], fp: dict) -> dict:
189
+ return {"written": written, "from": sorted(sources), **{k: v for k, v in fp.items() if k != "id"}}
190
+
191
+
192
+ def _anchors_for(con, names, written_from: dict[str, list[str]]) -> list[dict]:
193
+ """An anchor for each name that resolves to code on the map. A name that could be several things, or is not
194
+ code, gets none."""
195
+ out = []
196
+ for w, sources in sorted(written_from.items()):
197
+ r = names.resolve(w)
198
+ for i in r.get("ids", []):
199
+ fp = fingerprint(con, names, i)
200
+ if fp is not None:
201
+ out.append(_anchor(w, sources, fp))
202
+ out.sort(key=lambda a: (a["written"], a["node"]))
203
+ return out
204
+
205
+
206
+ # -- keeping anchors ---------------------------------------------------------------------------------
207
+ def _today() -> str:
208
+ return datetime.date.today().isoformat()
209
+
210
+
211
+ def read_file(openspec: Optional[Path]) -> tuple[dict, Optional[str]]:
212
+ """key -> {"recorded", "anchors"} from openspec/leyline-anchors.json, and a problem reading it, if any."""
213
+ f = openspec / ANCHOR_FILE if openspec else None
214
+ if f is None or not f.is_file():
215
+ return {}, None
216
+ try:
217
+ data = json.loads(f.read_text(encoding="utf-8"))
218
+ return dict(data.get("anchors") or {}), None
219
+ except (ValueError, AttributeError, RecursionError) as e:
220
+ return {}, f"{f} could not be read ({type(e).__name__}: {e}); its anchors were left out"
221
+
222
+
223
+ def _write_file(openspec: Path, anchors: dict) -> Path:
224
+ if (why := store.escapes(openspec)):
225
+ raise store.UntrustedStore(why)
226
+ f = openspec / ANCHOR_FILE
227
+ data = {"about": "Written by Leyline (`leyline check`, `leyline drift --accept`): what each code name in these specs"
228
+ " meant when it was last known to be right. `leyline drift` compares the code with it. Commit it"
229
+ " with the specs.",
230
+ "anchors": anchors, "version": 1}
231
+ text = json.dumps(data, sort_keys=True, indent=2, ensure_ascii=False) + "\n"
232
+ if not f.is_file() or f.read_text(encoding="utf-8", errors="replace") != text:
233
+ store.write_file(f, text)
234
+ return f
235
+
236
+
237
+ def _place(openspec: Optional[Path]) -> str:
238
+ """Which openspec/ folder a row in the store belongs to: a workspace of several repositories has one each."""
239
+ return str(openspec.resolve()) if openspec else ""
240
+
241
+
242
+ def _store(con, place: str, key: str, entry: dict) -> None:
243
+ roots = store.roots(con)
244
+ rows = []
245
+ for a in entry["anchors"]:
246
+ full = next((f"{r}:{a['node']}" for r in roots if _row(con, f"{r}:{a['node']}") is not None), a["node"])
247
+ rest = {k: v for k, v in a.items() if k not in ("written", "kind", "path", "decl", "decl_hash", "body_hash")}
248
+ rows.append((place, key, a["written"], full, a["kind"], a["path"], a["decl"], a["decl_hash"], a["body_hash"],
249
+ json.dumps(rest, sort_keys=True), entry["recorded"]))
250
+ with con:
251
+ con.execute("DELETE FROM spec_anchors WHERE place = ? AND change_id = ?", (place, key))
252
+ con.executemany("INSERT OR REPLACE INTO spec_anchors VALUES (?,?,?,?,?,?,?,?,?,?,?)", rows)
253
+
254
+
255
+ def _from_store(con, place: str) -> dict:
256
+ out: dict = {}
257
+ for r in con.execute("SELECT * FROM spec_anchors WHERE place = ? ORDER BY change_id, written, node_id", (place,)):
258
+ e = out.setdefault(r["change_id"], {"recorded": r["recorded"], "anchors": []})
259
+ e["anchors"].append({"written": r["written"], "kind": r["kind"], "path": r["path"], "decl": r["decl"],
260
+ "decl_hash": r["decl_hash"], "body_hash": r["body_hash"], **json.loads(r["attrs"] or "{}")})
261
+ return out
262
+
263
+
264
+ def _put(con, openspec: Optional[Path], key: str, anchors: list[dict], write_file: bool = True) -> dict:
265
+ """Keep a key's anchors in the store and, when there is an openspec/ folder, in its file. The date stays when
266
+ nothing changed, so checking again does not touch a committed file. A file that cannot be read is left as it
267
+ is: writing it would lose what it held."""
268
+ held, problem = read_file(openspec)
269
+ old = held.get(key) or _from_store(con, _place(openspec)).get(key)
270
+ entry = {"recorded": old["recorded"] if old and old.get("anchors") == anchors else _today(), "anchors": anchors}
271
+ _store(con, _place(openspec), key, entry)
272
+ out = {"key": key, "count": len(anchors), **({"problem": problem} if problem else {})}
273
+ if openspec is not None and write_file and not problem and (why := store.escapes(openspec)):
274
+ out["problem"] = why
275
+ elif openspec is not None and write_file and not problem:
276
+ held[key] = entry
277
+ if not anchors:
278
+ held.pop(key)
279
+ out["file"] = str(_write_file(openspec, held))
280
+ return out
281
+
282
+
283
+ def load(con, openspec: Optional[Path]) -> tuple[dict, list[str]]:
284
+ """Every anchor for one openspec/ folder: the file's, then the store's for keys the file does not have."""
285
+ held, problem = read_file(openspec)
286
+ for key, e in _from_store(con, _place(openspec)).items():
287
+ held.setdefault(key, e)
288
+ return held, [problem] if problem else []
289
+
290
+
291
+ def record(con, change_dir: str | Path, write_file: bool = True) -> dict:
292
+ """After `check` finds a change done as agreed: anchor every code name its tasks and spec deltas resolve to."""
293
+ folder = Path(change_dir)
294
+ names = spec._Names(con)
295
+ anchors = _anchors_for(con, names, _change_names(folder))
296
+ return _put(con, openspec_of(folder), DATED.sub("", folder.name), anchors, write_file)
297
+
298
+
299
+ # -- comparing -----------------------------------------------------------------------------------------
300
+ class _Map:
301
+ """The map as drift reads it: names, rows by id, and the repositories ids are made from."""
302
+
303
+ def __init__(self, con):
304
+ self.con = con
305
+ self.names = spec._Names(con)
306
+ self._snaps: dict = {} # change -> its baseline, opened once (leyline.renames)
307
+ self.repos = list(store.roots(con)) or sorted({r["id"].split(":", 1)[0] for r in self.names.rows})
308
+ self.claimed: set = set() # nodes some anchor still finds: not what another name was renamed to
309
+ self._git = None
310
+
311
+ def git(self):
312
+ """What git says about the mapped repositories (leyline.renames.Git), or None when none is a git repository."""
313
+ if self._git is None:
314
+ from .renames import Git
315
+ self._git = Git(list(store.roots(self.con).values()))
316
+ return self._git if self._git.repos else None
317
+
318
+ def find(self, a: dict) -> Optional[str]:
319
+ """The node an anchor was made from, if it is still on the map under the same id."""
320
+ for i in [a.get("id")] + [f"{r}:{a['node']}" for r in self.repos]:
321
+ if i and i in self.names.by_id:
322
+ return i
323
+ return None
324
+
325
+ def close(self) -> None:
326
+ for db in self._snaps.values():
327
+ if db is not None:
328
+ db.close()
329
+ self._snaps.clear()
330
+
331
+ def label(self, i: str) -> str:
332
+ return spec._label(self.names, i)
333
+
334
+ def labels(self, ids: list[str]) -> list[str]:
335
+ """Names for several things one name could be, with the file when two would read the same."""
336
+ plain = [self.label(i) for i in ids]
337
+ return [f"{x} in {self.names.by_id[i]['path']}" if plain.count(x) > 1 and i in self.names.by_id else x
338
+ for i, x in zip(ids, plain)]
339
+
340
+
341
+ def _same_thing(m: _Map, a: dict, taken: set) -> Optional[str]:
342
+ """Where an anchored node went when its id is gone: what the written name resolves to now, when it is the same
343
+ owner and name (its id changed with its parameters, or its file moved) or keeps its declaration or body; or
344
+ else the one node of the same name and kind with the same body or declaration."""
345
+ r = m.names.resolve(a["written"])
346
+ cands = [i for i in r.get("ids", []) if i not in taken]
347
+ fps = {i: fingerprint(m.con, m.names, i) for i in cands}
348
+ same_kind = [i for i in cands if fps[i] and fps[i]["kind"] == a["kind"]]
349
+ for test in ("label", "decl_hash", "body_hash"):
350
+ hit = [i for i in same_kind if fps[i][test] == a.get(test)]
351
+ if hit:
352
+ return hit[0]
353
+ if "ambiguous" in r:
354
+ return None
355
+ hits = []
356
+ for row in m.names.by_name.get(a.get("name") or a["written"].split(".")[-1], []):
357
+ if row["kind"] != a["kind"] or row["id"] in taken:
358
+ continue
359
+ fp = fingerprint(m.con, m.names, row["id"])
360
+ if fp and (fp["body_hash"] == a.get("body_hash") or fp["decl_hash"] == a.get("decl_hash")):
361
+ hits.append(row["id"])
362
+ return hits[0] if len(hits) == 1 else None
363
+
364
+
365
+ def judge(m: _Map, written: str, anchors: list[dict], key: Optional[str] = None) -> dict:
366
+ """One name against its anchors (those of `key`, a change or specs/<capability>): what became of it."""
367
+ item = {"written": written, "anchored": True, "was": [a["label"] for a in anchors], "ids": [],
368
+ "was_ids": [f"{r}:{a['node']}" for a in anchors for r in m.repos]}
369
+ found = {id(a): m.find(a) for a in anchors}
370
+ taken = {i for i in found.values() if i}
371
+ gone = 0
372
+ for a in anchors:
373
+ i = found[id(a)] or _same_thing(m, a, taken)
374
+ if i is None:
375
+ gone += 1
376
+ continue
377
+ taken.add(i)
378
+ item["ids"].append(i)
379
+ fp = fingerprint(m.con, m.names, i)
380
+ if fp["path"] != a["path"] or fp["label"] != a["label"]:
381
+ item.setdefault("moved", {"to": fp["label"], "to_path": fp["path"], "was": a["label"], "was_path": a["path"]})
382
+ lost = sorted(set(a.get("members") or []) - set(fp.get("members") or [])) if a.get("members") is not None else []
383
+ if a.get("decl_hash") and fp["decl_hash"] and (fp["decl_hash"] != a["decl_hash"] or lost):
384
+ item.setdefault("signature", {"was": a["decl"], "now": fp["decl"], "lost": lost})
385
+ elif fp["body_hash"] != a.get("body_hash"):
386
+ item["body"] = True
387
+ if gone and gone == len(anchors):
388
+ from . import renames # gone under this name, but likely there under another
389
+ to = renames.find(m, anchors[0], key) if len(anchors) == 1 or len({a["label"] for a in anchors}) == 1 else None
390
+ if to is not None:
391
+ item["renamed"] = {**to, "was": anchors[0]["label"], "was_path": anchors[0]["path"]}
392
+ item["ids"] += [to["id"]] if to.get("id") else []
393
+ else:
394
+ item["gone"] = {"was": anchors[0]["label"], "was_path": anchors[0]["path"]}
395
+ elif gone and "signature" not in item: # one of several overloads went
396
+ item["signature"] = {"was": f"{len(anchors)} overloads", "now": f"{len(anchors) - gone}", "lost": []}
397
+ r = m.names.resolve(written)
398
+ if "ambiguous" in r and "gone" not in item:
399
+ item["ambiguous"] = m.labels(r["ambiguous"])
400
+ item["state"] = next(s for s in ORDER if s == "ok" or item.get(s))
401
+ return item
402
+
403
+
404
+ def _owners(m: _Map, written: str) -> list[str]:
405
+ """For `Owner.member`, the types called Owner (when there are several, the name alone cannot pick one)."""
406
+ parts = written.split(".")
407
+ if len(parts) != 2 or not all(spec.IDENT.fullmatch(x) for x in parts):
408
+ return []
409
+ return [r["id"] for r in m.names.by_name.get(parts[0], []) if r["kind"] == "type"]
410
+
411
+
412
+ def judge_name(m: _Map, written: str) -> Optional[dict]:
413
+ """A name with no anchor, by name alone. None when it is a word, not code on the map."""
414
+ r = m.names.resolve(written)
415
+ item = {"written": written, "anchored": False, "ids": r.get("ids", [])}
416
+ if "ids" in r:
417
+ item["state"] = "ok"
418
+ elif "ambiguous" in r: # never pinned down, so not drift: the spec should say which
419
+ item.update(state="unclear", ambiguous=m.labels(r["ambiguous"]))
420
+ elif "new" in r and r.get("parent") in m.names.by_id:
421
+ owner = m.names.by_id[r["parent"]]
422
+ item.update(state="gone", gone={"owner": owner["path"] if owner["kind"] == "file" else owner["name"],
423
+ "leaf": r["new"]}, ids=[r["parent"]])
424
+ elif "new" in r and _owners(m, written): # `Journal.forget` where every type called Journal lacks `forget`
425
+ owners = _owners(m, written)
426
+ item.update(state="gone", gone={"owner": m.names.by_id[owners[0]]["name"], "leaf": written.rsplit(".", 1)[1]},
427
+ ids=owners)
428
+ elif "new" in r and r.get("file") and "/" in written:
429
+ from . import renames # a file git records as renamed
430
+ to = renames.for_file(m, written)
431
+ if to:
432
+ item.update(state="renamed", renamed={**to, "was": written, "was_path": written})
433
+ else:
434
+ item.update(state="gone", gone={"file": True})
435
+ else:
436
+ return None
437
+ return item
438
+
439
+
440
+ def _pick(index: dict, written: str, cap: Optional[str]) -> Optional[tuple[str, list[dict]]]:
441
+ """The anchors a living spec's name is read against: its own (`specs/<capability>`), else those of the change
442
+ that wrote this spec's delta, else the latest change that named it."""
443
+ held = index.get(written) or {}
444
+ if not held:
445
+ return None
446
+ if cap and f"specs/{cap}" in held:
447
+ return f"specs/{cap}", held[f"specs/{cap}"][1]
448
+ mine = [k for k, (_, xs) in held.items() if cap and any(f"specs/{cap}/spec.md" in x.get("from", []) for x in xs)]
449
+ keys = mine or [k for k in held if not k.startswith("specs/")]
450
+ if not keys:
451
+ return None
452
+ key = max(keys, key=lambda k: (held[k][0] or "", k))
453
+ return key, held[key][1]
454
+
455
+
456
+ def _index(anchors: dict) -> dict:
457
+ """written -> key -> (recorded, anchors). A spec may write a name another way than the change did
458
+ (`make_engine` for `core.make_engine`), so an anchor's label and bare name find it too."""
459
+ index: dict = {}
460
+ for how in ("written", "label", "name"):
461
+ by: dict = defaultdict(dict)
462
+ for key, e in anchors.items():
463
+ for a in e.get("anchors") or []:
464
+ if a.get(how):
465
+ by[a[how]].setdefault(key, (e.get("recorded"), []))[1].append(a)
466
+ for k, v in by.items():
467
+ index.setdefault(k, v)
468
+ return index
469
+
470
+
471
+ def report(con, dirs: list[Path]) -> dict:
472
+ """Every living spec, archived change and anchored change in these openspec/ folders, name by name."""
473
+ m = _Map(con)
474
+ try:
475
+ return _report(con, dirs, m)
476
+ finally:
477
+ m.close()
478
+
479
+
480
+ def _report(con, dirs: list[Path], m) -> dict:
481
+ groups, problems, used = [], [], set()
482
+ m.claimed = {i for osd in dirs for e in load(con, osd)[0].values() for a in e.get("anchors") or [] for i in [m.find(a)] if i}
483
+ for osd in dirs:
484
+ anchors, trouble = load(con, osd)
485
+ problems += trouble
486
+ index = _index(anchors)
487
+ root = osd.parent
488
+ rel = lambda p: Path(p).resolve().relative_to(root.resolve()).as_posix()
489
+ for f in sorted((osd / "specs").rglob("spec.md")) if (osd / "specs").is_dir() else []:
490
+ cap = f.parent.relative_to(osd / "specs").as_posix()
491
+ items = []
492
+ for w in _code_names(f.read_text(encoding="utf-8", errors="replace")):
493
+ hit = _pick(index, w, cap)
494
+ if hit: # reported here, so not again under the change that anchored it
495
+ used |= {(hit[0], a["written"]) for a in hit[1]} | {a["node"] for a in hit[1]}
496
+ it = judge(m, w, hit[1], hit[0]) if hit else judge_name(m, w)
497
+ if it is not None and hit:
498
+ it["anchor_key"] = hit[0]
499
+ items.append(it)
500
+ groups.append({"kind": "spec", "source": rel(f), "capability": cap, "items": items})
501
+ archived = {}
502
+ arch = osd / "changes" / "archive"
503
+ for d in sorted(arch.iterdir()) if arch.is_dir() else []:
504
+ if d.is_dir():
505
+ archived[DATED.sub("", d.name)] = d
506
+ for key in sorted(set(archived) | {k for k in anchors if not k.startswith("specs/")}):
507
+ mine = {a["written"]: [] for a in anchors.get(key, {}).get("anchors") or []}
508
+ for a in anchors.get(key, {}).get("anchors") or []:
509
+ mine[a["written"]].append(a)
510
+ items = []
511
+ for w, xs in sorted(mine.items()):
512
+ if (key, w) not in used and not all(a["node"] in used for a in xs):
513
+ items.append({**judge(m, w, xs, key), "anchor_key": key})
514
+ if key in archived:
515
+ for w in _change_names(archived[key]):
516
+ if w not in mine:
517
+ items.append(judge_name(m, w))
518
+ groups.append({"kind": "change", "source": key, "change": key,
519
+ **({"archived_as": rel(archived[key])} if key in archived else {}), "items": items})
520
+ for g in groups:
521
+ g["words"] = sum(1 for it in g["items"] if it is None)
522
+ g["items"] = [it for it in g["items"] if it is not None]
523
+ counts = {s: sum(1 for g in groups for it in g["items"] if it["state"] == s) for s in ORDER}
524
+ counts["words"] = sum(g["words"] for g in groups)
525
+ counts["unanchored"] = sum(1 for g in groups for it in g["items"] if it["state"] == "ok" and not it["anchored"])
526
+ return {"places": [str(p) for p in dirs], "groups": groups, "counts": counts, "problems": problems,
527
+ "specs": sum(g["kind"] == "spec" for g in groups), "changes": sum(g["kind"] == "change" for g in groups),
528
+ "names": sum(len(g["items"]) for g in groups), "fails": any(counts[s] for s in FAILS)}
529
+
530
+
531
+ def accept(con, dirs: list[Path]) -> dict:
532
+ """The person says the specs and the code agree as they are now: anchor each living spec's names afresh, and
533
+ bring each change's anchors up to the code they found. Accepting cannot make code that is gone agree, so an
534
+ anchor whose code is gone is kept as it was, and goes on being reported until the spec stops naming it."""
535
+ m = _Map(con)
536
+ try:
537
+ return _accept(con, dirs, m)
538
+ finally:
539
+ m.close()
540
+
541
+
542
+ def _accept(con, dirs: list[Path], m) -> dict:
543
+ done = {}
544
+ for osd in dirs:
545
+ anchors, _ = load(con, osd)
546
+ index = _index(anchors)
547
+ for key, e in sorted(anchors.items()):
548
+ if key.startswith("specs/"):
549
+ continue
550
+ fresh = []
551
+ for w in sorted({a["written"] for a in e.get("anchors") or []}):
552
+ xs = [a for a in e["anchors"] if a["written"] == w]
553
+ taken: set = set()
554
+ for a in xs:
555
+ i = m.find(a) or _same_thing(m, a, taken)
556
+ if i and i not in taken:
557
+ taken.add(i)
558
+ fresh.append(_anchor(w, a.get("from") or [], fingerprint(con, m.names, i)))
559
+ elif i is None:
560
+ fresh.append(a)
561
+ fresh.sort(key=lambda a: (a["written"], a["node"]))
562
+ done[key] = _put(con, osd, key, fresh)["count"]
563
+ for f in sorted((osd / "specs").rglob("spec.md")) if (osd / "specs").is_dir() else []:
564
+ cap = f.parent.relative_to(osd / "specs").as_posix()
565
+ src = f"specs/{cap}/spec.md"
566
+ written = _code_names(f.read_text(encoding="utf-8", errors="replace"))
567
+ fresh = _anchors_for(con, m.names, {w: [src] for w in written})
568
+ have = {a["written"] for a in fresh}
569
+ for w in written: # gone now: keep what it meant, so it is still reported
570
+ hit = None if w in have or "ambiguous" in m.names.resolve(w) else _pick(index, w, cap)
571
+ fresh += [{**a, "written": w, "from": [src]} for a in hit[1] if not m.find(a)] if hit else []
572
+ fresh.sort(key=lambda a: (a["written"], a["node"]))
573
+ done[f"specs/{cap}"] = _put(con, osd, f"specs/{cap}", fresh)["count"]
574
+ return done
575
+
576
+
577
+ def run(db: str | Path, path: Optional[str | Path] = None, do_accept: bool = False) -> dict:
578
+ """`leyline drift`: bring the map up to date, then compare every spec's code names with it."""
579
+ from . import loop
580
+ reindexed = loop.refresh(db)
581
+ con = store.connect(db)
582
+ try:
583
+ dirs = places(con, path if path is not None else Path.cwd())
584
+ accepted = accept(con, dirs) if do_accept else None
585
+ out = report(con, dirs)
586
+ finally:
587
+ con.close()
588
+ out["reindexed"] = bool(reindexed)
589
+ if accepted is not None:
590
+ out["accepted"] = accepted
591
+ return out
592
+
593
+
594
+ # -- saying it --------------------------------------------------------------------------------------
595
+ def _where(path: Optional[str]) -> str:
596
+ return f" in {path}" if path else ""
597
+
598
+
599
+ def line(it: dict) -> str:
600
+ """One name, as a sentence."""
601
+ w = f"`{it['written']}`"
602
+ if it.get("gone"):
603
+ g = it["gone"]
604
+ if g.get("file"):
605
+ return f"{w} is gone: there is no such file now."
606
+ if "owner" in g:
607
+ return f"{w} is gone: {g['owner']} has no `{g['leaf']}` now."
608
+ return f"{w} is gone: nothing on the map answers to it now (it was {g['was']}{_where(g['was_path'])})."
609
+ if it.get("renamed"):
610
+ rn = it["renamed"]
611
+ if rn["how"].startswith("git"):
612
+ return f"{w} has been renamed to `{rn['to']}`: {rn['how']}."
613
+ return (f"{w} has been renamed to `{rn['to']}`{_where(rn['to_path'])}: nothing answers to the old name now, and"
614
+ f" `{rn['to']}` {rn['how']}.")
615
+ bits = []
616
+ if it.get("signature"):
617
+ s = it["signature"]
618
+ if s["lost"]:
619
+ bits.append(f"{w} has lost {spec._and([f'`{x}`' for x in s['lost']])} since the spec was written")
620
+ if s["was"] != s["now"]:
621
+ bits.append(f"{w} has changed signature since the spec was written: was `{s['was']}`, now `{s['now']}`")
622
+ if it.get("moved"):
623
+ mv = it["moved"]
624
+ same = mv["to"] == mv["was"]
625
+ where = f"it is now in {mv['to_path']}" if same else f"it is now {mv['to']}{_where(mv['to_path'])}"
626
+ was = f"was in {mv['was_path']}" if same else f"was {mv['was']}{_where(mv['was_path'])}"
627
+ bits.append(f"{w} has moved: {where} ({was})" if not bits else f"it has also moved: {where}")
628
+ if it.get("ambiguous"):
629
+ could = it["ambiguous"]
630
+ now = " now" if it["anchored"] else ""
631
+ bits.append(f"{w} could{now} be {len(could)} things ({spec._some(could, 3)}); write it as `Owner.name` or"
632
+ " `path/to/file: name`" if not bits else f"the name could{now} be {len(could)} things")
633
+ if not bits and it.get("body"):
634
+ return f"{w} has changed inside since the spec was written; its signature is the same."
635
+ return ". ".join(b[0].upper() + b[1:] if k else b for k, b in enumerate(bits)) + "." if bits else f"{w} matches the code."
636
+
637
+
638
+ def _title(g: dict) -> str:
639
+ if g["kind"] == "spec":
640
+ return g["source"]
641
+ return f"Change {g['change']}" + (f" (archived in {g['archived_as']})" if g.get("archived_as") else "")
642
+
643
+
644
+ def text(r: dict) -> str:
645
+ c = r["counts"]
646
+ bad = sum(c[s] for s in DRIFTED)
647
+ L = ["# Spec drift", ""]
648
+ if not r["places"]:
649
+ return ("# Spec drift\n\nNo openspec/ folder here or in the mapped repositories, so there are no specs to check.\n")
650
+ L.append(f"Checked {spec._n(r['names'], 'code name')} in {spec._n(r['specs'], 'living spec')} and"
651
+ f" {spec._n(r['changes'], 'change')} against the map.")
652
+ if bad:
653
+ parts = [f"{c['gone']} {'is' if c['gone'] == 1 else 'are'} gone" if c["gone"] else "",
654
+ f"{c['renamed']} {'was' if c['renamed'] == 1 else 'were'} renamed" if c["renamed"] else "",
655
+ f"{c['signature']} changed signature" if c["signature"] else "",
656
+ f"{c['moved']} moved" if c["moved"] else "",
657
+ f"{c['ambiguous']} could now be several things" if c["ambiguous"] else ""]
658
+ L.append(f"{spec._n(bad, 'name no longer matches', 'names no longer match')} the code: "
659
+ f"{spec._and([p for p in parts if p])}.")
660
+ else:
661
+ L.append("Every name that is code still matches it.")
662
+ if c["unclear"]:
663
+ L[-1] += (f" {spec._n(c['unclear'], 'name')} could be several things, so what {'it names' if c['unclear'] == 1 else 'they name'}"
664
+ " cannot be followed; write each more fully.")
665
+ for g in r["groups"]:
666
+ shown = [it for it in g["items"] if it["state"] in SHOWN]
667
+ inside = [it for it in g["items"] if it["state"] == "body"]
668
+ if not shown and not inside:
669
+ continue
670
+ L += ["", f"## {_title(g)}", ""] + [f"- {line(it)}" for it in sorted(shown, key=lambda x: ORDER.index(x["state"]))]
671
+ if inside:
672
+ quoted = ["`" + it["written"] + "`" for it in inside]
673
+ L.append(f"- Changed inside since the spec was written, same signature (read them to be sure the spec still"
674
+ f" holds): {spec._some(quoted, 6)}.")
675
+ L.append("")
676
+ tail = [f"Up to date: {spec._n(c['ok'], 'name')}."]
677
+ if c["unanchored"]:
678
+ one = c["unanchored"] == 1
679
+ tail.append(f"{c['unanchored']} of them {'has' if one else 'have'} no record of what {'it' if one else 'they'}"
680
+ f" meant, so only the name is checked and a changed signature would not show; `leyline drift --accept`"
681
+ f" records {'it' if one else 'them'}.")
682
+ if c["words"]:
683
+ tail.append(f"{spec._n(c['words'], 'other word')} in backticks {'is' if c['words'] == 1 else 'are'} not code on the map"
684
+ " and not checked.")
685
+ L.append(" ".join(tail))
686
+ for p in r.get("problems") or []:
687
+ L.append(f"Note: {p}.")
688
+ if r.get("accepted") is not None:
689
+ L.append(f"Recorded the code as it is now for {spec._n(len(r['accepted']), 'spec or change', 'specs and changes')}"
690
+ f" ({sum(r['accepted'].values())} names) in openspec/{ANCHOR_FILE}.")
691
+ return "\n".join(L) + "\n"
692
+
693
+
694
+ def next_steps(r: dict, for_agent: bool = False) -> list[str]:
695
+ c = r["counts"]
696
+ if not r["places"]:
697
+ return []
698
+ if any(c[s] for s in SHOWN):
699
+ return ["Next: for each name above, update the spec to name the code as it is (or change the code back, if the"
700
+ " spec is right), then " + ("ask the person to confirm and call `drift` with accept=true" if for_agent else
701
+ "run `leyline drift --accept`") + " to record the code as it is now."]
702
+ if c["unanchored"] and not r.get("accepted"):
703
+ return ["Next: nothing has drifted. When the specs and the code agree, " + (
704
+ "call `drift` with accept=true" if for_agent else "`leyline drift --accept`")
705
+ + " records what each name means now, so a later change of signature shows."]
706
+ return ["Next: nothing to do; the specs match the code."]
707
+
708
+
709
+ # -- the plan's warning ----------------------------------------------------------------------------------
710
+ def _near(a: str, b: str) -> bool:
711
+ return a == b or b.startswith((a + ".", a + "(", a + "/")) or a.startswith((b + ".", b + "(", b + "/"))
712
+
713
+
714
+ def touching(con, change_dir: str | Path, ids: list[str]) -> list[str]:
715
+ """Specs that have drifted from code a planned change touches, one sentence each: "The living spec
716
+ auth/spec.md names `Session.renew`, which has changed signature since it was written." """
717
+ if not ids:
718
+ return []
719
+ dirs = places(con, change_dir)
720
+ if not dirs:
721
+ return []
722
+ r = report(con, dirs)
723
+ names = spec._Names(con)
724
+
725
+ def ancestors(i):
726
+ out = []
727
+ while i in names.by_id:
728
+ out.append(i)
729
+ i = names.by_id[i]["parent_id"]
730
+ return out
731
+ own = DATED.sub("", Path(change_dir).name) # the planned change's own anchors, from an earlier check
732
+ out = []
733
+ for g in r["groups"]:
734
+ for it in g["items"]:
735
+ if it["state"] not in DRIFTED or it.get("anchor_key") == own or g.get("change") == own:
736
+ continue
737
+ mine = set(it.get("ids", [])) | set(it.get("was_ids", []))
738
+ if not any(_near(t, x) or t in ancestors(x) for t in ids for x in mine):
739
+ continue
740
+ who = (f"The living spec {g['capability']}/spec.md" if g["kind"] == "spec" else f"The change {g['change']}")
741
+ what = ("which is gone" if it["state"] == "gone" else
742
+ f"which has been renamed to `{it['renamed']['to']}`" if it["state"] == "renamed" else
743
+ "which has changed signature since it was written" if it["state"] == "signature" else
744
+ f"which has moved to {it['moved']['to_path']} since it was written" if it["state"] == "moved" else
745
+ f"which could now be {len(it['ambiguous'])} things")
746
+ out.append(f"{who} names `{it['written']}`, {what}. `leyline drift` says more.")
747
+ return list(dict.fromkeys(out))