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