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/coverage.py ADDED
@@ -0,0 +1,376 @@
1
+ """Measured coverage: which functions ran, and under which test.
2
+
3
+ Static flows say what a test can reach. A coverage file says what it did reach. Importing one lets
4
+ the map tell the two apart: a step on a test's static path that never ran sits behind a branch the
5
+ test does not take, and a function that ran without being on the path was reached through a link
6
+ the indexer could not see.
7
+
8
+ Formats read:
9
+ - coverage.py's data file (`.coverage`). Run the tests with `--cov-context=test` (pytest-cov) or
10
+ `dynamic_context = test_function` and each function is tied to the tests that ran it.
11
+ - Cobertura XML (coverlet, `coverage xml`, many others). No per-test detail: a function ran or did not.
12
+ - Istanbul's `coverage-final.json` (vitest `--coverage.reporter=json`, jest `--coverageReporters=json`, c8, nyc). It
13
+ is one file per run, not per test. Run one test file at a time and import each result with `test` naming the file,
14
+ and every function is tied to the test file that ran it (not to the single test inside it).
15
+ - lcov (`lcov.info`: c8, vitest and jest `--coverage.reporter=lcov`, `go tool`/llvm-cov exports, genhtml's input). Its
16
+ `SF:`, `DA:`, `FN:` and `FNDA:` records give the lines and functions that ran; a `TN:` before them names the test,
17
+ and its records are tied to that test (by pytest node id or by name), else the file is one run like Istanbul's.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime
23
+ import json
24
+ import re
25
+ import sqlite3
26
+ import xml.etree.ElementTree as ET
27
+ from collections import defaultdict
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+ from . import store
32
+
33
+
34
+ def _functions(con):
35
+ """(repo, path) -> [(first body line, last line, node id)] for every function on the map."""
36
+ out = defaultdict(list)
37
+ for r in con.execute("SELECT id, repo_id, path, span_start, span_end, attrs FROM nodes"
38
+ " WHERE kind IN ('callable', 'test') AND path IS NOT NULL"):
39
+ if not r["span_start"]:
40
+ continue
41
+ a = json.loads(r["attrs"] or "{}")
42
+ end = r["span_end"] or r["span_start"]
43
+ # The line that declares a function runs when its file loads or its parent runs. Only the body counts.
44
+ first = a.get("body_line") or (r["span_start"] + 1 if end > r["span_start"] else r["span_start"])
45
+ out[(r["repo_id"], r["path"])].append((min(first, end), end, r["id"]))
46
+ return out
47
+
48
+
49
+ def _owners(funcs, lines):
50
+ """For each executed line, the innermost function whose body holds it. Returns node id -> line count."""
51
+ hit = defaultdict(int)
52
+ for line in lines:
53
+ best = None
54
+ for first, end, node in funcs:
55
+ if first <= line <= end and (best is None or first > best[0]):
56
+ best = (first, node)
57
+ if best:
58
+ hit[best[1]] += 1
59
+ return hit
60
+
61
+
62
+ def _relative(path: str, roots: dict, known: set) -> Optional[tuple]:
63
+ """A measured file's (repo, path) on the map. `roots` is repo id -> the directory it was indexed from."""
64
+ p = path.replace("\\", "/")
65
+ for repo, root in roots.items():
66
+ root = (root or "").replace("\\", "/").rstrip("/") # a Windows root is stored with backslashes
67
+ if root and p.startswith(root + "/") and (repo, p[len(root) + 1:]) in known:
68
+ return repo, p[len(root) + 1:]
69
+ exact = sorted(k for k in known if k[1] == p)
70
+ if exact:
71
+ return exact[0]
72
+ hits = [k for k in known if p.endswith("/" + k[1]) or k[1].endswith("/" + p)] # a different checkout location
73
+ return max(hits, key=lambda k: (len(k[1]), k)) if hits else None
74
+
75
+
76
+ def _numbits(blob: bytes) -> list[int]:
77
+ return [i * 8 + b for i, byte in enumerate(blob) for b in range(8) if byte & (1 << b)]
78
+
79
+
80
+ def _test_node(con, context: str, cache: dict, home: str = "") -> Optional[str]:
81
+ """`tests/test_x.py::TestC::test_m[param]|run` -> the test function's node id. In a workspace, a test
82
+ in `home` (the repo whose code was measured) wins over one at the same path in another repo."""
83
+ name = context.split("|", 1)[0]
84
+ name = re.sub(r"\[.*\]$", "", name)
85
+ if name in cache:
86
+ return cache[name]
87
+ node = None
88
+ order, extra = (" ORDER BY repo_id != ?", (home,)) if home else ("", ())
89
+ if "::" in name:
90
+ path, *parts = name.split("::")
91
+ row = con.execute("SELECT id FROM nodes WHERE kind IN ('callable', 'test') AND path = ? AND id LIKE ?" + order,
92
+ (path, "%." + ".".join(parts), *extra)).fetchone()
93
+ if row is None:
94
+ row = con.execute("SELECT id FROM nodes WHERE kind IN ('callable', 'test') AND path LIKE ? AND id LIKE ?" + order,
95
+ ("%" + path, "%." + ".".join(parts), *extra)).fetchone()
96
+ node = row[0] if row else None
97
+ elif name:
98
+ row = con.execute("SELECT id FROM nodes WHERE kind IN ('callable', 'test') AND (id LIKE ? OR name = ?)" + order,
99
+ ("%" + name, name, *extra)).fetchone() # dynamic_context = test_function gives a dotted name
100
+ node = row[0] if row else None
101
+ cache[name] = node
102
+ return node
103
+
104
+
105
+ def _test_file_node(con, test: str, roots: dict) -> Optional[str]:
106
+ """The map's node for a test file named on the command line (relative to anywhere, or absolute)."""
107
+ files = {(r["repo_id"], r["path"]): r["id"] for r in con.execute(
108
+ "SELECT id, repo_id, path FROM nodes WHERE kind = 'file' AND path IS NOT NULL")}
109
+ whole = str(Path(test).resolve()) if Path(test).exists() else test
110
+ rel = _relative(whole, roots, set(files)) or _relative(test.replace("\\", "/").lstrip("./"), roots, set(files))
111
+ return files.get(rel) if rel else None
112
+
113
+
114
+ def _istanbul(data) -> bool:
115
+ return isinstance(data, dict) and bool(data) and all(
116
+ isinstance(v, dict) and "statementMap" in v and "s" in v for v in list(data.values())[:5])
117
+
118
+
119
+ def import_file(con, path: str | Path, run: str = "default", test: Optional[str] = None) -> dict:
120
+ """Read a coverage file into the store, replacing any earlier import under the same run name. `test` names the
121
+ test file a run-wide report (Istanbul, Cobertura) came from when only that file ran; such an import is kept
122
+ under its own run name (`test:<file>`) unless `run` names one."""
123
+ path = Path(path)
124
+ if not path.is_file():
125
+ return {"error": f"no file at {path}"}
126
+ head = path.read_bytes()[:64]
127
+ if test and run == "default":
128
+ run = "test:" + test
129
+ roots = {k: str(v) for k, v in store.roots(con).items()}
130
+ funcs = _functions(con)
131
+ known = set(funcs)
132
+ rows: list[tuple] = []
133
+ tests_seen, tests_matched, files_matched, files_unknown = set(), set(), 0, 0
134
+ if head.startswith(b"SQLite format 3"):
135
+ fmt = "coverage.py"
136
+ src = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True) # quoted: `#`, `?`, `%` in a folder's name
137
+ contexts = dict(src.execute("SELECT id, context FROM context"))
138
+ cache: dict = {}
139
+ per: dict[tuple, set] = defaultdict(set) # (file path, context id) -> lines
140
+ files = dict(src.execute("SELECT id, path FROM file"))
141
+ rel = {i: _relative(p, roots, known) for i, p in files.items()}
142
+ files_matched = sum(1 for v in rel.values() if v)
143
+ files_unknown = sum(1 for v in rel.values() if not v)
144
+ home = max(roots, key=lambda r: sum(1 for v in rel.values() if v and v[0] == r)) if len(roots) > 1 else ""
145
+ for fid, cid, a, b in src.execute("SELECT file_id, context_id, fromno, tono FROM arc"):
146
+ if rel.get(fid):
147
+ per[(rel[fid], cid)].update(x for x in (a, b) if x > 0)
148
+ for fid, cid, bits in src.execute("SELECT file_id, context_id, numbits FROM line_bits"):
149
+ if rel.get(fid):
150
+ per[(rel[fid], cid)].update(_numbits(bits))
151
+ src.close()
152
+ merged: dict[tuple, int] = defaultdict(int) # (test name, node) -> lines, with setup, run and teardown together
153
+ for (file, cid), lines in per.items():
154
+ ctx = contexts.get(cid, "")
155
+ test = re.sub(r"\[.*\]$", "", ctx.split("|", 1)[0])
156
+ for node, n in _owners(funcs[file], lines).items():
157
+ merged[(test, node)] += n
158
+ for (test, node), n in merged.items():
159
+ tid = _test_node(con, test, cache, home) if test else None
160
+ if test:
161
+ tests_seen.add(test)
162
+ if tid:
163
+ tests_matched.add(test)
164
+ rows.append((run, test, tid, node, n))
165
+ elif b"<?xml" in head or b"<coverage" in head:
166
+ fmt = "cobertura"
167
+ tree = ET.parse(path)
168
+ sources = [s.text for s in tree.getroot().iter("source") if s.text]
169
+ per_file: dict[str, set] = defaultdict(set)
170
+ for cls in tree.getroot().iter("class"):
171
+ name = cls.get("filename") or ""
172
+ rel = _relative(name, roots, known) or next(
173
+ (r for s in sources for r in [_relative(s.rstrip("/") + "/" + name, roots, known)] if r), None)
174
+ if not rel:
175
+ files_unknown += 1
176
+ continue
177
+ for ln in cls.iter("line"):
178
+ if int(ln.get("hits", "0") or 0) > 0:
179
+ per_file[rel].add(int(ln.get("number")))
180
+ files_matched = len(per_file)
181
+ for file, lines in per_file.items():
182
+ for node, n in _owners(funcs[file], lines).items():
183
+ rows.append((run, "", None, node, n))
184
+ elif head.lstrip().startswith(b"{"):
185
+ try:
186
+ data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
187
+ except (ValueError, RecursionError):
188
+ data = None
189
+ if not _istanbul(data):
190
+ return {"error": "a JSON file, but not Istanbul's coverage-final.json (vitest --coverage.reporter=json,"
191
+ " jest --coverageReporters=json). V8's raw output is not read: convert it with c8 or vitest."}
192
+ fmt = "istanbul"
193
+ per_file = defaultdict(set)
194
+ for key, f in data.items():
195
+ rel = _relative(f.get("path") or key, roots, known)
196
+ if not rel:
197
+ files_unknown += 1
198
+ continue
199
+ smap, fmap = f.get("statementMap") or {}, f.get("fnMap") or {}
200
+ # v8's conversion counts a declaration or closing-brace line as run when the file loads: a line inside a
201
+ # function that never ran did not run.
202
+ fns = [((loc.get("start") or {}).get("line") or 0, (loc.get("end") or {}).get("line") or 0, (f.get("f") or {}).get(k, 0))
203
+ for k, m in fmap.items() for loc in [m.get("loc") or m.get("decl") or {}]]
204
+ for sid, count in (f.get("s") or {}).items():
205
+ line = ((smap.get(sid) or {}).get("start") or {}).get("line")
206
+ if not count or not line:
207
+ continue
208
+ inner = [x for x in fns if x[0] <= line <= x[1]]
209
+ if inner and not max(inner, key=lambda x: (x[0], -x[1]))[2]:
210
+ continue
211
+ per_file[rel].add(int(line))
212
+ files_matched = len(per_file)
213
+ for file, lines in per_file.items():
214
+ for node, n in _owners(funcs[file], lines).items():
215
+ rows.append((run, "", None, node, n))
216
+ elif head.lstrip().startswith((b"TN:", b"SF:")):
217
+ fmt = "lcov"
218
+ per_test: dict[tuple, set] = defaultdict(set) # (test name, file) -> lines run
219
+ seen_files: dict[str, Optional[tuple]] = {}
220
+ state = {"test": "", "file": None, "lines": set(), "fns": {}}
221
+
222
+ def close() -> None:
223
+ # as for Istanbul: a line inside a function that never ran did not run (v8's conversion counts a
224
+ # declaration or closing-brace line as run when the file loads); FN gives the end line from lcov 2.0
225
+ rel, fns = state["file"], list(state["fns"].values())
226
+ if rel:
227
+ for line in state["lines"]:
228
+ inner = [x for x in fns if x[1] is not None and x[0] <= line <= x[1]]
229
+ if inner and not max(inner, key=lambda x: (x[0], -x[1]))[2]:
230
+ continue
231
+ per_test[(state["test"], rel)].add(line)
232
+ state.update(file=None, lines=set(), fns={})
233
+ for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
234
+ line = raw.strip()
235
+ key, _, val = line.partition(":")
236
+ try:
237
+ if key == "TN":
238
+ state["test"] = val.strip()
239
+ elif key == "SF":
240
+ name = val.strip()
241
+ if name not in seen_files:
242
+ seen_files[name] = _relative(name, roots, known)
243
+ state.update(file=seen_files[name], lines=set(), fns={})
244
+ elif key == "FN":
245
+ parts = val.split(",")
246
+ if len(parts) >= 3 and parts[1].strip().isdigit(): # FN:<start>,<end>,<name>
247
+ state["fns"][",".join(parts[2:])] = [int(parts[0]), int(parts[1]), 0]
248
+ elif len(parts) >= 2: # FN:<start>,<name>
249
+ state["fns"][",".join(parts[1:])] = [int(parts[0]), None, 0]
250
+ elif key == "FNDA":
251
+ count, _, name = val.partition(",")
252
+ if name in state["fns"]:
253
+ state["fns"][name][2] = float(count or 0)
254
+ elif key == "DA":
255
+ parts = val.split(",")
256
+ if len(parts) >= 2 and float(parts[1] or 0) > 0:
257
+ state["lines"].add(int(parts[0]))
258
+ elif line == "end_of_record":
259
+ close()
260
+ except ValueError: # a malformed record line: the rest of the file is still read
261
+ continue
262
+ close()
263
+ files_matched = len({v for v in seen_files.values() if v})
264
+ files_unknown = sum(1 for v in seen_files.values() if not v)
265
+ home = max(roots, key=lambda r: sum(1 for v in seen_files.values() if v and v[0] == r)) if len(roots) > 1 else ""
266
+ cache: dict = {}
267
+ merged = defaultdict(int)
268
+ for (name, file), lines in per_test.items():
269
+ for node, n in _owners(funcs[file], lines).items():
270
+ merged[(name, node)] += n
271
+ for (name, node), n in merged.items():
272
+ tid = _test_node(con, name, cache, home) if name else None
273
+ if name:
274
+ tests_seen.add(name)
275
+ if tid:
276
+ tests_matched.add(name)
277
+ rows.append((run, name, tid, node, n))
278
+ else:
279
+ return {"error": "not a coverage.py data file, a Cobertura XML report, Istanbul's coverage-final.json or an"
280
+ " lcov file"}
281
+ if test and fmt != "coverage.py" and not tests_seen: # a run of one test file: everything that ran, ran under it
282
+ tid = _test_file_node(con, test, roots)
283
+ rows = [(r[0], test, tid, r[3], r[4]) for r in rows]
284
+ tests_seen, tests_matched = {test}, ({test} if tid else set())
285
+ head_commit = con.execute("SELECT commit_sha FROM nodes WHERE kind = 'repo' LIMIT 1").fetchone()
286
+ ran = {r[3] for r in rows}
287
+ stats = {"functions_ran": len(ran), "tests": len(tests_seen), "tests_matched_to_the_map": len(tests_matched),
288
+ "files_matched": files_matched, "files_not_on_the_map": files_unknown, "per_test": bool(tests_seen),
289
+ **({"per": "test file"} if test and fmt != "coverage.py" else {})}
290
+ with con:
291
+ con.execute("DELETE FROM covered WHERE run = ?", (run,))
292
+ con.executemany("INSERT INTO covered VALUES (?,?,?,?,?)", rows)
293
+ con.execute("INSERT OR REPLACE INTO coverage_runs VALUES (?,?,?,?,?)",
294
+ (run, fmt, datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
295
+ head_commit[0] if head_commit else None, json.dumps(stats)))
296
+ for repo in con.execute("SELECT id FROM nodes WHERE kind = 'repo'").fetchall():
297
+ con.execute("INSERT OR REPLACE INTO extractor_coverage VALUES (?,?,?,?,?,?)",
298
+ (repo[0], "coverage", fmt, "ok", head_commit[0] if head_commit else None, json.dumps(stats)))
299
+ try: # the orientation tour quotes these numbers
300
+ from . import tours
301
+ for repo in con.execute("SELECT id FROM nodes WHERE kind = 'repo'").fetchall():
302
+ tours.generate(con, repo[0])
303
+ except Exception:
304
+ pass
305
+ return {"run": run, "format": fmt, **stats}
306
+
307
+
308
+ def has(con) -> bool:
309
+ try:
310
+ return con.execute("SELECT 1 FROM covered LIMIT 1").fetchone() is not None
311
+ except sqlite3.OperationalError:
312
+ return False
313
+
314
+
315
+ def ran(con) -> set:
316
+ """Every function some imported run executed."""
317
+ return {r[0] for r in con.execute("SELECT DISTINCT node_id FROM covered")} if has(con) else set()
318
+
319
+
320
+ def tests_for(con, node_id: str, limit: int = 50) -> list[dict]:
321
+ """The tests under which a function ran."""
322
+ return [{"test": r["test"], "id": r["test_id"], "lines": r["n"]} for r in con.execute(
323
+ "SELECT test, test_id, SUM(lines) AS n FROM covered WHERE node_id = ? AND test != '' GROUP BY test ORDER BY n DESC LIMIT ?",
324
+ (node_id, limit))]
325
+
326
+
327
+ def summary(con) -> dict:
328
+ if not has(con):
329
+ return {"imported": False, "how": "Run the tests under a coverage tool, then `leyline coverage <file>`. "
330
+ "With pytest: `pytest --cov=<package> --cov-context=test`."}
331
+ module = {r["node_id"]: r["module_id"] for r in con.execute("SELECT node_id, module_id FROM ancestry")}
332
+ names = {r["id"]: r["name"] for r in con.execute("SELECT id, name FROM nodes WHERE kind = 'module'")}
333
+ executed = ran(con)
334
+ measured_files = {r[0] for r in con.execute(
335
+ "SELECT DISTINCT a.file_id FROM covered c JOIN ancestry a ON a.node_id = c.node_id")}
336
+ static = {r[0] for r in con.execute(store.TESTED)}
337
+ by_module = defaultdict(lambda: {"functions": 0, "ran": 0, "on_a_test_path": 0, "path_but_never_ran": 0, "ran_off_every_path": 0})
338
+ for r in con.execute("SELECT n.id, a.file_id FROM nodes n JOIN ancestry a ON a.node_id = n.id WHERE n.kind = 'callable'"):
339
+ if r["file_id"] not in measured_files:
340
+ continue # a file the coverage tool did not watch says nothing either way
341
+ m = by_module[module.get(r["id"])]
342
+ m["functions"] += 1
343
+ m["ran"] += r["id"] in executed
344
+ m["on_a_test_path"] += r["id"] in static
345
+ m["path_but_never_ran"] += r["id"] in static and r["id"] not in executed
346
+ m["ran_off_every_path"] += r["id"] in executed and r["id"] not in static
347
+ head = con.execute("SELECT commit_sha FROM nodes WHERE kind = 'repo' LIMIT 1").fetchone()
348
+ runs = [{"run": r["run"], "format": r["format"], "created": r["created"], **json.loads(r["stats"] or "{}"),
349
+ "stale": bool(head and r["commit_sha"] and r["commit_sha"] != head[0])}
350
+ for r in con.execute("SELECT * FROM coverage_runs ORDER BY created DESC")]
351
+ return {"imported": True, "runs": runs,
352
+ "modules": [{"module": names.get(m, m), **v} for m, v in sorted(by_module.items(), key=lambda kv: -kv[1]["functions"])],
353
+ "note": "Only files the coverage tool watched are counted. 'Path but never ran' is code a test can reach on paper "
354
+ "and did not; 'ran off every path' is code that ran through a link the map does not have."}
355
+
356
+
357
+ def compare_flow(con, flow_id: str) -> dict:
358
+ """One test's static path against what ran when it was measured."""
359
+ f = con.execute("SELECT id, name, entry_id FROM flows WHERE id = ?", (flow_id,)).fetchone()
360
+ if f is None:
361
+ return {"error": f"No flow {flow_id!r}."}
362
+ executed = {r[0] for r in con.execute("SELECT node_id FROM covered WHERE test_id = ?", (f["entry_id"],))}
363
+ if not executed:
364
+ return {"flow": flow_id, "measured": False}
365
+ watched = {r[0] for r in con.execute("SELECT DISTINCT a.file_id FROM covered c JOIN ancestry a ON a.node_id = c.node_id")}
366
+ file_of = {r["node_id"]: r["file_id"] for r in con.execute("SELECT node_id, file_id FROM ancestry")}
367
+ names = {r["id"]: r["name"] for r in con.execute("SELECT id, name FROM nodes")}
368
+ steps = [r[0] for r in con.execute("SELECT callable_id FROM flow_steps WHERE flow_id = ? ORDER BY seq", (flow_id,))]
369
+ in_scope = [s for s in steps if file_of.get(s) in watched]
370
+ never = [s for s in in_scope if s not in executed]
371
+ extra = sorted(i for i in executed - set(steps) if not names.get(i, "").startswith("<")) # not module bodies
372
+ return {"flow": flow_id, "test": f["name"], "measured": True, "steps_watched": len(in_scope),
373
+ "ran": len(in_scope) - len(never),
374
+ "on_path_but_did_not_run": [{"id": i, "name": names.get(i, i)} for i in never[:60]],
375
+ "ran_but_not_on_path": [{"id": i, "name": names.get(i, i)} for i in extra[:60]],
376
+ "ran_but_not_on_path_total": len(extra)}