leyline-code 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/affected.py ADDED
@@ -0,0 +1,509 @@
1
+ """Tests chosen by what they ran: proving a scenario by execution, and picking the tests a change needs.
2
+
3
+ A scenario is proven by a passing test of the same name. With per-test coverage imported (`pytest --cov=<package>
4
+ --cov-context=test`, or one Istanbul report per test file), the map also knows which functions that test ran, so
5
+ `check` can say whether it ran the code the change edited. A test that passes without running the changed code
6
+ proves less: it would pass whatever the change did.
7
+
8
+ The same data picks the tests to run for a change, the way Datadog's Test Impact Analysis or `jest
9
+ --findRelatedTests` do: the tests measured running any changed function, and, for changed code no measured test
10
+ ran (new code, or code the measurement did not watch), the tests whose path on the map passes through it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime
16
+ import json
17
+ import re
18
+ import os
19
+ import shlex
20
+ import sqlite3
21
+ from collections import defaultdict
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from . import store
26
+
27
+ PY_TEST = re.compile(r"\.py$")
28
+ JS_TEST = re.compile(r"\.[cm]?[jt]sx?$")
29
+ GO_TEST = re.compile(r"_test\.go$")
30
+
31
+
32
+
33
+ def _quote(path: str) -> str:
34
+ """A path as the person's shell takes it: POSIX quoting, or on Windows double quotes when it has a space."""
35
+ if os.name == "nt":
36
+ return f'"{path}"' if " " in path else path
37
+ return shlex.quote(path)
38
+
39
+ def per_test(con) -> bool:
40
+ """True when some imported coverage ties functions to the test (or test file) that ran them."""
41
+ try:
42
+ return con.execute("SELECT 1 FROM covered WHERE test != '' LIMIT 1").fetchone() is not None
43
+ except sqlite3.OperationalError:
44
+ return False
45
+
46
+
47
+ def _json(ids) -> str:
48
+ return json.dumps(sorted(set(ids)))
49
+
50
+
51
+ def _strip_param(name: str) -> str:
52
+ return re.sub(r"\[.*\]$", "", name or "")
53
+
54
+
55
+ def _kinds(con, ids) -> dict:
56
+ return {r[0]: r[1] for r in con.execute("SELECT id, kind FROM nodes WHERE id IN (SELECT value FROM json_each(?))", (_json(ids),))}
57
+
58
+
59
+ def _tests_among(con, ids) -> set:
60
+ """The tests themselves among `ids`: test nodes, and functions a test flow starts from."""
61
+ return {r[0] for r in con.execute(
62
+ "SELECT id FROM nodes WHERE id IN (SELECT value FROM json_each(?)) AND (kind = 'test' OR json_extract(attrs, '$.is_test') = 1"
63
+ " OR id IN (SELECT entry_id FROM flows WHERE json_extract(attrs, '$.kind') = 'test'))", (_json(ids),))}
64
+
65
+
66
+ def product_code(con, ids) -> set:
67
+ """The functions among `ids` that are not test code: what a test has to run to prove a change. A file's top
68
+ level (`<module>`) is left out: it runs when the file is imported, not under any one test."""
69
+ tests, test_modules = store.test_places(con)
70
+ out = set()
71
+ for r in con.execute("SELECT n.id, n.kind, n.name, n.path, a.module_id FROM nodes n LEFT JOIN ancestry a ON a.node_id = n.id"
72
+ " WHERE n.id IN (SELECT value FROM json_each(?))", (_json(ids),)):
73
+ if (r["kind"] == "callable" and not r["name"].startswith("<") and r["path"] not in tests
74
+ and r["module_id"] not in test_modules):
75
+ out.add(r["id"])
76
+ return out
77
+
78
+
79
+ def _expand(con, ids) -> set:
80
+ """Functions inside the types, files and modules among `ids`, with the functions and tests themselves. A
81
+ container something else in `ids` sits in (`scale` in `ops.ts`) only says where that is, and is not expanded."""
82
+ kinds = _kinds(con, ids)
83
+ out = {i for i, k in kinds.items() if k in ("callable", "test")}
84
+ homes = set()
85
+ for r in con.execute("SELECT file_id, module_id FROM ancestry WHERE node_id IN (SELECT value FROM json_each(?))",
86
+ (_json(out | {i for i, k in kinds.items() if k == "type"}),)):
87
+ homes |= {r[0], r[1]}
88
+ for i, k in kinds.items():
89
+ if i in homes or (k == "type" and any(o.startswith(i + ".") for o in out)):
90
+ continue
91
+ if k == "type":
92
+ out |= {r[0] for r in con.execute("SELECT id FROM nodes WHERE kind IN ('callable', 'test') AND id LIKE ?",
93
+ (i.replace("%", "\\%") + ".%",))}
94
+ elif k in ("file", "module"):
95
+ out |= {r[0] for r in con.execute("SELECT a.node_id FROM ancestry a JOIN nodes n ON n.id = a.node_id"
96
+ " WHERE (a.file_id = ? OR a.module_id = ?) AND n.kind IN ('callable', 'test')", (i, i))}
97
+ return out
98
+
99
+
100
+ # -- one scenario's test: did it run the changed code? -------------------------------------------------------
101
+ def _runs(con) -> dict:
102
+ return {r["run"]: r for r in con.execute("SELECT run, created, commit_sha FROM coverage_runs")}
103
+
104
+
105
+ def _ran(con, ids: set, names: set, only: Optional[str] = None) -> tuple[Optional[set], Optional[str], Optional[str]]:
106
+ """What a test ran, from the newest import that measured it (or from the import `only`): (functions, "test" or
107
+ "test file", the run). Matched by its node on the map, or by the name the runner gave it; else, with coverage
108
+ taken per test file, by the file it is in. (None, None, None) when no import measured it."""
109
+ runs = _runs(con)
110
+
111
+ def newest(rows):
112
+ by_run = defaultdict(set)
113
+ for r in rows:
114
+ if only is None or r[0] == only:
115
+ by_run[r[0]].add(r[1])
116
+ if not by_run:
117
+ return None, None
118
+ run = max(by_run, key=lambda k: (runs.get(k)["created"] or "") if runs.get(k) else "")
119
+ return by_run[run], run
120
+ rows = con.execute("SELECT run, node_id FROM covered WHERE test != '' AND test_id IN (SELECT value FROM json_each(?))",
121
+ (_json(ids),)).fetchall()
122
+ if names:
123
+ wanted = {_strip_param(n) for n in names}
124
+ rows += [(r[0], r[1]) for r in con.execute("SELECT run, node_id, test FROM covered WHERE test != '' AND test_id IS NULL")
125
+ if r[2] in wanted]
126
+ nodes, run = newest(rows)
127
+ if nodes is not None:
128
+ return nodes, "test", run
129
+ files = {r[0] for r in con.execute("SELECT file_id FROM ancestry WHERE node_id IN (SELECT value FROM json_each(?))", (_json(ids),))}
130
+ nodes, run = newest(con.execute("SELECT run, node_id FROM covered WHERE test != '' AND test_id IN (SELECT value FROM json_each(?))",
131
+ (_json(files),)).fetchall()) if files else (None, None)
132
+ return (nodes, "test file", run) if nodes is not None else (None, None, None)
133
+
134
+
135
+ def _label(con, i: str) -> str:
136
+ n = con.execute("SELECT n.name, p.name AS owner, p.kind AS owner_kind FROM nodes n LEFT JOIN nodes p ON p.id = n.parent_id"
137
+ " WHERE n.id = ?", (i,)).fetchone()
138
+ if n is None:
139
+ return i
140
+ return f"{n['owner']}.{n['name']}" if n["owner_kind"] in ("type", "callable") else n["name"]
141
+
142
+
143
+ def _some(xs: list[str], k: int = 3, total: int = 0) -> str:
144
+ """The first k, and how many more: of `total` when the list was cut before it got here."""
145
+ xs = sorted(xs)
146
+ more = max(total, len(xs)) - k
147
+ return ", ".join(xs[:k]) + (f" and {more} more" if more > 0 else "")
148
+
149
+
150
+ def _taken_before(con, run: Optional[str], change_id: str) -> bool:
151
+ """True when the import was taken before the change's baseline: it measured the code as it was, not the change."""
152
+ from . import diff
153
+ row = con.execute("SELECT created FROM coverage_runs WHERE run = ?", (run,)).fetchone() if run else None
154
+ snap = diff.snapshot_path(con, change_id)
155
+ if row is None or not row[0] or not snap.exists():
156
+ return False
157
+ try:
158
+ made = datetime.datetime.fromisoformat(row[0])
159
+ except ValueError:
160
+ return False
161
+ return made.timestamp() < int(snap.stat().st_mtime) # `created` is to the second: within one, say it is not
162
+
163
+
164
+ def mark_scenarios(con, scenarios: list[dict], changed: set, after_run: Optional[str], test_names, change_id: str = "") -> None:
165
+ """Add `ran_changed_code` (True, False or None) and a one-line `ran_changed_code_note` to each scenario
166
+ result from `spec.verify`. None, with no note, when no per-test coverage was imported."""
167
+ from . import spec
168
+ measured = per_test(con)
169
+ code = product_code(con, changed)
170
+ index = spec._results_index(con.execute("SELECT name, status, message, test_id FROM test_results WHERE run = ?",
171
+ (after_run,)).fetchall()) if after_run and measured else None
172
+ # `check --coverage` imports the coverage under the label of the test run it came with.
173
+ same_run = bool(index) and con.execute("SELECT 1 FROM covered WHERE run = ? AND test != '' LIMIT 1",
174
+ (after_run,)).fetchone() is not None
175
+ for s in scenarios:
176
+ s["ran_changed_code"] = None
177
+ if not measured or s["state"] != "passes":
178
+ continue
179
+ rows = spec._scenario_results(index, s["name"]) if index else []
180
+ ids = {s["test"]} if s.get("test") else set()
181
+ if not ids:
182
+ gen = spec._generated(test_names, s["name"])
183
+ ids |= {gen["id"]} if gen else set()
184
+ ids |= {r["test_id"] for r in rows if r["test_id"]}
185
+ ran, per, run = _ran(con, ids, {r["name"] for r in rows}, after_run if same_run else None)
186
+ if ran is None:
187
+ if same_run: # measured on the very run that passed it: it ran none of the measured code
188
+ s["ran_changed_code"] = s["measured_running_the_change"] = False
189
+ s["ran_changed_code_note"] = ("passed without running the changed code: the coverage of the same run records"
190
+ " it running none of the measured code")
191
+ else:
192
+ s["ran_changed_code_note"] = ("its test left no record in the measured coverage: it ran none of the measured"
193
+ " code, or was not in the measured run (pass the coverage of the same run to"
194
+ " `leyline check --coverage` to tell which)")
195
+ continue
196
+ if change_id and _taken_before(con, run, change_id):
197
+ s["ran_changed_code_note"] = ("the coverage was measured before the change was planned, so it shows what the test"
198
+ " ran in the old code; measure it again (`leyline check --coverage`)")
199
+ continue
200
+ if not code:
201
+ s["ran_changed_code_note"] = "the change edited no function the test could run"
202
+ continue
203
+ hit = ran & code
204
+ where = " (measured per test file, so another test in the file may be the one that ran it)" if per == "test file" else ""
205
+ s["ran_changed_code"] = bool(hit)
206
+ s["measured_running_the_change"] = bool(hit)
207
+ s["ran_changed_code_note"] = (f"ran the changed code: {_some([_label(con, i) for i in hit])}{where}" if hit else
208
+ f"passed without running the changed code ({_some([_label(con, i) for i in code])}){where}")
209
+
210
+
211
+ # -- the tests a change needs ---------------------------------------------------------------------------------
212
+ def change_id_for(con, target: str, folder: Optional[Path] = None) -> str:
213
+ """The stored id of a change: spec-<id> for a change folder or its id, or an id as stored (pr-123)."""
214
+ if folder is not None:
215
+ from . import spec
216
+ parsed = spec.parse(folder)
217
+ if "error" not in parsed:
218
+ return "spec-" + parsed["id"]
219
+ for cid in (target, "spec-" + target, "pr-" + target):
220
+ if con.execute("SELECT 1 FROM change_proposals WHERE id = ? UNION SELECT 1 FROM views WHERE id = ?",
221
+ (cid, "view-" + cid)).fetchone():
222
+ return cid
223
+ return target
224
+
225
+
226
+ def change_code(con, change_id: str) -> tuple[set, Optional[str]]:
227
+ """The code a change edits, adds or must edit: what its plan (or review) marked, the code its tasks name, and
228
+ what has changed since its baseline. (ids, error)."""
229
+ from . import diff
230
+ ids = set()
231
+ v = con.execute("SELECT spec FROM views WHERE id = ?", ("view-" + change_id,)).fetchone()
232
+ found = v is not None
233
+ if v is not None:
234
+ ids |= {m["id"] for m in json.loads(v[0] or "{}").get("marks", [])
235
+ if m.get("role") in ("changed", "new", "must_edit", "contract")}
236
+ for r in con.execute("SELECT nodes FROM spec_items WHERE change_id = ? AND kind = 'task'", (change_id,)):
237
+ found = True
238
+ ids |= set(json.loads(r[0] or "[]"))
239
+ snap = diff.snapshot_path(con, change_id)
240
+ if snap.exists() and diff.moved_on(con, change_id):
241
+ before = diff._open(snap)
242
+ try:
243
+ g = diff.compare(before, con)["nodes"]
244
+ finally:
245
+ before.close()
246
+ ids |= {n["id"] for key in ("added", "resigned", "edited", "types_edited") for n in g[key]}
247
+ if not found:
248
+ return set(), (f"No change {change_id!r} on record. Run `leyline plan <change>` for a spec, or `leyline pr` for a"
249
+ " branch, first.")
250
+ return {i for i in ids if i in _kinds(con, ids)}, None
251
+
252
+
253
+ def _test_row(con, i: str) -> Optional[dict]:
254
+ r = con.execute("SELECT id, name, kind, repo_id, path, parent_id FROM nodes WHERE id = ?", (i,)).fetchone()
255
+ if r is None:
256
+ return None
257
+ chain, up = [r["name"]], r["parent_id"]
258
+ while up: # a test method inside test classes: Class::method, as pytest names it
259
+ p = con.execute("SELECT name, kind, parent_id FROM nodes WHERE id = ?", (up,)).fetchone()
260
+ if p is None or p["kind"] != "type":
261
+ break
262
+ chain.insert(0, p["name"])
263
+ up = p["parent_id"]
264
+ out = {"name": r["name"], "id": r["id"], "repo": r["repo_id"], "path": r["path"] or ""}
265
+ if r["kind"] == "file":
266
+ out["file_only"] = True
267
+ elif PY_TEST.search(out["path"]):
268
+ out["pytest"] = out["path"] + "::" + "::".join(chain)
269
+ return out
270
+
271
+
272
+ def _as_ran(test: str, row: dict) -> bool:
273
+ """Whether a pytest node id as the run named it can stand for a test on the map: only when it names the test's
274
+ file by its path in the repository. pytest run from a folder below (`backend/`) names it from there, and the
275
+ command runs from the repository's root."""
276
+ return "::" in test and bool(row.get("pytest")) and test.split("::", 1)[0] == row["path"]
277
+
278
+
279
+ def _file_repo(con, path: str) -> Optional[str]:
280
+ r = con.execute("SELECT repo_id FROM nodes WHERE kind = 'file' AND (path = ? OR ? LIKE '%/' || path) ORDER BY length(path) DESC",
281
+ (path, path)).fetchone()
282
+ return r[0] if r else None
283
+
284
+
285
+ def select(con, change_id: str) -> dict:
286
+ """The tests to run for a change, why each one, and a command that runs them where the runner is known."""
287
+ changed, err = change_code(con, change_id)
288
+ if err:
289
+ return {"error": err}
290
+ fns = _expand(con, changed)
291
+ fns -= {r[0] for r in con.execute("SELECT id FROM nodes WHERE id IN (SELECT value FROM json_each(?)) AND name LIKE '<%'",
292
+ (_json(fns),))} # a file's top level runs on import, under no one test
293
+ picked: dict[str, dict] = {}
294
+
295
+ def add(key, row, why):
296
+ if key not in picked:
297
+ picked[key] = {**row, "why": why}
298
+ measured = per_test(con)
299
+ ran_by_some = set()
300
+ if measured and fns:
301
+ for r in con.execute("SELECT test, test_id, node_id FROM covered WHERE test != '' AND node_id IN"
302
+ " (SELECT value FROM json_each(?)) ORDER BY test", (_json(fns),)):
303
+ ran_by_some.add(r["node_id"])
304
+ row = _test_row(con, r["test_id"]) if r["test_id"] else None
305
+ if row is None: # a test the map does not hold: the name its runner gave it
306
+ path = r["test"].split("::", 1)[0]
307
+ row = {"name": r["test"].split("::")[-1], "id": None, "repo": _file_repo(con, path), "path": path,
308
+ **({"pytest": r["test"]} if "::" in r["test"] and PY_TEST.search(path) else {"file_only": True})}
309
+ elif _as_ran(r["test"], row):
310
+ row["pytest"] = r["test"] # exactly as pytest named it when it ran
311
+ add(row["id"] or row.get("pytest") or row["path"], row,
312
+ "ran the changed code when coverage was measured" + (" (measured per test file)" if row.get("file_only") else ""))
313
+ tests_in_change = _tests_among(con, fns)
314
+ for i in sorted(tests_in_change):
315
+ row = _test_row(con, i)
316
+ if row:
317
+ add(row["id"], row, "is itself new or changed")
318
+ unmeasured = set(fns - tests_in_change - ran_by_some)
319
+ # The map's paths: every test without a measurement, and, for code no measured test ran (new since the
320
+ # measurement, or not watched), every test that reaches it. A measured test that ran none of it is left out.
321
+ seen = {r[0] for r in con.execute("SELECT DISTINCT test_id FROM covered WHERE test != '' AND test_id IS NOT NULL")}
322
+ through = defaultdict(set)
323
+ for r in con.execute("SELECT f.entry_id, s.callable_id FROM flows f JOIN flow_steps s ON s.flow_id = f.id"
324
+ " WHERE json_extract(f.attrs, '$.kind') = 'test' AND s.callable_id IN (SELECT value FROM json_each(?))",
325
+ (_json(fns - tests_in_change),)):
326
+ through[r[0]].add(r[1])
327
+ file_of = {r[0]: r[1] for r in con.execute("SELECT node_id, file_id FROM ancestry WHERE node_id IN (SELECT value FROM json_each(?))",
328
+ (_json(through),))}
329
+ from_map = 0
330
+ for entry, hit in sorted(through.items()):
331
+ was_measured = entry in seen or file_of.get(entry) in seen
332
+ if measured and was_measured and not hit & unmeasured:
333
+ continue
334
+ row = _test_row(con, entry)
335
+ if row:
336
+ from_map += entry not in picked
337
+ add(row["id"], row, "its path on the map passes through the change" + (
338
+ "" if not measured else ", in code no measured test ran" if was_measured else
339
+ "; the measured run did not include it"))
340
+ unmeasured = sorted(unmeasured)
341
+ rank = {"ran": 0, "is ": 1}
342
+ tests = sorted(picked.values(), key=lambda t: (rank.get(t["why"][:3], 2), t.get("pytest") or t["path"], t["name"]))
343
+ basis = ("measured coverage" if measured and not from_map else "measured coverage and the map" if measured else "the map")
344
+ note = ("From per-test coverage: the tests that ran the changed code when it was measured"
345
+ + (", and, from the map, the tests that reach changed code no measured test ran or that the measured run left"
346
+ " out." if from_map else ".")
347
+ if measured else
348
+ "No per-test coverage is imported, so these are the tests whose path on the map passes through the change."
349
+ " Import it (`pytest --cov=<package> --cov-context=test`, then `leyline coverage .coverage`) to pick by"
350
+ " what each test ran.")
351
+ return {"change_id": change_id, "basis": basis, "tests": tests, "commands": commands(con, tests),
352
+ "changed_functions": len(fns), "changed_not_measured": [_label(con, i) for i in unmeasured][:30] if measured else [],
353
+ "note": note}
354
+
355
+
356
+ def measured_tests(con, ids) -> list[dict]:
357
+ """The tests measured running any of these functions (per-test coverage only), most lines first."""
358
+ fns = product_code(con, _expand(con, ids))
359
+ if not per_test(con) or not fns:
360
+ return []
361
+ out = []
362
+ for r in con.execute("SELECT test, test_id, COUNT(DISTINCT node_id) AS hits FROM covered WHERE test != '' AND node_id IN"
363
+ " (SELECT value FROM json_each(?)) GROUP BY test ORDER BY hits DESC, test", (_json(fns),)):
364
+ row = (_test_row(con, r["test_id"]) if r["test_id"] else None) or {
365
+ "name": r["test"].split("::")[-1], "id": None, "repo": _file_repo(con, r["test"].split("::", 1)[0]),
366
+ "path": r["test"].split("::", 1)[0]}
367
+ if (_as_ran(r["test"], row) if row.get("id") else "::" in r["test"] and PY_TEST.search(row["path"])):
368
+ row["pytest"] = r["test"]
369
+ out.append({**row, "functions": r["hits"]})
370
+ return out
371
+
372
+
373
+ # -- a command that runs them ----------------------------------------------------------------------------------
374
+ def _js_runner(root: Path, path: str) -> tuple[Optional[str], Path]:
375
+ """vitest or jest, from the nearest package.json (or config file) above the test that names one."""
376
+ here = (root / path).parent
377
+ while True:
378
+ for runner in ("vitest", "jest"):
379
+ if any(here.glob(f"{runner}.config.*")):
380
+ return runner, here
381
+ pkg = here / "package.json"
382
+ if pkg.is_file():
383
+ text = pkg.read_text(encoding="utf-8", errors="replace")
384
+ for runner in ("vitest", "jest"):
385
+ if f'"{runner}"' in text:
386
+ return runner, here
387
+ if here == root or root not in here.parents:
388
+ return None, root
389
+ here = here.parent
390
+
391
+
392
+ CS_TEST = re.compile(r"\.cs$")
393
+
394
+
395
+ def _csproj(root: Path, path: str) -> Optional[str]:
396
+ """The test project a C# test file is in: the nearest folder above it with a .csproj, relative to the root."""
397
+ d = (root / path).parent
398
+ while True:
399
+ found = sorted(d.glob("*.csproj")) if d.is_dir() else []
400
+ if found:
401
+ return str(found[0].relative_to(root)).replace("\\", "/")
402
+ if d == root or root not in d.parents:
403
+ return None
404
+ d = d.parent
405
+
406
+
407
+ def _dotnet_name(t: dict) -> tuple[str, str]:
408
+ """("FullyQualifiedName", Ns.Class.Method) from the test's id, or ("Name", Method) when it has none."""
409
+ i = t.get("id") or ""
410
+ if ":csharp:" in i:
411
+ return ("FullyQualifiedName", i.split("::")[-1].split("(")[0])
412
+ return ("Name", t["name"].split("(")[0])
413
+
414
+
415
+ def _dotnet_filter(names) -> str:
416
+ """`dotnet test --filter`: one test by name each, joined by |; the filter's own operators escaped."""
417
+ esc = lambda v: re.sub(r"([\\(),|&=!~])", r"\\\1", v)
418
+ if len(names) > 200: # too long for one command line: run their classes
419
+ names = {("FullyQualifiedName~" if k == "FullyQualifiedName" else "Name~", v.rsplit(".", 1)[0] if k == "FullyQualifiedName" else v)
420
+ for k, v in names}
421
+ return "|".join(k + esc(v) for k, v in sorted(names))
422
+ return "|".join(f"{k}={esc(v)}" for k, v in sorted(names))
423
+
424
+
425
+ def commands(con, tests: list[dict]) -> list[dict]:
426
+ """One command per runner and directory: pytest node ids, `npx vitest run <files>`, `npx jest --runTestsByPath <files>`, `go test
427
+ -run`, `dotnet test <project> --filter`. Tests no runner was recognized for are listed by name instead."""
428
+ roots = store.roots(con)
429
+ groups: dict[tuple, list] = defaultdict(list)
430
+ other = []
431
+ for t in tests:
432
+ root = roots.get(t.get("repo")) or (next(iter(roots.values())) if len(roots) == 1 else None)
433
+ path = t.get("path") or ""
434
+ if root is None or not path:
435
+ other.append(t["name"])
436
+ elif PY_TEST.search(path):
437
+ groups[("pytest", str(root))].append(t.get("pytest") or path)
438
+ elif GO_TEST.search(path):
439
+ groups[("go", str(root))].append((str(Path(path).parent), t["name"]))
440
+ elif CS_TEST.search(path):
441
+ proj = _csproj(Path(root), path)
442
+ if proj is None:
443
+ other.append(f"{t['name']} ({path})")
444
+ else:
445
+ groups[("dotnet", str(root))].append((proj, _dotnet_name(t)))
446
+ elif JS_TEST.search(path):
447
+ runner, cwd = _js_runner(Path(root), path)
448
+ if runner:
449
+ groups[(runner, str(cwd))].append(str((Path(root) / path).relative_to(cwd)).replace("\\", "/"))
450
+ else:
451
+ other.append(f"{t['name']} ({path})")
452
+ else:
453
+ other.append(f"{t['name']} ({path})")
454
+ out = []
455
+ for (runner, cwd), items in sorted(groups.items()):
456
+ # a path that starts with - (a folder named --config=x) is a file to the runner, not an option
457
+ items = list(dict.fromkeys(i if not isinstance(i, str) or not i.startswith("-") else "./" + i for i in items))
458
+ if runner == "pytest":
459
+ if len(items) > 200: # too long for one command line: run their files
460
+ items = list(dict.fromkeys(i.split("::", 1)[0] for i in items))
461
+ cmd = "pytest " + " ".join(shlex.quote(i) for i in items)
462
+ elif runner == "vitest":
463
+ cmd = "npx vitest run " + " ".join(shlex.quote(i) for i in items)
464
+ elif runner == "dotnet":
465
+ by_proj = defaultdict(set)
466
+ for proj, name in items:
467
+ by_proj[proj].add(name)
468
+ cmd = " && ".join("dotnet test " + shlex.quote(proj) + " --filter " + shlex.quote(_dotnet_filter(names))
469
+ for proj, names in sorted(by_proj.items()))
470
+ elif runner == "jest":
471
+ # by path: jest reads a plain argument as a pattern (`[id].test.tsx` is a character class)
472
+ cmd = "npx jest --runTestsByPath " + " ".join(shlex.quote(i) for i in items)
473
+ else:
474
+ by_dir = defaultdict(list)
475
+ for d, name in items:
476
+ by_dir[d].append(name)
477
+ cmd = " && ".join("go test " + shlex.quote("./" + (d if d != "." else "")) + " -run "
478
+ + shlex.quote("^(" + "|".join(sorted(set(ns))) + ")$")
479
+ for d, ns in sorted(by_dir.items()))
480
+ out.append({"runner": runner, "cwd": cwd, "command": cmd, "tests": len(items)})
481
+ if other:
482
+ out.append({"runner": None, "cwd": None, "command": None, "tests": len(other), "unrecognized": other[:40]})
483
+ return out
484
+
485
+
486
+ def text(r: dict) -> str:
487
+ """The selection as a person reads it."""
488
+ t = r["tests"]
489
+ if not t:
490
+ return (f"No test runs the code {r['change_id']} changes, as far as {'the measurement and ' if 'measured' in r['basis'] else ''}"
491
+ "the map can tell.\n\n" + r["note"])
492
+ L = [f"{len(t)} test{'s' if len(t) != 1 else ''} to run for {r['change_id']} (chosen by {r['basis']}):", ""]
493
+ by_why = defaultdict(list)
494
+ for x in t:
495
+ by_why[x["why"]].append(x.get("pytest") or (x["path"] if x.get("file_only") else x["name"]))
496
+ for why, names in by_why.items():
497
+ L.append(f"- {why}: {_some(names, 8)}")
498
+ if r.get("changed_not_measured"):
499
+ L += ["", "Changed code no measured test ran: " + _some(r["changed_not_measured"], 6) + "."]
500
+ runnable = [c for c in r["commands"] if c["command"]]
501
+ if runnable:
502
+ L += ["", "Run them:" if len(runnable) == 1 else "Run them, from each directory:"]
503
+ for c in runnable:
504
+ L.append(f" cd {_quote(c['cwd'])} && {c['command']}")
505
+ for c in r["commands"]:
506
+ if not c["command"]:
507
+ L += ["", "No runner recognized for: " + _some(c["unrecognized"], 6, c["tests"]) + "."]
508
+ L += ["", r["note"]]
509
+ return "\n".join(L)