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/spec.py ADDED
@@ -0,0 +1,1819 @@
1
+ """The spec loop: a change stated in an open spec format, tied to the map before and after it is made.
2
+
3
+ A change is an OpenSpec change folder (`openspec/changes/<id>/` with proposal.md, tasks.md and spec
4
+ deltas). Leyline reads it and writes one page back, `leyline.md`, that answers four questions:
5
+
6
+ What code will be written? tasks, each tied to nodes on the map
7
+ What will it affect? blast radius, and anything it reaches that no task covers
8
+ How will I know it was done? scenarios, each tied to a test of the same name
9
+ Was it done as agreed? after implementation: tasks, scenarios, drift, rules
10
+
11
+ Conventions a spec author follows, and nothing more:
12
+ - Name code in backticks in tasks: `Vehicle.Speed`, `Queue.Push`, `Core/Controls.cs`.
13
+ A name that is not on the map yet is taken as new code; say where it goes: `Owner.NewName` for a member,
14
+ `module.new_func`, `path/to/file.py: new_func` or `new_func` in `file.py` for a top-level function.
15
+ Other words in backticks are noted, not checked; a task that names no code is the person's to check.
16
+ - Start a task with what it does: add, remove, rename, change. Anything else is read as a change in behavior.
17
+ The code right after the verb is what the task changes; other code in the sentence is only mentioned.
18
+ - Give each scenario a test with the same name, written or made at run time.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import datetime
24
+ import hashlib
25
+ import json
26
+ import re
27
+ import sqlite3
28
+ from collections import Counter, defaultdict
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ from . import change, diff, rules, store
33
+ from . import verdicts
34
+
35
+ # A task's number ends at a dot, a colon, a bracket or a space: `2FA login` is text, not task 2.
36
+ # matched against a line with its trailing spaces cut: a lazy text before \s*$ is quadratic in a run of spaces inside it
37
+ TASK = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s*\[([^\]]*)\]\s*(?:(\d+(?:\.\d+)*)(?=[.:)\s])[.:)]?)?\s*(.+)$")
38
+ # A scenario's step: `- **WHEN** ...`, also with the colon inside or after the bold (`**WHEN:**`, `**WHEN**:`).
39
+ STEP = re.compile(r"^\s*[-*+]\s*\*\*(WHEN|GIVEN|AND|THEN)\s*:?\*\*:?\s*(.*)$")
40
+ CODE = re.compile(r"`([^`\n]+)`")
41
+ VERBS = (("add", ("add ", "create ", "introduce ", "new ", "implement ")), ("remove", ("remove ", "delete ", "drop ")),
42
+ ("rename", ("rename ",)), ("signature", ("change the signature", "change signature", "add a parameter", "add parameter",
43
+ "remove a parameter", "change the return", "change return")))
44
+ BEGIN, END = "<!-- leyline:begin -->", "<!-- leyline:end -->"
45
+ REVIEWERS = ("logic", "performance")
46
+
47
+
48
+ def action_of(lower: str) -> str:
49
+ """What a task (lower-cased) does to the code it names. "Add a parameter to `X`" changes X's signature: the longer
50
+ phrase wins over the "add " it starts with."""
51
+ if lower.startswith(dict(VERBS)["signature"]):
52
+ return "signature"
53
+ return next((a for a, starts in VERBS if lower.startswith(starts) or any(s in lower for s in starts if len(s) > 12)),
54
+ "behavior")
55
+
56
+
57
+ def run_label(change_id: str, when: str) -> str:
58
+ """The label a change's own test runs are stored under: before:spec-<id> and after:spec-<id>."""
59
+ return f"{when}:{change_id}"
60
+
61
+
62
+ def record_review(con, change_id: str, reviewer: str) -> None:
63
+ """Note that a review of this kind ran. A review that finds nothing files nothing, so this is its only trace."""
64
+ row = con.execute("SELECT value FROM meta WHERE key = ?", ("review:" + change_id,)).fetchone()
65
+ done = json.loads(row[0]) if row else {}
66
+ done[reviewer] = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
67
+ with con:
68
+ con.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", ("review:" + change_id, json.dumps(done)))
69
+
70
+
71
+ def reviews(con, change_id: str) -> list[str]:
72
+ """The kinds of review that have run on a change: recorded ones, and any that filed a finding."""
73
+ row = con.execute("SELECT value FROM meta WHERE key = ?", ("review:" + change_id,)).fetchone()
74
+ done = set(json.loads(row[0])) if row else set()
75
+ done |= {r[0] for r in con.execute("SELECT DISTINCT reviewer FROM findings WHERE change_id = ?", (change_id,))}
76
+ return sorted(done)
77
+
78
+
79
+ # -- reading the folder ------------------------------------------------------------------------
80
+ FENCE = re.compile(r"^\s*(`{3,}|~{3,})")
81
+
82
+
83
+ def unfenced(text: str) -> list[str]:
84
+ """The lines of Markdown outside fenced code (``` or ~~~): an example task or scenario in a fence is not one.
85
+ A fence closes on a line of the same character, at least as long, with nothing after it."""
86
+ out, fence = [], None
87
+ for line in text.splitlines():
88
+ m = FENCE.match(line)
89
+ if fence is None and m:
90
+ fence = m.group(1)
91
+ elif fence is not None:
92
+ if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line.strip().strip(fence[0]):
93
+ fence = None
94
+ else:
95
+ out.append(line)
96
+ return out
97
+
98
+
99
+ def parse(change_dir: str | Path) -> dict:
100
+ """Read an OpenSpec change folder into its title, tasks, requirements and scenarios."""
101
+ d = Path(change_dir)
102
+ if not d.is_dir():
103
+ return {"error": f"no change folder at {d}"}
104
+ out = {"id": d.name, "dir": str(d), "title": d.name.replace("-", " "), "why": "", "what": "", "tasks": [], "scenarios": [],
105
+ "requirements": [], "problems": []}
106
+ # The repository the folder is in (above openspec/), else the folder: a file linked from outside it is not read.
107
+ top = next((p.parent for p in d.resolve().parents if p.name == "openspec"), d)
108
+ proposal = d / "proposal.md"
109
+ if proposal.is_file() and store.inside(proposal, top):
110
+ text = proposal.read_text(encoding="utf-8-sig", errors="replace")
111
+ m = re.search(r"^#\s+(.+)$", text, re.M)
112
+ if m:
113
+ out["title"] = re.sub(r"^(change|proposal)\s*:\s*", "", m.group(1).strip(), flags=re.I)
114
+ for key, head in (("why", "Why"), ("what", "What Changes")):
115
+ m = re.search(rf"^##\s+{head}\s*$(.*?)(?=^##\s|\Z)", text, re.M | re.S)
116
+ if m:
117
+ out[key] = " ".join(m.group(1).split())
118
+ else:
119
+ out["problems"].append("proposal.md is missing")
120
+ tasks = d / "tasks.md"
121
+ keys = Counter() # numbering that restarts in each section (`1.` under two headings) still gives one key per task
122
+ if tasks.is_file() and store.inside(tasks, top):
123
+ n = 0
124
+ for line in unfenced(tasks.read_text(encoding="utf-8-sig", errors="replace")):
125
+ m = TASK.match(line.rstrip())
126
+ if not m:
127
+ continue
128
+ n += 1
129
+ text = m.group(3)
130
+ lower = text.lower().lstrip("`* ")
131
+ action = action_of(lower)
132
+ key = m.group(2) or str(n)
133
+ keys[key] += 1
134
+ out["tasks"].append({"key": key if keys[key] == 1 else f"{key} ({keys[key]})", "text": text,
135
+ "done": m.group(1).strip().lower() == "x", "action": action, "names": CODE.findall(text)})
136
+ else:
137
+ out["problems"].append("tasks.md is missing")
138
+ for spec in sorted((d / "specs").rglob("spec.md")) if (d / "specs").is_dir() else []:
139
+ if not store.inside(spec, top):
140
+ continue
141
+ capability = str(spec.parent.relative_to(d / "specs"))
142
+ section = req = None
143
+ cur = None
144
+ for line in unfenced(spec.read_text(encoding="utf-8-sig", errors="replace")):
145
+ if line.startswith("## "):
146
+ section = (line[3:].split() or [""])[0].upper() or None # `## ` alone names no section
147
+ elif line.startswith("### Requirement:"):
148
+ req = line.split(":", 1)[1].strip()
149
+ out["requirements"].append({"capability": capability, "name": req, "kind": section or "ADDED"})
150
+ elif line.startswith("#### Scenario:"):
151
+ name = line.split(":", 1)[1].strip()
152
+ key = f"{capability}/{name}"
153
+ keys[key] += 1
154
+ if keys[key] > 1: # one test of that name would prove both
155
+ out["problems"].append(f"{spec.relative_to(d)}: two scenarios are named \"{name}\"; a scenario is"
156
+ " proven by the test of its name, so give each its own")
157
+ key += f" ({keys[key]})"
158
+ cur = {"key": key, "name": name,
159
+ "requirement": req, "capability": capability, "kind": section or "ADDED", "when": [], "then": []}
160
+ out["scenarios"].append(cur)
161
+ elif re.match(r"^#{1,6}\s*scenario\s*:", line, re.I): # ####Scenario:, #### scenario :, ### Scenario:
162
+ cur = None # not read as a scenario, and its steps are not the previous scenario's
163
+ out["problems"].append(f"{spec.relative_to(d)}: a scenario heading is written `#### Scenario: <name>`"
164
+ f" (four #, a space, then Scenario:), found: {line.strip()}")
165
+ elif line.startswith("#"):
166
+ cur = None # any other heading ends the scenario: steps under it belong to no scenario
167
+ elif cur is not None and STEP.match(line):
168
+ word = STEP.match(line)
169
+ (cur["then"] if word.group(1) == "THEN" or (word.group(1) == "AND" and cur["then"]) else cur["when"]).append(word.group(2))
170
+ for r in out["requirements"]:
171
+ if r["kind"] != "REMOVED" and not any(s["requirement"] == r["name"] and s["capability"] == r["capability"] for s in out["scenarios"]):
172
+ out["problems"].append(f"requirement \"{r['name']}\" has no scenario")
173
+ return out
174
+
175
+
176
+ # -- tying names to the map ---------------------------------------------------------------------
177
+ FILE_EXT = ("cs", "py", "pyi", "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "gd", "go", "java", "kt", "rs", "rb", "php",
178
+ "c", "cc", "cpp", "h", "hpp", "swift", "scala", "lua", "json", "md", "yaml", "yml", "toml", "txt", "csv", "html",
179
+ "css", "sql", "sh", "xml", "proto")
180
+ FILE_NAME = re.compile(r"(?:[\w.@-]+/)*[\w.@-]+\.(?:" + "|".join(FILE_EXT) + r")")
181
+ # A path written in a task without backticks: `Add two mutants to tooling/scripts/mutation_probe.py`.
182
+ BARE_PATH = re.compile(r"(?<![\w/.`-])((?:[\w.@-]+/)+[\w.@-]+\.(?:" + "|".join(FILE_EXT) + r"))(?![\w/])")
183
+ # A file of tests: an edit in it is how a scenario gets proven, not a change to the product.
184
+ TEST_FILE = re.compile(r"(^|/)(tests?|spec|specs|__tests__)/|(^|/)test_[^/]*$|_test\.\w+$|\.(test|spec)\.\w+$|Tests?\.cs$")
185
+ IDENT = re.compile(r"[A-Za-z_$][\w$]*")
186
+ # Extensions that are also common member names (`res.json`, `obj.go`): only a file when one is there.
187
+ WORDLIKE_EXT = ("go", "c", "h", "sh", "rs", "css", "html", "sql", "xml", "txt", "json", "lua", "rb", "kt", "csv")
188
+ HOMES = "`Owner.name` for a member, `module.name` or `path/to/file.py: name` for a top-level function"
189
+
190
+
191
+ class _Names:
192
+ def __init__(self, con):
193
+ self.con = con
194
+ self.rows = con.execute("SELECT id, kind, name, parent_id, path FROM nodes WHERE layer = 'fact'"
195
+ " AND kind IN ('type', 'callable', 'field', 'file', 'module', 'test')").fetchall()
196
+ self.by_id = {r["id"]: r for r in self.rows}
197
+ self.by_name = defaultdict(list)
198
+ self.by_stem = defaultdict(list) # build_cases -> the file build_cases.py, for `build_cases.func`
199
+ for r in self.rows:
200
+ self.by_name[r["name"]].append(r)
201
+ if r["kind"] == "file":
202
+ self.by_stem[re.sub(r"\.[^./]+$", "", r["name"])].append(r)
203
+ self.module = {r["node_id"]: r["module_id"] for r in con.execute("SELECT node_id, module_id FROM ancestry")}
204
+ self.test_files, self.test_modules = store.test_places(con)
205
+ roots = store.roots(con)
206
+ self.roots = list(roots.values())
207
+ # a repository, by its id and by its folder's name: how a path in a workspace says which repository it is in
208
+ self.repo_by_dir = {**{Path(p).name: rid for rid, p in roots.items()}, **{rid: rid for rid in roots}}
209
+
210
+ def in_tests(self, i: str) -> bool:
211
+ """Test code: a test, anything in a file that holds tests, or in a module that is mostly such files."""
212
+ n = self.by_id.get(i)
213
+ return bool(n) and (n["kind"] == "test" or n["path"] in self.test_files or self.module.get(i) in self.test_modules)
214
+
215
+ def file(self, written: str) -> list:
216
+ """File or module nodes a written path names: the whole path, or its end. In a workspace, a path may start
217
+ with the repository's id or folder (`wslib/src/libpkg/core.py`): the only way to say which repository."""
218
+ name = written.strip().lstrip("./")
219
+ found = [r for r in self.rows if r["kind"] in ("file", "module") and (r["path"] == name or (r["path"] or "").endswith("/" + name))]
220
+ head, _, rest = name.partition("/")
221
+ repo = self.repo_by_dir.get(head) if rest else None
222
+ if rest and not found and repo is not None:
223
+ found = [r for r in self.rows if r["kind"] in ("file", "module") and r["id"].startswith(repo + ":")
224
+ and (r["path"] == rest or (r["path"] or "").endswith("/" + rest))]
225
+ return found
226
+
227
+ def on_disk(self, written: str) -> bool:
228
+ name = written.strip().lstrip("./")
229
+ return any((root / name).exists() for root in self.roots)
230
+
231
+ def _module_file(self, dotted: list[str]) -> list:
232
+ """The file a dotted module path names: `build_cases`, `validator.build_cases` or the full package path."""
233
+ hits = self.by_stem.get(dotted[-1], [])
234
+ if len(dotted) > 1:
235
+ tail = "/".join(dotted)
236
+ hits = [r for r in hits if re.sub(r"\.[^./]+$", "", r["path"] or "").endswith(tail)] or hits
237
+ return hits
238
+
239
+ def resolve(self, written: str) -> dict:
240
+ """One backticked name -> {"ids": [...]} | {"new": name, "parent": id or None} | {"ambiguous": [...]}
241
+ | {"note": why} (words and files that are not code on the map) | {"skip": True}"""
242
+ w = written.strip()
243
+ # `path/to/file.py: func`, `file.py:func`, `file.py::func`: a function at the top of a file.
244
+ m = re.fullmatch(r"(" + FILE_NAME.pattern + r")\s*::?\s*([A-Za-z_$][\w$.]*)(?:\(.*\))?", w)
245
+ if m:
246
+ files = [f for f in self.file(m.group(1)) if f["kind"] == "file"]
247
+ if len(files) != 1:
248
+ return {"note": f"`{m.group(1)}` is not a file on the map"} if not files else {
249
+ "ambiguous": sorted(f["id"] for f in files)[:8]}
250
+ return self._in_file(files[0], m.group(2))
251
+ name = re.sub(r"\(.*\)$", "", w)
252
+ if not name or " " in name or name[0].isdigit() or name.startswith(("-", "/")) and "." not in name:
253
+ return {"skip": True}
254
+ ext = name.rsplit(".", 1)[-1] if "." in name else ""
255
+ if "/" in name or (FILE_NAME.fullmatch(name) and not w.endswith(")")):
256
+ hits = self.file(name)
257
+ if hits:
258
+ return {"ids": [h["id"] for h in hits]}
259
+ if self.on_disk(name):
260
+ return {"note": f"`{name}` is a file the map does not read, so it is not checked as code", "doc": name}
261
+ if "/" in name or ext not in WORDLIKE_EXT:
262
+ return {"new": name, "parent": None, "file": True}
263
+ parts = name.split(".")
264
+ leaf = parts[-1]
265
+ cands = [r for r in self.by_name.get(leaf, []) if r["kind"] != "file"]
266
+ if len(parts) > 1:
267
+ owner = parts[-2]
268
+ cands = [r for r in cands if r["parent_id"] in self.by_id and self.by_id[r["parent_id"]]["name"] == owner]
269
+ if not cands: # `module.func`: a function at the top of the file build_cases.py
270
+ files = self._module_file(parts[:-1])
271
+ cands = [r for r in self.by_name.get(leaf, []) if r["kind"] != "file" and r["parent_id"] in {f["id"] for f in files}]
272
+ if not cands:
273
+ if len(parts) > 1:
274
+ owners = [r for r in self.by_name.get(parts[-2], []) if r["kind"] in ("type", "module", "callable")]
275
+ if len({o["id"] for o in owners}) == 1:
276
+ return {"new": leaf, "parent": owners[0]["id"]}
277
+ files = self._module_file(parts[:-1])
278
+ if not owners and len(files) == 1 and IDENT.fullmatch(leaf):
279
+ return self._in_file(files[0], leaf)
280
+ if not owners and not IDENT.fullmatch(parts[-2]):
281
+ return {"skip": True}
282
+ if not IDENT.fullmatch(leaf):
283
+ return {"skip": True}
284
+ return {"new": name, "parent": None}
285
+ product = [r for r in cands if not self.in_tests(r["id"])] or cands
286
+ owners = {(r["parent_id"], r["kind"]) for r in product}
287
+ if len(owners) == 1:
288
+ return {"ids": sorted(r["id"] for r in product)} # overloads of one method are one target
289
+ types = [r for r in product if r["kind"] == "type"]
290
+ if len(types) == 1 and len(parts) == 1:
291
+ return {"ids": [types[0]["id"]]} # a bare name that is a type means the type
292
+ return {"ambiguous": sorted(r["id"] for r in product)[:8]}
293
+
294
+ def _in_file(self, f, name: str) -> dict:
295
+ """A name at the top of one file: the node if it is there, else new code whose home is that file."""
296
+ parts = name.split(".")
297
+ cur = f["id"]
298
+ for part in parts:
299
+ nxt = [r for r in self.by_name.get(part, []) if r["parent_id"] == cur and r["kind"] != "file"]
300
+ if not nxt:
301
+ if part is parts[-1] and cur in self.by_id:
302
+ return {"new": part, "parent": cur, "label": f"{f['name']}: {name}"}
303
+ return {"note": f"`{name}` is not in {f['path']}"}
304
+ cur = nxt[0]["id"]
305
+ return {"ids": [cur]}
306
+
307
+
308
+ # Where a name sits in a task decides what it is. The code right after the verb (and any joined to it by "and" or a
309
+ # comma, or named after a later "and add" or "and change") is what the task changes; code after "in", "into" or
310
+ # "to" is where it goes; any other code in the sentence is context the task only mentions ("slower than
311
+ # `SimConfig.QueueSpeed`", "one `Metrics.QueuedVehicles` per env"), shown in the plan but not counted as changed.
312
+ HOME_BEFORE = re.compile(r"\b(?:in|into|inside|to|under|from|of)\s+(?:the\s+)?$", re.I)
313
+ JOINED = re.compile(r"\s*(?:,\s*|/\s*)?(?:(?:and|or|&)\s+)?(?:(?:the|its)\s+)?", re.I)
314
+ CLAUSE = re.compile(r"(?:\band|[,;]|\bthen)\s+(?:then\s+|also\s+)?(?:add|change|update|remove|delete|rename|create|extend|move"
315
+ r"|register|introduce|implement|replace|modify)\b", re.I)
316
+
317
+
318
+ def _roles(text: str) -> list[tuple[str, str]]:
319
+ """Each backticked name in a task with its part: lead (what the task changes), home (where it goes) or mention."""
320
+ occ = [(m.start(), m.end(), m.group(1)) for m in CODE.finditer(text)]
321
+ lead = set()
322
+ for start in [0] + [m.end() for m in CLAUSE.finditer(text)]:
323
+ k = next((k for k, (a, _, _) in enumerate(occ) if a >= start), None)
324
+ while k is not None and k not in lead:
325
+ lead.add(k)
326
+ k = k + 1 if k + 1 < len(occ) and JOINED.fullmatch(text[occ[k][1]:occ[k + 1][0]]) else None
327
+ return [(w, "lead" if k in lead else "home" if HOME_BEFORE.search(text[:a]) else "mention")
328
+ for k, (a, _, w) in enumerate(occ)]
329
+
330
+
331
+ def _narrow(names: _Names, could_be: list[str], context: list[str]) -> list[str]:
332
+ """Of the things an ambiguous name could be, those that sit with the code the task mentions (same owner or file)."""
333
+ def file_of(i):
334
+ row = names.con.execute("SELECT file_id FROM ancestry WHERE node_id = ?", (i,)).fetchone()
335
+ return row[0] if row else None
336
+ near = {x for i in context if i in names.by_id for x in (i, names.by_id[i]["parent_id"], file_of(i)) if x}
337
+ return [c for c in could_be if c in names.by_id and (names.by_id[c]["parent_id"] in near or file_of(c) in near)]
338
+
339
+
340
+ def _targets(names: _Names, parsed: dict) -> tuple[list[dict], list[dict]]:
341
+ """Turn tasks into change targets. Returns (targets, per-task links)."""
342
+ targets, links, seen = [], [], set()
343
+ for t in parsed["tasks"]:
344
+ link = {"key": t["key"], "text": t["text"], "action": t["action"], "nodes": [], "new": [], "ambiguous": [], "unknown": [],
345
+ "into": [], "notes": [], "mentions": [], "mention_ids": [],
346
+ # A task about a test covers a scenario's test when it quotes the scenario's name. A task that quotes a
347
+ # value (`return "LOUD"`) is not held to writing a test, even when a scenario is named like the value.
348
+ "scenarios": [s["key"] for s in parsed["scenarios"]
349
+ if (t["action"] == "add" or re.search(r"\b(tests?|scenarios?|checks?|asserts?)\b", t["text"], re.I))
350
+ and _norm(s["name"]) in {_norm(q) for q in re.findall(r'["\u201c]([^"\u201d]+)["\u201d]', t["text"])}]}
351
+ roles = _roles(t["text"])
352
+ mentioned = [(w, names.resolve(w)) for w, role in roles if role == "mention"]
353
+ context = [i for _, r in mentioned for i in r.get("ids", [])]
354
+ for written, r in mentioned: # context: shown with the task, never counted as changed
355
+ if r.get("skip"):
356
+ continue
357
+ if "note" in r:
358
+ link["notes"].append(r["note"])
359
+ elif "ids" in r:
360
+ link["mention_ids"] += [i for i in r["ids"] if i not in link["mention_ids"]]
361
+ link["mentions"] += [x for x in dict.fromkeys(_label(names, i) for i in r["ids"]) if x not in link["mentions"]]
362
+ elif r.get("parent") in names.by_id:
363
+ link["mentions"].append(f"{names.by_id[r['parent']]['name']}.{r['new']} (not on the map yet)")
364
+ elif "ambiguous" in r:
365
+ link["mentions"].append(f"{written} (could be {len(r['ambiguous'])} things)")
366
+ else:
367
+ link["notes"].append(f"`{written}` is not on the map; read as a word, not code")
368
+ resolved = []
369
+ for w, role in roles:
370
+ if role == "mention":
371
+ continue
372
+ r = names.resolve(w)
373
+ near = _narrow(names, r["ambiguous"], context) if "ambiguous" in r and context else []
374
+ resolved.append((w, {"ids": near} if len(near) == 1 else r, False)) # the one beside what the task mentions
375
+ # A path written without backticks counts when it is a file on the map; otherwise it is only prose.
376
+ unquoted = re.sub(r"`[^`]*`", " ", t["text"])
377
+ resolved += [(w, {"ids": [h["id"] for h in names.file(w)]}, True) for w in dict.fromkeys(BARE_PATH.findall(unquoted))
378
+ if names.file(w)]
379
+ files = {i for _, r, _ in resolved for i in r.get("ids", []) if names.by_id[i]["kind"] == "file"}
380
+ names_code = any("ids" in r or r.get("parent") or r.get("file") for _, r, _ in resolved)
381
+ for written, r, bare in resolved:
382
+ if r.get("skip"):
383
+ continue
384
+ if "note" in r:
385
+ link["notes"].append(r["note"])
386
+ continue
387
+ if "new" in r and not r.get("parent") and not r.get("file") and len(files) == 1:
388
+ f = names.by_id[next(iter(files))] # "add `func` in `build_cases.py`": the file is its home
389
+ r = {"new": r["new"], "parent": f["id"], "label": f"{f['name']}: {r['new']}"}
390
+ if "ids" in r and (t["action"] == "add" or bare) and all(names.by_id[i]["kind"] in ("type", "file", "module") for i in r["ids"]):
391
+ link["into"].extend(r["ids"]) # "add X to `Foo`": Foo is where it goes, not something whose behavior changes
392
+ elif "ids" in r:
393
+ link["nodes"].extend(r["ids"])
394
+ for i in r["ids"]:
395
+ # Adding something to an existing type changes the type's behavior, not its contract.
396
+ action = "behavior" if t["action"] == "add" else t["action"]
397
+ if (i, action) not in seen and names.by_id[i]["kind"] != "module":
398
+ seen.add((i, action))
399
+ targets.append({"id": i, "action": action, "note": f"task {t['key']}: {t['text'][:120]}"})
400
+ elif "ambiguous" in r:
401
+ link["ambiguous"].append({"written": written, "could_be": r["ambiguous"]})
402
+ elif r.get("parent") or r.get("file") or (t["action"] == "add" and not names_code and not re.fullmatch(r"[A-Z0-9_]+", r["new"])):
403
+ owner = names.by_id[r["parent"]]["name"] + "." if r.get("parent") in names.by_id else ""
404
+ link["new"].append({"name": r["new"], "parent": r.get("parent"), "label": r.get("label") or owner + r["new"],
405
+ **({"file": True} if r.get("file") else {})})
406
+ if r.get("parent"):
407
+ targets.append({"action": "add", "name": r["new"], "parent": r["parent"],
408
+ "note": f"task {t['key']}", "used_by": [], "related": list(link["mention_ids"])})
409
+ else: # a word in backticks that is not on the map: an issue code, a value, a type of effect
410
+ link["notes"].append(f"`{written}` is not on the map; read as a word, not code")
411
+ links.append(link)
412
+ return targets, links
413
+
414
+
415
+ def _label(names: _Names, i: str) -> str:
416
+ r = names.by_id.get(i)
417
+ if r is None:
418
+ return i
419
+ owner = names.by_id.get(r["parent_id"])
420
+ if r["kind"] in ("callable", "field") and owner is not None and owner["kind"] == "type":
421
+ return f"{owner['name']}.{r['name']}"
422
+ return r["path"] if r["kind"] == "file" else r["name"]
423
+
424
+
425
+ def _tests(con) -> dict:
426
+ """Test name (lower-cased, punctuation squeezed) -> node id."""
427
+ out = {}
428
+ for r in con.execute("SELECT n.id, n.name FROM nodes n WHERE n.kind = 'test'"
429
+ " OR json_extract(n.attrs, '$.is_test') = 1"):
430
+ out[_norm(r["name"])] = r["id"]
431
+ return out
432
+
433
+
434
+ def _norm(s: str) -> str:
435
+ # `test_` only where a name starts: `test_shortest_path` is "shortest path", not "shorpath". Letters of every
436
+ # script count: a scenario named 日本語 or Über is proven by the test of that name, not read as empty or "ber".
437
+ return re.sub(r"[\W_]+", " ", re.sub(r"(?<![^\W_])test_", "", s.casefold())).strip()
438
+
439
+
440
+ def _result_keys(name: str) -> tuple[set, set]:
441
+ """The names a recorded result answers to. Strong: the test's own name (with its parameter, for pytest), or
442
+ `suite > test`. Weak, used only when nothing answers strongly: a suite around it, a parametrized test's
443
+ function (all its parameters), or a parameter id."""
444
+ p = diff.result_parts(name)
445
+ strong = {_norm(p["leaf"]), _norm(" > ".join(p["suites"] + [p["leaf"]])), _norm(name)}
446
+ weak = {_norm(x) for x in p["suites"]}
447
+ if p["func"]:
448
+ weak |= {_norm(p["func"]), _norm(p["param"])}
449
+ return strong - {""}, weak - strong - {""}
450
+
451
+
452
+ def _results_index(rows) -> tuple[dict, dict]:
453
+ strong, weak = defaultdict(list), defaultdict(list)
454
+ for r in rows:
455
+ st, wk = _result_keys(r["name"])
456
+ for k in st:
457
+ strong[k].append(r)
458
+ for k in wk:
459
+ weak[k].append(r)
460
+ return strong, weak
461
+
462
+
463
+ def _scenario_results(index: tuple[dict, dict], name: str) -> list:
464
+ """The recorded results that carry a scenario's name. Every one must pass for the scenario to pass."""
465
+ k = _norm(name)
466
+ return index[0].get(k) or index[1].get(k) or []
467
+
468
+
469
+ def _generated(test_names, name: str) -> Optional[dict]:
470
+ """A test on the map that makes a test of this name at run time: one named with a template ("agrees on {}"), or
471
+ a parametrized pytest test whose name the scenario's starts with (`test_check` for "check zero")."""
472
+ hits = [(size, t) for pattern, size, t in test_names.templates if re.compile(pattern.pattern, re.I).match(name)]
473
+ key = _norm(name)
474
+ hits += [(len(_norm(t["name"])), t) for t in test_names.parametrized
475
+ if _norm(t["name"]) and key.startswith(_norm(t["name"]) + " ")]
476
+ return max(hits, key=lambda h: h[0])[1] if hits else None
477
+
478
+
479
+ # -- the brief -------------------------------------------------------------------------------------
480
+ def brief(con, change_dir: str | Path, write: bool = True, new_baseline: bool = False) -> dict:
481
+ """Assess a spec before it is implemented, store the result, and write `leyline.md` into the folder.
482
+ Once the code has moved on from the first brief, later briefs keep that first picture of the code to
483
+ compare against, so a spec can be amended part-way through. `new_baseline` starts over from the code as it is."""
484
+ parsed = parse(change_dir)
485
+ if "error" in parsed:
486
+ return parsed
487
+ names = _Names(con)
488
+ targets, links = _targets(names, parsed)
489
+ cid = "spec-" + parsed["id"]
490
+ intent = parsed["what"] or parsed["why"] or parsed["title"]
491
+ self_tests = _self_tests(con, [run_label(cid, "before"), run_label(cid, "after")])
492
+ report = change.propose(con, intent, targets, parsed["title"], source="spec", change_id=cid,
493
+ keep_baseline=not new_baseline, test_entries={i: x["name"] for i, x in self_tests.items()}) \
494
+ if targets or parsed["tasks"] else {"error": "tasks.md has no tasks (`- [ ] 1.1 ...`)."}
495
+ # Tasks that name no code (docs, say) are for a person to check; the baseline still shows what else changed.
496
+ tests = _tests(con)
497
+ test_names = diff.TestNames(con)
498
+ ran = _results_index(con.execute("SELECT name, status, message FROM test_results WHERE run = ?",
499
+ (run_label(cid, "before"),)).fetchall())
500
+ scenarios = []
501
+ for s in parsed["scenarios"]:
502
+ tid = tests.get(_norm(s["name"]))
503
+ gen = None if tid else _generated(test_names, s["name"])
504
+ in_run = bool(_scenario_results(ran, s["name"]))
505
+ scenarios.append({**s, "test": tid, "test_exists": bool(tid or gen or in_run),
506
+ **({"generated_by": gen["name"]} if gen else {"in_run": True} if in_run and not tid else {})})
507
+ from . import props # a scenario that states an invariant is proven better by a property test
508
+ props.mark_scenarios(con, scenarios, [i for l in links for i in l["nodes"]])
509
+ tasked = {i for l in links for i in l["nodes"]}
510
+ planned = {k for l in links for k in l["scenarios"]} # scenarios some task says it will write the test for
511
+
512
+ def covered(i): # a node is covered by a task that names it, its type or its file
513
+ cur = i
514
+ while cur:
515
+ if cur in tasked:
516
+ return True
517
+ cur = names.by_id[cur]["parent_id"] if cur in names.by_id else None
518
+ row = con.execute("SELECT file_id FROM ancestry WHERE node_id = ?", (i,)).fetchone()
519
+ return bool(row and row[0] in tasked)
520
+ uncovered = [m for m in report.get("must_edit", []) if not covered(m["id"])] if "error" not in report else []
521
+ if "error" not in report:
522
+ _remember_folder(con, cid, Path(parsed["dir"]))
523
+ crossings, agree = _crossings(con, names, links) if "error" not in report else ([], [])
524
+ if "error" not in report:
525
+ report["channels"] = crossings
526
+ # The channels themselves say this, end by end (see brief_text).
527
+ report["risks"] = [r for r in report.get("risks") or [] if "far side of a channel" not in r["what"]]
528
+ _say_what_tests_are_known(con, cid, report, self_tests)
529
+ state = _shared_state_touched(con, tasked)
530
+ others = _left_alone(con, names, links, cid)
531
+ patterns = _patterns_touched(con, tasked)
532
+ from . import coupling # files that usually change with what the tasks touch, from git history
533
+ history = coupling.for_spec(con, names, links)
534
+ rule_state = rules.check(con)
535
+ gaps = list(parsed["problems"])
536
+ for l in links:
537
+ gaps += [f"task {l['key']}: `{a['written']}` could be {len(a['could_be'])} things; write it as `Owner.Name` or"
538
+ f" `path/to/file: Name`" for a in l["ambiguous"]]
539
+ gaps += [f"task {l['key']}: new `{n['name']}` has no stated home; write it as {HOMES}"
540
+ for n in l["new"] if not n["parent"] and not n.get("file")]
541
+ # A task that names no code (docs, a mutant list in a file the map does not read) is for the person to check.
542
+ l["by_you"] = not (l["nodes"] or l["new"] or l["into"] or l["scenarios"])
543
+ gaps += [f"must be edited but no task covers it: {m['name']} ({m.get('note', '')})" for m in uncovered]
544
+ if report.get("snapshot_error"):
545
+ gaps.append(f"the baseline of the code as it is now could not be kept ({report['snapshot_error']}), so `check`"
546
+ " would have nothing to compare the change with: fix that, then plan again before changing the code")
547
+ gaps += [f"scenario \"{s['name']}\" has no test of that name, and no task says it will add one"
548
+ for s in scenarios if not s["test_exists"] and s["key"] not in planned]
549
+ result = {"change_id": cid, "title": parsed["title"], "why": parsed["why"], "what": parsed["what"], "dir": parsed["dir"],
550
+ "tasks": [{**l, "labels": [_label(names, i) for i in l["nodes"]], "into_labels": [_label(names, i) for i in l["into"]]} for l in links],
551
+ "notes": [f"task {l['key']}: {n}" for l in links for n in dict.fromkeys(l["notes"])],
552
+ "scenarios": scenarios, "impact": {k: report.get(k) for k in ("summary", "risks", "by_module", "tests_to_run", "channels", "untested")}
553
+ if "error" not in report else {"error": report["error"]},
554
+ "must_edit_uncovered": uncovered, "must_agree": agree, "self_tests": self_tests,
555
+ "shared_state": state, "left_alone": others, "patterns": patterns, "usually_changes_with": history,
556
+ "rules_failing_now": [r for r in rule_state["rules"] if not r["passes"]],
557
+ "findings": findings(con, cid)["findings"], "gaps": gaps,
558
+ "learnings_unreadable": _learnings_unreadable(con),
559
+ "baseline": report.get("snapshot"), "reviews": reviews(con, cid),
560
+ "baseline_tests": con.execute("SELECT 1 FROM test_results WHERE run = ? LIMIT 1",
561
+ (run_label(cid, "before"),)).fetchone() is not None,
562
+ "ready": not gaps and not any(f["status"] == "open" and f["severity"] == "high" for f in findings(con, cid)["findings"])}
563
+ from . import drift # specs, living or of finished changes, that no longer match code this change touches
564
+ result["drifted_specs"] = drift.touching(con, parsed["dir"], sorted(tasked | {i for l in links for i in l["into"]}))
565
+ from . import diagrams # the planned path through the changed code, drawn from the map as it is now
566
+ result["how_it_runs"] = diagrams.safe(diagrams.sequence, con, [i for l in links for i in l["nodes"]])
567
+ from . import related # finished changes, earlier reviews (or, with neither, commits) that touched the same code
568
+ result["related_changes"] = related.find(con, names, sorted(tasked | {i for l in links for i in l["into"]}), exclude=cid)
569
+ with con:
570
+ con.execute("DELETE FROM spec_items WHERE change_id = ?", (cid,))
571
+ con.executemany("INSERT INTO spec_items VALUES (?,?,?,?,?,?,?)",
572
+ [(cid, "task", l["key"], l["text"], l["action"], json.dumps(l["nodes"]),
573
+ json.dumps({"new": l["new"], "into": l["into"], "scenarios": l["scenarios"], "by_you": l["by_you"],
574
+ "mentions": l["mention_ids"]}))
575
+ for l in links]
576
+ + [(cid, "scenario", s["key"], s["name"], s["kind"], json.dumps([s["test"]] if s["test"] else []),
577
+ json.dumps({"when": s["when"], "then": s["then"], "requirement": s["requirement"]})) for s in scenarios])
578
+ if write and (why := store.escapes(parsed["dir"])):
579
+ result["not_written"] = why
580
+ elif write:
581
+ _write(Path(parsed["dir"]) / "leyline.md", brief_text(result))
582
+ result["written"] = str(Path(parsed["dir"]) / "leyline.md")
583
+ return result
584
+
585
+
586
+ def _remember_folder(con, cid: str, folder: Path) -> None:
587
+ """Note where a change's folder is, relative to its repository when it is inside one (a store finds its
588
+ repository after a move), so its baseline can go once the folder is archived or removed."""
589
+ from . import store
590
+ where = {"dir": str(folder.resolve())}
591
+ for repo, root in store.roots(con).items():
592
+ try:
593
+ where = {"dir_repo": repo, "dir_rel": folder.resolve().relative_to(root.resolve()).as_posix()}
594
+ break
595
+ except ValueError:
596
+ continue
597
+ row = con.execute("SELECT attrs FROM change_proposals WHERE id = ?", (cid,)).fetchone()
598
+ attrs = {k: v for k, v in json.loads(row[0] or "{}").items() if k not in ("dir", "dir_repo", "dir_rel")} if row else {}
599
+ with con:
600
+ con.execute("UPDATE change_proposals SET attrs = ? WHERE id = ?", (json.dumps({**attrs, **where}), cid))
601
+
602
+
603
+ def folder_gone(con, cid: str) -> bool:
604
+ """True when the folder a change was planned from is no longer where it was: archived, or removed."""
605
+ from . import store
606
+ row = con.execute("SELECT attrs FROM change_proposals WHERE id = ?", (cid,)).fetchone()
607
+ attrs = json.loads(row[0] or "{}") if row else {}
608
+ if attrs.get("dir_repo"):
609
+ root = store.roots(con).get(attrs["dir_repo"])
610
+ if root is None or not root.is_dir() or (root / attrs["dir_rel"]).is_dir():
611
+ return False
612
+ folder = root / attrs["dir_rel"]
613
+ archived = folder.parent / "archive"
614
+ if archived.is_dir() and any(f.is_dir() and f.name.endswith("-" + folder.name) for f in archived.iterdir()):
615
+ return True
616
+ # A folder committed on a branch that is not checked out is out of the working tree only until it is again.
617
+ return not _on_a_branch(root, attrs["dir_rel"])
618
+ return bool(attrs.get("dir")) and not Path(attrs["dir"]).is_dir()
619
+
620
+
621
+ def _on_a_branch(root: Path, rel: str) -> bool:
622
+ """True when the head of some local branch of the repository at `root` holds the folder `rel` (relative to root)."""
623
+ import subprocess
624
+ try:
625
+ git = lambda *a, **k: subprocess.run(["git", "-C", str(root), *a], capture_output=True, text=True, check=True,
626
+ **k).stdout
627
+ heads = git("for-each-ref", "--format=%(refname)", "refs/heads").split()
628
+ if not heads:
629
+ return False
630
+ path = git("rev-parse", "--show-prefix").strip() + rel # as the commit names it: from the top of the repository
631
+ out = git("cat-file", "--batch-check", input="".join(f"{h}:{path}\n" for h in heads))
632
+ except (OSError, subprocess.CalledProcessError): # not a git repository, or no git
633
+ return False
634
+ return any(line.split()[1:2] == ["tree"] for line in out.splitlines()) # "<oid> tree <size>", or "<name> missing"
635
+
636
+
637
+ # -- channels and tests the map does not see as such -------------------------------------------------
638
+ CHANNEL_SKIP = ("event",) # inside one program, where the compiler joins the two sides
639
+
640
+
641
+ def _crossing_line(c: dict) -> str:
642
+ """One channel the change crosses, as the plan says it."""
643
+ at = f" ({c.get('program') or c.get('address')})" if c.get("program") or c.get("address") else ""
644
+ if "hub" not in c: # a brief stored before channels had ends
645
+ return f"Crosses a {c['channel']} boundary{at}: {c['from_name']} to {c['to_name']}."
646
+ spokes = [x["name"] for x in sorted(c["spokes"], key=lambda x: not x["changed"])] # the ends that change first
647
+ one = len(spokes) == 1
648
+ if c["channel"] == "di":
649
+ return (f"Crosses a di boundary{at}: calls to {_and(spokes)} reach {c['hub_name']} because a container registers it."
650
+ " The calling code never names the implementation.")
651
+ who, s_ = _and(spokes), "s" if one else ""
652
+ how = (f"{who} start{s_} {c['hub_name']} and talk{s_} to it over its stdio pipe." if c["channel"] == "process" else
653
+ f"{who} read{s_} what {c['hub_name']} writes." if c.get("data") else f"{who} call{s_} {c['hub_name']}.")
654
+ changed = list(dict.fromkeys(c["hub_changed"] + [x for s in c["spokes"] for x in s["changed"]]))
655
+ if c["hub_changed"] and any(x["changed"] for x in c["spokes"]):
656
+ return (f"Crosses a {c['channel']} boundary{at}: {how} Both ends change ({_and(changed)}); nothing checks one against"
657
+ " the other, so a mismatch fails only at run time.")
658
+ return (f"Crosses a {c['channel']} boundary{at}: {how} One end changes ({_and(changed)}); the other has no"
659
+ " compile-time link to it.")
660
+
661
+
662
+ def _crossings(con, names: _Names, links: list[dict]) -> tuple[list[dict], list[dict]]:
663
+ """Channels the change crosses, and their other ends that no task names. A channel is crossed when the code
664
+ a task changes sits at one of its ends. For a process, the launched end is the type (or script) around the
665
+ program's entry, which reads its input and writes its output, and the launcher's end is the type that holds the
666
+ process, whose methods talk over the pipe. For a request or a message it is the two functions linked. Every
667
+ other launcher or caller of the same end must agree with what changed there."""
668
+ changed = [(i, _label(names, i)) for l in links for i in l["nodes"]]
669
+ changed += [(n["parent"], n.get("label") or n["name"]) for l in links for n in l["new"] if n.get("parent") in names.by_id]
670
+ named = {i for l in links for i in l["nodes"] + l["into"] + l.get("mention_ids", [])} | {p for p, _ in changed}
671
+
672
+ def within(i, end):
673
+ while i:
674
+ if i == end:
675
+ return True
676
+ i = names.by_id[i]["parent_id"] if i in names.by_id else None
677
+ return False
678
+
679
+ def at(end):
680
+ return list(dict.fromkeys(lab for i, lab in changed if within(i, end)))
681
+
682
+ def unit(i, keep_function): # the type around a node; with none, the function itself or its file
683
+ cur = i
684
+ while cur in names.by_id and names.by_id[cur]["kind"] not in ("file", "module"):
685
+ if names.by_id[cur]["kind"] == "type":
686
+ return cur
687
+ cur = names.by_id[cur]["parent_id"]
688
+ return i if keep_function or cur not in names.by_id else cur
689
+ groups: dict = defaultdict(list)
690
+ for r in con.execute("SELECT src_id, dst_id, precision, attrs FROM edges WHERE kind = 'communicates' ORDER BY src_id, dst_id"):
691
+ a = json.loads(r["attrs"] or "{}")
692
+ ch = a.get("channel", "channel")
693
+ if ch in CHANNEL_SKIP or r["src_id"] not in names.by_id or r["dst_id"] not in names.by_id:
694
+ continue
695
+ if ch == "process" and not (a.get("pipes") or a.get("direction") == "both"):
696
+ continue # started and left to run: it reads nothing the program sends
697
+ data = ch in change.DATA_CHANNELS
698
+ # The hub is the end others connect to: the launched program, the handler, or the writer of the data.
699
+ hub, spoke = (r["src_id"], r["dst_id"]) if data else (r["dst_id"], r["src_id"])
700
+ hub_end = unit(hub, False) if ch == "process" else hub
701
+ spoke_end = unit(spoke, True) if ch == "process" else spoke
702
+ # One registrar of many routes (or a writer of many tables) is many channels: one per address.
703
+ groups[(ch, hub_end, "" if ch == "process" else a.get("address") or "")].append({"hub": hub, "spoke": spoke, "end": spoke_end, "address": a.get("address") or "",
704
+ "guessed": r["precision"] == "guess"})
705
+ crossings, agree, seen = [], [], set()
706
+
707
+ def label(i): # `Owner.name`, or `file.name` at the top of a file
708
+ return change._label(names.by_id, i)
709
+ for (ch, hub_end, _), members in groups.items():
710
+ there = at(hub_end)
711
+ moved = [m for m in members if at(m["end"])]
712
+ if not there and not moved:
713
+ continue
714
+ hub = members[0]["hub"]
715
+ script = names.by_id[hub]["name"] in ("<module>", "<top-level>") # a script is named by its file
716
+ module = names.by_id.get(names.module.get(hub))
717
+ program = (names.by_id[hub]["path"] if script else module["name"] if module else "") if ch == "process" else ""
718
+ ends = list({m["end"]: m for m in members}.values())
719
+ crossings.append({"channel": ch, "address": members[0]["address"], "data": ch in change.DATA_CHANNELS,
720
+ "hub": hub, "hub_name": names.by_id[hub]["path"] if script else label(hub), "program": program,
721
+ "hub_changed": there, "spokes": [{"id": m["spoke"], "name": label(m["end"]), "changed": at(m["end"])}
722
+ for m in ends],
723
+ "guessed": all(m["guessed"] for m in moved or members),
724
+ # kept for older readers of the brief: one end and the other
725
+ "from": (moved or members)[0]["spoke"], "to": hub,
726
+ "from_name": label((moved or members)[0]["end"]), "to_name": label(hub)})
727
+ if ch == "di": # the container picks the implementation; its callers name only the interface
728
+ continue
729
+ what = program or label(hub)
730
+ if there: # what the hub sends or answers changed: everyone who talks to it must agree
731
+ mine = {lab.rsplit(".", 1)[-1] for m in moved for lab in at(m["end"])}
732
+ for m in members:
733
+ if m in moved or m["end"] in seen or any(within(n, m["end"]) or within(m["end"], n) for n in named):
734
+ continue
735
+ seen.add(m["end"])
736
+ twins = [r["id"] for r in names.rows if r["kind"] == "callable" and r["name"] in mine and within(r["id"], m["end"])]
737
+ agree.append({"id": m["end"], "name": label(m["end"]), "path": (names.by_id.get(m["end"]) or {})["path"],
738
+ "reads": [label(t) for t in twins], "channel": ch,
739
+ "why": (f"also starts {what} and reads what it sends" if ch == "process" else
740
+ f"also reads what {what} writes" if ch in change.DATA_CHANNELS else
741
+ f"also calls {what} over {ch}")})
742
+ elif hub_end not in seen and not any(within(n, hub_end) or within(hub_end, n) for n in named):
743
+ seen.add(hub_end) # one side changed what it sends or expects: the hub must agree
744
+ agree.append({"id": hub_end, "name": label(hub_end), "path": (names.by_id.get(hub_end) or {})["path"],
745
+ "reads": [], "channel": ch,
746
+ "why": (f"the program {what}, at the other end of the pipe" if ch == "process" else
747
+ f"writes what {_some([x['name'] for x in crossings[-1]['spokes'] if x['changed']])} reads"
748
+ if ch in change.DATA_CHANNELS else
749
+ f"answers {_some([x['name'] for x in crossings[-1]['spokes'] if x['changed']])}")})
750
+ return crossings, agree
751
+
752
+
753
+ def _self_tests(con, runs: list[str]) -> dict:
754
+ """Entry points that are tests of their own: a script whose checks print one PASS or FAIL line each, which the
755
+ map has no test node for (`check(ok, "queued reported per env")` in a file run as a script). Found by the
756
+ recorded results whose names are written in the entry's file. entry id -> {name, path, results}."""
757
+ names = set()
758
+ for run in runs:
759
+ for r in con.execute("SELECT name FROM test_results WHERE run = ? AND test_id IS NULL", (run,)):
760
+ leaf = diff.result_parts(r["name"])["leaf"].strip()
761
+ if len(leaf) >= 8 and " " in leaf: # a phrase, not a word that could be written anywhere
762
+ names.add(leaf)
763
+ if not names:
764
+ return {}
765
+ by_file = defaultdict(list)
766
+ for r in con.execute("SELECT f.entry_id, n.repo_id, n.path FROM flows f JOIN nodes n ON n.id = f.entry_id"
767
+ " WHERE COALESCE(json_extract(f.attrs, '$.kind'), '') != 'test' AND n.path IS NOT NULL"):
768
+ by_file[(r["repo_id"], r["path"])].append(r["entry_id"])
769
+ root_of = diff.roots(con)
770
+ out = {}
771
+ for (repo, path), ids in sorted(by_file.items()):
772
+ data = diff.source(con, repo, path, root_of)
773
+ text = data.decode("utf-8", errors="replace") if data else ""
774
+ found = sorted(n for n in names if n in text and re.search(r"[\"']" + re.escape(n) + r"[\"']", text))
775
+ if len(found) >= 2:
776
+ for i in ids:
777
+ out[i] = {"name": f"{path}, run as a script (its own checks)", "path": path, "results": found}
778
+ return out
779
+
780
+
781
+ def _say_what_tests_are_known(con, cid: str, report: dict, self_tests: dict) -> None:
782
+ """A function on "no test's path" is on none the map knows. When the recorded run has passing results the map
783
+ cannot place, one of them may run the change after all: say what is known instead."""
784
+ placed = {n for x in self_tests.values() for n in x["results"]}
785
+ loose = {r[0] for run in (run_label(cid, "before"), run_label(cid, "after")) for r in con.execute(
786
+ "SELECT name FROM test_results WHERE run = ? AND test_id IS NULL AND status = 'pass'", (run,))
787
+ if diff.result_parts(r[0])["leaf"].strip() not in placed}
788
+ if not loose:
789
+ return
790
+ for r in report.get("risks") or []:
791
+ if r["what"].endswith("changed functions are on no test's path."):
792
+ r["level"] = "medium"
793
+ r["what"] = (r["what"][:-1] + " on the map. The recorded test run has " + _n(len(loose), "passing result")
794
+ + " the map cannot place, so one of them may run it.")
795
+
796
+
797
+ def _shared_state_touched(con, tasked: set) -> list[dict]:
798
+ """Fields the named code assigns that other types also assign."""
799
+ from . import query
800
+ shared = {f["id"]: f for f in query.shared_state(con, limit=100000, guesses=False)["fields"]}
801
+ out = {}
802
+ for i in tasked:
803
+ for r in con.execute(f"SELECT dst_id FROM edges WHERE kind = 'writes' AND (src_id = ? OR src_id LIKE ?) AND {SURE}",
804
+ (i, i + ".%")):
805
+ if r[0] in shared and len(shared[r[0]]["written_from"]) >= 2:
806
+ out[r[0]] = {"name": shared[r[0]]["name"], "also_written_from": shared[r[0]]["written_from"][:5]}
807
+ if i in shared:
808
+ out[i] = {"name": shared[i]["name"], "also_written_from": shared[i]["written_from"][:5]}
809
+ return sorted(out.values(), key=lambda x: x["name"])
810
+
811
+
812
+ def _words(name: str) -> list[str]:
813
+ return [w.lower() for w in re.findall(r"[A-Z]+(?![a-z])|[A-Z]?[a-z0-9]+", name)]
814
+
815
+
816
+ def _task_functions(names: _Names, links: list[dict]) -> list[str]:
817
+ """Existing functions the tasks change. A named type stands for its functions."""
818
+ fns = []
819
+ for l in links:
820
+ for i in l["nodes"]:
821
+ kind = names.by_id[i]["kind"]
822
+ if kind in ("callable", "test"):
823
+ fns.append(i)
824
+ elif kind == "type":
825
+ fns += [r["id"] for r in names.rows if r["parent_id"] == i and r["kind"] == "callable"]
826
+ return list(dict.fromkeys(fns))
827
+
828
+
829
+ SURE = "COALESCE(precision, '') != 'guess'" # an edge found by more than a name that happens to be unique
830
+
831
+
832
+ def _in_baseline(con, cid: str, i: str) -> bool:
833
+ """Whether the change's baseline (the code as it was when first planned) holds this node. True with no baseline."""
834
+ snap = diff.snapshot_path(con, cid)
835
+ if not cid or not snap.exists():
836
+ return True
837
+ before = diff._open(snap)
838
+ try:
839
+ return before.execute("SELECT 1 FROM nodes WHERE id = ?", (i,)).fetchone() is not None
840
+ except sqlite3.Error:
841
+ return True
842
+ finally:
843
+ before.close()
844
+
845
+
846
+ def _left_alone(con, names: _Names, links: list[dict], cid: str = "") -> dict:
847
+ """Code that shares something with the change and that no task names: other callers of a changed function,
848
+ and other users of a field a changed function uses. A parallel edit is most often forgotten here."""
849
+ fns = _task_functions(names, links)
850
+ inside = set(fns)
851
+
852
+ def product(i):
853
+ return i in names.by_id and names.by_id[i]["kind"] != "test" and not names.in_tests(i)
854
+ def owner(i):
855
+ return names.by_id[i]["parent_id"] if i in names.by_id else None
856
+
857
+ def is_ctor(i):
858
+ return names.by_id[i]["name"] in (".ctor", "__init__", "constructor")
859
+
860
+ counted: dict = {}
861
+
862
+ def fields_of(t):
863
+ if t not in counted:
864
+ counted[t] = con.execute("SELECT COUNT(*) FROM nodes WHERE parent_id = ? AND kind = 'field'", (t,)).fetchone()[0]
865
+ return counted[t]
866
+ reads: dict = {}
867
+
868
+ def walker(fn, t):
869
+ """A function that uses most of a type's fields (at least five): it walks them all, not one in particular."""
870
+ if (fn, t) not in reads:
871
+ reads[(fn, t)] = con.execute(
872
+ "SELECT COUNT(DISTINCT e.dst_id) FROM edges e JOIN nodes n ON n.id = e.dst_id WHERE e.kind IN ('reads', 'writes')"
873
+ " AND e.src_id = ? AND n.parent_id = ?", (fn, t)).fetchone()[0]
874
+ n = fields_of(t) if t else 0
875
+ return n >= 5 and reads[(fn, t)] * 2 >= n
876
+ callers, state, seen = [], [], set()
877
+ for i in fns:
878
+ # A function that registers a route's handler gets nothing back from it: its requesters are the other ends.
879
+ who = sorted({r[0] for r in con.execute("SELECT DISTINCT src_id FROM calls WHERE dst_id = ? AND dispatch != 'registers'", (i,))
880
+ if r[0] not in inside and product(r[0])})
881
+ if who:
882
+ callers.append({"id": i, "changed": _label(names, i), "callers": [_label(names, w) for w in who], "caller_ids": who,
883
+ "far": len({names.module.get(w) for w in who} - {names.module.get(i)}),
884
+ "other_types": len({owner(w) for w in who} - {owner(i)})})
885
+ about = _task_words(names, links, i)
886
+ # A field link guessed by name alone (`.Size` read on some other type) is not evidence: only sure links count.
887
+ for f in con.execute(f"SELECT dst_id, MAX(kind = 'writes') FROM edges WHERE kind IN ('reads', 'writes') AND src_id = ?"
888
+ f" AND {SURE} GROUP BY dst_id ORDER BY 2 DESC", (i,)):
889
+ if f[0] in seen or f[0] not in names.by_id:
890
+ continue
891
+ # A constructor setting a field up is not a second user of it.
892
+ users = sorted({r[0] for r in con.execute(
893
+ f"SELECT DISTINCT src_id FROM edges WHERE kind IN ('reads', 'writes') AND dst_id = ? AND {SURE}", (f[0],))
894
+ if r[0] not in inside and product(r[0]) and not is_ctor(r[0])})
895
+ if not 0 < len(users) <= 8: # a field half the program uses says nothing about this change
896
+ continue
897
+ # A reader that walks every field of its type (a serializer, a copy, a dump) reads this one too
898
+ # whatever it holds, so a field only such readers share is not shared in any way that matters.
899
+ plain = [u for u in users if not walker(u, owner(f[0]))]
900
+ if not plain:
901
+ continue
902
+ seen.add(f[0])
903
+ close = bool(about & _field_words(names.by_id[f[0]]["name"]))
904
+ quiet = "" if close else (
905
+ f"{_label(names, i)} reads most fields of its type, so using this one says little" if walker(i, owner(f[0]))
906
+ else f"{names.by_id[owner(f[0])]['name']} has {fields_of(owner(f[0]))} fields and this one's name shares"
907
+ " no word with the tasks" if fields_of(owner(f[0])) > BIG_TYPE else "")
908
+ state.append({"field": _label(names, f[0]), "used_by_changed": _label(names, i),
909
+ "also_used_by_unchanged": [_label(names, u) for u in users], "user_ids": users,
910
+ # State the changed type owns is where a parallel edit gets forgotten; a field of
911
+ # some other type that the change only reads rarely is. The page lists only these
912
+ # (`own`), and of them only the ones the change is close to: on a large type, sharing
913
+ # a field the tasks never mention is common and mostly unrelated.
914
+ "own": owner(f[0]) == owner(i) and not quiet, "close": close,
915
+ **({"quiet": quiet} if quiet and owner(f[0]) == owner(i) else {}),
916
+ "changed_writes_it": bool(f[1]),
917
+ "other_types": len({owner(u) for u in users} - {owner(f[0])})})
918
+ # A new member named like one its type already has (EmergencyQueues beside EntryQueues) is usually a second
919
+ # one of the same thing, and whoever uses the first is a candidate to need the second.
920
+ beside = []
921
+ fresh = [(n["name"].split(".")[-1], n.get("parent")) for l in links for n in l["new"] if n.get("parent")]
922
+ # Code an add task names that is on the map already is new only when the baseline does not hold it (a plan made
923
+ # again after the code was written); otherwise the task changes it, and it is not new.
924
+ fresh += [(names.by_id[i]["name"], owner(i)) for l in links if l["action"] == "add" for i in l["nodes"]
925
+ if names.by_id[i]["kind"] in ("field", "callable") and not _in_baseline(con, cid, i)]
926
+ fresh_names = {(n, p) for n, p in fresh}
927
+ for name, parent in dict.fromkeys(fresh):
928
+ last = _words(name)[-1:]
929
+ if not last or len(_words(name)) < 2:
930
+ continue
931
+ for r in names.rows:
932
+ if r["parent_id"] != parent or r["kind"] != "field" or (r["name"], parent) in fresh_names or _words(r["name"])[-1:] != last:
933
+ continue
934
+ users = sorted({u[0] for u in con.execute(
935
+ f"SELECT DISTINCT src_id FROM edges WHERE kind IN ('reads', 'writes') AND dst_id = ? AND {SURE}", (r["id"],))
936
+ if u[0] not in inside and product(u[0]) and not is_ctor(u[0])})
937
+ if users:
938
+ beside.append({"new": _label(names, parent) + "." + name, "existing": _label(names, r["id"]),
939
+ "existing_used_by_unchanged": [_label(names, u) for u in users], "user_ids": users})
940
+ callers.sort(key=lambda c: (-c["far"], -c["other_types"], len(c["callers"])))
941
+ # Closest first: a field the tasks' words name, then state of the changed type, then the rest. The page shows
942
+ # the first few and counts the others; `spec facts` lists them all.
943
+ state.sort(key=lambda x: (not x["close"], not x["own"], not x["changed_writes_it"], -x["other_types"],
944
+ len(x["also_used_by_unchanged"])))
945
+ quiet = [x for x in state if x.get("quiet")]
946
+ return {"beside": beside, "callers": callers, "state": state,
947
+ # What the page leaves off as weak, for it to count: "and 3 more ...".
948
+ "left_out": {"count": len(quiet), "fields": [x["field"] for x in quiet],
949
+ "why": "they share only a large type, or a reader of every field, with the change"}}
950
+
951
+
952
+ BIG_TYPE = 12 # fields; on a type this large, two methods sharing one is weak evidence that they change together
953
+ _PLAIN = set("the a an and or not for with where when then each same its it is are be to of in on at by as from into "
954
+ "that this than any all one two three new add change make use set get call run test case cases code "
955
+ "remove rename write read return value values number true false none null so".split())
956
+
957
+
958
+ def _field_words(name: str) -> set:
959
+ return {w.rstrip("s") for w in _words(name) if len(w) > 2} - _PLAIN
960
+
961
+
962
+ def _task_words(names: _Names, links: list[dict], fn: str) -> set:
963
+ """What the tasks that change a function are about, as words: the text of each task naming it (all tasks when
964
+ none names it directly), less the words of the function's own name and its type's, which every such task has."""
965
+ texts = [l["text"] for l in links if fn in l["nodes"]] or [l["text"] for l in links]
966
+ own = set(_words(names.by_id[fn]["name"]))
967
+ parent = names.by_id.get(names.by_id[fn]["parent_id"])
968
+ if parent is not None:
969
+ own |= set(_words(parent["name"]))
970
+ words = set()
971
+ for t in texts:
972
+ for tok in re.findall(r"[A-Za-z][A-Za-z0-9_]*", t):
973
+ words |= _field_words(tok)
974
+ return words - {w.rstrip("s") for w in own}
975
+
976
+
977
+ def _patterns_touched(con, tasked: set) -> list[dict]:
978
+ from . import patterns
979
+ out = []
980
+ owner = {r["id"]: r["parent_id"] for r in con.execute("SELECT id, parent_id FROM nodes WHERE kind IN ('callable', 'field')")}
981
+ near = set(tasked) | {owner[i] for i in tasked if i in owner} # a method stands for its type here
982
+ for p in patterns.listing(con, limit=2000)["patterns"]:
983
+ ids = {n["id"] for ns in p["roles"].values() for n in ns}
984
+ if p["pattern"] in ("factory", "process boundary") and not (ids & set(tasked)):
985
+ continue
986
+ if any(i in ids for i in near):
987
+ out.append({"pattern": p["pattern"], "rationale": p["rationale"]})
988
+ return out[:12]
989
+
990
+
991
+ def _write(path: Path, body: str) -> None:
992
+ """Replace the generated block of a file, keeping anything a person wrote around it. A block whose end marker
993
+ is missing (a write cut off, a merge gone wrong) runs to the end of the file. Written whole or not at all."""
994
+ block = f"{BEGIN}\n{body.rstrip()}\n{END}\n"
995
+ old = path.read_text(encoding="utf-8", errors="replace") if path.is_file() else ""
996
+ start = old.find(BEGIN)
997
+ end = old.find(END, start) if start >= 0 else -1
998
+ if start < 0:
999
+ store.write_file(path, block)
1000
+ elif end < 0:
1001
+ store.write_file(path, old[:start] + block)
1002
+ else:
1003
+ store.write_file(path, old[:start] + block + old[end + len(END):].lstrip("\n"))
1004
+
1005
+
1006
+ def _some(xs: list[str], n: int = 4) -> str:
1007
+ return ", ".join(xs[:n]) + (f" and {len(xs) - n} more" if len(xs) > n else "")
1008
+
1009
+
1010
+ def _and(xs: list[str], n: int = 4) -> str:
1011
+ """A list as a sentence says it: A, B and C."""
1012
+ return _some(xs, n) if len(xs) > n else ", ".join(xs[:-1]) + " and " + xs[-1] if len(xs) > 1 else "".join(xs)
1013
+
1014
+
1015
+ def _first_sentence(text: str, limit: int = 220) -> str:
1016
+ m = re.match(r"(.+?[.!?])(\s|$)", text.strip(), re.S)
1017
+ out = (m.group(1) if m else text.strip()).replace("\n", " ")
1018
+ return out if len(out) <= limit else out[:limit - 3].rstrip() + "..."
1019
+
1020
+
1021
+ def _clip(text: str, limit: int) -> str:
1022
+ """Text cut at a word boundary to fit, with an ellipsis when cut."""
1023
+ text = " ".join(text.split())
1024
+ if len(text) <= limit:
1025
+ return text
1026
+ cut = text[:limit].rsplit(" ", 1)[0] if " " in text[:limit] else text[:limit]
1027
+ if cut.count("`") % 2: # not inside a code name: it would leave a backtick open
1028
+ cut = cut[:cut.rindex("`")]
1029
+ return cut.rstrip(" ,;:") + "…"
1030
+
1031
+
1032
+ def _n(n: int, word: str, plural: str = "") -> str:
1033
+ return f"{n} {word if n == 1 else plural or word + 's'}"
1034
+
1035
+
1036
+ def brief_status(b: dict) -> dict:
1037
+ """Where a plan stands: what blocks implementation, what is left to decide, and what is worth knowing."""
1038
+ blocking = ([b["impact"]["error"]] if b["impact"].get("error") else []) + list(b["gaps"])
1039
+ opened = [f for f in b["findings"] if f["status"] == "open"]
1040
+ blocking += [f"decide the open high finding {f['id']}: {_first_sentence(f['claim'], 120)}" for f in opened if f["severity"] == "high"]
1041
+ decide = [f"decide the open {f['severity']} finding {f['id']}: {_first_sentence(f['claim'], 120)}"
1042
+ for f in opened if f["severity"] != "high"]
1043
+ done = b.get("reviews") or sorted({f["reviewer"] for f in b["findings"]})
1044
+ notes = []
1045
+ if not done:
1046
+ notes.append("No review has run. A logic and a performance review find what the plan misses before code exists.")
1047
+ elif missing := [r for r in REVIEWERS if r not in done]:
1048
+ notes.append(f"No {' or '.join(missing)} review has run.")
1049
+ mine = [t["key"] for t in b["tasks"] if t.get("by_you")]
1050
+ if mine and len(mine) == len(b["tasks"]):
1051
+ notes.append("No task names code that is on the map, so only you can check the tasks; `check` still says what"
1052
+ " else changed. Put code names in backticks in tasks.md if the change is to code.")
1053
+ elif mine:
1054
+ notes.append(f"{'Task' if len(mine) == 1 else 'Tasks'} {', '.join(mine)} {'names' if len(mine) == 1 else 'name'} no code,"
1055
+ " so you check {} yourself after the change; {} not hold up the verdict.".format(
1056
+ "it" if len(mine) == 1 else "them", "it does" if len(mine) == 1 else "they do"))
1057
+ if b.get("baseline_tests") is False and b.get("baseline") != "kept":
1058
+ notes.append("The tests have not been recorded as they pass now, so `leyline check` will not be able to tell a test "
1059
+ "the change breaks from one that already failed.")
1060
+ return {"ready": not blocking, "blocking": blocking, "decide": decide, "notes": notes, "reviewed": bool(done)}
1061
+
1062
+
1063
+ def _state_line(b: dict) -> str:
1064
+ st = brief_status(b)
1065
+ if st["blocking"]:
1066
+ return (f"**State: not ready to implement.** {_n(len(st['blocking']), 'thing')} to settle first, listed under "
1067
+ f"\"Before implementation\" at the end.")
1068
+ return "**State: ready to implement.**" + ("" if st["reviewed"] else " It has not been reviewed.")
1069
+
1070
+
1071
+ def _plain_summary(b: dict) -> str:
1072
+ """One paragraph a newcomer can read without knowing Leyline."""
1073
+ S = [_first_sentence(b["why"], 300)] if b["why"] else []
1074
+ existing = sorted({x for t in b["tasks"] for x in t["labels"]})
1075
+ new = list(dict.fromkeys(n.get("label") or n["name"] for t in b["tasks"] for n in t["new"]))
1076
+ # a new function at the top of a file is labelled `file: name`; a sentence says it as `name` in file
1077
+ new = [f"`{x.split(': ', 1)[1]}` in {x.split(': ', 1)[0]}" if ": " in x else x for x in new]
1078
+ did = ([f"changes {_some(existing)}"] if existing else []) + ([f"adds {_some(new)}"] if new else [])
1079
+ S.append(f"The plan has {_n(len(b['tasks']), 'task')}" + (f": it {' and '.join(did)}." if did else "."))
1080
+ imp = b["impact"]
1081
+ if not imp.get("error"):
1082
+ s = imp["summary"]
1083
+ must = f"{_n(s['must_edit'], 'other place')} must be edited along with it" if s["must_edit"] else "Nothing else must be edited with it"
1084
+ # the modules the places that run into it are in, not every module the change touches
1085
+ mods = sum(1 for g in imp.get("by_module") or [] if g.get("reached")) or s["modules"]
1086
+ reach = (f"{_n(s['reached'], 'place')} in {_n(mods, 'module')} {'runs' if s['reached'] == 1 else 'run'} into the "
1087
+ "changed code and may behave differently") if s["reached"] else "no other code runs into it"
1088
+ tests = (f"{_n(s['tests_to_run'], 'existing test')} already {'runs' if s['tests_to_run'] == 1 else 'run'} through it"
1089
+ if s["tests_to_run"] else "no existing test runs through it")
1090
+ S.append(f"{must}; {reach}; {tests}.")
1091
+ sc = b["scenarios"]
1092
+ if sc:
1093
+ made = sum(1 for x in sc if x.get("generated_by") or x.get("in_run"))
1094
+ have = sum(1 for x in sc if x["test_exists"]) - made
1095
+ need = len(sc) - have - made
1096
+ S.append(f"It is done when {_n(len(sc), 'scenario passes', 'scenarios pass')}: " + ", ".join(
1097
+ x for x in ((f"{have} already {'has' if have == 1 else 'have'} a test" if have else ""),
1098
+ (f"{made} {'gets its test' if made == 1 else 'get their tests'} made at run time" if made else ""),
1099
+ (f"{need} {'needs' if need == 1 else 'need'} a test written" if need else "")) if x) + ".")
1100
+ else:
1101
+ S.append("No scenario says yet what done means.")
1102
+ return " ".join(S)
1103
+
1104
+
1105
+ def _past_decision(l: dict) -> str:
1106
+ """How an open finding that repeats a learning says so: with a warning when the code the learning was about has
1107
+ changed since, which the person weighs; the finding stays either way."""
1108
+ if l.get("stale"):
1109
+ from .learnings import changed_text
1110
+ return (f"Matches a past decision, but the code it was about has changed since:"
1111
+ f" {changed_text(l.get('edited') or [], l.get('gone') or [])}. Decided then: {l['reason']}"
1112
+ " Does it still hold?")
1113
+ if l.get("code") == "unknown":
1114
+ return (f"Matches a past decision: {l['reason']} Whether its code changed since is not known: it was kept"
1115
+ " before Leyline recorded that.")
1116
+ return f"Matches a past decision: {l['reason']}"
1117
+
1118
+
1119
+ def _learnings_unreadable(con) -> list[str]:
1120
+ from . import learnings
1121
+ return learnings.unreadable(con)
1122
+
1123
+
1124
+ def unreadable_lines(problems: Optional[list[str]]) -> list[str]:
1125
+ """The page's word that past decisions could not be read, so their absence is not read as none applying."""
1126
+ return ["", "Past decisions (learnings) were left out, so none are matched or listed here: "
1127
+ + "; ".join(problems) + "."] if problems else []
1128
+
1129
+
1130
+ def review_lines(found: list[dict], kinds: list[str], full: bool = True) -> list[str]:
1131
+ """Each kind of review: whether it ran, and what it filed. Open findings in full; settled ones as the claim,
1132
+ then the decision."""
1133
+ L = []
1134
+ order = {"high": 0, "medium": 1, "low": 2}
1135
+ ran = list(dict.fromkeys([*REVIEWERS, *kinds, *(f["reviewer"] for f in found)]))
1136
+ for kind in ran:
1137
+ mine = [f for f in found if f["reviewer"] == kind]
1138
+ if kind not in kinds and not mine:
1139
+ L.append(f"- {kind.capitalize()} review: not run.")
1140
+ elif not mine:
1141
+ L.append(f"- {kind.capitalize()} review: ran, and filed nothing.")
1142
+ else:
1143
+ left = sum(f["status"] == "open" for f in mine)
1144
+ L.append(f"- {kind.capitalize()} review: {_n(len(mine), 'finding')}, "
1145
+ + (f"{left} still open." if left else "all settled."))
1146
+ if not full:
1147
+ return L
1148
+ opened = sorted((f for f in found if f["status"] == "open"), key=lambda f: order.get(f["severity"], 3))
1149
+ if opened:
1150
+ L += ["", "Open:"] + [f"- **{f['severity']}** ({f['reviewer']}, {f['id']}): {f['claim']}"
1151
+ + (f" Proposed: {f['proposal']}" if f["proposal"] else "")
1152
+ + (" (Its evidence is not near the change: question it first.)" if f.get("evidence_far_from_change") else "")
1153
+ + (f" ({_past_decision(f['learned'])})" if f.get("learned") else "")
1154
+ + (" (Its code changed since it was filed: re-check it.)" if f.get("code_changed_since") else "")
1155
+ for f in opened]
1156
+ closed = sorted((f for f in found if f["status"] != "open"), key=lambda f: order.get(f["severity"], 3))
1157
+ if closed: # a settled finding is one line: the full text stays in `leyline spec findings`
1158
+ L += ["", "Settled (full text: `leyline spec findings`):"]
1159
+ def said(text):
1160
+ text = _first_sentence(text, 160)
1161
+ return text if text.endswith((".", "!", "?")) else text + "."
1162
+ L += [f"- {f['severity']}: {said(f['claim'])} **{f['status'].capitalize()}**"
1163
+ + (f": {said(f['resolution'])}" if f["resolution"] else ".")
1164
+ + (f" (Matched a past decision: {said(f['learned']['reason'])})" if f.get("learned") else "") for f in closed]
1165
+ return L
1166
+
1167
+
1168
+ def brief_text(b: dict) -> str:
1169
+ d = Path(b["dir"])
1170
+ rel = "/".join(d.parts[-3:]) if d.parent.name == "changes" else d.name
1171
+ L = [f"# {b['title']}", "",
1172
+ f"The one-page plan for the change in `{rel}/`, written by Leyline from the spec files beside it. "
1173
+ "`leyline plan` rewrites it; edit the spec, not this page.", "",
1174
+ _state_line(b), "", _plain_summary(b), "",
1175
+ "## 1. What code will be written", ""]
1176
+ if b["tasks"]:
1177
+ L += ["| Task | Does | Code |", "| --- | --- | --- |"]
1178
+ for t in b["tasks"]:
1179
+ code = ", ".join(sorted(set(t["labels"]))[:6]) + (f" and {len(set(t['labels'])) - 6} more" if len(set(t["labels"])) > 6 else "")
1180
+ new = ", ".join(f"{n.get('label') or n['name']} (new)" for n in t["new"])
1181
+ into = ", ".join(f"in {x}" for x in t.get("into_labels", []))
1182
+ test = "a test for the scenario" if t.get("scenarios") else ""
1183
+ does = ", ".join(x for x in (code, new, test, into) if x) or "no code: checked by you"
1184
+ if t.get("mentions"): # context the task reads or compares with: not changed by it
1185
+ does += "; mentions " + _some(t["mentions"], 3)
1186
+ L.append(f"| {t['key']} | {t['text'].replace('|', '/')} | {does} |")
1187
+ if b.get("notes"):
1188
+ L += ["", "Not read as code (nothing to check on the map): " + "; ".join(b["notes"][:8])
1189
+ + (f"; and {len(b['notes']) - 8} more" if len(b["notes"]) > 8 else "") + "."]
1190
+ else:
1191
+ L.append("No tasks yet.")
1192
+ from . import diagrams
1193
+ drawn = diagrams.markdown(b.get("how_it_runs"), "How execution reaches the code the tasks change, and what that code calls,"
1194
+ " as the code is now:", "The code the tasks change is shaded.")
1195
+ if drawn:
1196
+ L += ["", "### How it runs", ""] + drawn
1197
+ imp = b["impact"]
1198
+ L += ["", "## 2. What it will affect", ""]
1199
+ if imp.get("error"):
1200
+ L.append(imp["error"])
1201
+ else:
1202
+ s = imp["summary"]
1203
+ n = s["changed"] + s["added"]
1204
+ L.append(f"{_n(n, 'thing changes', 'things change')}, {s['must_edit']} more must be edited with {'it' if n == 1 else 'them'},"
1205
+ f" and {s['reached']} {'is' if s['reached'] == 1 else 'are'} reached without needing an edit, across"
1206
+ f" {_n(s['modules'], 'module')}. {_n(s['tests_to_run'], 'existing test runs', 'existing tests run')}"
1207
+ " through the change.")
1208
+ L += ["", "*Must be edited*: code that breaks unless it changes too, such as the callers of a function whose parameters "
1209
+ "change. *Reached*: code that runs into the change, directly or through other calls; it needs no edit but may "
1210
+ "behave differently.", ""]
1211
+ for m in (imp.get("by_module") or [])[:8]:
1212
+ bits = [f"{m[k]} {label}" for k, label in (("changed", "changed"), ("must_edit", "to edit"), ("reached", "reached")) if m.get(k)]
1213
+ L.append(f"- {m['module']}: {', '.join(bits)}")
1214
+ for r in imp.get("risks") or []:
1215
+ L.append(f"- **{r['level']} risk:** {r['what']}")
1216
+ for c in (imp.get("channels") or [])[:6]:
1217
+ # A channel is the risk, said once: high when a mismatch can only show at run time.
1218
+ loud = c.get("hub") and c["channel"] != "di" and (c["hub_changed"] and any(x["changed"] for x in c["spokes"])
1219
+ or any(x["channel"] == c["channel"] for x in b.get("must_agree") or []))
1220
+ L.append("- " + ("**high risk:** " if loud else "") + _crossing_line(c))
1221
+ if b["must_edit_uncovered"]:
1222
+ L += ["", "**Must be edited, and no task covers it:**"] + [f"- {m['name']}: {m.get('note', '')}" for m in b["must_edit_uncovered"][:20]]
1223
+ if b.get("must_agree"):
1224
+ L += ["", "**Must agree with the change, and no task names it** (another end of a channel it crosses):"] + [
1225
+ f"- {_and(x['reads']) if x.get('reads') else x['name']}" + (f" ({x['path']})" if x.get("path") else "")
1226
+ + f": {x['name'] + ' ' if x.get('reads') else ''}{x['why']}" for x in b["must_agree"][:10]]
1227
+ la = {"beside": [], "callers": [], "state": [], **(b.get("left_alone") or {})}
1228
+ # One page: new members that double an existing one, callers outside the changed function's own type,
1229
+ # and state the changed type owns.
1230
+ cal = [c for c in la["callers"] if c.get("other_types", 1)]
1231
+ own = [x for x in la["state"] if x.get("own", True) and not x.get("quiet")]
1232
+ rows = [f"- {x['new']} (new) sits beside {x['existing']}, which is used by {_some(x['existing_used_by_unchanged'], 6)}" for x in la["beside"][:3]]
1233
+ rows += [f"- {c['changed']} is also called by {_some(c['callers'])}" for c in cal[:max(2, 5 - len(rows))]]
1234
+ rows += [f"- {x['field']} (used by {x['used_by_changed']}) is also used by {_some(x['also_used_by_unchanged'])}" for x in own[:max(2, 8 - len(rows))]]
1235
+ quiet = (la.get("left_out") or {}).get("count", 0)
1236
+ more = len(la["beside"]) + len(la["callers"]) + len(la["state"]) - quiet - len(rows)
1237
+ if rows:
1238
+ L += ["", "**Shares a caller or a field with the change, and no task names it.** Each line is either right to leave "
1239
+ "alone or a missing task:"] + rows
1240
+ if more > 0:
1241
+ L.append(f"- and {more} more: `leyline spec facts`")
1242
+ if quiet: # said, so a short list is not read as the whole list
1243
+ L += ["", f"{quiet} more field{'s' if quiet != 1 else ''} shared with the change left off this page: "
1244
+ f"{(la.get('left_out') or {}).get('why', 'weak links')}. `leyline spec facts` lists them."]
1245
+ hist = b.get("usually_changes_with") or {}
1246
+ if hist.get("files"): # from git history: docs, schemas, config and fixtures the map has no link to
1247
+ from .coupling import line as coupling_line
1248
+ L += ["", f"**Usually changes with the files the tasks touch, and no task names it** ({hist['about']}). Each line "
1249
+ "is either right to leave alone or a missing task:"]
1250
+ L += [f"- {coupling_line(x, 'no task names it')}." for x in hist["files"][:4]]
1251
+ if hist["total"] > 4:
1252
+ L.append(f"- and {hist['total'] - 4} more: `leyline spec facts`")
1253
+ if hist.get("functions"): # the same, by function, for the functions the tasks name
1254
+ from .fncoupling import line as fn_line
1255
+ L += ["", "**Usually changes with the functions the tasks name, and no task names it.** Each line is either right"
1256
+ " to leave alone or a missing task:"]
1257
+ L += [f"- {fn_line(x, 'no task names it')}." for x in hist["functions"][:4]]
1258
+ if b["patterns"]:
1259
+ L += ["", "**Design patterns the change sits in** (found from the shape of the code):"] + [
1260
+ f"- {p['pattern']}: {p['rationale']}" for p in b["patterns"][:5]]
1261
+ if b.get("drifted_specs"):
1262
+ L += ["", "**Specs that no longer match code this change touches** (update them with it):"] + [
1263
+ f"- {x}" for x in b["drifted_specs"][:8]] + ([f"- and {len(b['drifted_specs']) - 8} more"] if len(b["drifted_specs"]) > 8 else [])
1264
+ from . import related
1265
+ L += [x.replace("Earlier changes to this code (", "**Earlier changes to this code** (", 1) for x in related.lines(b.get("related_changes"))]
1266
+ L += ["", "## 3. How you will know it was done", "",
1267
+ "Each scenario is proven by a test with the same name. After the change, `leyline check` marks each one from "
1268
+ "the test results.", ""]
1269
+ if b["scenarios"]:
1270
+ L += ["| Scenario | When | Then | Test |", "| --- | --- | --- | --- |"]
1271
+ for s in b["scenarios"]:
1272
+ test = (f"made at run time by `{s['generated_by']}`" if s.get("generated_by") else
1273
+ "in the test run (made at run time)" if s.get("in_run") else
1274
+ "exists" if s["test_exists"] else "to be written, with this name")
1275
+ from .props import cell
1276
+ test += cell(s)
1277
+ L.append(f"| {s['name']} | {'; '.join(s['when']).replace('|', '/')} | {'; '.join(s['then']).replace('|', '/')} | {test} |")
1278
+ else:
1279
+ L.append("No scenarios yet: nothing says what done means.")
1280
+ L += ["", "## Review findings", ""] + review_lines(b["findings"], b.get("reviews") or [])
1281
+ L += unreadable_lines(b.get("learnings_unreadable"))
1282
+ st = brief_status(b)
1283
+ L += ["", "## Before implementation", ""]
1284
+ if st["blocking"]:
1285
+ L += [f"- {g}" for g in st["blocking"]]
1286
+ else:
1287
+ L.append("Nothing blocks implementation: every task that names code is tied to it, everything that must be edited has"
1288
+ " a task, and every scenario has a test or a task to write one.")
1289
+ if st["decide"]:
1290
+ L += ["", "Still to decide, not blocking:"] + [f"- {d}" for d in st["decide"]]
1291
+ if st["notes"]:
1292
+ L += [""] + [f"- {x}" for x in st["notes"]]
1293
+ return "\n".join(L) + "\n"
1294
+
1295
+
1296
+ # -- findings ------------------------------------------------------------------------------------
1297
+ def add_finding(con, change_id: str, reviewer: str, severity: str, claim: str, evidence: Optional[list[str]] = None,
1298
+ proposal: str = "") -> dict:
1299
+ """A reviewer's finding against a change: a claim, the nodes behind it, and what to change in the spec."""
1300
+ if severity not in ("high", "medium", "low"):
1301
+ return {"error": "severity must be high, medium or low"}
1302
+ if not con.execute("SELECT 1 FROM change_proposals WHERE id = ?", (change_id,)).fetchone():
1303
+ return {"error": f"no change {change_id!r}; run the brief first"}
1304
+ known = {r[0] for r in con.execute("SELECT id FROM nodes")}
1305
+ kept = [e for e in (evidence or []) if e in known]
1306
+ if not kept:
1307
+ return {"error": "a finding needs evidence: at least one node id on the map that shows the problem"}
1308
+ fid = "f-" + hashlib.sha1(f"{change_id}|{reviewer}|{claim}".encode()).hexdigest()[:6]
1309
+ with con:
1310
+ con.execute("INSERT OR REPLACE INTO findings VALUES (?,?,?,?,?,?,?,COALESCE((SELECT status FROM findings WHERE id = ?), 'open'),"
1311
+ "(SELECT resolution FROM findings WHERE id = ?),?)",
1312
+ (fid, change_id, reviewer, severity, claim.strip(), json.dumps(kept), proposal.strip(), fid, fid,
1313
+ datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")))
1314
+ out = {"id": fid, "status": "open"}
1315
+ if near_change(con, change_id, kept) is False:
1316
+ out["warning"] = ("None of the evidence is on the change's blast radius (what it changes, what must change with"
1317
+ " it, the other ends of its channels, or one call from those). Kept, and marked so the person"
1318
+ " questions it first; if the link is real, add evidence that shows it.")
1319
+ from . import learnings
1320
+ out.update(learnings.on_finding(con, fid, reviewer, claim.strip(), kept)) # a repeat of a past decision is marked
1321
+ return out
1322
+
1323
+
1324
+ def resolve_finding(con, finding_id: str, status: str, resolution: str = "") -> dict:
1325
+ """The person's call on a finding: accepted (the spec changes), rejected or deferred, with the reason."""
1326
+ if status not in ("accepted", "rejected", "deferred", "open"):
1327
+ return {"error": "status must be accepted, rejected, deferred or open"}
1328
+ with con:
1329
+ n = con.execute("UPDATE findings SET status = ?, resolution = ? WHERE id = ?", (status, resolution, finding_id)).rowcount
1330
+ if not n:
1331
+ return {"error": f"no finding {finding_id!r}"}
1332
+ from . import learnings # a rejection with a reason is kept, so later reviews do not ask again
1333
+ return {"id": finding_id, "status": status, **learnings.on_resolve(con, finding_id, status, resolution)}
1334
+
1335
+
1336
+ def near_change(con, change_id: str, evidence: list[str]) -> Optional[bool]:
1337
+ """Whether any of a finding's evidence lies on the change's blast radius: a node the change's view marks (changed,
1338
+ must edit, the other end of a channel, a caller...), something nested in one or holding one, or a node one call
1339
+ from one. A finding whose evidence is elsewhere is not wrong for that, but it is the first a person should
1340
+ question: the reviewer may have wandered. None when the change has no view to judge by."""
1341
+ row = con.execute("SELECT spec FROM views WHERE id = ?", ("view-" + change_id,)).fetchone()
1342
+ marks = {m["id"] for m in json.loads(row[0] or "{}").get("marks", [])} if row else set()
1343
+ if not marks:
1344
+ return None
1345
+ for e in evidence:
1346
+ if e in marks or any(e.startswith(m + ".") or m.startswith(e + ".") for m in marks):
1347
+ return True
1348
+ for r in con.execute("SELECT src_id, dst_id FROM calls WHERE src_id = ? OR dst_id = ?", (e, e)):
1349
+ if r[0] in marks or r[1] in marks:
1350
+ return True
1351
+ return False
1352
+
1353
+
1354
+ def findings(con, change_id: str) -> dict:
1355
+ from . import learnings
1356
+ names = {r["id"]: r["name"] for r in con.execute("SELECT id, name FROM nodes")}
1357
+ learned = learnings.by_finding(con, change_id)
1358
+ out = []
1359
+ for r in con.execute("SELECT * FROM findings WHERE change_id = ? ORDER BY created", (change_id,)):
1360
+ ev = json.loads(r["evidence"] or "[]")
1361
+ near = near_change(con, change_id, ev)
1362
+ out.append({"id": r["id"], "reviewer": r["reviewer"], "severity": r["severity"], "claim": r["claim"],
1363
+ "proposal": r["proposal"], "status": r["status"], "resolution": r["resolution"] or "",
1364
+ "evidence": [{"id": e, "name": names.get(e, e)} for e in ev],
1365
+ **({"evidence_far_from_change": True} if near is False else {}),
1366
+ **({"learned": learned[r["id"]]} if r["id"] in learned else {})})
1367
+ return {"change_id": change_id, "open": sum(1 for f in out if f["status"] == "open"), "findings": out}
1368
+
1369
+
1370
+ # -- verification --------------------------------------------------------------------------------
1371
+ def verify(con, change_dir: str | Path, before_run: Optional[str] = None, after_run: Optional[str] = None, write: bool = True,
1372
+ old_run: Optional[str] = None) -> dict:
1373
+ """After implementation and a re-index: was the change made as the spec says? `old_run` names results left out
1374
+ because the code changed after they ran: a scenario they carry has a test, whose results are out of date."""
1375
+ parsed = parse(change_dir)
1376
+ if "error" in parsed:
1377
+ return parsed
1378
+ cid = "spec-" + parsed["id"]
1379
+ planned = con.execute("SELECT 1 FROM change_proposals WHERE id = ?", (cid,)).fetchone()
1380
+ if planned and parsed["tasks"] and not con.execute("SELECT 1 FROM spec_items WHERE change_id = ? LIMIT 1", (cid,)).fetchone():
1381
+ # The plan stored the change, and stopped before its tasks and scenarios: there is nothing to judge.
1382
+ kept = diff.snapshot_path(con, cid).exists()
1383
+ return {"error": f"the last plan of {parsed['id']} did not finish, so its tasks were not stored.",
1384
+ "next": [f"Next: run `leyline plan {parsed['id']}` again" + (" (the baseline it took is kept)" if kept else
1385
+ " on the code as it was before the change") + ", then check it again."]}
1386
+ review = diff.review(con, cid, before_run, after_run)
1387
+ if "error" in review:
1388
+ if review["error"].startswith("No change"): # never planned, or the plan stopped at an error
1389
+ return {"error": f"{parsed['id']} has not been planned, so there is nothing to compare the code with.",
1390
+ "next": [f"Next: run `leyline plan {parsed['id']}` (the `plan` tool) on the code as it was before the"
1391
+ " change (undo the edits, or start from the commit before them), then check it again."]}
1392
+ return {"error": review["error"] + (f" Run `leyline plan {parsed['id']}` on the change before it is implemented."
1393
+ if review["error"].startswith("No snapshot") else "")}
1394
+ names = _Names(con)
1395
+ g = review["graph"]["nodes"]
1396
+ touched = {n["id"] for key in ("added", "resigned", "edited", "types_edited") for n in g[key]}
1397
+ removed = {n["id"] for n in g["removed"]}
1398
+ added = {n["id"] for n in g["added"]}
1399
+ home = {} # node -> the file and module it sits in, for tasks that name a file
1400
+ for i in touched | removed:
1401
+ row = con.execute("SELECT file_id, module_id FROM ancestry WHERE node_id = ?", (i,)).fetchone()
1402
+ home[i] = set(row) - {None} if row else set()
1403
+ items = con.execute("SELECT * FROM spec_items WHERE change_id = ? AND kind = 'task' ORDER BY key", (cid,)).fetchall()
1404
+ tests = _tests(con)
1405
+ test_names = diff.TestNames(con)
1406
+ results = {r["name"]: r for r in con.execute("SELECT * FROM test_results WHERE run = ?", (after_run,))} if after_run else {}
1407
+ index = _results_index(results.values())
1408
+ older = _results_index(con.execute("SELECT * FROM test_results WHERE run = ?", (old_run,)).fetchall()) if old_run else None
1409
+ from . import coverage as measured
1410
+ own_checks = _self_tests(con, [r for r in (before_run, after_run) if r])
1411
+
1412
+ scenarios, scenario_ran = [], {}
1413
+ scenario_name = {s["key"]: s["name"] for s in parsed["scenarios"]}
1414
+ for s in parsed["scenarios"]:
1415
+ tid = tests.get(_norm(s["name"]))
1416
+ gen = None if tid else _generated(test_names, s["name"])
1417
+ rows = _scenario_results(index, s["name"])
1418
+ scenario_ran[s["key"]] = bool(rows)
1419
+ failed = [r for r in rows if r["status"] == "fail"]
1420
+ state = ("fails" if failed else "passes" if any(r["status"] == "pass" for r in rows) else "skipped" if rows
1421
+ else "results older than the code" if older and _scenario_results(older, s["name"])
1422
+ else "test exists, not run" if tid or gen else "no test")
1423
+ ran_change = None
1424
+ if tid and measured.has(con):
1425
+ ran = {r[0] for r in con.execute("SELECT node_id FROM covered WHERE test_id = ?", (tid,))}
1426
+ ran_change = bool(ran & touched) if ran else None
1427
+ static = bool(tid) and bool({r[0] for r in con.execute(
1428
+ "SELECT s.callable_id FROM flow_steps s JOIN flows f ON f.id = s.flow_id WHERE f.entry_id = ?", (tid,))} & touched)
1429
+ script = next((x["path"] for x in own_checks.values() if s["name"] in x["results"]), None) if not tid else None
1430
+ from .props import from_message # a failing property test's smallest failing input
1431
+ ex = next((x for x in (from_message(r["message"]) for r in failed) if x), None)
1432
+ scenarios.append({"name": s["name"], "state": state, "test": tid, "generated": not tid and bool(gen),
1433
+ # written since the plan: a test the baseline does not hold (one the run before the change
1434
+ # merely left out is not new, and must reach the changed code to prove anything)
1435
+ "test_new": bool(tid) and not _in_baseline(con, cid, tid),
1436
+ **({"counterexample": ex} if ex else {}),
1437
+ "off_map": not tid and not gen and bool(rows), **({"script": script} if script else {}),
1438
+ "results": len(rows), "reaches_the_change": static, "measured_running_the_change": ran_change,
1439
+ "message": (failed[0]["message"] or "") if failed else ""})
1440
+
1441
+ tasks, declared, named = [], set(), set()
1442
+ for row in items:
1443
+ extra = json.loads(row["attrs"] or "{}")
1444
+ ids, new = json.loads(row["nodes"] or "[]"), extra.get("new", [])
1445
+ named |= set(ids)
1446
+
1447
+ def did(i):
1448
+ if names.by_id.get(i, {"kind": ""})["kind"] in ("file", "module"): # "change `file.py`": anything in it
1449
+ return any(i in home.get(t, ()) for t in touched | removed)
1450
+ return i in touched or i in removed or any(t.startswith((i + ".", i + "/", i + "(")) or t.split("(")[0] == i.split("(")[0]
1451
+ for t in touched | removed)
1452
+ hit = [i for i in ids if did(i)]
1453
+ made = []
1454
+ for n in new:
1455
+ if n.get("file"):
1456
+ found = bool(names.file(n["name"]))
1457
+ else:
1458
+ leaf = n["name"].split(".")[-1]
1459
+ found_ids = [a for a in added if names.by_id.get(a) is not None and names.by_id[a]["name"] == leaf
1460
+ and (not n.get("parent") or names.by_id[a]["parent_id"] == n["parent"]
1461
+ or n["parent"] in home.get(a, ()) or a.startswith(n["parent"] + "."))]
1462
+ declared |= set(found_ids)
1463
+ found = bool(found_ids)
1464
+ if found:
1465
+ made.append(n["name"])
1466
+ want = len({i.split("(")[0] for i in ids}) + len(new)
1467
+ got = len({i.split("(")[0] for i in hit}) + len(made)
1468
+ unproven = []
1469
+ for key in extra.get("scenarios", []): # a task to write a scenario's test is done when that test exists or ran
1470
+ want += 1
1471
+ title = scenario_name.get(key, key.split("/", 1)[-1])
1472
+ there = _norm(title) in tests or scenario_ran.get(key, False)
1473
+ got += there
1474
+ if not there:
1475
+ unproven.append(f"a result for the test \"{title}\"")
1476
+ if not want and extra.get("into"): # "add something to `Foo`": done when something inside Foo is new or edited
1477
+ want = 1
1478
+ files = {r[0] for i in extra["into"] for r in con.execute("SELECT node_id FROM ancestry WHERE file_id = ? OR module_id = ?", (i, i))}
1479
+ got = int(any(t in files or any(t.startswith(i + ".") for i in extra["into"]) for t in touched))
1480
+ by_you = extra.get("by_you", not want)
1481
+ state = ("checked by you" if by_you else "done" if want and got >= want else "partly" if got else "not done")
1482
+ from . import removal # "Remove `X`" is done when X is gone and nothing still calls it, not when X is edited
1483
+ gone = None if by_you else removal.check(con, cid, row, names, touched, removed)
1484
+ state = gone["state"] if gone else state
1485
+ declared |= set(gone["made"]) if gone else set() # a rename's new name is the task's, not an edit of its own
1486
+ checked = next((t["done"] for t in parsed["tasks"] if t["key"] == row["key"]), False)
1487
+ tasks.append({"key": row["key"], "text": row["text"], "state": state, "ticked": checked,
1488
+ "missing": [_label(names, i) for i in ids if i not in hit][:6] + [n["name"] for n in new if n["name"] not in made][:6]
1489
+ + unproven})
1490
+ if gone:
1491
+ tasks[-1].update(missing=gone["missing"], removal=gone)
1492
+
1493
+ all_findings = findings(con, cid)["findings"]
1494
+ open_high = [f for f in all_findings if f["status"] == "open" and f["severity"] == "high"]
1495
+ test_ids = set(tests.values())
1496
+ # A new or edited test is how a scenario gets proven, not an edit outside the spec.
1497
+ tests_touched = [n for n in review["not_predicted"] if n["id"] in test_ids or n["id"].split("/test:")[0] in test_ids and "/test:" in n["id"]
1498
+ or TEST_FILE.search(n.get("path") or "")]
1499
+ # "Add X to `Foo`" covers whatever is new or edited inside Foo.
1500
+ into = {i for row in items for i in json.loads(row["attrs"] or "{}").get("into", [])}
1501
+ inside = {r[0] for i in into for r in con.execute("SELECT node_id FROM ancestry WHERE file_id = ? OR module_id = ?", (i, i))}
1502
+
1503
+ def in_container(i):
1504
+ return i in into or i in inside or any(i.startswith(c + ".") or i.startswith(c + "/") for c in into)
1505
+ # New code a task named (`build_cases.case_x`, `func` in `file.py`) is the task's, found the way the plan found it.
1506
+ drift = [n for n in review["not_predicted"] if n not in tests_touched and not in_container(n["id"])
1507
+ and n["id"] not in declared and n["id"] not in named]
1508
+ # Named code that is gone took what was inside it along (a class's members, a file's functions): not edits of their own.
1509
+ gone_named = sorted(i for i in named if i not in names.by_id)
1510
+ if gone_named and drift:
1511
+ was = diff._open(diff.snapshot_path(con, cid))
1512
+ try:
1513
+ marks = ",".join("?" * len(gone_named))
1514
+ went = {r[0] for r in was.execute(f"SELECT node_id FROM ancestry WHERE file_id IN ({marks}) OR module_id IN ({marks})",
1515
+ gone_named + gone_named)}
1516
+ finally:
1517
+ was.close()
1518
+ drift = [n for n in drift if not (n["id"] in removed and (n["id"] in went or any(
1519
+ n["id"].startswith((i + ".", i + "/", i + "(")) for i in gone_named)))]
1520
+ # A new function that only code named in the spec calls is how a task got done, not a change of its own.
1521
+ in_spec = {t for t in touched if t not in {n["id"] for n in drift}} | named
1522
+ helpers = []
1523
+ for n in list(drift):
1524
+ if n["id"] not in added or n.get("kind") != "callable":
1525
+ continue
1526
+ callers = {r[0] for r in con.execute("SELECT DISTINCT src_id FROM calls WHERE dst_id = ?", (n["id"],))} - {n["id"]}
1527
+ if callers and all(c in in_spec or in_container(c) or any(c.split("(")[0] == i.split("(")[0] for i in named) for c in callers):
1528
+ helpers.append({"name": n["name"], "id": n["id"], "called_by": sorted(_label(names, c) for c in callers)})
1529
+ drift.remove(n)
1530
+ # What is left of a body around the task's code (a module's top level, a class around a new method) changed
1531
+ # only next to that code, or to import or register it.
1532
+ spans, code_names = diff._spans(con, sorted(in_spec | declared | {h["id"] for h in helpers}))
1533
+ drift = [n for n in drift if not (n.get("own") and diff.explained(n, spans.get(n.get("path"), []), code_names))]
1534
+ from . import affected # with per-test coverage: did each passing scenario's test run the code its tasks changed?
1535
+ affected.mark_scenarios(con, scenarios, ((in_spec | declared | {h["id"] for h in helpers}) & touched) or touched,
1536
+ after_run, test_names, cid)
1537
+ # One verdict per task and scenario; which of them hold up "done as agreed" is the project's to set.
1538
+ judged = verdicts.judge(con, cid, parsed["dir"], tasks, scenarios, names, touched, removed, before_run, after_run)
1539
+ verdict = list(judged.pop("holds_up"))
1540
+ if drift:
1541
+ verdict.append(f"{_n(len(drift), 'edit is', 'edits are')} outside the spec")
1542
+ if review["rules"]["new_violations"]:
1543
+ verdict.append("a rule that held now fails")
1544
+ if review["graph"]["structure"]["new_dependencies"]:
1545
+ verdict.append("new links between modules")
1546
+ if review["tests"] and review["tests"]["newly_failing"]:
1547
+ verdict.append("tests that passed now fail")
1548
+ if review["tests"] and review["tests"].get("new_failing"):
1549
+ verdict.append("tests added since the plan fail")
1550
+ after_fails = [r["name"] for r in results.values() if r["status"] == "fail"]
1551
+ if not review["tests"] and after_fails:
1552
+ verdict.append(f"{_n(len(after_fails), 'test fails', 'tests fail')}, and with no run from before the change it cannot"
1553
+ " be told whether the change broke them")
1554
+ if open_high:
1555
+ verdict.append("a high review finding is still open")
1556
+ for n in drift:
1557
+ n.pop("own", None)
1558
+ out = {"change_id": cid, "title": parsed["title"], "tasks": tasks, "scenarios": scenarios, "drift": drift,
1559
+ "predicted_not_edited": [{**n, "name": _label(names, n["id"]) if n["id"] in names.by_id else n["name"]}
1560
+ for n in review["predicted_untouched"]], "new_dependencies": review["graph"]["structure"]["new_dependencies"],
1561
+ "rules_newly_failing": review["rules"]["new_violations"], "tests": review["tests"], "open_high_findings": open_high,
1562
+ "tests_added_or_changed": [n["name"] for n in tests_touched], "helpers_added": helpers,
1563
+ "review": {"findings": len(all_findings), "open": sum(f["status"] == "open" for f in all_findings),
1564
+ "kinds": reviews(con, cid), "all": all_findings},
1565
+ "baseline": review.get("baseline"),
1566
+ "baseline_other_version": review.get("baseline_other_version", False),
1567
+ "after_tests": {"passed": sum(r["status"] == "pass" for r in results.values()), "total": len(results),
1568
+ "skipped": sum(r["status"] == "skip" for r in results.values())} if results else None,
1569
+ "after_failing": [{"name": n} for n in after_fails],
1570
+ "done_as_agreed": not verdict, "why_not": verdict, "verdicts": judged, "view_id": review.get("view_id")}
1571
+ from . import diagrams # the changed code as it runs now, and the calls and channel links it gained and lost
1572
+ out["how_it_runs"] = diagrams.safe(diagrams.for_snapshot, diff.snapshot_path(con, cid), con,
1573
+ [n["id"] for k in ("edited", "resigned", "added") for n in g[k]]
1574
+ or [n["id"] for n in g["types_edited"]], [n["id"] for n in g["removed"]])
1575
+ if write and (why := store.escapes(parsed["dir"])):
1576
+ out["not_written"] = why
1577
+ elif write:
1578
+ path = Path(parsed["dir"]) / "leyline.md"
1579
+ body = path.read_text(encoding="utf-8", errors="replace") if path.is_file() else ""
1580
+ # The block runs to its end marker, or to the end of the file when a cut-off page lost the marker (as _write
1581
+ # reads it): the plan above section 4 is kept either way.
1582
+ start = body.find(BEGIN)
1583
+ stop = body.find(END, start) if start >= 0 else -1
1584
+ head = body[start + len(BEGIN):stop if stop >= 0 else len(body)].strip() if start >= 0 else ""
1585
+ head = head.split("\n## 4. Was it done as agreed")[0].rstrip()
1586
+ # The state at the top of the page is now the verdict.
1587
+ head = re.sub(r"^\*\*State:.*$", lambda _: check_state(out), head, count=1, flags=re.M)
1588
+ _write(path, head + "\n\n" + verify_text(out))
1589
+ out["written"] = str(path)
1590
+ # The latest check decides: the change is finished (verified) only while its last check found it done. Each check
1591
+ # is kept in `checks`. Done, the baseline stays (it is small), so a later edit can be checked against the same
1592
+ # start; it goes with `leyline spec forget`, or once the change folder is archived or removed.
1593
+ row = con.execute("SELECT attrs FROM change_proposals WHERE id = ?", (cid,)).fetchone()
1594
+ attrs = json.loads(row[0] or "{}") if row else {}
1595
+ at = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
1596
+ attrs["checks"] = (attrs.get("checks") or [])[-19:] + [{"at": at, "done": bool(out["done_as_agreed"])}]
1597
+ if out["done_as_agreed"]:
1598
+ attrs["verified"] = at
1599
+ else:
1600
+ attrs.pop("verified", None)
1601
+ with con:
1602
+ con.execute("UPDATE change_proposals SET status = ?, attrs = ? WHERE id = ?",
1603
+ ("verified" if out["done_as_agreed"] else "implemented", json.dumps(attrs), cid))
1604
+ if out["done_as_agreed"]:
1605
+ from . import drift # what each code name meant now that it is agreed: `leyline drift` compares later code with it
1606
+ out["anchors"] = drift.record(con, change_dir, write_file=write)
1607
+ return out
1608
+
1609
+
1610
+ def _yes(v: dict) -> str:
1611
+ other = verdicts.yes_text(v) # something other than proven was let through: say so
1612
+ if other:
1613
+ return other
1614
+ mine = [t["key"] for t in v["tasks"] if t["state"] == "checked by you"]
1615
+ return ("Every task " + ("that names code " if mine else "") + "is done, every scenario is proven, and nothing outside"
1616
+ " the spec changed." + (f" Task{'s' if len(mine) > 1 else ''} {', '.join(mine)} {'are' if len(mine) > 1 else 'is'}"
1617
+ " yours to check." if mine else ""))
1618
+
1619
+
1620
+ def check_state(v: dict) -> str:
1621
+ if v["done_as_agreed"]:
1622
+ return "**State: done as agreed.** " + _yes(v)
1623
+ return "**State: not done as agreed yet:** " + "; ".join(v["why_not"]) + ". Details under \"Was it done as agreed\"."
1624
+
1625
+
1626
+ def _drift_line(n: dict) -> str:
1627
+ if n["name"] in ("<module>", "<top-level>"):
1628
+ return f"- the top level of {n.get('path') or 'a file'} (code outside any function)"
1629
+ return f"- {n['name']} ({n.get('why') or n['kind']}) {n.get('path') or ''}".rstrip()
1630
+
1631
+
1632
+ def verify_text(v: dict) -> str:
1633
+ L = ["## 4. Was it done as agreed", "",
1634
+ "Leyline marks each task from what changed in the code since the plan, whether or not it is ticked in tasks.md,"
1635
+ " and each scenario from the test results.", "",
1636
+ "**Yes.** " + _yes(v) if v["done_as_agreed"]
1637
+ else "**Not yet:** " + "; ".join(v["why_not"]) + ".", "",
1638
+ *(["*The baseline was taken by another version of Leyline (or one that did not record its version), which may"
1639
+ " have read the same code differently: a change below that the diff does not show comes from that, not from"
1640
+ " the edit.*", ""] if v.get("baseline_other_version") else []),
1641
+ "| Task | Result | Verdict | Missing |", "| --- | --- | --- | --- |"]
1642
+ for t in v["tasks"]:
1643
+ L.append(f"| {t['key']} {_clip(t['text'], 70).replace('|', '/')} | {t['state']} | {t.get('verdict', '')} | {', '.join(t['missing'])} |")
1644
+ if any(t["state"] == "checked by you" for t in v["tasks"]):
1645
+ L += ["", "*Checked by you*: the task names no code, so the map cannot see it done; "
1646
+ + ("this project makes that hold up the verdict." if verdicts.PERSON in (v.get("verdicts") or {}).get("blocking", ())
1647
+ else "it does not hold up the verdict.")]
1648
+ L += ["", "| Scenario | Result | Verdict | Evidence |", "| --- | --- | --- | --- |"]
1649
+ for s in v["scenarios"]:
1650
+ ev = ("measured running the changed code" if s["measured_running_the_change"] else
1651
+ "passed without running the changed code" if s.get("ran_changed_code") is False else
1652
+ "proven by the test run (the test is generated, so it is not on the map)"
1653
+ if s["generated"] and s["state"] == "passes" else
1654
+ f"proven by the test run (a check in {s['script']}, which runs as a script; the test is not on the map)"
1655
+ if s.get("script") and s["state"] == "passes" else
1656
+ "proven by the test run (the test is not on the map)" if s.get("off_map") and s["state"] == "passes" else
1657
+ "its test reaches the changed code on the map" if s["reaches_the_change"] else
1658
+ "its test does not reach the changed code" if s["test"] else "")
1659
+ if s["state"] == "passes" and s.get("results", 0) > 1:
1660
+ ev += f" ({s['results']} results carry its name; all pass)"
1661
+ msg = " ".join((s["message"] or "").split()).replace("|", "/")
1662
+ L.append(f"| {s['name']} | {s['state']} | {s.get('verdict', '')} | {(msg[:200] + '...' if len(msg) > 200 else msg) or ev} |")
1663
+ L += verdicts.page_lines(v)
1664
+ if v["drift"]:
1665
+ L += ["", "**Changed, but not in the spec:**"] + [_drift_line(n) for n in v["drift"][:25]]
1666
+ if v.get("helpers_added"):
1667
+ L += ["", "**Helpers added** (new, and called only by code the spec names):"] + [
1668
+ f"- {h['name']}, called by {_some(h['called_by'])}" for h in v["helpers_added"][:15]]
1669
+ if v["predicted_not_edited"]:
1670
+ L += ["", "**Expected to change, and did not:**"] + [f"- {n['name']}" for n in v["predicted_not_edited"][:25]]
1671
+ if v["new_dependencies"]:
1672
+ L += ["", "**New links between modules:**"] + [f"- {d['from']} to {d['to']}" for d in v["new_dependencies"]]
1673
+ if v["rules_newly_failing"]:
1674
+ L += ["", "**Rules that held before and fail now:**"] + [f"- {r['kind']} {r['from']} {r['to']}" for r in v["rules_newly_failing"]]
1675
+ t = v["tests"]
1676
+ if t:
1677
+ L += ["", f"Tests: {diff.passed_pair_text(t['before'], t['after'])}."]
1678
+ L += [f"- now fails: {x['name']}" + (f": {x['message']}" if x["message"] else "") for x in t["newly_failing"]]
1679
+ L += [f"- new, and fails: {x['name']}" + (f": {x['message']}" if x["message"] else "") for x in t.get("new_failing", [])]
1680
+ elif v.get("after_tests"):
1681
+ a = v["after_tests"]
1682
+ L += ["", f"Tests: {diff.passed_text(a)} after the change. No run from before it was recorded, so a test "
1683
+ "the change broke cannot be told from one that already failed."]
1684
+ L += [f"- fails: {r['name']}" for r in v.get("after_failing", [])[:10]]
1685
+ else:
1686
+ L += ["", "No test results were recorded after the change, so scenarios cannot be marked as passing."]
1687
+ from . import diagrams
1688
+ L += diagrams.section(v.get("how_it_runs"), "### How it runs now")
1689
+ r = v.get("review") or {}
1690
+ L += ["", "Review before implementation:"] + review_lines(r.get("all") or [], r.get("kinds") or [], full=False)
1691
+ if v.get("baseline"):
1692
+ L += ["", f"Compared with the code as it was at {v['baseline']}."]
1693
+ return "\n".join(L) + "\n"
1694
+
1695
+
1696
+ # -- facts for reviewers ---------------------------------------------------------------------------
1697
+ SPEED_PLACE = re.compile(r"perf|bench|budget|latency|throughput|load[-_ ]?test|stress", re.I)
1698
+ CLOCK = re.compile(r"performance\.now|process\.hrtime|perf_counter|time\.time\(|time\.monotonic|timeit|Stopwatch|"
1699
+ r"System\.nanoTime|System\.currentTimeMillis|Instant::now|time\.Now\(|Date\.now\(\)|console\.time|"
1700
+ r"benchmark|\bbench\(", re.I)
1701
+
1702
+
1703
+ def _speed_tests(con, changed: set, self_tests: Optional[dict] = None) -> list[dict]:
1704
+ """Tests that measure speed: those in a file or suite named for it, and those whose own text reads a clock.
1705
+ A test named "adds 5s of slow_down" does not measure speed; perf-budget.test.ts does. A script's own checks
1706
+ (see _self_tests) count too, by their names and by a clock read near where each is named."""
1707
+ tests = con.execute("SELECT id, name, path, repo_id, span_start, span_end, attrs FROM nodes WHERE kind = 'test'"
1708
+ " OR json_extract(attrs, '$.is_test') = 1").fetchall()
1709
+ root_of = diff.roots(con)
1710
+ texts: dict = {}
1711
+ out = []
1712
+ for r in tests:
1713
+ a = json.loads(r["attrs"] or "{}")
1714
+ place = bool(SPEED_PLACE.search(r["path"] or "") or SPEED_PLACE.search(a.get("suite") or "")
1715
+ or SPEED_PLACE.search(r["name"] or ""))
1716
+ key = (r["repo_id"], r["path"])
1717
+ if key not in texts:
1718
+ data = diff.source(con, *key, root_of=root_of) if r["path"] else None
1719
+ texts[key] = data.decode("utf-8", errors="replace").split("\n") if data else []
1720
+ body = "\n".join(texts[key][(r["span_start"] or 1) - 1:r["span_end"] or 0])
1721
+ clock = bool(body and CLOCK.search(body))
1722
+ if place or clock:
1723
+ out.append({"id": r["id"], "name": r["name"], "path": r["path"], "why": " and ".join(
1724
+ x for x in ("named for speed" if place else "", "reads a clock" if clock else "") if x)})
1725
+ done = set()
1726
+ for entry, x in (self_tests or {}).items():
1727
+ if x["path"] in done:
1728
+ continue
1729
+ done.add(x["path"])
1730
+ repo = con.execute("SELECT repo_id FROM nodes WHERE id = ?", (entry,)).fetchone()
1731
+ data = diff.source(con, repo[0], x["path"], root_of) if repo else None
1732
+ lines = data.decode("utf-8", errors="replace").split("\n") if data else []
1733
+ for name in x["results"]:
1734
+ at = [k for k, ln in enumerate(lines) if name in ln]
1735
+ near = "\n".join(lines[max(0, at[0] - 15):at[0] + 3]) if at else ""
1736
+ place, clock = bool(SPEED_PLACE.search(name)), bool(near and CLOCK.search(near))
1737
+ if place or clock:
1738
+ out.append({"id": entry, "name": name, "path": x["path"], "why": " and ".join(
1739
+ w for w in ("named for speed" if place else "", "reads a clock" if clock else "") if w)
1740
+ + " (a check the script runs and prints)"})
1741
+ if out and changed:
1742
+ reach = {r[0] for r in con.execute(
1743
+ f"SELECT DISTINCT f.entry_id FROM flow_steps s JOIN flows f ON f.id = s.flow_id"
1744
+ f" WHERE s.callable_id IN ({','.join('?' * len(changed))})", sorted(changed))} if len(changed) < 900 else set()
1745
+ for x in out:
1746
+ x["runs_the_change"] = x["id"] in reach
1747
+ out.sort(key=lambda x: (not x.get("runs_the_change"), "named" not in x["why"], "clock" not in x["why"], x["path"] or "", x["name"]))
1748
+ return out
1749
+
1750
+
1751
+ def hot_functions(con, names, fns: list[str]) -> list[dict]:
1752
+ """Changed functions by how much runs through them: flows that pass through, program entries (not tests) that
1753
+ reach them, and call sites. A function most flows pass through is on a hot path."""
1754
+ n_flows = con.execute("SELECT COUNT(*) FROM flows").fetchone()[0] or 1
1755
+ hot = []
1756
+ for i in dict.fromkeys(fns):
1757
+ through = con.execute("SELECT COUNT(DISTINCT flow_id) FROM flow_steps WHERE callable_id = ?", (i,)).fetchone()[0]
1758
+ entry = con.execute("SELECT COUNT(DISTINCT s.flow_id) FROM flow_steps s JOIN flows f ON f.id = s.flow_id"
1759
+ " WHERE s.callable_id = ? AND json_extract(f.attrs, '$.kind') != 'test'", (i,)).fetchone()[0]
1760
+ sites = con.execute("SELECT COUNT(*) FROM calls WHERE dst_id = ?", (i,)).fetchone()[0]
1761
+ hot.append({"id": i, "name": _label(names, i), "flows_through": through, "share_of_flows": round(through / n_flows, 2),
1762
+ "program_entries_that_reach_it": entry, "call_sites": sites})
1763
+ hot.sort(key=lambda h: (-h["flows_through"], -h["call_sites"]))
1764
+ return hot
1765
+
1766
+
1767
+ def review_facts(con, change_dir: str | Path, reviewer: Optional[str] = None) -> dict:
1768
+ """What the graph says about a change, arranged as the questions each reviewer must answer.
1769
+ A reviewer reads this, reads the code behind anything suspicious, and files findings. Naming the
1770
+ `reviewer` (logic or performance) records that this review ran, so the plan can say so."""
1771
+ b = brief(con, change_dir, write=False)
1772
+ if "error" in b:
1773
+ return b
1774
+ if reviewer:
1775
+ record_review(con, b["change_id"], reviewer)
1776
+ names = _Names(con)
1777
+ tasked = sorted({i for t in b["tasks"] for i in t["nodes"]})
1778
+ fns = [i for i in tasked if names.by_id[i]["kind"] in ("callable", "test")]
1779
+ for i in tasked: # a named type stands for its functions
1780
+ if names.by_id[i]["kind"] == "type":
1781
+ fns += [r["id"] for r in names.rows if r["parent_id"] == i and r["kind"] == "callable"]
1782
+ hot = hot_functions(con, names, fns)
1783
+ perf_tests = _speed_tests(con, set(fns), b.get("self_tests"))
1784
+ la = b["left_alone"]
1785
+ imp = b["impact"] if "error" not in b["impact"] else {}
1786
+ from . import learnings
1787
+ return {
1788
+ "change_id": b["change_id"], "title": b["title"],
1789
+ "learnings_that_apply": learnings.applying(con, b["change_id"], tasked), # past decisions: read these first
1790
+ "learnings_unreadable": learnings.unreadable(con), # so an empty list above is not taken as "none apply"
1791
+ "related_changes": b.get("related_changes") or {}, # earlier changes to the same code
1792
+ "logic": {
1793
+ "must_edit_with_no_task": b["must_edit_uncovered"],
1794
+ "channels_crossed": imp.get("channels") or [],
1795
+ "other_ends_of_those_channels_no_task_names": b.get("must_agree") or [],
1796
+ "shared_state_written": b["shared_state"],
1797
+ "scenarios_with_no_test": [s["name"] for s in b["scenarios"] if not s["test_exists"]],
1798
+ "new_members_named_like_existing_ones": la["beside"][:20],
1799
+ "callers_of_changed_functions_the_spec_leaves_alone": la["callers"][:30],
1800
+ "state_shared_with_functions_the_spec_leaves_alone": la["state"][:30],
1801
+ "usually_changes_with_no_task": b.get("usually_changes_with") or {},
1802
+ "changed_code_no_test_reaches": imp.get("untested") or [],
1803
+ "patterns_involved": b["patterns"],
1804
+ "rules_failing_before_the_change": b["rules_failing_now"],
1805
+ "spec_problems": b["gaps"],
1806
+ "ask": "For each list: is the spec wrong, or is the map? Read the code before filing. Then ask what the scenarios "
1807
+ "leave out: error paths, empty inputs, ordering, the second caller, the other end of each channel.",
1808
+ },
1809
+ "performance": {
1810
+ "changed_functions_by_how_much_runs_through_them": hot[:25],
1811
+ "tests_that_measure_speed": perf_tests[:20],
1812
+ "speed_tests_note": "Tests in files or suites named for speed (perf, bench, budget, latency, throughput), and"
1813
+ " tests that read a clock. Those that run the changed functions come first."
1814
+ if perf_tests else "No test on the map measures speed.",
1815
+ "ask": "A function most flows pass through is on a hot path. For each one the spec changes: does the change add work "
1816
+ "per call, allocation, I/O or a process hop? Name the test that would show a regression, or say none exists.",
1817
+ },
1818
+ "how_to_file": "Call spec_finding with a claim, a severity, node ids as evidence, and the change to the spec you propose.",
1819
+ }