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/verdicts.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""One verdict for each task and scenario that `check` judges, and which verdicts hold up "done as agreed".
|
|
2
|
+
|
|
3
|
+
proven task: the code it names changed as it says.
|
|
4
|
+
scenario: its test passes after the change, and failed or did not exist before, or passed
|
|
5
|
+
both times while running the changed code.
|
|
6
|
+
partial task: some of the code it names changed, some did not.
|
|
7
|
+
scenario: some results that carry its name pass and some fail.
|
|
8
|
+
contradicted task: the code it names did not change (or what it adds is missing) while other code did.
|
|
9
|
+
scenario: its test fails after the change.
|
|
10
|
+
inconclusive task: the map cannot place the code it names, or nothing changed at all.
|
|
11
|
+
scenario: no pass or fail was recorded for it.
|
|
12
|
+
needs a person task: it names no code (docs, say). scenario: its test passes, but Leyline can tell it does not
|
|
13
|
+
run the changed code, or cannot tell whether it does.
|
|
14
|
+
|
|
15
|
+
By default partial, contradicted and inconclusive block; "needs a person" is listed and does not. A project can
|
|
16
|
+
change that in `openspec/leyline.toml`:
|
|
17
|
+
|
|
18
|
+
[check]
|
|
19
|
+
blocking = ["contradicted", "inconclusive", "partial"]
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
PROVEN, PARTIAL, CONTRADICTED, INCONCLUSIVE, PERSON = "proven", "partial", "contradicted", "inconclusive", "needs a person"
|
|
30
|
+
VERDICTS = (PROVEN, PARTIAL, CONTRADICTED, INCONCLUSIVE, PERSON)
|
|
31
|
+
DEFAULT_BLOCKING = (PARTIAL, CONTRADICTED, INCONCLUSIVE)
|
|
32
|
+
CONFIG = "leyline.toml" # in the `openspec/` folder that holds the changes
|
|
33
|
+
# The order the summary line counts them in, and the words it uses.
|
|
34
|
+
ORDER = (PROVEN, PERSON, PARTIAL, CONTRADICTED, INCONCLUSIVE)
|
|
35
|
+
SHORT = {PERSON: "needs you"}
|
|
36
|
+
ALIASES = {"needs you": PERSON, "needs person": PERSON, "person": PERSON, "proved": PROVEN, "partly": PARTIAL}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# -- config ----------------------------------------------------------------------------------------
|
|
40
|
+
def _toml(text: str) -> dict:
|
|
41
|
+
"""Read TOML: tomllib where Python has it (3.11+), else a small reader for what this file needs: tables,
|
|
42
|
+
and keys set to a string, a number, true or false, or a list of strings (across lines too)."""
|
|
43
|
+
try:
|
|
44
|
+
import tomllib
|
|
45
|
+
except ImportError:
|
|
46
|
+
return _tiny_toml(text)
|
|
47
|
+
return tomllib.loads(text)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_STRING = re.compile(r'"((?:[^"\\]|\\.)*)"|\'([^\']*)\'')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _strip_comment(line: str) -> str:
|
|
54
|
+
quote = None
|
|
55
|
+
for k, ch in enumerate(line):
|
|
56
|
+
if quote:
|
|
57
|
+
if ch == quote and (quote == "'" or line[k - 1] != "\\"):
|
|
58
|
+
quote = None
|
|
59
|
+
elif ch in "\"'":
|
|
60
|
+
quote = ch
|
|
61
|
+
elif ch == "#":
|
|
62
|
+
return line[:k]
|
|
63
|
+
return line
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _tiny_toml(text: str) -> dict:
|
|
67
|
+
out: dict = {}
|
|
68
|
+
table = out
|
|
69
|
+
pending = ""
|
|
70
|
+
for n, raw in enumerate(text.splitlines(), 1):
|
|
71
|
+
line = (pending + " " + _strip_comment(raw).strip()) if pending else _strip_comment(raw).strip()
|
|
72
|
+
if not line:
|
|
73
|
+
continue
|
|
74
|
+
if not pending and line.startswith("[") and "=" not in line:
|
|
75
|
+
if not line.endswith("]") or line.startswith("[["):
|
|
76
|
+
raise ValueError(f"line {n}: cannot read the table name {line!r}")
|
|
77
|
+
table = out
|
|
78
|
+
for part in line[1:-1].strip().split("."):
|
|
79
|
+
table = table.setdefault(part.strip().strip('"'), {})
|
|
80
|
+
continue
|
|
81
|
+
key, eq, value = line.partition("=")
|
|
82
|
+
if not eq:
|
|
83
|
+
raise ValueError(f"line {n}: expected `key = value`")
|
|
84
|
+
value = value.strip()
|
|
85
|
+
if value.startswith("[") and value.count("[") > value.count("]"):
|
|
86
|
+
pending = line # a list that goes on to the next line
|
|
87
|
+
continue
|
|
88
|
+
pending = ""
|
|
89
|
+
table[key.strip().strip('"')] = _tiny_value(value, n)
|
|
90
|
+
if pending:
|
|
91
|
+
raise ValueError("a list is not closed with ]")
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _tiny_value(value: str, n: int):
|
|
96
|
+
if value.startswith("["):
|
|
97
|
+
if not value.endswith("]"):
|
|
98
|
+
raise ValueError(f"line {n}: a list is not closed with ]")
|
|
99
|
+
inner = value[1:-1]
|
|
100
|
+
if _STRING.sub("", inner).replace(",", "").strip():
|
|
101
|
+
raise ValueError(f"line {n}: only lists of quoted words are read here")
|
|
102
|
+
return [_unquote(m) for m in _STRING.finditer(inner)]
|
|
103
|
+
m = _STRING.fullmatch(value)
|
|
104
|
+
if m:
|
|
105
|
+
return _unquote(m)
|
|
106
|
+
if value in ("true", "false"):
|
|
107
|
+
return value == "true"
|
|
108
|
+
try:
|
|
109
|
+
return int(value)
|
|
110
|
+
except ValueError:
|
|
111
|
+
raise ValueError(f"line {n}: cannot read the value {value!r}") from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _unquote(m) -> str:
|
|
115
|
+
if m.group(1) is None:
|
|
116
|
+
return m.group(2) # a 'literal string': no escapes
|
|
117
|
+
try:
|
|
118
|
+
return json.loads('"' + m.group(1) + '"')
|
|
119
|
+
except ValueError:
|
|
120
|
+
return m.group(1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _name(word) -> Optional[str]:
|
|
124
|
+
w = re.sub(r"[-_\s]+", " ", str(word)).strip().lower()
|
|
125
|
+
w = ALIASES.get(w, w)
|
|
126
|
+
return w if w in VERDICTS else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def config_path(change_dir: str | Path) -> Optional[Path]:
|
|
130
|
+
"""`openspec/leyline.toml` for the `openspec/` folder the change sits in, when there is one."""
|
|
131
|
+
for p in Path(change_dir).resolve().parents:
|
|
132
|
+
if p.name == "openspec":
|
|
133
|
+
return p / CONFIG
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_config(change_dir: str | Path) -> dict:
|
|
138
|
+
"""Which verdicts block, from the project's file or the default. Never raises: a file that cannot be read
|
|
139
|
+
leaves the default in place and says why in `notes`."""
|
|
140
|
+
out = {"blocking": list(DEFAULT_BLOCKING), "file": None, "notes": []}
|
|
141
|
+
path = config_path(change_dir)
|
|
142
|
+
if path is None or not path.is_file():
|
|
143
|
+
return out
|
|
144
|
+
shown = f"{path.parent.name}/{path.name}"
|
|
145
|
+
try:
|
|
146
|
+
data = _toml(path.read_text(encoding="utf-8"))
|
|
147
|
+
except (OSError, ValueError, UnicodeDecodeError) as e: # tomllib.TOMLDecodeError is a ValueError
|
|
148
|
+
out["notes"].append(f"{shown} could not be read ({e}), so the default applies.")
|
|
149
|
+
return out
|
|
150
|
+
section = data.get("check")
|
|
151
|
+
if not isinstance(section, dict) or "blocking" not in section:
|
|
152
|
+
return out
|
|
153
|
+
listed = section["blocking"]
|
|
154
|
+
if isinstance(listed, str):
|
|
155
|
+
listed = [listed]
|
|
156
|
+
if not isinstance(listed, list):
|
|
157
|
+
out["notes"].append(f"{shown}: `blocking` should be a list of verdicts, so the default applies.")
|
|
158
|
+
return out
|
|
159
|
+
blocking = []
|
|
160
|
+
for w in listed:
|
|
161
|
+
v = _name(w)
|
|
162
|
+
if v is None:
|
|
163
|
+
out["notes"].append(f"{shown} names \"{w}\", which is not a verdict; it was left out."
|
|
164
|
+
f" The verdicts are {', '.join(VERDICTS)}.")
|
|
165
|
+
elif v != PROVEN and v not in blocking:
|
|
166
|
+
blocking.append(v)
|
|
167
|
+
out["blocking"] = [v for v in VERDICTS if v in blocking]
|
|
168
|
+
out["file"] = shown
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# -- judging ---------------------------------------------------------------------------------------
|
|
173
|
+
def _task_verdict(t: dict, row, names, touched: set, removed: set, after_run: Optional[str]) -> tuple[str, str]:
|
|
174
|
+
missing = ", ".join(t["missing"][:3])
|
|
175
|
+
if t["state"] == "checked by you":
|
|
176
|
+
return PERSON, "it names no code, so the map cannot see it done"
|
|
177
|
+
if t.get("removal"): # a removal or rename, judged by what is left (leyline.removal)
|
|
178
|
+
return t["removal"]["verdict"], t["removal"]["why"]
|
|
179
|
+
if t["state"] == "done":
|
|
180
|
+
return PROVEN, "the code it names changed"
|
|
181
|
+
if t["state"] == "partly":
|
|
182
|
+
return PARTIAL, "some of the code it names changed" + (f"; not {missing}" if missing else "")
|
|
183
|
+
if not (touched or removed):
|
|
184
|
+
return INCONCLUSIVE, "nothing in the code changed since the plan; was the code mapped again after the edit?"
|
|
185
|
+
extra = json.loads(row["attrs"] or "{}") if row is not None else {}
|
|
186
|
+
ids = json.loads(row["nodes"] or "[]") if row is not None else []
|
|
187
|
+
new = extra.get("new", [])
|
|
188
|
+
# Inconclusive only when none of what it names can be placed; code that is there and unchanged contradicts it.
|
|
189
|
+
gone = [i for i in ids if i not in names.by_id and i not in removed]
|
|
190
|
+
homeless = [n for n in new if n.get("parent") and n["parent"] not in names.by_id]
|
|
191
|
+
base = lambda xs: {i.split("(")[0] for i in xs}
|
|
192
|
+
if (gone or homeless) and base(gone) == base(ids) and len(homeless) == len(new):
|
|
193
|
+
what = ", ".join(([i.split("(")[0].split("/")[-1] for i in gone] + [n["name"] for n in homeless])[:3])
|
|
194
|
+
return INCONCLUSIVE, f"the map no longer has {what}, so it cannot tell whether it changed"
|
|
195
|
+
if extra.get("scenarios") and not ids and not new and not after_run:
|
|
196
|
+
return INCONCLUSIVE, "no test results were recorded, so the test it adds cannot be seen"
|
|
197
|
+
action = row["action"] if row is not None else ""
|
|
198
|
+
if action == "remove":
|
|
199
|
+
return CONTRADICTED, f"{missing or 'what it removes'} is still there, unchanged, though other code changed"
|
|
200
|
+
if action == "add":
|
|
201
|
+
return CONTRADICTED, f"{missing or 'what it adds'} is missing, though other code changed"
|
|
202
|
+
return CONTRADICTED, f"{missing or 'the code it names'} did not change, though other code did"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _get(r, key: str):
|
|
206
|
+
"""A field of a result row, whether a stored row or a plain dict that may leave it out."""
|
|
207
|
+
try:
|
|
208
|
+
return r[key]
|
|
209
|
+
except (KeyError, IndexError):
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _scenario_verdict(s: dict, after: list, before: Optional[list]) -> tuple[str, str]:
|
|
214
|
+
passed = [r for r in after if r["status"] == "pass"]
|
|
215
|
+
failed = [r for r in after if r["status"] == "fail"]
|
|
216
|
+
from .props import from_message # a property test's smallest failing input, when it printed one
|
|
217
|
+
ex = next((x for x in (from_message(_get(r, "message")) for r in failed) if x), None)
|
|
218
|
+
if passed and failed:
|
|
219
|
+
return PARTIAL, (f"{len(passed)} of {len(passed) + len(failed)} results that carry its name pass"
|
|
220
|
+
+ (f"; one fails for {ex}" if ex else ""))
|
|
221
|
+
if failed:
|
|
222
|
+
if ex:
|
|
223
|
+
return CONTRADICTED, f"fails for {ex}" + ("; its test passed before the change"
|
|
224
|
+
if before and all(r["status"] == "pass" for r in before) else "")
|
|
225
|
+
if before and all(r["status"] == "pass" for r in before):
|
|
226
|
+
return CONTRADICTED, "its test passed before the change and fails now"
|
|
227
|
+
return CONTRADICTED, "its test fails after the change"
|
|
228
|
+
if s["state"] != "passes":
|
|
229
|
+
if s["state"] == "skipped":
|
|
230
|
+
return INCONCLUSIVE, "its test was skipped, so no pass or fail was recorded"
|
|
231
|
+
if s["state"] == "results older than the code":
|
|
232
|
+
return INCONCLUSIVE, ("its test has results, but they are older than the code: run the tests again and"
|
|
233
|
+
" pass their output")
|
|
234
|
+
if s["state"] == "no test":
|
|
235
|
+
return INCONCLUSIVE, "it has no test, and no result carries its name"
|
|
236
|
+
return INCONCLUSIVE, "no result for its test was recorded"
|
|
237
|
+
measured, static = s.get("measured_running_the_change"), s.get("reaches_the_change")
|
|
238
|
+
ran = s.get("ran_changed_code") # per-test coverage from the run after the change (leyline.affected)
|
|
239
|
+
if ran is False:
|
|
240
|
+
return PERSON, ("its test passes, but measured per-test coverage shows it never ran the changed code, so it"
|
|
241
|
+
" would pass whatever the change did")
|
|
242
|
+
# No result before: the test is new since the plan when the baseline does not hold it. One the baseline holds was
|
|
243
|
+
# merely left out of the run before (a subset was passed), so it is judged by whether it reaches the change.
|
|
244
|
+
new_test = s.get("test") is None or s.get("test_new", True)
|
|
245
|
+
if before is not None and (any(r["status"] == "fail" for r in before) or (not before and new_test)):
|
|
246
|
+
return PROVEN, ("its test failed before the change and passes now" if before
|
|
247
|
+
else "its test is new since the plan, and passes")
|
|
248
|
+
if ran or measured or (measured is None and static):
|
|
249
|
+
return PROVEN, "its test passes and runs the changed code" + (", as it did before" if before else "")
|
|
250
|
+
if measured is False or (s.get("test") and not static):
|
|
251
|
+
return PERSON, ("its test passes, but " + ("measured coverage shows it never runs the changed code"
|
|
252
|
+
if measured is False else "on the map it does not reach the changed code")
|
|
253
|
+
+ ", so it may not test the change")
|
|
254
|
+
if before is None:
|
|
255
|
+
return PERSON, ("its test passes, but no run from before the change was recorded, and the test is not on the"
|
|
256
|
+
" map, so whether it tests the change cannot be told")
|
|
257
|
+
if not before:
|
|
258
|
+
return PERSON, ("its test passes, but the run before the change left it out, and it is not on the map, so"
|
|
259
|
+
" whether it runs the changed code cannot be told")
|
|
260
|
+
return PERSON, ("its test passed before the change too, and it is not on the map, so whether it runs the changed"
|
|
261
|
+
" code cannot be told")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def blocks(x: dict, blocking) -> bool:
|
|
265
|
+
"""Whether an item holds up "done as agreed". A tick in tasks.md does not clear one: the agent ticks tasks as it
|
|
266
|
+
goes, and the verdict comes from the code and the test results, not from the agent's account of its work."""
|
|
267
|
+
return x.get("verdict") in blocking
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def judge(con, cid: str, change_dir: str | Path, tasks: list[dict], scenarios: list[dict], names, touched: set,
|
|
271
|
+
removed: set, before_run: Optional[str], after_run: Optional[str]) -> dict:
|
|
272
|
+
"""Give each task and scenario its `verdict` and `verdict_why`, in place. Returns the summary: counts, which
|
|
273
|
+
verdicts block, and the reasons (in the words `why_not` already uses) that hold up "done as agreed"."""
|
|
274
|
+
from . import spec # spec imports this module
|
|
275
|
+
rows = {r["key"]: r for r in con.execute("SELECT * FROM spec_items WHERE change_id = ? AND kind = 'task'", (cid,))}
|
|
276
|
+
for t in tasks:
|
|
277
|
+
t["verdict"], t["verdict_why"] = _task_verdict(t, rows.get(t["key"]), names, touched, removed, after_run)
|
|
278
|
+
|
|
279
|
+
def index(run):
|
|
280
|
+
if not run:
|
|
281
|
+
return None
|
|
282
|
+
return spec._results_index(con.execute("SELECT name, status, message FROM test_results WHERE run = ?", (run,)).fetchall())
|
|
283
|
+
after_ix, before_ix = index(after_run), index(before_run)
|
|
284
|
+
for s in scenarios:
|
|
285
|
+
after = spec._scenario_results(after_ix, s["name"]) if after_ix else []
|
|
286
|
+
before = spec._scenario_results(before_ix, s["name"]) if before_ix else None
|
|
287
|
+
s["verdict"], s["verdict_why"] = _scenario_verdict(s, after, before)
|
|
288
|
+
|
|
289
|
+
config = load_config(change_dir)
|
|
290
|
+
holds = []
|
|
291
|
+
if any(blocks(t, config["blocking"]) and t["verdict"] != PERSON for t in tasks):
|
|
292
|
+
holds.append("some tasks are not done")
|
|
293
|
+
if any(blocks(s, config["blocking"]) and s["verdict"] != PERSON for s in scenarios):
|
|
294
|
+
holds.append("some scenarios are not proven")
|
|
295
|
+
if any(blocks(x, config["blocking"]) and x["verdict"] == PERSON for x in tasks + scenarios):
|
|
296
|
+
holds.append(f"some items need a person, and {config['file'] or 'the project'} makes that block")
|
|
297
|
+
counts = {v: sum(x["verdict"] == v for x in tasks + scenarios) for v in VERDICTS}
|
|
298
|
+
return {"counts": counts, "blocking": config["blocking"], "config": config["file"], "notes": config["notes"],
|
|
299
|
+
"holds_up": holds}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# -- words -----------------------------------------------------------------------------------------
|
|
303
|
+
def summary_line(counts: dict) -> str:
|
|
304
|
+
""""7 proven, 1 needs you, 1 inconclusive"."""
|
|
305
|
+
return ", ".join(f"{counts[v]} {SHORT.get(v, v)}" for v in ORDER if counts.get(v))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def blocking_line(info: dict) -> str:
|
|
309
|
+
""""Blocking: partial, contradicted, inconclusive. Not blocking: needs you (the default)."."""
|
|
310
|
+
b = [SHORT.get(v, v) for v in ORDER if v in info["blocking"]]
|
|
311
|
+
free = [SHORT.get(v, v) for v in ORDER if v not in info["blocking"] and v != PROVEN]
|
|
312
|
+
return (f"Blocking: {', '.join(b) or 'nothing'}." + (f" Not blocking: {', '.join(free)}." if free else "")
|
|
313
|
+
+ (f" (Set in {info['config']}.)" if info.get("config") else " (The default.)"))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _item(x: dict, kind: str) -> str:
|
|
317
|
+
return f"task {x['key']}" if kind == "task" else f"scenario \"{x['name']}\""
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def page_lines(v: dict) -> list[str]:
|
|
321
|
+
"""The lines under the tables: the count, what blocks, and why each item that is not proven got its verdict."""
|
|
322
|
+
info = v.get("verdicts")
|
|
323
|
+
if not info:
|
|
324
|
+
return []
|
|
325
|
+
L = ["", f"Verdicts: {summary_line(info['counts']) or 'nothing to judge'}. " + blocking_line(info)]
|
|
326
|
+
L += [n for n in info.get("notes", [])]
|
|
327
|
+
rest = [(x, k) for k, xs in (("task", v["tasks"]), ("scenario", v["scenarios"])) for x in xs if x.get("verdict") != PROVEN]
|
|
328
|
+
if rest:
|
|
329
|
+
L += ["", "**Not proven, and why:**"]
|
|
330
|
+
L += [f"- {_item(x, k)}, {x['verdict']}: {x['verdict_why']}"
|
|
331
|
+
+ ("" if blocks(x, info["blocking"]) else " (does not block)") for x, k in rest[:20]]
|
|
332
|
+
if len(rest) > 20:
|
|
333
|
+
L.append(f"- and {len(rest) - 20} more")
|
|
334
|
+
return L
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def yes_text(v: dict) -> Optional[str]:
|
|
338
|
+
"""The "Yes." sentence when something other than proven (or a task that names no code) is let through.
|
|
339
|
+
None leaves the usual sentence, which is right when every item is proven or is a task that names no code."""
|
|
340
|
+
info = v.get("verdicts")
|
|
341
|
+
if not info:
|
|
342
|
+
return None
|
|
343
|
+
odd = [x for x in v["tasks"] if x.get("verdict") not in (PROVEN, PERSON)] + \
|
|
344
|
+
[x for x in v["scenarios"] if x.get("verdict") != PROVEN]
|
|
345
|
+
if not odd:
|
|
346
|
+
return None
|
|
347
|
+
mine = [t["key"] for t in v["tasks"] if t.get("verdict") == PERSON]
|
|
348
|
+
yours = [f"\"{s['name']}\"" for s in v["scenarios"] if s.get("verdict") == PERSON]
|
|
349
|
+
let = [x for x in odd if x.get("verdict") != PERSON]
|
|
350
|
+
head = ("Nothing that blocks is left" + (f" under {info['config']}" if info.get("config") else "")
|
|
351
|
+
if let else "Every task " + ("that names code " if mine else "") + "is done, every scenario's test passes")
|
|
352
|
+
out = head + ", and nothing outside the spec changed."
|
|
353
|
+
if mine:
|
|
354
|
+
out += f" Task{'s' if len(mine) > 1 else ''} {', '.join(mine)} {'are' if len(mine) > 1 else 'is'} yours to check."
|
|
355
|
+
if yours:
|
|
356
|
+
out += (f" Scenario{'s' if len(yours) > 1 else ''} {', '.join(yours[:3])}{' and more' if len(yours) > 3 else ''}"
|
|
357
|
+
f" {'need' if len(yours) > 1 else 'needs'} you to confirm {'they test' if len(yours) > 1 else 'it tests'}"
|
|
358
|
+
" the change.")
|
|
359
|
+
if let:
|
|
360
|
+
out += f" Not proven, and let through: {summary_line({k: sum(x['verdict'] == k for x in let) for k in VERDICTS})}."
|
|
361
|
+
return out
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def next_note(v: dict) -> str:
|
|
365
|
+
"""For `Next:` after a check that passed: the scenarios left for the person to confirm."""
|
|
366
|
+
yours = [f"\"{s['name']}\"" for s in v["scenarios"] if s.get("verdict") == PERSON]
|
|
367
|
+
if not yours:
|
|
368
|
+
return ""
|
|
369
|
+
many = len(yours) > 1
|
|
370
|
+
return (f" Scenario{'s' if many else ''} {', '.join(yours[:3])}{' and more' if len(yours) > 3 else ''}"
|
|
371
|
+
f" {'need' if many else 'needs'} a person to confirm {'they test' if many else 'it tests'} the change:"
|
|
372
|
+
f" {'their tests pass' if many else 'its test passes'} but may not run the changed code.")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def waiting_lines(v: dict) -> list[str]:
|
|
376
|
+
"""For `Next:` when the project makes a person's check block: what waits on the person, and how to clear it."""
|
|
377
|
+
info = v.get("verdicts") or {}
|
|
378
|
+
waiting = [x for x in v["tasks"] + v["scenarios"] if x.get("verdict") == PERSON and blocks(x, info.get("blocking", ()))]
|
|
379
|
+
mine = [x["key"] for x in waiting if "key" in x] # tasks have a key; scenarios only a name
|
|
380
|
+
yours = [f"\"{x['name']}\"" for x in waiting if "key" not in x]
|
|
381
|
+
out = []
|
|
382
|
+
if mine:
|
|
383
|
+
out.append(f"task{'s' if len(mine) > 1 else ''} {', '.join(mine)} {'name' if len(mine) > 1 else 'names'} no code,"
|
|
384
|
+
f" and {info.get('config') or 'the project'} makes an item that needs a person block: name the code"
|
|
385
|
+
f" {'they change' if len(mine) > 1 else 'it changes'} in backticks (then plan again), or take"
|
|
386
|
+
" \"needs a person\" out of `blocking`.")
|
|
387
|
+
if yours:
|
|
388
|
+
out.append(f"make the test for scenario{'s' if len(yours) > 1 else ''} {', '.join(yours[:3])} run the changed"
|
|
389
|
+
" code; it passes, but Leyline cannot see it test the change.")
|
|
390
|
+
return out
|