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/props.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Property-based tests: scenarios that state an invariant, and the counterexample a failing property test prints.
|
|
2
|
+
|
|
3
|
+
A scenario that says something holds for any input ("for any amount, the balance is never negative") is proven
|
|
4
|
+
better by a property test than by one example: the library generates inputs, and when one breaks the property it
|
|
5
|
+
shrinks it to the smallest that still does. Kiro writes specs this way; here it is two small things.
|
|
6
|
+
|
|
7
|
+
`plan`: a scenario whose words state an invariant is marked, with the property-test library for the language
|
|
8
|
+
of the code it is about (Hypothesis, fast-check, FsCheck).
|
|
9
|
+
`check`: a failing property test's output carries its counterexample. Read from the runner's text, it is kept
|
|
10
|
+
on the test's result, so the scenario's verdict says what input broke it: "fails for amount=-1".
|
|
11
|
+
|
|
12
|
+
Formats read:
|
|
13
|
+
Hypothesis Falsifying example: test_x(a=0, b=-1) (or "Failing test case:", Hypothesis 6.130 and later)
|
|
14
|
+
fast-check Property failed after 1 tests ... Counterexample: [0,-1] ... Shrunk 32 time(s)
|
|
15
|
+
FsCheck Falsifiable, after 3 tests (1 shrink) (...): Original: ... Shrunk: ...
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
# -- plan: which scenarios state an invariant ---------------------------------------------------------
|
|
25
|
+
# Words that say a statement holds over all inputs, not for one example. Kept narrow: "each" and "any" alone are
|
|
26
|
+
# too common in ordinary scenarios ("each request is logged") to mean an invariant.
|
|
27
|
+
INVARIANT = re.compile(r"\b(for (any|every|all|each possible)\b|always\b|never\b|no matter\b|regardless of\b|"
|
|
28
|
+
r"invariant\b|whatever (the|its)\b|under any\b|in any order\b)", re.I)
|
|
29
|
+
|
|
30
|
+
LIBRARIES = {
|
|
31
|
+
"python": "Hypothesis",
|
|
32
|
+
"typescript": "fast-check",
|
|
33
|
+
"javascript": "fast-check",
|
|
34
|
+
"csharp": "FsCheck",
|
|
35
|
+
"fsharp": "FsCheck",
|
|
36
|
+
}
|
|
37
|
+
EXT = {"py": "python", "pyi": "python", "ts": "typescript", "tsx": "typescript", "mts": "typescript", "cts": "typescript",
|
|
38
|
+
"js": "javascript", "jsx": "javascript", "mjs": "javascript", "cjs": "javascript", "cs": "csharp", "fs": "fsharp"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def invariant(s: dict) -> bool:
|
|
42
|
+
"""Whether a scenario's name or its WHEN/THEN lines state an invariant: for any, for every, always, never..."""
|
|
43
|
+
text = " ".join([s.get("name") or ""] + list(s.get("when") or []) + list(s.get("then") or []))
|
|
44
|
+
return bool(INVARIANT.search(text))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _language(paths: list) -> Optional[str]:
|
|
48
|
+
langs = Counter(EXT.get((p or "").rsplit(".", 1)[-1].lower()) for p in paths if p and "." in p)
|
|
49
|
+
langs.pop(None, None)
|
|
50
|
+
return langs.most_common(1)[0][0] if langs else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def language(con, ids: list[str], test_id: Optional[str] = None) -> Optional[str]:
|
|
54
|
+
"""The language a scenario's test is written in: its test's file, else the code the tasks change, else the
|
|
55
|
+
repository's most common one."""
|
|
56
|
+
def paths(where, args):
|
|
57
|
+
return [r[0] for r in con.execute(f"SELECT path FROM nodes WHERE {where}", args)]
|
|
58
|
+
if test_id:
|
|
59
|
+
lang = _language(paths("id = ?", (test_id,)))
|
|
60
|
+
if lang:
|
|
61
|
+
return lang
|
|
62
|
+
if ids:
|
|
63
|
+
lang = _language(paths(f"id IN ({','.join('?' * len(ids))})", ids))
|
|
64
|
+
if lang:
|
|
65
|
+
return lang
|
|
66
|
+
return _language(paths("kind = 'file' AND layer = 'fact'", ()))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def mark_scenarios(con, scenarios: list[dict], ids: list[str]) -> None:
|
|
70
|
+
"""Mark each scenario that states an invariant, in place: `invariant` and `property_library`."""
|
|
71
|
+
for s in scenarios:
|
|
72
|
+
if invariant(s):
|
|
73
|
+
s["invariant"] = True
|
|
74
|
+
s["property_library"] = LIBRARIES.get(language(con, ids, s.get("test")) or "")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cell(s: dict) -> str:
|
|
78
|
+
"""What the plan's scenario table adds for an invariant scenario."""
|
|
79
|
+
if not s.get("invariant"):
|
|
80
|
+
return ""
|
|
81
|
+
lib = s.get("property_library")
|
|
82
|
+
return "; invariant: a property test fits" + (f" ({lib})" if lib else "")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# -- check: the counterexample a failing property test prints ------------------------------------------
|
|
86
|
+
_HYPOTHESIS = re.compile(r"(?:Falsifying(?: explicit)? example|Failing test case):\s*([A-Za-z_]\w*)\(")
|
|
87
|
+
_FASTCHECK = re.compile(r"Counterexample:\s*(.*\S)") # greedy: a lazy one before \s*$ is quadratic in a run of spaces
|
|
88
|
+
_FC_RUNS = re.compile(r"Property failed after (\d+) tests?")
|
|
89
|
+
_FSCHECK = re.compile(r"Falsifiable, after (\d+) tests? \((\d+) shrinks?\)")
|
|
90
|
+
_PREFIX = re.compile(r"^(?:E\s{1,8}|\s*[|>]\s?)") # pytest's `E ` before an assertion's lines
|
|
91
|
+
MARK = re.compile(r"^fails for (.+) \((Hypothesis|fast-check|FsCheck)\)\.?$")
|
|
92
|
+
LIMIT = 200
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _clip(text: str) -> str:
|
|
96
|
+
text = " ".join(text.split())
|
|
97
|
+
return text if len(text) <= LIMIT else text[:LIMIT - 1].rstrip() + "…"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _split_top(text: str) -> list[str]:
|
|
101
|
+
"""Split at commas that are not inside brackets or quotes."""
|
|
102
|
+
out, depth, quote, cur = [], 0, None, []
|
|
103
|
+
for k, ch in enumerate(text):
|
|
104
|
+
if quote:
|
|
105
|
+
if ch == quote and text[k - 1] != "\\":
|
|
106
|
+
quote = None
|
|
107
|
+
elif ch in "\"'":
|
|
108
|
+
quote = ch
|
|
109
|
+
elif ch in "([{<":
|
|
110
|
+
depth += 1
|
|
111
|
+
elif ch in ")]}>":
|
|
112
|
+
depth -= 1
|
|
113
|
+
elif ch == "," and depth == 0:
|
|
114
|
+
out.append("".join(cur))
|
|
115
|
+
cur = []
|
|
116
|
+
continue
|
|
117
|
+
cur.append(ch)
|
|
118
|
+
out.append("".join(cur))
|
|
119
|
+
return [x.strip() for x in out if x.strip()]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _hypothesis_args(lines: list[str], k: int, start: int) -> Optional[str]:
|
|
123
|
+
"""The arguments of `test_x(...)` from the line it opens on to its closing parenthesis."""
|
|
124
|
+
buf, depth, quote, escaped = [], 1, None, False
|
|
125
|
+
for j in range(k, min(len(lines), k + 60)):
|
|
126
|
+
line = lines[j] if j > k else lines[j][start:]
|
|
127
|
+
if j > k:
|
|
128
|
+
line = _PREFIX.sub("", line)
|
|
129
|
+
line = re.sub(r"\s#\s.*$", "", line) # `a=[], # or any other generated value`
|
|
130
|
+
for ch in line:
|
|
131
|
+
if quote:
|
|
132
|
+
if escaped:
|
|
133
|
+
escaped = False # 'a\'b"c': an escaped quote does not close it
|
|
134
|
+
elif ch == "\\":
|
|
135
|
+
escaped = True
|
|
136
|
+
else:
|
|
137
|
+
quote = None if ch == quote else quote
|
|
138
|
+
elif ch in "\"'":
|
|
139
|
+
quote = ch
|
|
140
|
+
elif ch in "([{":
|
|
141
|
+
depth += 1
|
|
142
|
+
elif ch in ")]}":
|
|
143
|
+
depth -= 1
|
|
144
|
+
if depth == 0:
|
|
145
|
+
args = [a for a in _split_top("".join(buf)) if not a.startswith("self=")]
|
|
146
|
+
return ", ".join(args)
|
|
147
|
+
buf.append(ch)
|
|
148
|
+
buf.append(" ")
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def counterexamples(text: str) -> list[dict]:
|
|
153
|
+
"""Every counterexample in a test run's text: {line, library, example, func (Hypothesis: the test function),
|
|
154
|
+
tests, shrinks}. The example is the smallest failing input the library found, as it printed it."""
|
|
155
|
+
lines = (text or "").splitlines()
|
|
156
|
+
out = []
|
|
157
|
+
for k, line in enumerate(lines):
|
|
158
|
+
m = _HYPOTHESIS.search(line)
|
|
159
|
+
if m:
|
|
160
|
+
args = _hypothesis_args(lines, k, m.end())
|
|
161
|
+
if args is not None:
|
|
162
|
+
out.append({"line": k, "library": "Hypothesis", "func": m.group(1), "example": _clip(args) or "no arguments"})
|
|
163
|
+
continue
|
|
164
|
+
m = _FASTCHECK.search(line)
|
|
165
|
+
head = next((x for x in reversed(lines[max(0, k - 6):k]) if _FC_RUNS.search(x)), None)
|
|
166
|
+
if m and head is not None:
|
|
167
|
+
ex = m.group(1)
|
|
168
|
+
if re.search(r"message:\s*\"", head): # TAP's YAML: the message is a quoted string, its quotes escaped
|
|
169
|
+
ex = ex.replace('\\"', '"').replace("\\\\", "\\")
|
|
170
|
+
if ex.startswith("[") and ex.endswith("]"):
|
|
171
|
+
ex = ", ".join(_split_top(ex[1:-1])) # the property's arguments, as a tuple
|
|
172
|
+
out.append({"line": k, "library": "fast-check", "example": _clip(ex) or "no arguments",
|
|
173
|
+
"tests": int(_FC_RUNS.search(head).group(1))})
|
|
174
|
+
continue
|
|
175
|
+
m = _FSCHECK.search(line)
|
|
176
|
+
if m:
|
|
177
|
+
blocks: dict = {}
|
|
178
|
+
cur = None
|
|
179
|
+
for x in lines[k + 1:k + 80]:
|
|
180
|
+
x = x.strip()
|
|
181
|
+
if x in ("Original:", "Shrunk:"): # each argument on a line of its own
|
|
182
|
+
cur = blocks.setdefault(x[:-1], [])
|
|
183
|
+
elif not x or x.startswith(("with exception:", "Last step was invoked")) or _FSCHECK.search(x):
|
|
184
|
+
if "Shrunk" in blocks or x.startswith("with exception:") or _FSCHECK.search(x):
|
|
185
|
+
break
|
|
186
|
+
cur = None
|
|
187
|
+
elif cur is not None:
|
|
188
|
+
cur.append(x)
|
|
189
|
+
args = blocks.get("Shrunk") or blocks.get("Original")
|
|
190
|
+
if args:
|
|
191
|
+
out.append({"line": k, "library": "FsCheck", "example": _clip(", ".join(args)), "tests": int(m.group(1)),
|
|
192
|
+
"shrinks": int(m.group(2))})
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def from_message(message: Optional[str]) -> Optional[str]:
|
|
197
|
+
"""The counterexample `annotate` put on a result's message: "amount=-1"."""
|
|
198
|
+
first = (message or "").split("\n", 1)[0]
|
|
199
|
+
m = MARK.match(first)
|
|
200
|
+
return m.group(1) if m else None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _keys(name: str) -> set[str]:
|
|
204
|
+
"""What a result's name can be found by in a runner's text: the test's own name, without a parameter."""
|
|
205
|
+
from .diff import result_parts
|
|
206
|
+
p = result_parts(name)
|
|
207
|
+
leaf = p["leaf"].strip()
|
|
208
|
+
out = {leaf, re.sub(r"\[.*\]$", "", leaf)}
|
|
209
|
+
if "." in leaf and " " not in leaf: # Namespace.Class.Method, as dotnet prints it
|
|
210
|
+
out.add(leaf.rsplit(".", 1)[-1])
|
|
211
|
+
return {k for k in out if len(k) >= 3}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _put(r: dict, ce: dict) -> None:
|
|
215
|
+
rest = (r.get("message") or "").strip()
|
|
216
|
+
if rest.startswith('"') and rest.count('"') == 1: # a quoted YAML message that went on past its first line
|
|
217
|
+
rest = rest[1:]
|
|
218
|
+
r["message"] = f"fails for {ce['example']} ({ce['library']})." + (f"\n{rest}" if rest else "")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def annotate(results: list[dict], text: Optional[str] = None) -> list[dict]:
|
|
222
|
+
"""Put each failing property test's counterexample at the head of its result's message, in place, and return
|
|
223
|
+
the results. A counterexample is found in the result's own message, or in the run's text: Hypothesis names its
|
|
224
|
+
test; for the others, the nearest line above that names a failing test is taken to be its header."""
|
|
225
|
+
failing = [r for r in results if r.get("status") == "fail" and not from_message(r.get("message"))]
|
|
226
|
+
if not failing:
|
|
227
|
+
return results
|
|
228
|
+
for r in failing:
|
|
229
|
+
found = counterexamples(r.get("message") or "")
|
|
230
|
+
if found:
|
|
231
|
+
_put(r, found[0])
|
|
232
|
+
left = [r for r in failing if not from_message(r.get("message"))]
|
|
233
|
+
if not left or not text:
|
|
234
|
+
return results
|
|
235
|
+
lines = text.splitlines()
|
|
236
|
+
keys = {id(r): _keys(r["name"]) for r in left}
|
|
237
|
+
def nearest(cands: list[dict], at: int) -> Optional[dict]:
|
|
238
|
+
"""The failing result named on the closest line above, by its longest name; a tie looks further up."""
|
|
239
|
+
for j in range(at, max(-1, at - 400), -1):
|
|
240
|
+
score = {id(x): max((len(k) for k in keys[id(x)] if k in lines[j]), default=0) for x in cands}
|
|
241
|
+
best = max(score.values(), default=0)
|
|
242
|
+
top = [x for x in cands if best and score[id(x)] == best]
|
|
243
|
+
if len(top) == 1:
|
|
244
|
+
return top[0]
|
|
245
|
+
return None
|
|
246
|
+
for ce in counterexamples(text):
|
|
247
|
+
cands = [r for r in left if ce["func"] in keys[id(r)]] if ce.get("func") else left
|
|
248
|
+
r = cands[0] if ce.get("func") and len(cands) == 1 else nearest(cands, ce["line"] - (0 if ce.get("func") else 1))
|
|
249
|
+
if r is None:
|
|
250
|
+
continue
|
|
251
|
+
_put(r, ce)
|
|
252
|
+
left.remove(r)
|
|
253
|
+
if not left:
|
|
254
|
+
break
|
|
255
|
+
return results
|