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/diff.py ADDED
@@ -0,0 +1,1078 @@
1
+ """Compare two stores of the same repo: what was edited, what links changed, and what that did
2
+ to the module and system structure. Also check a finished change against its proposal."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import array
7
+ import datetime
8
+ import difflib
9
+ import json
10
+ import re
11
+ import hashlib
12
+ import sqlite3
13
+ import zlib
14
+ from collections import defaultdict
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from . import change, rules
19
+
20
+ EDGE_KINDS = ("extends", "implements", "uses_type", "instantiates", "imports", "communicates", "overrides", "depends_on",
21
+ "reads", "writes")
22
+ CODE_KINDS = ("type", "callable", "field", "test")
23
+
24
+
25
+ def store_path(con) -> Path:
26
+ return Path(next(r[2] for r in con.execute("PRAGMA database_list") if r[1] == "main"))
27
+
28
+
29
+ def snapshot_path(con, name: str) -> Path:
30
+ return store_path(con).parent / "snapshots" / f"{name}.db"
31
+
32
+
33
+ # A snapshot keeps only what a later comparison reads: the nodes with their content hashes, calls and edges by
34
+ # their two ends, which file and module each node sits in, the steps of each flow, and a hash of each line of each
35
+ # source file (so an edit can be placed inside one function and not another). Ids are stored once, in `keys`, and
36
+ # the views give the column names the store has, so compare() and the rules read a snapshot as they read a store.
37
+ # It also keeps what a sequence diagram of the code as it was reads (diagrams.sequence): the line of each pair's
38
+ # first call site and whether the map guessed it, each step's place in its flow (seq, parent_seq, depth, how it
39
+ # was reached and from which line), and the channel, address and line of each channel link. A snapshot taken
40
+ # before these were kept still compares; it just cannot be drawn (diagrams.drawable).
41
+ _SNAPSHOT_SCHEMA = """
42
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);
43
+ CREATE TABLE keys (k INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE);
44
+ CREATE TABLE node_rows (node INTEGER PRIMARY KEY, kind TEXT, name TEXT, parent INTEGER, repo_id TEXT, path TEXT,
45
+ span_start INTEGER, span_end INTEGER, content_hash TEXT, layer TEXT);
46
+ CREATE TABLE node_files (node INTEGER PRIMARY KEY, file INTEGER, module INTEGER);
47
+ CREATE TABLE call_pairs (src INTEGER, dst INTEGER, line INTEGER, guess INTEGER, PRIMARY KEY (src, dst)) WITHOUT ROWID;
48
+ CREATE TABLE edge_pairs (kind TEXT, src INTEGER, dst INTEGER, guess INTEGER, PRIMARY KEY (kind, src, dst)) WITHOUT ROWID;
49
+ CREATE TABLE channel_rows (src INTEGER, dst INTEGER, guess INTEGER, attrs TEXT);
50
+ CREATE TABLE flow_rows (flow INTEGER PRIMARY KEY, name TEXT, entry INTEGER, kind TEXT);
51
+ CREATE TABLE steps (flow INTEGER, seq INTEGER, depth INTEGER, callable INTEGER, via INTEGER, site_line INTEGER,
52
+ parent_seq INTEGER, PRIMARY KEY (flow, seq)) WITHOUT ROWID;
53
+ CREATE TABLE source_lines (repo_id TEXT, path TEXT, hashes BLOB, PRIMARY KEY (repo_id, path));
54
+ CREATE TABLE annotations (node_id TEXT, key TEXT, value TEXT);
55
+ CREATE TABLE rules (id INTEGER PRIMARY KEY, kind TEXT, selector_from TEXT, selector_to TEXT, edge_kinds TEXT,
56
+ severity TEXT, reason TEXT, status TEXT, source TEXT, created TEXT);
57
+ CREATE VIEW nodes AS SELECT n.id, r.kind, r.name, p.id AS parent_id, r.repo_id, r.path, r.span_start, r.span_end,
58
+ r.content_hash, r.layer, NULL AS attrs FROM node_rows r JOIN keys n ON n.k = r.node LEFT JOIN keys p ON p.k = r.parent;
59
+ CREATE VIEW flows AS SELECT f.id, r.name, e.id AS entry_id, json_object('kind', r.kind) AS attrs
60
+ FROM flow_rows r JOIN keys f ON f.k = r.flow LEFT JOIN keys e ON e.k = r.entry;
61
+ CREATE VIEW ancestry AS SELECT n.id AS node_id, f.id AS file_id, m.id AS module_id
62
+ FROM node_files x JOIN keys n ON n.k = x.node LEFT JOIN keys f ON f.k = x.file LEFT JOIN keys m ON m.k = x.module;
63
+ CREATE VIEW calls AS SELECT s.id AS src_id, d.id AS dst_id, c.line AS site_start,
64
+ CASE WHEN c.guess THEN 'guess' END AS precision FROM call_pairs c JOIN keys s ON s.k = c.src JOIN keys d ON d.k = c.dst;
65
+ CREATE VIEW edges AS SELECT e.kind, s.id AS src_id, d.id AS dst_id, CASE WHEN e.guess THEN 'guess' END AS precision,
66
+ NULL AS attrs FROM edge_pairs e JOIN keys s ON s.k = e.src JOIN keys d ON d.k = e.dst WHERE e.kind != 'communicates'
67
+ UNION ALL SELECT 'communicates', s.id, d.id, CASE WHEN c.guess THEN 'guess' END, c.attrs
68
+ FROM channel_rows c JOIN keys s ON s.k = c.src JOIN keys d ON d.k = c.dst;
69
+ CREATE VIEW flow_steps AS SELECT f.id AS flow_id, s.seq, s.depth, c.id AS callable_id, __VIA__ AS via, s.site_line,
70
+ s.parent_seq FROM steps s JOIN keys f ON f.k = s.flow JOIN keys c ON c.k = s.callable;
71
+ """
72
+
73
+
74
+ def _snapshot_schema() -> str:
75
+ from . import store
76
+ return _SNAPSHOT_SCHEMA.replace("__VIA__", store._VIA_CASE)
77
+
78
+
79
+ # What a channel link's attrs keep in a snapshot: what a diagram names it by, and the line it sits on.
80
+ _CHANNEL_KEYS = ("channel", "address", "launched_at", "line")
81
+
82
+
83
+ def line_hashes(data: bytes) -> list[int]:
84
+ """One number per line, ignoring the indentation and trailing space the content hashes also ignore."""
85
+ return [zlib.crc32(ln.strip()) for ln in data.split(b"\n")]
86
+
87
+
88
+ def _pack(hashes: list[int]) -> bytes:
89
+ return zlib.compress(array.array("I", hashes).tobytes(), 6)
90
+
91
+
92
+ def _unpack(blob: bytes) -> list[int]:
93
+ a = array.array("I")
94
+ a.frombytes(zlib.decompress(blob))
95
+ return a.tolist()
96
+
97
+
98
+ def roots(con) -> dict[str, Path]:
99
+ from . import store
100
+ return store.roots(con)
101
+
102
+
103
+ def source(con, repo: str, path: str, root_of: Optional[dict] = None, resolved: bool = False) -> Optional[bytes]:
104
+ """A file's bytes as they are on disk now, if they are what the store indexed. `resolved`: the roots in `root_of`
105
+ are real paths already."""
106
+ root = (root_of if root_of is not None else roots(con)).get(repo)
107
+ if root is None:
108
+ return None
109
+ from . import store
110
+ if not store.inside(root / path, root, resolved): # a link out of the repository, checked out since it was mapped
111
+ return None
112
+ try:
113
+ return (root / path).read_bytes()
114
+ except OSError:
115
+ return None
116
+
117
+
118
+ def snapshot(con, name: str) -> Path:
119
+ """Keep the parts of the store a later check compares with. Returns the snapshot's path."""
120
+ target = snapshot_path(con, name)
121
+ target.parent.mkdir(parents=True, exist_ok=True)
122
+ tmp = target.with_name(target.name + ".part")
123
+ for p in (tmp, Path(str(tmp) + "-journal")):
124
+ p.unlink(missing_ok=True) # a link to nothing too: SQLite would make the file it points at
125
+ out = sqlite3.connect(str(tmp))
126
+ out.executescript(_snapshot_schema())
127
+ out.close()
128
+ con.commit()
129
+ con.execute("ATTACH DATABASE ? AS snap", (str(tmp),))
130
+ try:
131
+ with con:
132
+ con.execute("INSERT INTO snap.keys SELECT k, id FROM main.keys")
133
+ con.execute("INSERT OR IGNORE INTO snap.keys (id) SELECT id FROM main.nodes")
134
+ con.execute("INSERT OR IGNORE INTO snap.keys (id) SELECT id FROM main.flows UNION SELECT entry_id FROM main.flows"
135
+ " WHERE entry_id IS NOT NULL")
136
+ con.execute("INSERT OR IGNORE INTO snap.keys (id) SELECT file_id FROM main.ancestry WHERE file_id IS NOT NULL"
137
+ " UNION SELECT module_id FROM main.ancestry WHERE module_id IS NOT NULL")
138
+ con.execute("INSERT INTO snap.node_rows SELECT n.k, x.kind, x.name, p.k, x.repo_id, x.path, x.span_start,"
139
+ " x.span_end, x.content_hash, x.layer FROM main.nodes x JOIN snap.keys n ON n.id = x.id"
140
+ " LEFT JOIN snap.keys p ON p.id = x.parent_id")
141
+ con.execute("INSERT INTO snap.node_files SELECT n.k, f.k, m.k FROM main.ancestry a JOIN snap.keys n ON n.id = a.node_id"
142
+ " LEFT JOIN snap.keys f ON f.id = a.file_id LEFT JOIN snap.keys m ON m.id = a.module_id")
143
+ # One row per pair: where its first call sits in the caller, and whether every call of it is a guess.
144
+ con.execute("INSERT INTO snap.call_pairs SELECT src, dst, MIN(site_start), MIN(precision = 'guess')"
145
+ " FROM main.call_sites GROUP BY src, dst")
146
+ kinds = EDGE_KINDS + ("groups",)
147
+ con.execute(f"INSERT INTO snap.edge_pairs SELECT kind, src, dst, MIN(precision = 'guess') FROM main.links"
148
+ f" WHERE kind IN ({','.join('?' * len(kinds))}) GROUP BY kind, src, dst", kinds)
149
+ chans = set()
150
+ for s, d, prec, raw in con.execute("SELECT src, dst, precision, attrs FROM main.links WHERE kind = 'communicates'"):
151
+ try:
152
+ a = json.loads(raw) if raw else {}
153
+ except ValueError:
154
+ a = {}
155
+ a = {k: a[k] for k in _CHANNEL_KEYS if a.get(k) is not None} if isinstance(a, dict) else {}
156
+ chans.add((s, d, int(prec == "guess"), json.dumps(a, sort_keys=True)))
157
+ con.executemany("INSERT INTO snap.channel_rows VALUES (?,?,?,?)", sorted(chans))
158
+ con.execute("INSERT INTO snap.flow_rows SELECT f.k, x.name, e.k, json_extract(x.attrs, '$.kind')"
159
+ " FROM main.flows x JOIN snap.keys f ON f.id = x.id LEFT JOIN snap.keys e ON e.id = x.entry_id")
160
+ con.execute("INSERT OR IGNORE INTO snap.steps SELECT flow, seq, depth, callable, via, site_line, parent_seq"
161
+ " FROM main.steps")
162
+ # the names given to systems, which a rule's `system:Name` selects by
163
+ con.execute("INSERT INTO snap.annotations SELECT node_id, key, value FROM main.annotations WHERE key = 'name'")
164
+ con.execute("INSERT INTO snap.meta SELECT key, value FROM main.meta WHERE key LIKE 'root:%'")
165
+ con.execute("INSERT INTO snap.meta VALUES ('format', 'slim')")
166
+ from .incremental import code_version # what read the code: another version may read it differently
167
+ con.execute("INSERT INTO snap.meta VALUES ('made_by', ?)", (code_version(),))
168
+ where = {}
169
+ for r in con.execute("SELECT repo_id, path, content_hash FROM main.nodes WHERE kind = 'file' AND layer = 'fact'"):
170
+ where[(r[0], r[1])] = r[2]
171
+ root_of = {r: p.resolve() for r, p in roots(con).items()}
172
+ rows = []
173
+ for (repo, path), sha in where.items():
174
+ data = source(con, repo, path, root_of, resolved=True)
175
+ if data is not None and hashlib.sha1(data).hexdigest() == sha: # only text the store describes
176
+ rows.append((repo, path, _pack(line_hashes(data))))
177
+ con.executemany("INSERT INTO snap.source_lines VALUES (?,?,?)", rows)
178
+ finally:
179
+ con.execute("DETACH DATABASE snap")
180
+ for p in (target, Path(str(target) + "-wal"), Path(str(target) + "-shm")):
181
+ if p.exists():
182
+ p.unlink()
183
+ tmp.replace(target)
184
+ with con: # so a plan can tell a baseline deleted by hand from one never taken or forgotten (see lost)
185
+ con.execute("INSERT OR REPLACE INTO meta VALUES (?, 'taken')", ("baseline:" + name,))
186
+ return target
187
+
188
+
189
+ def drop_snapshot(con, name: str) -> bool:
190
+ """Forget a change's baseline. True when there was one. The store notes that it was forgotten on purpose (see
191
+ lost), so a plan can tell it from a baseline deleted by hand."""
192
+ target = snapshot_path(con, name)
193
+ gone = False
194
+ for p in (target, Path(str(target) + "-wal"), Path(str(target) + "-shm")):
195
+ if p.exists():
196
+ p.unlink()
197
+ gone = True
198
+ with con:
199
+ con.execute("INSERT OR REPLACE INTO meta VALUES (?, 'forgotten')", ("baseline:" + name,))
200
+ return gone
201
+
202
+
203
+ def lost(con, name: str) -> bool:
204
+ """True when a baseline was taken for this change and is gone without being forgotten (deleted by hand, or with
205
+ .leyline/snapshots): a plan that took a new one now would take the changed code as the start. A store that did
206
+ not note it (one written before Leyline did) is given the benefit of the doubt."""
207
+ row = con.execute("SELECT value FROM meta WHERE key = ?", ("baseline:" + name,)).fetchone()
208
+ return row is not None and row[0] == "taken" and not snapshot_path(con, name).exists()
209
+
210
+
211
+ def _fingerprint(con) -> str:
212
+ """The code as the map has it, in one hash: every type, function, field and test, and every source file. Files
213
+ count because code outside any function (a module's constant, a table, an import) has no node of its own in most
214
+ languages: without them "make the retry count 3" did not move the code on. An edited comment counts too."""
215
+ h = hashlib.sha1()
216
+ kinds = CODE_KINDS + ("file",)
217
+ for r in con.execute(f"SELECT id, content_hash FROM nodes WHERE layer = 'fact' AND kind IN ({','.join('?' * len(kinds))})"
218
+ " ORDER BY id", kinds):
219
+ h.update(f"{r[0]}\0{r[1]}\n".encode())
220
+ return h.hexdigest()
221
+
222
+
223
+ def moved_on(con, name: str) -> bool:
224
+ """True when a snapshot of this name exists and the code has changed since it was taken."""
225
+ snap = snapshot_path(con, name)
226
+ if not snap.exists():
227
+ return False
228
+ before = _open(snap)
229
+ try:
230
+ return _fingerprint(before) != _fingerprint(con)
231
+ finally:
232
+ before.close()
233
+
234
+
235
+ class DamagedBaseline(sqlite3.DatabaseError):
236
+ """A baseline (a snapshot) that SQLite cannot read: the store beside it is not the one damaged."""
237
+
238
+
239
+ def _open(path: Path) -> sqlite3.Connection:
240
+ con = sqlite3.connect(Path(path).resolve().as_uri() + "?mode=ro", uri=True) # quoted: `#`, `?`, `%` in a folder's name
241
+ con.row_factory = sqlite3.Row
242
+ try: # told apart from damage to the store: not a database, or cut off short of the pages its header counts
243
+ tables = con.execute("SELECT COUNT(*) FROM sqlite_master").fetchone()[0]
244
+ pages, size = con.execute("PRAGMA page_count").fetchone()[0], con.execute("PRAGMA page_size").fetchone()[0]
245
+ short = Path(path).stat().st_size < pages * size and not Path(str(path) + "-wal").exists() # pages in a WAL count too
246
+ # SQLite reads an empty file as an empty database: a baseline with nothing in it is damaged too
247
+ ok = "cut off" if short else "empty: it holds no tables" if not tables else "ok"
248
+ except sqlite3.OperationalError:
249
+ con.close()
250
+ raise
251
+ except sqlite3.DatabaseError as e:
252
+ ok = str(e)
253
+ if ok != "ok":
254
+ con.close()
255
+ raise DamagedBaseline(f"the baseline kept at {path} is damaged ({ok}). Delete that file, then take a new baseline:"
256
+ " plan the change again on the code as it was before it.")
257
+ return con
258
+
259
+
260
+ def _base(qualified_id: str) -> str:
261
+ # The id without its parameter list: a method keeps this when only its signature changes.
262
+ head, sep, _ = qualified_id.rpartition("(")
263
+ return head if sep and qualified_id.endswith(")") else qualified_id
264
+
265
+
266
+ def _changed_lines(old: list[int], new: list[int], gone: Optional[set] = None) -> set[int]:
267
+ """The lines of the new text (from 1) that are new or edited, plus the line where any line was deleted. A
268
+ deletion of nothing but code that is gone from the map (`gone`, old line numbers from 1: a removed function)
269
+ and blank lines marks no line: the removal is said as a removal, not as an edit of what was around it."""
270
+ out = set()
271
+ blank = zlib.crc32(b"")
272
+ for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old, new, autojunk=False).get_opcodes():
273
+ if op in ("replace", "insert"):
274
+ out.update(range(j1 + 1, j2 + 1))
275
+ elif op == "delete":
276
+ if gone and all(k + 1 in gone or old[k] == blank for k in range(i1, i2)) and any(k + 1 in gone for k in range(i1, i2)):
277
+ continue
278
+ out.add(max(1, j1))
279
+ return out
280
+
281
+
282
+ def own_changes(before: sqlite3.Connection, after: sqlite3.Connection, ids: list[str]) -> Optional[dict]:
283
+ """For each node, the changed lines inside it that are not inside a node nested in it, as (line, text). A node
284
+ whose content hash changed only because something nested in it changed has none. None when the baseline
285
+ keeps no line hashes (taken by an older Leyline)."""
286
+ try:
287
+ before.execute("SELECT 1 FROM source_lines LIMIT 1").fetchone()
288
+ except sqlite3.Error:
289
+ return None
290
+ rows = {}
291
+ for i in ids:
292
+ r = after.execute("SELECT id, kind, repo_id, path, span_start, span_end FROM nodes WHERE id = ?", (i,)).fetchone()
293
+ if r is not None:
294
+ rows[i] = r
295
+ root_of = roots(after)
296
+ files: dict = {}
297
+ out = {}
298
+ for i in ids:
299
+ n = rows.get(i)
300
+ if n is None or not n["path"]:
301
+ continue
302
+ key = (n["repo_id"], n["path"])
303
+ if key not in files:
304
+ data = source(after, *key, root_of=root_of)
305
+ old = before.execute("SELECT hashes FROM source_lines WHERE repo_id = ? AND path = ?", key).fetchone()
306
+ if data is None:
307
+ files[key] = None
308
+ else:
309
+ text = data.decode("utf-8", errors="replace").split("\n")
310
+ gone = set() # the old lines of code the map no longer has: removed functions, types, fields
311
+ try:
312
+ for j, a, b in before.execute(
313
+ f"SELECT id, span_start, span_end FROM nodes WHERE repo_id = ? AND path = ? AND span_start IS NOT NULL"
314
+ f" AND kind IN ({','.join('?' * len(CODE_KINDS))})", (*key, *CODE_KINDS)).fetchall():
315
+ if after.execute("SELECT 1 FROM nodes WHERE id = ?", (j,)).fetchone() is None:
316
+ gone.update(range(a, (b or a) + 1))
317
+ except sqlite3.Error:
318
+ gone = set()
319
+ changed = _changed_lines(_unpack(old[0]) if old else [], line_hashes(data), gone)
320
+ inner = [(r[0], r[1], r[2]) for r in after.execute(
321
+ f"SELECT id, span_start, span_end FROM nodes WHERE repo_id = ? AND path = ? AND span_start IS NOT NULL"
322
+ f" AND kind IN ({','.join('?' * len(CODE_KINDS))})", (*key, *CODE_KINDS))]
323
+ files[key] = (text, changed, inner)
324
+ if files[key] is None:
325
+ continue
326
+ text, changed, inner = files[key]
327
+ s, e = (1, len(text)) if n["kind"] == "file" or not n["span_start"] else (n["span_start"], n["span_end"] or n["span_start"])
328
+ nested = [(a, b) for j, a, b in inner if j != i and s <= a and b <= e and (a, b) != (s, e)]
329
+ out[i] = [(ln, text[ln - 1] if ln <= len(text) else "") for ln in sorted(changed)
330
+ if s <= ln <= e and not any(a <= ln <= b for a, b in nested)]
331
+ return out
332
+
333
+
334
+ COMMENT_LINE = re.compile(r"^(//|/\*|\*(\s|/|$)|#(\s|!|$)|--\s)")
335
+
336
+
337
+ def _code_line(text: str) -> bool:
338
+ """A line that holds code: not blank, and not only a comment (`// ...`, `# ...`, a doc comment's `* ...`)."""
339
+ t = text.strip()
340
+ return bool(t) and not COMMENT_LINE.match(t)
341
+
342
+
343
+ IMPORT = re.compile(r"^\s*(import\s|from\s+\S+\s+import\s|using\s|#include\s|use\s|require\(|const\s.*=\s*require\(|export\s.*\sfrom\s)")
344
+
345
+
346
+ def explained(node: dict, spans: list[tuple[int, int]], names: set[str], reach: int = 2) -> bool:
347
+ """Whether the changed lines a body holds around code a task covers (a module's top level, a class around a new
348
+ method) are all part of that work: blank, a comment, an import, next to (within `reach` lines of) that code, or in a run
349
+ of changed lines that names it (an entry in a registration table, up to 15 lines long). A function
350
+ that holds no task code is never explained this way: its own lines are its own edit."""
351
+ own = node.get("own") or []
352
+ top = node.get("name") in ("<module>", "<top-level>")
353
+ start, end = node.get("line") or 0, node.get("end") or 0
354
+ if not top and not any(start <= a and b <= end for a, b in spans):
355
+ return False
356
+
357
+ def names_code(text):
358
+ return any(re.search(r"(?<![A-Za-z0-9_$])" + re.escape(n) + r"(?![A-Za-z0-9_$])", text) for n in names if n)
359
+ runs, cur = [], []
360
+ for ln, text in own: # runs of changed lines next to each other
361
+ if cur and ln > cur[-1][0] + 1:
362
+ runs.append(cur)
363
+ cur = []
364
+ cur.append((ln, text))
365
+ runs += [cur] if cur else []
366
+ for run in runs:
367
+ if len(run) <= 15 and any(names_code(text) for _, text in run):
368
+ continue
369
+ for ln, text in run:
370
+ if not _code_line(text) or (spans and IMPORT.match(text)) or any(a - reach <= ln <= b + reach for a, b in spans):
371
+ continue
372
+ return False
373
+ return True
374
+
375
+
376
+ def compare(before: sqlite3.Connection, after: sqlite3.Connection) -> dict:
377
+ def nodes(con):
378
+ return {r["id"]: r for r in con.execute(
379
+ f"SELECT id, kind, name, parent_id, path, span_start, span_end, content_hash, attrs FROM nodes"
380
+ f" WHERE layer = 'fact' AND kind IN ({','.join('?' * len(CODE_KINDS))})", CODE_KINDS)}
381
+ b, a = nodes(before), nodes(after)
382
+ removed, added = set(b) - set(a), set(a) - set(b)
383
+ # A method whose parameters changed has a new id. Pair it with the old one by owner and name.
384
+ resigned: dict[str, str] = {}
385
+ by_base = defaultdict(list)
386
+ for i in added:
387
+ by_base[(_base(i), a[i]["kind"])].append(i)
388
+ for i in sorted(removed):
389
+ cands = by_base.get((_base(i), b[i]["kind"]), [])
390
+ if b[i]["kind"] == "callable" and len(cands) == 1 and cands[0] not in resigned.values():
391
+ resigned[i] = cands[0]
392
+ removed -= set(resigned)
393
+ added -= set(resigned.values())
394
+ # Anything nested under a re-signed method moved with it.
395
+ moved = {}
396
+ for old, new in resigned.items():
397
+ for i in list(removed):
398
+ if i.startswith(old + "/") and (new + i[len(old):]) in added:
399
+ moved[i] = new + i[len(old):]
400
+ for old, new in moved.items():
401
+ removed.discard(old)
402
+ added.discard(new)
403
+ remap = {**resigned, **moved}
404
+ edited = {i for i in set(a) & set(b) if a[i]["content_hash"] != b[i]["content_hash"] and a[i]["kind"] != "type"}
405
+ edited |= {new for old, new in moved.items() if a[new]["content_hash"] != b[old]["content_hash"]}
406
+ types_edited = {i for i in set(a) & set(b) if a[i]["kind"] == "type" and a[i]["content_hash"] != b[i]["content_hash"]}
407
+
408
+ def links(con, mapping):
409
+ out = set()
410
+ for r in con.execute("SELECT DISTINCT src_id, dst_id FROM calls"):
411
+ out.add(("calls", mapping.get(r[0], r[0]), mapping.get(r[1], r[1])))
412
+ for r in con.execute(f"SELECT kind, src_id, dst_id FROM edges WHERE kind IN ({','.join('?' * len(EDGE_KINDS))})", EDGE_KINDS):
413
+ out.add((r[0], mapping.get(r[1], r[1]), mapping.get(r[2], r[2])))
414
+ return out
415
+ lb, la = links(before, remap), links(after, {})
416
+ links_added, links_removed = la - lb, lb - la
417
+
418
+ def rollup(con, link_set):
419
+ mod = {r["node_id"]: r["module_id"] for r in con.execute("SELECT node_id, module_id FROM ancestry")}
420
+ for r in con.execute("SELECT id FROM nodes WHERE kind IN ('module', 'external')"):
421
+ mod[r[0]] = r[0]
422
+ out = defaultdict(int)
423
+ for k, s, d in link_set:
424
+ ms, md = mod.get(s), mod.get(d)
425
+ if ms and md and ms != md:
426
+ out[(ms, md)] += 1
427
+ return out
428
+ names_a = {r["id"]: r["name"] for r in after.execute("SELECT id, name FROM nodes")}
429
+ names_b = {r["id"]: r["name"] for r in before.execute("SELECT id, name FROM nodes")}
430
+ rb, ra = rollup(before, links(before, {})), rollup(after, la)
431
+ new_deps = [{"from": names_a.get(s, s), "to": names_a.get(d, d), "links": n, "from_id": s, "to_id": d}
432
+ for (s, d), n in sorted(ra.items()) if (s, d) not in rb]
433
+ gone_deps = [{"from": names_b.get(s, s), "to": names_b.get(d, d), "links": n}
434
+ for (s, d), n in sorted(rb.items()) if (s, d) not in ra]
435
+
436
+ def flows(con, mapping):
437
+ out = {}
438
+ for f in con.execute("SELECT id, name, entry_id FROM flows"):
439
+ steps = [mapping.get(r[0], r[0]) for r in con.execute(
440
+ "SELECT callable_id FROM flow_steps WHERE flow_id = ? ORDER BY seq", (f["id"],))]
441
+ out[mapping.get(f["entry_id"], f["entry_id"])] = (f["name"], steps)
442
+ return out
443
+ fb, fa = flows(before, remap), flows(after, {})
444
+ flow_changes = []
445
+ flow_gained, flow_lost = defaultdict(int), defaultdict(int)
446
+ for key in sorted(set(fb) | set(fa)):
447
+ if key not in fb:
448
+ flow_changes.append({"name": fa[key][0], "change": "new", "entry": key})
449
+ elif key not in fa:
450
+ flow_changes.append({"name": fb[key][0], "change": "gone"})
451
+ else:
452
+ gained, lost = set(fa[key][1]) - set(fb[key][1]), set(fb[key][1]) - set(fa[key][1])
453
+ for i in gained:
454
+ flow_gained[i] += 1
455
+ for i in lost:
456
+ flow_lost[i] += 1
457
+ if gained or lost:
458
+ flow_changes.append({"name": fa[key][0], "change": "path changed", "entry": key,
459
+ "gained": len(gained), "lost": len(lost)})
460
+
461
+ def label(con_nodes, i):
462
+ n = con_nodes[i]
463
+ parent = con_nodes.get(n["parent_id"])
464
+ name = f"{parent['name']}.{n['name']}" if parent is not None and parent["kind"] == "type" else n["name"]
465
+ return {"id": i, "name": name,
466
+ "kind": n["kind"], "path": n["path"], "line": n["span_start"], "end": n["span_end"]}
467
+ return {
468
+ "nodes": {
469
+ "added": [label(a, i) for i in sorted(added)],
470
+ "removed": [label(b, i) for i in sorted(removed)],
471
+ "resigned": [{**label(a, new), "was": old.rsplit("(", 1)[-1].rstrip(")"),
472
+ "now": new.rsplit("(", 1)[-1].rstrip(")")} for old, new in sorted(resigned.items())],
473
+ "edited": [label(a, i) for i in sorted(edited)],
474
+ "types_edited": [label(a, i) for i in sorted(types_edited)],
475
+ },
476
+ "links": {"added": len(links_added), "removed": len(links_removed),
477
+ "added_examples": [{"kind": k, "from": names_a.get(s, s), "to": names_a.get(d, d), "from_id": s, "to_id": d}
478
+ for k, s, d in sorted(links_added)[:40]],
479
+ "removed_examples": [{"kind": k, "from": names_b.get(s, names_a.get(s, s)), "to": names_b.get(d, names_a.get(d, d))}
480
+ for k, s, d in sorted(links_removed)[:40]]},
481
+ "structure": {"new_dependencies": new_deps, "removed_dependencies": gone_deps},
482
+ "flows": {"changed": len(flow_changes), "items": flow_changes[:60],
483
+ "now_pass_through": [{**label(a, i), "flows": n} for i, n in sorted(flow_gained.items(), key=lambda x: -x[1])[:15] if i in a],
484
+ "no_longer_pass_through": [{"id": i, "name": names_b.get(i, names_a.get(i, i)), "flows": n}
485
+ for i, n in sorted(flow_lost.items(), key=lambda x: -x[1])[:15]]},
486
+ "remap": remap,
487
+ }
488
+
489
+
490
+ _SOURCE_FILE = re.compile(r"\.(?:[cm]?[jt]sx?|py|cs|go|rb|java|kt|rs|php|swift|scala|lua|gd|dart|ex|exs)$")
491
+
492
+
493
+ def result_parts(name: str) -> dict:
494
+ """A recorded result's name taken apart: the file, the suites around the test, the test's own name, and for a
495
+ parametrized pytest test its function and parameter id. Names come as `file > suite > test` (TAP, vitest),
496
+ `path.py::Class::test[param]` (pytest), or just the test's name."""
497
+ out = {"file": None, "suites": [], "leaf": name, "func": None, "param": None}
498
+ if "::" in name:
499
+ parts = name.split("::")
500
+ out["file"], out["suites"], out["leaf"] = parts[0], parts[1:-1], parts[-1]
501
+ elif " > " in name:
502
+ segs = name.split(" > ")
503
+ # A file has a path or a source file's extension; a suite named for what it tests (`Engine.start`) is not one.
504
+ if len(segs) > 1 and ("/" in segs[0] or "\\" in segs[0] or _SOURCE_FILE.search(segs[0])):
505
+ out["file"], segs = segs[0], segs[1:]
506
+ out["suites"], out["leaf"] = segs[:-1], segs[-1]
507
+ m = re.match(r"^(.+?)\[(.*)\]$", out["leaf"])
508
+ if m and out["file"] and out["file"].endswith(".py"):
509
+ out["func"], out["param"] = m.group(1), m.group(2)
510
+ m = re.match(r"^([A-Za-z_]\w*)\((.+)\)$", out["leaf"])
511
+ if m and not out["file"] and out["suites"] and "." in out["suites"][-1]: # dotnet: a theory's row, Method(x: 1)
512
+ out["func"], out["param"] = m.group(1), m.group(2)
513
+ return out
514
+
515
+
516
+ def _template(name: str):
517
+ """A test named with an interpolated string ("{tag}: reproduces ...") matches any text in its holes. A name that
518
+ is nearly all holes ("{}") would match every test, so it matches none."""
519
+ literal = re.sub(r"\{[^}]*\}", "", name)
520
+ if len(re.sub(r"[^A-Za-z0-9]", "", literal)) < 3:
521
+ return None
522
+ return re.compile("^" + "(.+?)".join(re.escape(part) for part in re.split(r"\{[^}]*\}", name)) + "$")
523
+
524
+
525
+ class TestNames:
526
+ """The test nodes on the map, to tie a recorded result to the one test it ran. A result names its file and
527
+ suites when the runner printed them, and only a test in that file and suite can be its node."""
528
+
529
+ def __init__(self, con):
530
+ self.by_name = defaultdict(list)
531
+ self.templates = []
532
+ self.parametrized = [] # pytest tests run once per parameter: test_x[a], test_x[b]
533
+ for r in con.execute("SELECT id, name, path, attrs FROM nodes WHERE kind = 'test'"
534
+ " OR json_extract(attrs, '$.is_test') = 1"):
535
+ a = json.loads(r["attrs"] or "{}")
536
+ t = {"id": r["id"], "name": r["name"], "path": r["path"] or "", "suite": a.get("suite") or "",
537
+ "full": a.get("full_name") or ""}
538
+ self.by_name[r["name"]].append(t)
539
+ if any("parametrize" in d for d in a.get("decorators") or []):
540
+ self.parametrized.append(t)
541
+ if "{" in r["name"]:
542
+ pattern = _template(r["name"])
543
+ if pattern is not None:
544
+ self.templates.append((pattern, len(re.sub(r"\{[^}]*\}", "", r["name"])), t))
545
+
546
+ @staticmethod
547
+ def _narrow(cands: list, p: dict) -> list:
548
+ if p["file"]:
549
+ f = p["file"].replace("\\", "/").lstrip("./")
550
+ cands = [t for t in cands if t["path"] == f or t["path"].endswith("/" + f) or f.endswith("/" + t["path"])]
551
+ if p["suites"] and cands:
552
+ chain = " > ".join(p["suites"])
553
+ same = [t for t in cands if (t["full"] and t["full"].rsplit(" > ", 1)[0] in (chain, " > ".join(p["suites"][-1:])))
554
+ or (t["suite"] and t["suite"] in (chain, p["suites"][-1]))
555
+ or re.search(r"[.:]" + re.escape(p["suites"][-1]) + r"\.", t["id"])]
556
+ if same or not p["file"]:
557
+ cands = same
558
+ return cands
559
+
560
+ def node(self, name: str) -> Optional[str]:
561
+ """The id of the test node a result ran, or None when there is none or it could be more than one."""
562
+ p = result_parts(name)
563
+ leaf = p["func"] or p["leaf"]
564
+ # The map keeps a name as written in the source: a tab in the result is `\t` there.
565
+ written = leaf.translate({ord(c): "\\" + e for e, c in _TAP_CONTROL.items()})
566
+ cands = self._narrow(self.by_name.get(leaf, []) or self.by_name.get(written, []), p)
567
+ if not cands:
568
+ hits = [(size, t) for pattern, size, t in self.templates if pattern.match(leaf)]
569
+ hits = [(size, t) for size, t in hits if t in self._narrow([t for _, t in hits], p)]
570
+ if hits:
571
+ best = max(size for size, _ in hits)
572
+ cands = [t for size, t in hits if size == best]
573
+ ids = {t["id"] for t in cands}
574
+ return ids.pop() if len(ids) == 1 else None
575
+
576
+
577
+ def record_tests(con, run: str, results: list[dict]) -> dict:
578
+ """Store one test run. Each result is {name, status: pass|fail|skip, message?}."""
579
+ names = TestNames(con)
580
+ from . import props # results passed whole (MCP `test_results`) may carry a property test's counterexample
581
+ results = props.annotate([dict(r) for r in results])
582
+ now = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
583
+ rows = [(run, r["name"], names.node(r["name"]), r["status"], r.get("message"), now) for r in results]
584
+ with con:
585
+ con.execute("DELETE FROM test_results WHERE run = ?", (run,))
586
+ con.executemany("INSERT OR REPLACE INTO test_results VALUES (?,?,?,?,?,?)", rows)
587
+ counts = defaultdict(int)
588
+ for r in results:
589
+ counts[r["status"]] += 1
590
+ return {"run": run, **counts, "matched_to_test_nodes": sum(1 for r in rows if r[2])}
591
+
592
+
593
+ # Each is matched against a line with its trailing spaces cut (parse_test_output does): a lazy name followed by `\s*$`
594
+ # tries every split of a long run of spaces inside the name, and a test that prints one line of a million spaces would
595
+ # hold the parse for hours.
596
+ _PLAIN = re.compile(r"^\s*(PASS(?:ED)?|FAIL(?:ED)?|ERROR|SKIP(?:PED)?|XFAIL|XPASS)\b[\s:]*(.+)$")
597
+ _POINT = re.compile(r"^(\s*)(not ok|ok)\b(?:\s+\d+)?(?:\s*-(?=\s|$))?\s*(.*)$")
598
+ _SUBTEST = re.compile(r"^(\s*)# Subtest:\s*(.*)$")
599
+ _YAML_START = re.compile(r"^\s*---\s*$")
600
+ # go test: `--- PASS: TestX/sub (0.00s)`, `=== RUN TestX`, and a package's line `ok \tpkg\t0.3s`, `FAIL\tpkg\t0.1s`,
601
+ # `? \tpkg\t[no test files]` or `FAIL\tpkg [build failed]`.
602
+ # jest's default reporter: ` PASS src/a.test.js (5.1 s)`, and with --verbose `✓ name (2 ms)`, `✕`, `○ skipped`, `✎ todo`
603
+ # (√ and × on Windows).
604
+ _JEST_FILE = re.compile(r"^\s*(?:PASS|FAIL)\s+(\S+)(?:\s+\([\d.]+\s*m?s\))?$") # and the file is JavaScript
605
+ _JS_FILE = re.compile(r"\.[cm]?[jt]sx?$")
606
+ _JEST_TEST = re.compile(r"^(\s*)(✓|✕|○|✎|√|×)\s+(.*)$")
607
+ _JEST_TIME = re.compile(r"\(([\d.]+\s*m?s)\)$") # `name (2 ms)`: searched from the `(`, so a long name costs nothing
608
+ _GO_RESULT = re.compile(r"^(\s*)--- (PASS|FAIL|SKIP): (\S+) \([\d.]+m?s\)\s*$")
609
+ # dotnet test with `--logger "console;verbosity=detailed"`: ` Passed Ns.Class.Method [3 ms]`, ` Failed ... [<1 ms]`,
610
+ # then for a failure ` Error Message:` and the message on the lines below it.
611
+ _DOTNET = re.compile(r"^\s*(Passed|Failed|Skipped)\s+(\S.*?)\s+\[[^\]]*\]\s*$")
612
+ _GO_RUN = re.compile(r"^=== (?:RUN|CONT|PAUSE|NAME)\s+(\S+)\s*$")
613
+ _GO_PACKAGE = re.compile(r"^(ok|FAIL|\?)\s+(\S+)(?:\t.*| \[(build failed|setup failed)\])$")
614
+
615
+
616
+ _TAP_CONTROL = {"t": "\t", "n": "\n", "r": "\r", "b": "\b", "f": "\f", "v": "\v"}
617
+
618
+
619
+ def _tap_unescape(text: str) -> str:
620
+ r"""A TAP name as the runner meant it: `\#` is `#`, `\\` a backslash, and (as node:test writes them) `\\t` and `\\n`
621
+ a tab and a newline."""
622
+ return re.sub(r"\\#|\\\\([tnrbfv])?",
623
+ lambda m: "#" if m.group(0) == "\\#" else _TAP_CONTROL.get(m.group(1) or "", "\\"), text)
624
+
625
+
626
+ def _tap_point(rest: str, can_open: bool = True) -> tuple[str, str, bool]:
627
+ """A TAP test line after `ok N -`: (description, directive, opens a block of subtests). A ` {` at the end opens a
628
+ block (vitest) only when `can_open`, the lines after it being the block's; else it is part of the name."""
629
+ opens = can_open and rest.endswith("{")
630
+ if opens:
631
+ rest = rest[:-1].rstrip()
632
+ m = re.search(r"(?<!\\)\s#\s*(.*)$", rest)
633
+ desc = rest[:m.start()] if m else rest
634
+ directive = m.group(1).strip() if m else ""
635
+ return _tap_unescape(desc.strip()), directive, opens
636
+
637
+
638
+ _COLLECTED = re.compile(r"^(\S+\.py) - (.+)$")
639
+
640
+
641
+ def _outside_brackets(text: str, sep: str) -> int:
642
+ """Where `sep` first appears outside a pytest parameter's brackets (`test[a - b] - message`), or -1."""
643
+ depth = 0
644
+ for k, ch in enumerate(text):
645
+ if ch == "[":
646
+ depth += 1
647
+ elif ch == "]":
648
+ depth = max(0, depth - 1)
649
+ elif not depth and text.startswith(sep, k):
650
+ return k
651
+ return -1
652
+
653
+
654
+ # What parse_test_output reads, as the help, the tools and the next steps say it.
655
+ READS = ('TAP (vitest --reporter=tap, node --test), pytest -rA, go test -v, jest --verbose,'
656
+ ' dotnet test --logger "console;verbosity=detailed", or one PASS or FAIL line per test')
657
+
658
+
659
+ def parse_test_output(text: str) -> list[dict]:
660
+ """Read test results from runner output. Understood: TAP 13 and 14 as vitest, node:test and tap print it, where
661
+ nested subtests (indented, or opened with `{`) give names like `file > suite > test` and a suite is not a test of
662
+ its own; pytest -rA summaries (`PASSED path::test[param]`), kept whole so each parameter is one result; go test
663
+ (`--- PASS: TestX/sub`, read as `TestX > sub`; only -v prints the tests that pass); jest's default reporter (` PASS file`,
664
+ the `✓`/`✕` tree that --verbose adds, and a `●` section per failure), named `file > describe > test`; dotnet test's
665
+ detailed console logger (`Passed Ns.Class.Method [3 ms]`); and any
666
+ runner that prints one `PASS name` or `FAIL name: message` line per test. Other formats: pass results to
667
+ record_tests directly."""
668
+ out: dict[str, dict] = {}
669
+ stack: list[dict] = [] # open suites: {indent, name, kind: header|brace, status, children, failed}
670
+ last: Optional[dict] = None # the result a YAML block that follows belongs to
671
+ in_yaml = False
672
+ go_run: Optional[str] = None # go -v: the test whose log lines follow (`=== RUN TestX`)
673
+ go_after: Optional[int] = None # go: the indent of the last `--- FAIL:`, whose log lines follow it
674
+ go_logs: dict[str, str] = {} # go -v: a test's first log line, its message if it fails
675
+ go_tests: list[str] = [] # go: every result, to drop a parent that its subtests account for
676
+ jest: Optional[dict] = None # jest: the file whose tests follow, its open describe blocks, a failure awaiting its message
677
+
678
+ def emit(chain: list[str], status: str, message: Optional[str] = None) -> dict:
679
+ for f in stack:
680
+ f["children"] += 1
681
+ f["failed"] |= status == "fail"
682
+ name = " > ".join(x for x in chain if x)
683
+ if name in out and out[name]["status"] == "fail":
684
+ return out[name] # the same name twice: a failure is not hidden by a pass
685
+ out[name] = {"name": name, "status": status, "message": message}
686
+ return out[name]
687
+
688
+ def close(frame: dict) -> Optional[dict]:
689
+ # A suite passes or fails with its tests, which are recorded already. Only a suite that failed with no
690
+ # failing test inside it (a hook, an import) is a failure of its own.
691
+ if frame["children"]:
692
+ if frame["status"] == "fail" and not frame["failed"]:
693
+ return emit([f["name"] for f in stack] + [frame["name"]], "fail")
694
+ return None
695
+ return emit([f["name"] for f in stack] + [frame["name"]], frame["status"])
696
+
697
+ lines = [ln.rstrip() for ln in text.splitlines()]
698
+ for k, line in enumerate(lines):
699
+ if in_yaml:
700
+ if re.match(r"^\s*\.\.\.\s*$", line):
701
+ in_yaml = False
702
+ else:
703
+ # vitest nests `message:` under `error:`; node:test prints the message as `error:` itself
704
+ m = re.match(r"^\s*(?:message|error):\s*(?![|>][-+]?\s*$)(\S.*)$", line)
705
+ if m and last is not None and last["status"] == "fail" and not last["message"]:
706
+ msg = m.group(1).strip()
707
+ if len(msg) > 1 and msg[0] == msg[-1] == '"':
708
+ try:
709
+ msg = json.loads(msg) # YAML's double-quoted string escapes as JSON does
710
+ except ValueError:
711
+ msg = msg[1:-1]
712
+ elif len(msg) > 1 and msg[0] == msg[-1] == "'":
713
+ msg = msg[1:-1].replace("''", "'")
714
+ last["message"] = msg
715
+ continue
716
+ if _YAML_START.match(line):
717
+ in_yaml = True
718
+ continue
719
+ m = _GO_PACKAGE.match(line)
720
+ if m: # go: one line per package after its tests, `ok <pkg>\t0.1s`; not a test, unless it did not build
721
+ if m.group(3):
722
+ emit([m.group(2)], "fail", m.group(3))
723
+ go_run = None
724
+ continue
725
+ m = _GO_RUN.match(line)
726
+ if m:
727
+ go_run = m.group(1)
728
+ continue
729
+ m = _GO_RESULT.match(line)
730
+ if m: # go: `--- FAIL: TestX/sub (0.00s)`; a subtest's name is its parent's, then its own
731
+ indent, word, name = len(m.group(1).expandtabs()), m.group(2), m.group(3)
732
+ status = {"PASS": "pass", "FAIL": "fail", "SKIP": "skip"}[word]
733
+ last = emit(name.split("/"), status, go_logs.get(name) if status == "fail" else None)
734
+ go_tests.append(last["name"])
735
+ go_after, go_run = indent, None
736
+ continue
737
+ if go_run is not None or go_after is not None: # a test's log line: before its result (-v) or after it
738
+ m = re.match(r"^(\s+)(\S.*)$", line)
739
+ if m and go_run is not None:
740
+ go_logs.setdefault(go_run, m.group(2).strip())
741
+ continue
742
+ if m and len(m.group(1).expandtabs()) > go_after and last is not None and last["status"] == "fail":
743
+ last["message"] = last["message"] or m.group(2).strip()
744
+ continue
745
+ go_after = None
746
+ m = _JEST_FILE.match(line)
747
+ if m and _JS_FILE.search(m.group(1)): # jest: ` PASS src/a.test.js`, then (with --verbose) its tests as a tree, then a `●` section per failure
748
+ jest = {"file": m.group(1), "tree": [], "in_tree": True, "wait": None}
749
+ continue
750
+ if jest is not None:
751
+ if line.startswith("Test Suites:"):
752
+ jest = None
753
+ continue
754
+ m = re.match(r"^\s*●\s+(.*)$", line)
755
+ if m:
756
+ jest["in_tree"] = False
757
+ title = m.group(1)
758
+ chain = [] if title == "Test suite failed to run" else title.split(" › ")
759
+ jest["wait"] = last = emit([jest["file"]] + chain, "fail")
760
+ continue
761
+ if not line.strip():
762
+ continue
763
+ if jest["wait"] is not None: # the first line of a failure's section says what failed
764
+ jest["wait"]["message"] = jest["wait"]["message"] or line.strip()
765
+ jest["wait"] = None
766
+ continue
767
+ m = _JEST_TEST.match(line)
768
+ if jest["in_tree"] and (m or line[:1].isspace()):
769
+ indent = len((m.group(1) if m else re.match(r"^\s*", line).group(0)).expandtabs())
770
+ while jest["tree"] and jest["tree"][-1][0] >= indent:
771
+ jest["tree"].pop()
772
+ if not m: # a describe block's name
773
+ jest["tree"].append((indent, line.strip()))
774
+ continue
775
+ status = {"✓": "pass", "√": "pass", "✕": "fail", "×": "fail"}.get(m.group(2), "skip")
776
+ name = m.group(3)
777
+ t = _JEST_TIME.search(name) if name.endswith(")") else None
778
+ if t and name[:t.start()][-1:].isspace() and name[:t.start()].strip():
779
+ name = name[:t.start()].rstrip()
780
+ name = re.sub(r"^(?:skipped|todo)\s+", "", name) if status == "skip" else name
781
+ last = emit([jest["file"]] + [n for _, n in jest["tree"]] + [name], status)
782
+ continue
783
+ if not jest["in_tree"]:
784
+ continue
785
+ m = _SUBTEST.match(line)
786
+ if m:
787
+ indent = len(m.group(1).expandtabs())
788
+ while stack and stack[-1]["kind"] == "header" and stack[-1]["indent"] >= indent:
789
+ stack.pop()
790
+ name = _tap_unescape(m.group(2)) # escaped as on a test line
791
+ stack.append({"indent": indent, "name": name, "kind": "header", "status": "pass", "children": 0, "failed": False})
792
+ continue
793
+ if re.match(r"^\s*\}\s*$", line) and stack and stack[-1]["kind"] == "brace":
794
+ frame = stack.pop()
795
+ last = close(frame) or last
796
+ continue
797
+ m = _POINT.match(line)
798
+ if m:
799
+ indent = len(m.group(1).expandtabs())
800
+ after = next((lines[j] for j in range(k + 1, len(lines)) if lines[j].strip()), "") # a block opened with ` {` is indented
801
+ deeper = len(after.expandtabs()) - len(after.expandtabs().lstrip()) > indent and not _YAML_START.match(after)
802
+ desc, directive, opens = _tap_point(m.group(3), can_open=deeper)
803
+ word = directive.split()[0].upper() if directive else ""
804
+ status = "skip" if word in ("SKIP", "TODO") else "fail" if m.group(2) == "not ok" else "pass"
805
+ while stack and stack[-1]["kind"] == "header" and stack[-1]["indent"] > indent:
806
+ stack.pop() # a subtest that never printed its own line
807
+ if stack and stack[-1]["kind"] == "header" and stack[-1]["indent"] == indent:
808
+ frame = stack.pop() # node:test and tap: the line after the subtests closes the suite
809
+ frame.update(status=status, name=desc or frame["name"])
810
+ last = close(frame)
811
+ elif opens: # vitest: `ok 1 - suite {` ... `}`
812
+ stack.append({"indent": indent, "name": desc, "kind": "brace", "status": status, "children": 0, "failed": False})
813
+ last = None
814
+ else:
815
+ last = emit([f["name"] for f in stack] + [desc], status)
816
+ continue
817
+ m = _DOTNET.match(line)
818
+ if m:
819
+ full = m.group(2)
820
+ cut = full.find("(")
821
+ head, args = (full[:cut], full[cut:]) if cut > 0 else (full, "")
822
+ owner, _, method = head.rpartition(".") # Ns.Class.Method: the class is its suite
823
+ last = emit([owner, method + args] if owner else [full],
824
+ {"Passed": "pass", "Failed": "fail", "Skipped": "skip"}[m.group(1)])
825
+ continue
826
+ if re.match(r"^\s*Error Message:\s*$", line) and last is not None and last["status"] == "fail" and not last["message"]:
827
+ last["message"] = next((ln.strip() for ln in lines[k + 1:] if ln.strip()), None)
828
+ continue
829
+ m = _PLAIN.match(line)
830
+ if not m or m.group(2).startswith("["): # pytest's `SKIPPED [1] file:line: reason` names no test
831
+ continue
832
+ word, rest = m.group(1).upper(), m.group(2)
833
+ status = ("fail" if word.startswith(("FAIL", "ERROR")) else "skip" if word.startswith(("SKIP", "XFAIL"))
834
+ else "pass")
835
+ name, message = rest, None
836
+ if "::" in rest: # pytest: `path::test[a - b] - message`, and XFAIL and XPASS give a reason the same way
837
+ for sep in (" - ", ": "):
838
+ k = _outside_brackets(rest, sep)
839
+ if k >= 0:
840
+ name, message = rest[:k], (rest[k + len(sep):] if status == "fail" else None)
841
+ break
842
+ elif status == "fail" and _COLLECTED.match(rest): # pytest: `ERROR tests/x.py - ImportError: ...`, a file that did not load
843
+ name, message = _COLLECTED.match(rest).groups()
844
+ elif status == "fail":
845
+ for sep in (": ", " - "):
846
+ if sep in rest:
847
+ name, message = rest.split(sep, 1)
848
+ break
849
+ last = emit([name.strip()], status, message)
850
+ for parent in dict.fromkeys(go_tests): # go: a test with subtests passes or fails with them, as a TAP suite does,
851
+ kids = [out[n] for n in go_tests if n.startswith(parent + " > ") and n in out] # unless it failed on its own
852
+ if kids and parent in out and (out[parent]["status"] != "fail" or any(k["status"] == "fail" for k in kids)):
853
+ del out[parent]
854
+ from . import props # a failing property test's counterexample, from wherever in the text it was printed
855
+ return props.annotate(list(out.values()), text)
856
+
857
+
858
+ def _n(n: int, word: str, plural: str = "") -> str:
859
+ return f"{n} {word if n == 1 else plural or word + 's'}"
860
+
861
+
862
+ def review_text(r: dict) -> str:
863
+ p, g = r["prediction"], r["graph"]["nodes"]
864
+ lines = [f"Review of {r['change_id']}: {r['intent']}", "",
865
+ f"Prediction: {p['as_predicted']} of {p['predicted']} predicted edits happened, "
866
+ f"{_n(p['not_predicted'], 'edit was', 'edits were')} not predicted, {_n(p['predicted_untouched'], 'predicted edit', 'predicted edits')} did not happen. "
867
+ f"{_n(p['new_as_declared'], 'new node belongs', 'new nodes belong')} to what the proposal declared."]
868
+ for n in r["not_predicted"]:
869
+ lines.append(f" not predicted: {n['name']} {n['path']}:{n['line']}")
870
+ for n in r["predicted_untouched"]:
871
+ lines.append(f" not edited: {n['name']}")
872
+ lines.append(f"Graph: {len(g['added'])} added, {len(g['removed'])} removed, {len(g['resigned'])} signatures changed, "
873
+ f"{len(g['edited'])} bodies edited; {r['graph']['links']['added']} links added, {r['graph']['links']['removed']} removed.")
874
+ for d in r["graph"]["structure"]["new_dependencies"]:
875
+ lines.append(f" new dependency: {d['from']} -> {d['to']} ({_n(d['links'], 'link')})")
876
+ lines.append(f"Flows with a changed path: {r['graph']['flows']['changed']}")
877
+ for x in r["graph"]["flows"]["now_pass_through"][:5]:
878
+ lines.append(f" {_n(x['flows'], 'flow now passes', 'flows now pass')} through {x['name']}")
879
+ for x in r["graph"]["flows"]["no_longer_pass_through"][:5]:
880
+ lines.append(f" {_n(x['flows'], 'flow no longer passes', 'flows no longer pass')} through {x['name']}")
881
+ lines.append(f"Rules: {r['rules']['checked']} checked, {r['rules']['failing']} failing, {len(r['rules']['new_violations'])} newly failing.")
882
+ t = r["tests"]
883
+ if t:
884
+ lines.append(f"Tests: {t['before']['passed']}/{t['before']['total']} before, {t['after']['passed']}/{t['after']['total']} after; "
885
+ f"{len(t['newly_failing'])} newly failing.")
886
+ lines += [f" now fails: {x['name']}: {x['message']}" for x in t["newly_failing"]]
887
+ for v in r["verdict"]:
888
+ lines.append(f"[{v['level']}] {v['what']}")
889
+ if not r["verdict"]:
890
+ lines.append("Nothing flagged.")
891
+ return "\n".join(lines)
892
+
893
+
894
+ def passed_text(side: dict) -> str:
895
+ """`3854 of 3854 passed (1 skipped)`: a skipped test neither passed nor failed, so it is said apart."""
896
+ skipped = side.get("skipped", 0)
897
+ return f"{side['passed']} of {side['total'] - skipped} passed" + (f" ({skipped} skipped)" if skipped else "")
898
+
899
+
900
+ def passed_pair_text(before: dict, after: dict, now_first: bool = False) -> str:
901
+ """`3854 of 3854 passed before, 3866 of 3866 after (1 skipped in each run)`; `now_first`: `7 of 7 pass, against
902
+ 7 of 7 before`. A skipped test neither passed nor failed, so it is said apart."""
903
+ sb, sa = before.get("skipped", 0), after.get("skipped", 0)
904
+ out = (f"{after['passed']} of {after['total'] - sa} pass, against {before['passed']} of {before['total'] - sb} before"
905
+ if now_first else
906
+ f"{before['passed']} of {before['total'] - sb} passed before, {after['passed']} of {after['total'] - sa} after")
907
+ if sb or sa:
908
+ out += (f" ({sb} skipped in each run)" if sb == sa else f" (skipped: {sb} before, {sa} after)")
909
+ return out
910
+
911
+
912
+ def recorded_text(t: dict) -> str:
913
+ """`3854 pass, 0 fail, 1 skipped`: the counts of a recorded run."""
914
+ return f"{t.get('pass', 0)} pass, {t.get('fail', 0)} fail" + (f", {t['skip']} skipped" if t.get("skip") else "")
915
+
916
+
917
+ def test_delta(con, before_run: str, after_run: str) -> Optional[dict]:
918
+ def load(run):
919
+ return {r["name"]: r for r in con.execute("SELECT * FROM test_results WHERE run = ?", (run,))}
920
+ b, a = load(before_run), load(after_run)
921
+ if not b or not a:
922
+ return None
923
+ return {
924
+ "before": {"run": before_run, "passed": sum(1 for r in b.values() if r["status"] == "pass"), "total": len(b),
925
+ "skipped": sum(1 for r in b.values() if r["status"] == "skip")},
926
+ "after": {"run": after_run, "passed": sum(1 for r in a.values() if r["status"] == "pass"), "total": len(a),
927
+ "skipped": sum(1 for r in a.values() if r["status"] == "skip")},
928
+ "newly_failing": [{"name": n, "message": a[n]["message"]} for n in sorted(a) if a[n]["status"] == "fail" and b.get(n) and b[n]["status"] == "pass"],
929
+ "newly_passing": [n for n in sorted(a) if a[n]["status"] == "pass" and b.get(n) and b[n]["status"] == "fail"],
930
+ # A test that was not there before and fails now: as wrong as one that broke.
931
+ "new_failing": [{"name": n, "message": a[n]["message"]} for n in sorted(a) if a[n]["status"] == "fail" and n not in b],
932
+ "added": [n for n in sorted(a) if n not in b], "removed": [n for n in sorted(b) if n not in a],
933
+ }
934
+
935
+
936
+ def _spans(con, ids: list[str]) -> tuple[dict, set]:
937
+ """path -> the line spans of these nodes, and their names: the code a task covers, for explained()."""
938
+ spans, names = defaultdict(list), set()
939
+ for i in dict.fromkeys(ids):
940
+ r = con.execute("SELECT name, path, span_start, span_end FROM nodes WHERE id = ?", (i,)).fetchone()
941
+ if r is None:
942
+ continue
943
+ if r["span_start"] and r["name"] not in ("<module>", "<top-level>"):
944
+ spans[r["path"]].append((r["span_start"], r["span_end"] or r["span_start"]))
945
+ if re.fullmatch(r"[A-Za-z_$][\w$]{2,}", r["name"] or ""):
946
+ names.add(r["name"])
947
+ return spans, names
948
+
949
+
950
+ def review(con, change_id: str, before_run: Optional[str] = None, after_run: Optional[str] = None) -> dict:
951
+ """After a change is implemented and the repo re-indexed: compare the graph with the snapshot
952
+ taken when the change was proposed, and the edits made with the edits predicted."""
953
+ row = con.execute("SELECT * FROM change_proposals WHERE id = ?", (change_id,)).fetchone()
954
+ if row is None:
955
+ return {"error": f"No change {change_id!r}."}
956
+ snap = snapshot_path(con, change_id)
957
+ if not snap.exists():
958
+ return {"error": "No snapshot is kept for this change (it was forgotten, or its folder archived), so there is"
959
+ " nothing to compare with. Plan the change again to take a new one."}
960
+ before = _open(snap)
961
+ try:
962
+ from .incremental import code_version
963
+ made_by = before.execute("SELECT value FROM meta WHERE key = 'made_by'").fetchone()
964
+ d = compare(before, con)
965
+ # Today's rules, evaluated on the graph as it was: a rule added after the proposal still counts.
966
+ rules_before = rules.check(before, rules_from=con)
967
+ changed_ids = [n["id"] for key in ("resigned", "edited", "types_edited") for n in d["nodes"][key]]
968
+ # A file's top level (imports, constants, tables, calls outside any function) has a node of its own only in
969
+ # some languages, so the file stands for it.
970
+ fb = {r[0]: r[1] for r in before.execute("SELECT id, content_hash FROM nodes WHERE kind = 'file' AND layer = 'fact'")}
971
+ now_files = con.execute("SELECT id, name, path, content_hash, span_end FROM nodes WHERE kind = 'file' AND layer = 'fact'").fetchall()
972
+ files = [r for r in now_files if fb.get(r["id"]) != r["content_hash"]]
973
+ files_gone = set(fb) - {r["id"] for r in now_files} # a file removed since the snapshot: it changed
974
+ own = own_changes(before, con, changed_ids + [f["id"] for f in files])
975
+ finally:
976
+ before.close()
977
+ view = change.get_view(con, "view-" + change_id)
978
+ remap = d.pop("remap")
979
+ predicted = {remap.get(m["id"], m["id"]): m for m in view.get("marks", []) if m["role"] in ("changed", "must_edit", "contract")}
980
+ declared_new = {n["name"].split("(")[0].split(".")[-1] for n in view.get("new_nodes", [])}
981
+
982
+ def segments(i):
983
+ return set(re.split(r"[.:/]+", _base(i)))
984
+ touched = {n["id"]: n for key in ("resigned", "edited") for n in d["nodes"][key]}
985
+ # A type's text changes whenever a member's does. Report a type only when no member explains it.
986
+ for t in d["nodes"]["types_edited"]:
987
+ if own is not None:
988
+ if own.get(t["id"]):
989
+ touched[t["id"]] = {**t, "why": "declaration or fields changed"}
990
+ continue
991
+ inside = [i for i in list(touched) + [n["id"] for n in d["nodes"]["added"]] if i.startswith(t["id"] + ".")]
992
+ if not inside:
993
+ touched[t["id"]] = {**t, "why": "declaration or fields changed"}
994
+ for i, n in touched.items():
995
+ if own is not None and i in own:
996
+ n["own"] = own[i]
997
+ as_predicted = [touched[i] for i in sorted(touched) if i in predicted]
998
+ # A function whose text changed only inside a function nested in it (a closure, a module's body around its
999
+ # functions) was not edited itself: the nested one was, and it is reported on its own.
1000
+ not_predicted = [touched[i] for i in sorted(touched) if i not in predicted and touched[i].get("own", True) != []
1001
+ and not (own is not None and touched[i]["name"] in ("<module>", "<top-level>"))]
1002
+ if own is not None:
1003
+ not_predicted += [{"id": f["id"], "name": "<top-level>", "kind": "file", "path": f["path"], "line": 1,
1004
+ "end": f["span_end"], "why": "outside any function", "own": own[f["id"]]}
1005
+ for f in files if own.get(f["id"]) and any(_code_line(text) for _, text in own[f["id"]])]
1006
+ new_declared, new_undeclared = [], []
1007
+ for n in d["nodes"]["added"]:
1008
+ (new_declared if segments(n["id"]) & declared_new else new_undeclared).append(n)
1009
+ # Members of an undeclared new type are noise: keep the outermost new nodes only.
1010
+ outer = [n for n in new_undeclared if not any(n["id"].startswith(o["id"] + ".") for o in new_undeclared if o is not n)]
1011
+ not_predicted += [{**n, "why": "new, not declared in the proposal"} for n in outer]
1012
+ # The rest of a body (a module's top level) changed only next to the predicted code, or to register it.
1013
+ spans, names_of = _spans(con, list(predicted) + [n["id"] for n in new_declared])
1014
+ not_predicted = [n for n in not_predicted if not (n.get("own") and explained(n, spans.get(n.get("path"), []), names_of))]
1015
+ untouched = [{"id": i, "name": m.get("name", i), "note": m.get("note", "")} for i, m in sorted(predicted.items())
1016
+ if i not in touched]
1017
+ gone = {n["id"] for n in d["nodes"]["removed"]} | {n["id"] for n in d["nodes"]["added"]} | files_gone # new since the snapshot: it changed
1018
+ untouched = [u for u in untouched if u["id"] not in gone]
1019
+ removed_predicted = [n for n in d["nodes"]["removed"] if n["id"] in predicted]
1020
+ not_predicted += [{**n, "why": "removed"} for n in d["nodes"]["removed"] if n["id"] not in predicted]
1021
+ as_predicted += removed_predicted
1022
+ now_rules = rules.check(con)
1023
+ was_failing = {r["id"] for r in rules_before.get("rules", []) if not r["passes"]}
1024
+ new_violations = [r for r in now_rules["rules"] if not r["passes"] and r["id"] not in was_failing]
1025
+ tests = test_delta(con, before_run, after_run) if before_run and after_run else None
1026
+ verdict = []
1027
+ if not_predicted:
1028
+ verdict.append({"level": "medium", "what": f"{_n(len(not_predicted), 'edit was', 'edits were')} not in the proposal."})
1029
+ if untouched:
1030
+ verdict.append({"level": "medium", "what": f"{_n(len(untouched), 'predicted edit', 'predicted edits')} did not happen."})
1031
+ if d["structure"]["new_dependencies"]:
1032
+ verdict.append({"level": "high", "what": f"{_n(len(d['structure']['new_dependencies']), 'new dependency', 'new dependencies')} between modules."})
1033
+ if new_violations:
1034
+ verdict.append({"level": "high", "what": f"{_n(len(new_violations), 'rule now fails', 'rules now fail')} that passed before."})
1035
+ if tests and tests["newly_failing"]:
1036
+ verdict.append({"level": "high", "what": f"{_n(len(tests['newly_failing']), 'test fails', 'tests fail')} that passed before."})
1037
+ if tests is None:
1038
+ verdict.append({"level": "medium", "what": "No test runs were recorded, so behavior is unchecked."})
1039
+ report = {
1040
+ "change_id": change_id, "intent": row["intent"],
1041
+ "prediction": {"predicted": len(predicted), "as_predicted": len(as_predicted),
1042
+ "not_predicted": len(not_predicted), "predicted_untouched": len(untouched),
1043
+ "new_as_declared": len(new_declared)},
1044
+ "as_predicted": as_predicted, "not_predicted": not_predicted, "predicted_untouched": untouched,
1045
+ "graph": d, "rules": {"checked": now_rules["total"], "failing": now_rules["failing"],
1046
+ "new_violations": new_violations, "all": now_rules["rules"]},
1047
+ "tests": tests, "verdict": verdict,
1048
+ "limits": "The graph comparison shows structure, not behavior. Tests are the only behavior check here, "
1049
+ "and only as far as they reach.",
1050
+ }
1051
+ head = con.execute("SELECT commit_sha FROM nodes WHERE kind = 'repo' LIMIT 1").fetchone()
1052
+ attrs = json.loads(row["attrs"] or "{}")
1053
+ lean = {**report, **{k: [{x: y for x, y in n.items() if x != "own"} for n in report[k]] # changed lines stay out of the store
1054
+ for k in ("as_predicted", "not_predicted")}}
1055
+ attrs["review"] = {k: v for k, v in lean.items() if k != "graph"} | {"graph_summary": {
1056
+ "added": len(d["nodes"]["added"]), "removed": len(d["nodes"]["removed"]), "resigned": len(d["nodes"]["resigned"]),
1057
+ "edited": len(d["nodes"]["edited"]), "links_added": d["links"]["added"], "links_removed": d["links"]["removed"]}}
1058
+ with con:
1059
+ con.execute("UPDATE change_proposals SET status = 'implemented', head_commit = ?, attrs = ? WHERE id = ?",
1060
+ (head[0] if head else None, json.dumps(attrs), change_id))
1061
+ marks = ([{"id": n["id"], "role": "edited as predicted", "note": n.get("why", "") or n.get("now", "")} for n in as_predicted]
1062
+ + [{"id": n["id"], "role": "new, as declared", "note": n["kind"]} for n in new_declared if n["kind"] != "field"]
1063
+ + [{"id": n["id"], "role": "edited, not predicted", "note": n.get("why") or n["kind"]} for n in not_predicted if n["id"] not in gone]
1064
+ + [{"id": n["id"], "role": "predicted, not edited", "note": n["note"]} for n in untouched])
1065
+ title = "Review: " + (attrs.get("title") or row["intent"][:60])
1066
+ saved = change.save_view(
1067
+ con, title, row["intent"], marks or [{"id": m["id"], "role": "changed", "note": ""} for m in view.get("marks", [])[:1]],
1068
+ kind="review", source="leyline-review", change_id=change_id, view_id="view-review-" + change_id,
1069
+ legend={"edited as predicted": "edited, as the proposal said", "edited, not predicted": "edited or added, but not in the proposal",
1070
+ "new, as declared": "new code the proposal said it would add",
1071
+ "predicted, not edited": "in the proposal, but not edited"},
1072
+ extra={"review": {k: v for k, v in lean.items() if k not in ("as_predicted", "intent")}})
1073
+ report["view_id"] = saved.get("id")
1074
+ report["baseline"] = (row["base_commit"] or "")[:7] or None
1075
+ # Taken by another version of Leyline (or one that did not say): what that version read differently (a name defined
1076
+ # twice, an id it gave a file) shows as a change here, and is not one.
1077
+ report["baseline_other_version"] = made_by is None or made_by[0] != code_version()
1078
+ return report