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/change.py ADDED
@@ -0,0 +1,500 @@
1
+ """Assess a proposed change before any code is written, and save views of the result.
2
+
3
+ A change is a list of targets, each with an action. The assessment is deterministic: it follows
4
+ callers, interface links, channels and flows in the store. Deciding which nodes a written spec
5
+ touches is the caller's job (a person, or an agent using search and expand).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import hashlib
12
+ import json
13
+ import sqlite3
14
+ from collections import defaultdict
15
+ from typing import Optional
16
+
17
+ ACTIONS = ("behavior", "signature", "remove", "rename", "add")
18
+ DATA_CHANNELS = ("file", "db", "format") # one side writes, the other reads later; nothing runs across
19
+ BREAKING = ("signature", "remove", "rename")
20
+ ROLE_ORDER = ["changed", "new", "contract", "must_edit", "direct", "test", "indirect", "note"]
21
+
22
+
23
+ def _load(con):
24
+ nodes = {r["id"]: r for r in con.execute("SELECT id, kind, name, parent_id, path, span_start, attrs FROM nodes")}
25
+ anc = {r["node_id"]: (r["file_id"], r["module_id"]) for r in con.execute("SELECT * FROM ancestry")}
26
+ return nodes, anc
27
+
28
+
29
+ def _unit(nodes, i: str) -> Optional[str]:
30
+ n = nodes.get(i)
31
+ if n is None or n["kind"] in ("module", "repo", "external", "system"):
32
+ return None
33
+ if n["kind"] == "file":
34
+ return i
35
+ cur = i
36
+ while nodes[cur]["parent_id"] in nodes and nodes[nodes[cur]["parent_id"]]["kind"] != "file":
37
+ cur = nodes[cur]["parent_id"]
38
+ return cur if nodes[cur]["kind"] == "type" else nodes[cur]["parent_id"]
39
+
40
+
41
+ def _label(nodes, i: str) -> str:
42
+ n = nodes[i]
43
+ if n["kind"] in ("callable", "field", "test"):
44
+ u = _unit(nodes, i)
45
+ owner = nodes[u]["name"] if u and u != i else ""
46
+ return f"{owner}.{n['name']}" if owner and n["kind"] != "test" else n["name"]
47
+ return n["name"]
48
+
49
+
50
+ def enclosing(nodes, i: str) -> list[str]:
51
+ """The functions a nested function, closure or lambda is defined in, innermost first. Whoever calls one
52
+ of them can run the nested code, so a change inside it reaches their callers too. `nodes` maps an id
53
+ to a row with `kind` and `parent_id`."""
54
+ out = []
55
+ cur = nodes[i]["parent_id"] if i in nodes else None
56
+ while cur in nodes and nodes[cur]["kind"] not in ("file", "module", "repo"):
57
+ if nodes[cur]["kind"] in ("callable", "test"):
58
+ out.append(cur)
59
+ cur = nodes[cur]["parent_id"]
60
+ return out
61
+
62
+
63
+ def _side_entry(path: str) -> bool:
64
+ from .diagrams import _side_entry as side
65
+ return side(path)
66
+
67
+
68
+ def assess(con, intent: str, targets: list[dict], depth: int = 4, test_entries: Optional[dict] = None) -> dict:
69
+ """Work out what a change reaches. Each target is {id, action, note?}; for action `add` it is
70
+ {action: "add", name, parent, uses?: [ids], used_by?: [ids], related?: [ids], note?}. `test_entries` maps entry points that are
71
+ tests of their own (a script that runs its checks and prints a result for each) to what to call them."""
72
+ test_entries = test_entries or {}
73
+ nodes, anc = _load(con)
74
+ problems = []
75
+ for t in targets:
76
+ if t.get("action") not in ACTIONS:
77
+ problems.append(f"action must be one of {ACTIONS}, got {t.get('action')!r}")
78
+ elif t["action"] == "add":
79
+ if not t.get("name") or t.get("parent") not in nodes:
80
+ problems.append(f"an added node needs a name and an existing parent id: {t}")
81
+ elif t.get("id") not in nodes:
82
+ problems.append(f"no node with id {t.get('id')!r}")
83
+ if problems:
84
+ return {"error": "; ".join(problems)}
85
+
86
+ callers: dict[str, list] = defaultdict(list) # callee -> [(caller, precision)]
87
+ # A link is a guess only when every call site behind it is one.
88
+ for r in con.execute("SELECT src_id, dst_id, MIN(precision = 'guess') AS guess FROM calls GROUP BY src_id, dst_id"
89
+ " ORDER BY dst_id, src_id"):
90
+ callers[r["dst_id"]].append((r["src_id"], "calls", bool(r["guess"])))
91
+ addresses: dict[tuple, str] = {}
92
+ for r in con.execute("SELECT src_id, dst_id, precision, attrs FROM edges WHERE kind = 'communicates'"):
93
+ a = json.loads(r["attrs"]) if r["attrs"] else {}
94
+ ch = a.get("channel", "channel")
95
+ if ch in DATA_CHANNELS:
96
+ # Data, not control: a change to what is written reaches whoever reads it, so the reader depends on the writer.
97
+ callers[r["src_id"]].append((r["dst_id"], ch, r["precision"] == "guess"))
98
+ addresses[(r["dst_id"], r["src_id"])] = a.get("address") or ""
99
+ else:
100
+ callers[r["dst_id"]].append((r["src_id"], ch, r["precision"] == "guess"))
101
+ addresses[(r["src_id"], r["dst_id"])] = a.get("address") or ""
102
+ bases: dict[str, list] = defaultdict(list)
103
+ impls: dict[str, list] = defaultdict(list)
104
+ for r in con.execute("SELECT src_id, dst_id FROM edges WHERE kind = 'overrides'"):
105
+ bases[r["src_id"]].append(r["dst_id"])
106
+ impls[r["dst_id"]].append(r["src_id"])
107
+ type_users: dict[str, list] = defaultdict(list)
108
+ for r in con.execute("SELECT src_id, dst_id, kind FROM edges WHERE kind IN ('uses_type','instantiates','extends','implements')"):
109
+ type_users[r["dst_id"]].append((r["src_id"], r["kind"]))
110
+ field_users: dict[str, list] = defaultdict(list) # field -> [(function, reads|writes, guessed)]
111
+ for r in con.execute("SELECT src_id, dst_id, kind, precision FROM edges WHERE kind IN ('reads', 'writes')"):
112
+ field_users[r["dst_id"]].append((r["src_id"], r["kind"], r["precision"] == "guess"))
113
+ kids: dict[str, list] = defaultdict(list)
114
+ for i, n in nodes.items():
115
+ if n["parent_id"]:
116
+ kids[n["parent_id"]].append(i)
117
+
118
+ marks: dict[str, dict] = {}
119
+
120
+ def mark(i: str, role: str, note: str = "", dist: Optional[int] = None) -> None:
121
+ cur = marks.get(i)
122
+ if cur is None or ROLE_ORDER.index(role) < ROLE_ORDER.index(cur["role"]):
123
+ marks[i] = {"id": i, "role": role, "note": note or (cur or {}).get("note", ""), "distance": dist}
124
+ elif note and not cur.get("note"):
125
+ cur["note"] = note
126
+
127
+ touched: set[str] = set() # callables whose body or contract changes, and readers of a changed field
128
+ readers: set[str] = set() # of those, the readers: reached through what they read, not changed
129
+ own: set[str] = set() # the code the targets themselves hold
130
+ breaking: set[str] = set()
131
+ new_nodes = []
132
+ for t in targets:
133
+ if t["action"] == "add":
134
+ new_nodes.append({"name": t["name"], "parent": t["parent"], "note": t.get("note", ""),
135
+ "uses": [u for u in t.get("uses", []) if u in nodes],
136
+ "used_by": [u for u in t.get("used_by", []) if u in nodes]})
137
+ for u in t.get("used_by", []):
138
+ if u in nodes:
139
+ mark(u, "must_edit", f"will call the new {t['name']}", 1)
140
+ if nodes[u]["kind"] in ("callable", "test"):
141
+ touched.add(u)
142
+ for u in t.get("uses", []):
143
+ if u in nodes:
144
+ mark(u, "direct", f"used by the new {t['name']}", 1)
145
+ for u in t.get("related", []): # named beside it: worth reading, but adding code does not reach it
146
+ if u in nodes:
147
+ mark(u, "note", f"named beside the new {t['name']}", 1)
148
+ continue
149
+ i, n = t["id"], nodes[t["id"]]
150
+ mark(i, "changed", t.get("note", "") or t["action"], 0)
151
+ members = [i] if n["kind"] in ("callable", "test") else []
152
+ stack = [i] if n["kind"] not in ("callable", "test") else []
153
+ while stack:
154
+ cur = stack.pop()
155
+ for k in kids.get(cur, []):
156
+ if nodes[k]["kind"] in ("callable", "test"):
157
+ members.append(k)
158
+ elif nodes[k]["kind"] in ("type", "file"):
159
+ stack.append(k)
160
+ own.update(members)
161
+ if n["kind"] == "field":
162
+ # Everything that reads or assigns the field depends on what it holds.
163
+ how: dict[str, set] = defaultdict(set)
164
+ for user, kind, guess in field_users.get(i, []):
165
+ if user in nodes:
166
+ how[user].add(("assigns" if kind == "writes" else "reads") + (" (a guess)" if guess else ""))
167
+ for user, verbs in sorted(how.items()):
168
+ note = f"{' and '.join(sorted(verbs))} {n['name']}"
169
+ mark(user, "must_edit" if t["action"] in BREAKING else "direct", note, 1)
170
+ if t["action"] != "rename" and nodes[user]["kind"] in ("callable", "test"):
171
+ members.append(user) # what it computes may change, so its callers are reached
172
+ readers.add(user)
173
+ if n["kind"] == "system":
174
+ for r in con.execute("SELECT dst_id FROM edges WHERE kind = 'groups' AND src_id = ?", (i,)):
175
+ stack = [r[0]]
176
+ while stack:
177
+ cur = stack.pop()
178
+ for k in kids.get(cur, []):
179
+ (members.append(k) if nodes[k]["kind"] in ("callable", "test") else stack.append(k))
180
+ touched.update(members)
181
+ if t["action"] in BREAKING:
182
+ breaking.update(members if n["kind"] in ("callable",) else [])
183
+ if n["kind"] == "type":
184
+ for user, kind in type_users.get(i, []):
185
+ mark(user, "must_edit", f"{kind.replace('_', ' ')} {n['name']}", 1)
186
+ if n["kind"] == "callable":
187
+ for b in bases.get(i, []):
188
+ mark(b, "contract", "the interface or base method this implements; it must change too", 1)
189
+ for sib in impls.get(b, []):
190
+ if sib != i:
191
+ mark(sib, "must_edit", f"also implements {_label(nodes, b)}", 1)
192
+ for c, via, _g in callers.get(b, []):
193
+ mark(c, "must_edit", f"calls it through {_label(nodes, b)}", 1)
194
+ for impl in impls.get(i, []):
195
+ mark(impl, "must_edit", f"implements {_label(nodes, i)}", 1)
196
+
197
+ # Walk callers outward from everything touched. A function defined inside another runs only when its
198
+ # enclosing function does, so the encloser joins the walk at the same distance: the hops between a
199
+ # closure and the function around it are inside one body and do not use up the depth.
200
+ dist: dict[str, int] = {t: 0 for t in touched}
201
+ guessed_links = 0
202
+ channel_links = []
203
+ frontier = list(touched)
204
+
205
+ def lift(found: list[str], d: int) -> list[str]:
206
+ more = []
207
+ for i in found:
208
+ for e in enclosing(nodes, i):
209
+ if e in dist:
210
+ break
211
+ dist[e] = d
212
+ more.append(e)
213
+ if d == 0:
214
+ mark(e, "direct", f"defines {nodes[i]['name']}, so whoever calls it runs the change", 1)
215
+ return more
216
+
217
+ frontier += lift(frontier, 0)
218
+ for d in range(1, depth + 1):
219
+ nxt = []
220
+ for cur in frontier:
221
+ sources = list(callers.get(cur, []))
222
+ for b in bases.get(cur, []): # callers of the interface reach the implementation
223
+ sources.extend(callers.get(b, []))
224
+ for src, via, guess in sources:
225
+ if src not in nodes:
226
+ continue
227
+ if d == 1 and guess:
228
+ guessed_links += 1
229
+ if via not in ("calls",) and src not in dist:
230
+ channel_links.append({"from": src, "to": cur, "channel": via, "guessed": bool(guess),
231
+ "address": addresses.get((src, cur), ""), "data": via in DATA_CHANNELS})
232
+ if src in dist:
233
+ continue
234
+ dist[src] = d
235
+ nxt.append(src)
236
+ if d == 1 and via in DATA_CHANNELS: # a reader of what it writes makes no call to change
237
+ mark(src, "direct", f"reads what it writes ({via})" + (" (link is a guess)" if guess else ""), 1)
238
+ elif d == 1:
239
+ role = "must_edit" if cur in breaking else "direct"
240
+ mark(src, role, ("its call must change" if cur in breaking else "calls it directly")
241
+ + (" (link is a guess)" if guess else ""), 1)
242
+ else:
243
+ mark(src, "indirect", f"{d} calls away", d)
244
+ for e in lift(nxt, d):
245
+ nxt.append(e)
246
+ if d == 1:
247
+ mark(e, "direct", "defines a function that calls it", 1)
248
+ else:
249
+ mark(e, "indirect", f"{d} calls away, through a function defined inside it", d)
250
+ frontier = nxt
251
+
252
+ # Flows and tests.
253
+ flows = []
254
+ tested: set[str] = set()
255
+ if touched:
256
+ marks_sql = ",".join("?" * len(touched))
257
+ for f in con.execute(
258
+ f"SELECT f.id, f.name, f.entry_id, f.attrs, COUNT(*) AS hits FROM flows f JOIN flow_steps s"
259
+ f" ON s.flow_id = f.id WHERE s.callable_id IN ({marks_sql}) GROUP BY f.id ORDER BY hits DESC, f.name",
260
+ sorted(touched)):
261
+ a = json.loads(f["attrs"]) if f["attrs"] else {}
262
+ self_test = f["entry_id"] in test_entries
263
+ flows.append({"id": f["id"], "name": test_entries[f["entry_id"]] if self_test else f["name"],
264
+ "kind": "test" if self_test else a.get("kind"), "trigger": a.get("detail"),
265
+ "touched_steps": f["hits"], "entry": f["entry_id"], **({"self_test": True} if self_test else {})})
266
+ if a.get("kind") == "test" or self_test:
267
+ mark(f["entry_id"], "test", "passes through the change; run it", None)
268
+ for r in con.execute(
269
+ f"SELECT DISTINCT s.callable_id FROM flow_steps s JOIN flows f ON f.id = s.flow_id"
270
+ f" WHERE (json_extract(f.attrs, '$.kind') = 'test' OR f.entry_id IN ({','.join('?' * len(test_entries))}))"
271
+ f" AND s.callable_id IN ({marks_sql})", [*sorted(test_entries), *sorted(touched)]):
272
+ tested.add(r[0])
273
+ # Tests that were measured running the changed code, whether or not a static path leads there.
274
+ from . import coverage as measured
275
+ if measured.has(con):
276
+ have = {f["entry"] for f in flows}
277
+ for r in con.execute(
278
+ f"SELECT test, test_id, COUNT(DISTINCT node_id) AS hits FROM covered WHERE node_id IN ({marks_sql})"
279
+ f" AND test != '' GROUP BY test ORDER BY hits DESC, test", sorted(touched)):
280
+ if r["test_id"] and r["test_id"] in nodes and r["test_id"] not in have:
281
+ have.add(r["test_id"])
282
+ # its test flow: flow:<test>, or flow:<test>#test when the test is an entry point too
283
+ fid = con.execute("SELECT id FROM flows WHERE entry_id = ? AND json_extract(attrs, '$.kind') = 'test'"
284
+ " ORDER BY id LIMIT 1", (r["test_id"],)).fetchone()
285
+ flows.append({"id": fid[0] if fid else f"flow:{r['test_id']}", "name": nodes[r["test_id"]]["name"], "kind": "test",
286
+ "trigger": "measured", "touched_steps": r["hits"], "entry": r["test_id"], "measured": True})
287
+ mark(r["test_id"], "test", "ran the changed code when coverage was measured; run it", None)
288
+ elif r["test_id"] in have:
289
+ next(f for f in flows if f["entry"] == r["test_id"])["measured"] = True
290
+ for r in con.execute(f"SELECT DISTINCT node_id FROM covered WHERE node_id IN ({marks_sql})", sorted(touched)):
291
+ tested.add(r[0])
292
+ changed_fns = [t for t in touched if nodes[t]["kind"] == "callable" and (t in own or t not in readers)]
293
+ untested = sorted(t for t in changed_fns if t not in tested)
294
+
295
+ def module_of(i):
296
+ return (anc.get(i) or (None, None))[1]
297
+
298
+ system_of = {r["dst_id"]: r["src_id"] for r in con.execute("SELECT src_id, dst_id FROM edges WHERE kind = 'groups'")}
299
+ sys_name = {}
300
+ for s in con.execute("SELECT n.id, n.name, (SELECT value FROM annotations a WHERE a.node_id = n.id AND a.key = 'name'"
301
+ " ORDER BY a.layer = 'intent' DESC LIMIT 1) AS given FROM nodes n WHERE n.kind = 'system'"):
302
+ sys_name[s["id"]] = s["given"] or s["name"]
303
+ by_module: dict[str, dict] = {}
304
+ by_system: dict[str, dict] = {}
305
+ home_modules = {module_of(t) for t in touched} | {module_of(n["parent"]) or n["parent"] for n in new_nodes}
306
+ for i, m in marks.items():
307
+ if m["role"] in ("test", "note"):
308
+ continue
309
+ mod = module_of(i) or (i if nodes[i]["kind"] == "module" else None)
310
+ if mod:
311
+ g = by_module.setdefault(mod, {"module": nodes[mod]["name"], "id": mod, "repo": mod.split(":", 1)[0],
312
+ "changed": 0, "must_edit": 0, "reached": 0})
313
+ g["changed" if m["role"] in ("changed", "new") else "must_edit" if m["role"] in ("must_edit", "contract") else "reached"] += 1
314
+ u = _unit(nodes, i)
315
+ s = system_of.get(u) if u else None
316
+ if s:
317
+ g = by_system.setdefault(s, {"system": sys_name.get(s, s), "id": s, "changed": 0, "must_edit": 0, "reached": 0})
318
+ g["changed" if m["role"] in ("changed", "new") else "must_edit" if m["role"] in ("must_edit", "contract") else "reached"] += 1
319
+ for n in new_nodes: # new code counts where it will be written, as the summary counts it among what changes
320
+ p, mod = n.get("parent"), None
321
+ while p in nodes and mod is None:
322
+ mod = p if nodes[p]["kind"] == "module" else module_of(p)
323
+ p = nodes[p]["parent_id"]
324
+ if mod:
325
+ by_module.setdefault(mod, {"module": nodes[mod]["name"], "id": mod, "repo": mod.split(":", 1)[0],
326
+ "changed": 0, "must_edit": 0, "reached": 0})["changed"] += 1
327
+
328
+ must = [m for m in marks.values() if m["role"] in ("must_edit", "contract")]
329
+ tests = [f for f in flows if f["kind"] == "test"]
330
+ risks = []
331
+ crossing = sorted({by_module[m]["module"] for m in by_module if m not in home_modules
332
+ and (by_module[m]["must_edit"] or by_module[m]["reached"])})
333
+ if crossing:
334
+ risks.append({"level": "high" if any(by_module[m]["must_edit"] for m in by_module if m not in home_modules) else "medium",
335
+ "what": f"Reaches outside its own module: {', '.join(crossing)}."})
336
+ home_repos = {h.split(":", 1)[0] for h in home_modules if h}
337
+ other_repos = sorted({g["repo"] for g in by_module.values()} - home_repos)
338
+ if other_repos and home_repos:
339
+ risks.append({"level": "high" if any(g["must_edit"] for g in by_module.values() if g["repo"] in other_repos) else "medium",
340
+ "what": f"Reaches into another repository: {', '.join(other_repos)}. It is released and tested on its own, "
341
+ "so its side may need its own change."})
342
+ if channel_links:
343
+ chans = sorted({c["channel"] for c in channel_links})
344
+ behavioral = any(t["action"] in ("behavior", "remove") for t in targets)
345
+ risks.append({"level": "high" if behavioral else "low",
346
+ "what": f"Code on the far side of a channel ({', '.join(chans)}) reaches this. "
347
+ + ("Nothing type-checks the two sides against each other, so a change in what is sent or expected fails only at run time."
348
+ if behavioral else "A signature change cannot break it, but it will see any change in behavior.")})
349
+ if untested:
350
+ risks.append({"level": "high" if len(untested) == len(changed_fns) else "medium",
351
+ "what": f"{len(untested)} of {len(changed_fns)} changed functions are on no test's path."})
352
+ if guessed_links:
353
+ risks.append({"level": "low", "what": f"{guessed_links} direct caller links are guesses by name, so the caller list may be wrong."})
354
+ if any(t["action"] in BREAKING for t in targets) and not must:
355
+ risks.append({"level": "low", "what": "A breaking change with no callers found. Check for use through reflection, events or outside code."})
356
+
357
+ def brief(m):
358
+ n = nodes[m["id"]]
359
+ return {"id": m["id"], "name": _label(nodes, m["id"]), "kind": n["kind"], "path": n["path"],
360
+ "line": n["span_start"], "module": nodes[module_of(m["id"])]["name"] if module_of(m["id"]) else None,
361
+ "role": m["role"], "note": m["note"], "distance": m["distance"]}
362
+
363
+ ordered = sorted(marks.values(), key=lambda m: (ROLE_ORDER.index(m["role"]), m["distance"] or 0, m["id"]))
364
+ return {
365
+ "intent": intent,
366
+ "summary": {"changed": len([m for m in marks.values() if m["role"] == "changed"]), "added": len(new_nodes),
367
+ "must_edit": len(must), "reached": len([m for m in marks.values() if m["role"] in ("direct", "indirect")]),
368
+ "modules": len(by_module), "repos": len({g["repo"] for g in by_module.values()}), "systems": len(by_system), "flows": len(flows), "tests_to_run": len(tests),
369
+ "depth": depth},
370
+ "risks": risks,
371
+ "must_edit": [brief(m) for m in ordered if m["role"] in ("must_edit", "contract")][:80],
372
+ "tests_to_run": [{"name": f["name"], "id": f["entry"], "flow": f["id"], "touched_steps": f["touched_steps"],
373
+ **({"measured": True} if f.get("measured") else {})} for f in tests],
374
+ # a script's body is named by its file, never by the map's id for it (`app.tools.run.<module>`); the
375
+ # product's own entry points before scripts, benchmarks and examples
376
+ "entry_points_affected": [{"name": (nodes[f["entry"]]["path"] if f["entry"] in nodes and nodes[f["entry"]]["path"]
377
+ and nodes[f["entry"]]["name"] in ("<module>", "<top-level>") else f["name"]),
378
+ "trigger": f["trigger"], "id": f["entry"]}
379
+ for f in sorted((f for f in flows if f["kind"] != "test"),
380
+ key=lambda f: _side_entry(nodes[f["entry"]]["path"] or "" if f["entry"] in nodes else ""))][:40],
381
+ "untested": [{"id": t, "name": _label(nodes, t)} for t in untested][:40],
382
+ "channels": [{**c, "from_name": _label(nodes, c["from"]), "to_name": _label(nodes, c["to"])} for c in channel_links],
383
+ "by_module": sorted(by_module.values(), key=lambda g: -(g["changed"] * 100 + g["must_edit"] * 10 + g["reached"])),
384
+ "by_system": sorted(by_system.values(), key=lambda g: -(g["changed"] * 100 + g["must_edit"] * 10 + g["reached"])),
385
+ "new_nodes": new_nodes,
386
+ "marks": [brief(m) for m in ordered],
387
+ "limits": "Static analysis: it shows what the change can reach, not whether behavior stays correct. "
388
+ "Links found only at run time, and changes made by calling a method on a field (adding to a list), are not included.",
389
+ }
390
+
391
+
392
+ def propose(con, intent: str, targets: list[dict], title: Optional[str] = None, depth: int = 4,
393
+ source: str = "mcp", change_id: Optional[str] = None, keep_baseline: bool = True,
394
+ test_entries: Optional[dict] = None) -> dict:
395
+ """Assess a change, store it as a draft proposal, and save a view of its blast radius.
396
+ `change_id` keeps one id across revisions (a spec folder's name); without it the id follows the content.
397
+ The first proposal of a change keeps a baseline of the code to compare with later; a revision keeps that
398
+ baseline, unless `keep_baseline` is False, which takes a new one from the code as it is."""
399
+ report = assess(con, intent, targets, depth, test_entries)
400
+ if "error" in report:
401
+ return report
402
+ cid = change_id or "chg-" + hashlib.sha1((intent + json.dumps(targets, sort_keys=True)).encode()).hexdigest()[:8]
403
+ commit = con.execute("SELECT commit_sha FROM nodes WHERE kind = 'repo' LIMIT 1").fetchone()
404
+ title = title or (intent if len(intent) <= 70 else intent[:67] + "...")
405
+ # A revised proposal for code that has already moved on keeps the first picture of the code and the
406
+ # first prediction's ids, so what was done can still be compared with what the code was.
407
+ from . import diff
408
+ have = keep_baseline and diff.snapshot_path(con, cid).exists()
409
+ kept = have and diff.moved_on(con, cid)
410
+ old = con.execute("SELECT base_commit FROM change_proposals WHERE id = ?", (cid,)).fetchone() if kept else None
411
+ base = old[0] if old else (commit[0] if commit else None)
412
+ # Planned again after a check: the checks so far stay, and a change its last check found done is reopened.
413
+ was = con.execute("SELECT status, attrs FROM change_proposals WHERE id = ?", (cid,)).fetchone()
414
+ was_attrs = json.loads(was[1] or "{}") if was else {}
415
+ history = {k: was_attrs[k] for k in ("checks",) if k in was_attrs}
416
+ status = "draft"
417
+ if was and was[0] == "verified":
418
+ status = "reopened"
419
+ history["reopened"] = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
420
+ with con:
421
+ con.execute("INSERT OR REPLACE INTO change_proposals (id, intent, status, base_commit, head_commit, attrs)"
422
+ " VALUES (?,?,?,?,NULL,?)",
423
+ (cid, intent, status, base,
424
+ json.dumps({"title": title, "targets": targets, **history,
425
+ "report": {k: v for k, v in report.items() if k != "marks"}})))
426
+ marks = report["marks"]
427
+ if kept: # what the earlier brief predicted stays predicted: by now those things exist and look like plain edits
428
+ prior = con.execute("SELECT spec FROM views WHERE id = ?", ("view-" + cid,)).fetchone()
429
+ prior = json.loads(prior[0] or "{}") if prior else {}
430
+ have = {m["id"] for m in marks}
431
+ marks = marks + [m for m in prior.get("marks", []) if m["id"] not in have]
432
+ named = {n["name"] for n in report["new_nodes"]}
433
+ report["new_nodes"] = report["new_nodes"] + [n for n in prior.get("new_nodes", []) if n["name"] not in named]
434
+ if not any(m["role"] in ("changed", "new", "must_edit", "contract") for m in marks):
435
+ # A change that only adds code marks nothing it writes to: show it on where the new code will go.
436
+ marks = marks + [{"id": n["parent"], "role": "new", "note": f"will hold the new {n['name']}"} for n in report["new_nodes"]]
437
+ view = save_view(con, title, intent, marks, kind="change", source=source, change_id=cid,
438
+ extra={"summary": report["summary"], "risks": report["risks"], "tests_to_run": report["tests_to_run"],
439
+ "channels": report["channels"], "by_module": report["by_module"], "by_system": report["by_system"],
440
+ "new_nodes": report["new_nodes"], "untested": report["untested"],
441
+ "entry_points_affected": report["entry_points_affected"], "limits": report["limits"]},
442
+ view_id="view-" + cid)
443
+ if kept:
444
+ report["snapshot"] = "kept"
445
+ elif have: # the baseline is still the code as it is: nothing to copy
446
+ report["snapshot"] = "same"
447
+ else:
448
+ try: # keep the graph as it was when the change was proposed, to compare against later
449
+ diff.snapshot(con, cid)
450
+ report["snapshot"] = "new"
451
+ except (OSError, sqlite3.Error) as e: # a full disk, a folder that cannot be written
452
+ report["snapshot"] = False
453
+ report["snapshot_error"] = f"{type(e).__name__}: {e}"
454
+ report["change_id"], report["view_id"] = cid, view.get("id")
455
+ report["marks"] = report["marks"][:60]
456
+ return report
457
+
458
+
459
+ def save_view(con, title: str, narrative: str, marks: list[dict], kind: str = "custom", source: str = "mcp",
460
+ change_id: Optional[str] = None, extra: Optional[dict] = None, view_id: Optional[str] = None,
461
+ legend: Optional[dict] = None) -> dict:
462
+ """Save a named view: marked nodes, each with a role and a note, plus a narrative.
463
+ Roles are free text; the viewer colors changed, new, contract, must_edit, direct, indirect and test
464
+ specially and shows any other role as a labelled group."""
465
+ ids = {r[0] for r in con.execute("SELECT id FROM nodes")}
466
+ clean, missing = [], []
467
+ for m in marks:
468
+ if m.get("id") in ids:
469
+ clean.append({"id": m["id"], "role": str(m.get("role") or "note"), "note": str(m.get("note") or ""),
470
+ **({"distance": m["distance"]} if m.get("distance") is not None else {})})
471
+ else:
472
+ missing.append(m.get("id"))
473
+ if not clean:
474
+ return {"error": "a view needs at least one mark whose id exists in the store", "missing": missing[:5]}
475
+ vid = view_id or "view-" + hashlib.sha1((title + narrative).encode()).hexdigest()[:8]
476
+ spec = {"narrative": narrative, "marks": clean, "legend": legend or {}, **(extra or {})}
477
+ with con:
478
+ con.execute("INSERT OR REPLACE INTO views (id, title, kind, layer, source, created, change_id, spec)"
479
+ " VALUES (?,?,?,?,?,?,?,?)",
480
+ (vid, title, kind, "inferred", source,
481
+ datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), change_id, json.dumps(spec)))
482
+ return {"id": vid, "title": title, "marks": len(clean), "missing": missing[:10]}
483
+
484
+
485
+ def list_views(con) -> dict:
486
+ out = []
487
+ for v in con.execute("SELECT * FROM views ORDER BY created DESC"):
488
+ spec = json.loads(v["spec"])
489
+ out.append({"id": v["id"], "title": v["title"], "kind": v["kind"], "created": v["created"],
490
+ "change_id": v["change_id"], "marks": len(spec.get("marks", [])),
491
+ "summary": spec.get("summary")})
492
+ return {"total": len(out), "views": out}
493
+
494
+
495
+ def get_view(con, view_id: str) -> dict:
496
+ v = con.execute("SELECT * FROM views WHERE id = ?", (view_id,)).fetchone()
497
+ if v is None:
498
+ return {"error": f"No view with id {view_id!r}. Use `views` to list them."}
499
+ return {"id": v["id"], "title": v["title"], "kind": v["kind"], "created": v["created"],
500
+ "change_id": v["change_id"], **json.loads(v["spec"])}