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/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"])}
|