leyline-code 0.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/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))
|