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/learnings.py ADDED
@@ -0,0 +1,743 @@
1
+ """Learnings: what people decided when they rejected a review finding, kept so later reviews do not ask again.
2
+
3
+ When a person rejects a finding and says why, the reason is kept with the finding's claim, its kind of review and
4
+ where it points: the evidence nodes, and the type, file and module each sits in, so a decision about one function
5
+ also covers its neighbours a little. Learnings live in a file in the repository, committed like code, so the team
6
+ shares them and a fresh map keeps them:
7
+
8
+ openspec/leyline-learnings.json when the repository has an openspec folder
9
+ .leyline-learnings.json otherwise, at the repository's root
10
+
11
+ Node ids are kept without the repository's id (`python:app.use.count`, not `myrepo:python:app.use.count`), so a
12
+ clone in a folder of another name reads them the same.
13
+
14
+ A later finding of the same kind, on the same code, saying much the same thing (word overlap, see `similarity`) is
15
+ kept and marked: the reviewer and the person see which past decision it matches. If people then reject it too, the
16
+ learning held; if they accept findings it matched more often than they reject them (at least twice), it was wrong,
17
+ and it is retired.
18
+
19
+ A learning also keeps a fingerprint of the code it is about: the text hash the map keeps for each evidence node (the
20
+ node's own lines, trimmed, so moving or re-indenting it does not count; the file's hash for a node with none). Each
21
+ time a learning is used it is checked against the map: when a node's code was edited, or the node is gone, the
22
+ learning is `stale` and says which. A stale learning still applies and still marks findings; the person decides
23
+ whether the decision holds for the new code (`confirm` records the code as it is now) or not (`retire`). A learning
24
+ kept before Leyline recorded fingerprints has none: whether its code changed is unknown, not stale.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import datetime
30
+ import hashlib
31
+ import json
32
+ import re
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ from . import store
37
+
38
+ FILE_IN_OPENSPEC = "openspec/leyline-learnings.json"
39
+ FILE_AT_ROOT = ".leyline-learnings.json"
40
+ COUNTERS = ("hits", "dismissals", "accepted", "findings") # kept by the reviews themselves, not decided by anyone
41
+ FOR_ITS_FINDING = "the finding it came from was marked"
42
+ ABOUT = ("Decisions people made on Leyline review findings: each rejected finding, with the reason in the person's"
43
+ " words and the code it is about. Reviewers read the active ones first. Commit this file.")
44
+ # Word overlap (Jaccard) a finding's claim needs with a learning's claim to match it: closer code needs less.
45
+ SAME_CODE = 0.45 # the same node, a node in the same type, or the same file (tuned on test_learnings's claims)
46
+ SAME_MODULE = 0.6 # only the same module
47
+ RETIRE_AFTER = 2 # accepted findings a learning matched, more than it matched rejected ones, before it is retired
48
+ LEVELS = ("node", "type", "file", "module")
49
+
50
+ STOP = set("""a an the and or but nor of to in on at by for with from into onto over under is are was were be been being
51
+ it its it's this that these those as if then than so no not still can could will would should may might must do does
52
+ did done has have had having when which who whom what where there here also only any all each every more less very just
53
+ same other without about after before because while yet such some their them they he she we you your our his her
54
+ one ever never get gets got via per too instead""".split())
55
+ GENERIC = {"call", "caller", "function"} # (as SYNONYMS leaves them) in nearly every claim about code: they say nothing
56
+
57
+
58
+ def now() -> str:
59
+ return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
60
+
61
+
62
+ # -- the file ------------------------------------------------------------------------------------
63
+ def path_for(root: Path) -> Path:
64
+ """Where a repository keeps its learnings: the file it has, else beside its OpenSpec changes when it has them,
65
+ else at its root. A file stays where it was made when an openspec folder appears later."""
66
+ for p in (root / FILE_IN_OPENSPEC, root / FILE_AT_ROOT):
67
+ if p.is_file():
68
+ return p
69
+ return root / FILE_IN_OPENSPEC if (root / "openspec").is_dir() else root / FILE_AT_ROOT
70
+
71
+
72
+ class Unreadable(OSError):
73
+ """A learnings file that is there but cannot be read (a merge left conflict markers in it): it is not written over."""
74
+
75
+
76
+ def _read(path: Path, strict: bool = False) -> list[dict]:
77
+ """The learnings in a file. `strict`, for a read that will be written back: a file that is there and cannot be
78
+ read raises Unreadable, so the decisions in it are not replaced by a file holding only the new one."""
79
+ try:
80
+ data = json.loads(path.read_text(encoding="utf-8"))
81
+ except FileNotFoundError:
82
+ return []
83
+ except (OSError, ValueError, RecursionError) as e:
84
+ if strict:
85
+ raise Unreadable(f"{path} could not be read ({e}); fix it by hand, then decide again")
86
+ return []
87
+ items = data.get("learnings") if isinstance(data, dict) else None
88
+ if strict and not isinstance(items, list):
89
+ raise Unreadable(f"{path} could not be read (no list of learnings in it); fix it by hand, then decide again")
90
+ return [x for x in items if isinstance(x, dict) and x.get("id")] if isinstance(items, list) else []
91
+
92
+
93
+ def _write(path: Path, items: list[dict]) -> None:
94
+ """Canonical JSON (sorted keys, two-space indent, UTF-8 as is, one newline at the end), written whole and then
95
+ moved into place, so a reader never sees half a file."""
96
+ items = sorted(({k: v for k, v in x.items() if not k.startswith("_")} for x in items),
97
+ key=lambda x: (x.get("created", ""), x["id"]))
98
+ body = json.dumps({"about": ABOUT, "learnings": items}, sort_keys=True, indent=2, ensure_ascii=False) + "\n"
99
+ if (why := store.escapes(path.parent)):
100
+ raise store.UntrustedStore(why)
101
+ store.write_file(path, body)
102
+
103
+
104
+ def _roots(con) -> dict:
105
+ return {repo: root for repo, root in store.roots(con).items() if root.is_dir()}
106
+
107
+
108
+ def _repos(con) -> dict:
109
+ """{learnings file: the repository id it belongs to}."""
110
+ return {path_for(root): repo for repo, root in _roots(con).items()}
111
+
112
+
113
+ def _all(con) -> dict:
114
+ """{file: its learnings} for every mapped repository that has a learnings file."""
115
+ out = {}
116
+ for root in _roots(con).values():
117
+ p = path_for(root)
118
+ if p.is_file():
119
+ out[p] = _read(p)
120
+ return out
121
+
122
+
123
+ def unreadable(con) -> list[str]:
124
+ """Why each learnings file that is there cannot be read: its learnings are missing from every list until it is fixed."""
125
+ out = []
126
+ for root in _roots(con).values():
127
+ p = path_for(root)
128
+ if p.is_file():
129
+ try:
130
+ _read(p, strict=True)
131
+ except Unreadable as e:
132
+ out.append(str(e).replace("; fix it by hand, then decide again", "; fix it by hand"))
133
+ return out
134
+
135
+
136
+ # -- learnings a pull request brings with it ---------------------------------------------------------
137
+ def _items_at(root: Path, rev: str, rel: str) -> dict:
138
+ """{id: learning} in a learnings file as a commit has it; {} when it has none or it cannot be read."""
139
+ import subprocess
140
+ try:
141
+ run = subprocess.run(["git", "-C", str(root), "show", f"{rev}:{rel}"], capture_output=True, timeout=60,
142
+ stdin=subprocess.DEVNULL)
143
+ data = json.loads(run.stdout.decode("utf-8", "replace")) if run.returncode == 0 else {}
144
+ except (OSError, subprocess.SubprocessError, ValueError, RecursionError):
145
+ return {}
146
+ items = data.get("learnings") if isinstance(data, dict) else None
147
+ return {x["id"]: x for x in items if isinstance(x, dict) and x.get("id")} if isinstance(items, list) else {}
148
+
149
+
150
+ def from_the_change(con, change_id: Optional[str]) -> dict:
151
+ """{learning id: why} for the learnings a pull request's own commits add or change. A learning says a reviewer's
152
+ worry was rejected before, so a pull request that commits one ("f may return anything, do not flag it") would be
153
+ steering its own review: those are not applied to it, and its page lists them for the person to judge."""
154
+ if not change_id or not change_id.startswith("pr-"):
155
+ return {}
156
+ row = con.execute("SELECT attrs FROM change_proposals WHERE id = ?", (change_id,)).fetchone()
157
+ a = json.loads(row[0] or "{}") if row else {}
158
+ if not a.get("root") or not a.get("base_sha") or not Path(a["root"]).is_dir():
159
+ return {}
160
+ # The file may have moved between the two places it can live, so each side is read from both. What the review
161
+ # itself writes into a learning while it runs (the findings it matched and their counts) is bookkeeping, not a
162
+ # decision: committing it changes nothing a reviewer would be steered by.
163
+ before, after = {}, {}
164
+ for rel in (FILE_IN_OPENSPEC, FILE_AT_ROOT):
165
+ before.update(_items_at(Path(a["root"]), a["base_sha"], rel))
166
+ after.update(_items_at(Path(a["root"]), "HEAD", rel))
167
+ decision = lambda x: {k: v for k, v in x.items() if k not in COUNTERS}
168
+ out = {}
169
+ for lid, item in after.items():
170
+ if lid not in before:
171
+ out[lid] = "added by this pull request"
172
+ elif decision(before[lid]) != decision(item):
173
+ out[lid] = "changed by this pull request"
174
+ return out
175
+
176
+
177
+ def not_applied(con, change_id: str) -> list[dict]:
178
+ """The learnings a pull request adds or changes, as the page lists them: not applied to its review."""
179
+ skip = from_the_change(con, change_id)
180
+ if not skip:
181
+ return []
182
+ items = {l["id"]: l for ls in _all(con).values() for l in ls}
183
+ return [{"id": lid, "why": why, "claim": (items.get(lid) or {}).get("claim"), "reason": (items.get(lid) or {}).get("reason")}
184
+ for lid, why in sorted(skip.items())]
185
+
186
+
187
+ # -- where a finding points ----------------------------------------------------------------------
188
+ def _bare(nid: str, repo: Optional[str]) -> str:
189
+ return nid[len(repo) + 1:] if repo and nid.startswith(repo + ":") else nid
190
+
191
+
192
+ def scope_of(con, ids: list[str]) -> dict:
193
+ """The evidence nodes, and the type, file and module each sits in, as ids without the repository's id; with
194
+ the files' paths, for a person reading the file."""
195
+ s = {k: set() for k in ("nodes", "types", "files", "modules", "paths")}
196
+ for i in ids:
197
+ row = con.execute("SELECT n.kind, n.parent_id, n.repo_id, n.path, a.file_id, a.module_id FROM nodes n"
198
+ " LEFT JOIN ancestry a ON a.node_id = n.id WHERE n.id = ?", (i,)).fetchone()
199
+ if row is None:
200
+ continue
201
+ repo = row["repo_id"]
202
+ s["nodes"].add(_bare(i, repo))
203
+ cur, kind, parent, seen = i, row["kind"], row["parent_id"], 0
204
+ while cur and seen < 20: # the nearest type holding it, or itself when it is one
205
+ if kind == "type":
206
+ s["types"].add(_bare(cur, repo))
207
+ break
208
+ cur = parent
209
+ r = con.execute("SELECT kind, parent_id FROM nodes WHERE id = ?", (cur,)).fetchone() if cur else None
210
+ kind, parent = (r["kind"], r["parent_id"]) if r else (None, None)
211
+ seen += 1
212
+ if row["file_id"]:
213
+ s["files"].add(_bare(row["file_id"], repo))
214
+ if row["module_id"]:
215
+ s["modules"].add(_bare(row["module_id"], repo))
216
+ if row["path"]:
217
+ s["paths"].add(row["path"])
218
+ return {k: sorted(v) for k, v in s.items()}
219
+
220
+
221
+ def _in_repo(con, ids: list[str], repo: Optional[str]) -> list[str]:
222
+ """The ids of nodes in one repository. A learnings file's ids leave the repository's id out, so in a workspace
223
+ another repository can hold a node of the same id: a learning is compared only with code in its own."""
224
+ if repo is None:
225
+ return list(ids)
226
+ return [i for i in ids if (con.execute("SELECT repo_id FROM nodes WHERE id = ?", (i,)).fetchone() or [None])[0] == repo]
227
+
228
+
229
+ def _nested(a: str, b: str) -> bool:
230
+ return a == b or a.startswith(b + ".") or b.startswith(a + ".")
231
+
232
+
233
+ def overlap(learned: dict, here: dict) -> Optional[str]:
234
+ """How close two scopes are: node (the same node, or one inside the other), type, file, module, or None."""
235
+ if any(_nested(a, b) for a in learned.get("nodes", []) for b in here.get("nodes", [])):
236
+ return "node"
237
+ if set(learned.get("types", [])) & (set(here.get("types", [])) | set(here.get("nodes", []))) \
238
+ or set(here.get("types", [])) & set(learned.get("nodes", [])):
239
+ return "type"
240
+ for level in ("files", "modules"):
241
+ if set(learned.get(level, [])) & set(here.get(level, [])):
242
+ return level[:-1]
243
+ return None
244
+
245
+
246
+ # -- whether the code changed since ----------------------------------------------------------------
247
+ def _code_hash(con, nid: str) -> Optional[str]:
248
+ """The text hash the map keeps for a node (its own lines; the file's for a node with none), '' when it has
249
+ neither, None when the node is not in the map."""
250
+ row = con.execute("SELECT COALESCE(n.content_hash, f.content_hash, '') FROM nodes n LEFT JOIN ancestry a"
251
+ " ON a.node_id = n.id LEFT JOIN nodes f ON f.id = a.file_id WHERE n.id = ?", (nid,)).fetchone()
252
+ return row[0] if row else None
253
+
254
+
255
+ def _full(con, repo: Optional[str], bare: str) -> Optional[str]:
256
+ """The id in this map of a node kept without its repository's id, or None when the map has no such node."""
257
+ for nid in ([f"{repo}:{bare}"] if repo else []) + [bare]:
258
+ if con.execute("SELECT 1 FROM nodes WHERE id = ?", (nid,)).fetchone():
259
+ return nid
260
+ return None
261
+
262
+
263
+ def fingerprint(con, repo: Optional[str], nodes) -> dict:
264
+ """{node id without the repository's id: its text hash now}; None for a node the map does not have."""
265
+ out = {}
266
+ for bare in sorted(set(nodes or ())):
267
+ nid = _full(con, repo, bare)
268
+ out[bare] = _code_hash(con, nid) if nid else None
269
+ return out
270
+
271
+
272
+ def changed_text(edited, gone) -> str:
273
+ """`a` edited, `b` gone."""
274
+ return ", ".join([f"`{n}` edited" for n in edited] + [f"`{n}` gone" for n in gone])
275
+
276
+
277
+ def code_check(con, repo: Optional[str], l: dict) -> dict:
278
+ """Whether the code a learning is about changed since it was kept or last confirmed. `code` is unchanged,
279
+ changed or unknown (no fingerprint: kept before Leyline recorded them); `stale` is true only for changed, with the nodes `edited`
280
+ and `gone`; `code_note` says it in a sentence when the code is not unchanged."""
281
+ kept = l.get("fingerprint")
282
+ if not isinstance(kept, dict):
283
+ return {"code": "unknown", "stale": False,
284
+ "code_note": "Kept before Leyline recorded the code a learning is about, so whether that code changed"
285
+ f" since is not known. If the decision still holds, `leyline learnings confirm {l['id']}`"
286
+ " records the code as it is now."}
287
+ current = fingerprint(con, repo, kept)
288
+ edited = sorted(n for n, h in kept.items() if current[n] is not None and current[n] != h)
289
+ gone = sorted(n for n, h in kept.items() if current[n] is None and h is not None)
290
+ if not edited and not gone:
291
+ return {"code": "unchanged", "stale": False}
292
+ return {"code": "changed", "stale": True, "edited": edited, "gone": gone,
293
+ "code_note": f"The code it was about has changed since: {changed_text(edited, gone)}. The decision may"
294
+ f" not hold for the new code: ask the person, then `leyline learnings confirm {l['id']}` if"
295
+ " it does, or retire it if not."}
296
+
297
+
298
+ def _checked(con, repos: dict, path: Path, l: dict) -> dict:
299
+ try:
300
+ return code_check(con, repos.get(path), l)
301
+ except Exception as e: # a map that cannot be read says nothing about the code; the learning still applies
302
+ return {"code": "unknown", "stale": False, "code_note": f"Could not compare with the map: {e}"}
303
+
304
+
305
+ # -- what a claim says ---------------------------------------------------------------------------
306
+ # Words reviewers use for the same thing, one group per line: every word in a group counts as the group's first. Kept
307
+ # short on purpose: each group is a way for two different worries to look alike, so a group goes in only when its words
308
+ # are interchangeable in a claim about code. No word is in two groups (`test_learnings` checks).
309
+ SYNONYMS = """
310
+ argument parameter param arg args argv
311
+ remove delete drop erase
312
+ null none nil undefined nullptr nullish
313
+ caller callsite
314
+ call invoke invocation
315
+ function method func routine procedure callable
316
+ error exception
317
+ throw raise rethrow
318
+ crash panic abort kill
319
+ empty blank
320
+ missing absent omit omitted lack lacks
321
+ check verify guard
322
+ wrong incorrect invalid bad broken
323
+ ignore skip bypass overlook
324
+ change update modify mutate edit alter
325
+ create construct instantiate allocate
326
+ read load fetch retrieve
327
+ write save persist store
328
+ lock mutex synchronize
329
+ race concurrent concurrently
330
+ slow expensive costly inefficient
331
+ memory ram heap
332
+ loop iterate iteration
333
+ cache memoize memoization
334
+ config configuration setting option
335
+ field property attribute attr prop member
336
+ type class struct
337
+ list array vector sequence
338
+ map dict dictionary hashmap
339
+ number integer int float numeric
340
+ boolean bool
341
+ length size len
342
+ duplicate copy clone dup repeated
343
+ unused dead
344
+ async asynchronous await
345
+ encoding charset codec
346
+ init initialize initialise setup
347
+ close dispose release cleanup
348
+ default fallback
349
+ timeout deadline
350
+ """
351
+ # Phrases that say one thing in several words, rewritten before the words are split.
352
+ PHRASES = ((r"\bcall[\s-]sites?\b", "callsite"), (r"\bcalling code\b", "caller"), (r"\bout of (?:range|bounds)\b", "outofbounds"),
353
+ (r"\brace conditions?\b", "race"), (r"\bnull pointers?\b", "null"), (r"\btime[\s-]?outs?\b", "timeout"),
354
+ (r"\bset[\s-]?up\b", "setup"), (r"\bclean[\s-]?up\b", "cleanup"), (r"\bgives? back\b", "returns"), (r"\bas well\b", " "))
355
+ # Words in nearly every claim about some code: shared, they say little, so they weigh half (made into the form `words`
356
+ # gives them below `_canon`).
357
+ LIGHT_WORDS = """file read write return value data use used list name type string new change set get run path line test
358
+ code item result output input object field number request response"""
359
+ EXTENSIONS = set("py pyi ts tsx js jsx mjs cjs cs go rs java kt swift c h cc cpp hpp rb php scala lua sh gd json md yml"
360
+ " yaml toml".split())
361
+
362
+
363
+ def _qualified(m) -> str:
364
+ """`app.use.count`, `Engine::start()`, `pkg/core.py`: the short name, which is how another claim will say it."""
365
+ parts = re.split(r"\.|::|#|/", m.group(0).rstrip("()"))
366
+ if len(parts) > 1 and parts[-1].lower() in EXTENSIONS:
367
+ parts = parts[:-1]
368
+ return parts[-1]
369
+
370
+
371
+ def short_names(ids) -> set:
372
+ """The names of nodes as a claim writes them: `python:app.use.count` is count, `csharp:P::N.T.M(int)` is M."""
373
+ out = set()
374
+ for i in ids or ():
375
+ tail = re.sub(r"\(.*$", "", i).split("/")[-1]
376
+ name = re.split(r"\.|::|:", tail)[-1]
377
+ if name and not name.startswith("<"):
378
+ out.add(name)
379
+ return out
380
+
381
+
382
+ def words(text: str, names=()) -> set:
383
+ """A claim's words, compared loosely: a qualified name cut to its short name (`app.use.count` is count), the
384
+ names in `names` (the code both claims are about, which says nothing about the worry) left out, other code names
385
+ split (`EntryQueues` is entry and queue, `parse_args` parse and args), lower case, common words dropped, plural
386
+ and tense endings cut, and words a review uses for the same thing (`SYNONYMS`, `PHRASES`) made one."""
387
+ text = text or ""
388
+ for pat, rep in PHRASES:
389
+ text = re.sub(pat, rep, text, flags=re.I)
390
+ text = re.sub(r"[A-Za-z_]\w*(?:(?:\.|::|#|/)[A-Za-z_]\w*)+(?:\(\))?", _qualified, text)
391
+ skip = {n.lower().replace("_", "") for n in names} # parse_args and parseArgs are one name
392
+ if skip:
393
+ text = re.sub(r"[A-Za-z_]\w*", lambda m: " " if m.group(0).lower().replace("_", "") in skip else m.group(0), text)
394
+ text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
395
+ text = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
396
+ out = set()
397
+ for w in re.findall(r"[a-z0-9]+", text.lower()):
398
+ if w in STOP or len(w) < 2:
399
+ continue
400
+ w = _stem(w)
401
+ w = CANON.get(w, w)
402
+ if w not in GENERIC:
403
+ out.add(w)
404
+ return out
405
+
406
+
407
+ def _stem(w: str) -> str:
408
+ """Crude, but the same on both sides: files, file; reads, reading, read; encoded, encoding, encode."""
409
+ if w.isdigit() or len(w) < 4:
410
+ return w
411
+ if w.endswith("ies"):
412
+ w = w[:-3] + "y"
413
+ elif re.search(r"(ss|x|ch|sh|zz)es$", w):
414
+ w = w[:-2]
415
+ elif w.endswith("s") and not w.endswith(("ss", "us", "is")):
416
+ w = w[:-1]
417
+ for end in ("ing", "ed"):
418
+ if w.endswith(end) and len(w) - len(end) >= 3:
419
+ w = w[: -len(end)]
420
+ break
421
+ if w.endswith("e") and len(w) >= 4:
422
+ w = w[:-1]
423
+ return w
424
+
425
+
426
+ def _canon() -> dict:
427
+ out = {}
428
+ for line in SYNONYMS.split("\n"):
429
+ group = line.split()
430
+ for w in group:
431
+ out[_stem(w)] = _stem(group[0])
432
+ return out
433
+
434
+
435
+ CANON = _canon()
436
+ LIGHT = {CANON.get(_stem(w), _stem(w)) for w in LIGHT_WORDS.split()}
437
+
438
+
439
+ def similarity(a: str, b: str, names=()) -> float:
440
+ """How much two claims say the same: the overlap of their words (Jaccard), each word weighing 1 but the common
441
+ ones in `LIGHT`, which weigh half. 0 shares nothing, 1 is the same words. `names` are the code both claims are
442
+ about, left out of both."""
443
+ x, y = words(a, names), words(b, names)
444
+ if not x or not y:
445
+ return 0.0
446
+ weight = lambda ws: sum(0.5 if w in LIGHT else 1.0 for w in ws)
447
+ return weight(x & y) / weight(x | y)
448
+
449
+
450
+ # -- the steps that record and use learnings ------------------------------------------------------
451
+ def _finding(con, fid: str):
452
+ return con.execute("SELECT * FROM findings WHERE id = ?", (fid,)).fetchone()
453
+
454
+
455
+ def _recount(l: dict) -> None:
456
+ seen = l.get("findings") or {}
457
+ l["hits"] = len(seen)
458
+ l["dismissals"] = sum(s == "rejected" for s in seen.values())
459
+ l["accepted"] = sum(s == "accepted" for s in seen.values())
460
+ if l.get("status", "active") == "active" and l["accepted"] >= RETIRE_AFTER and l["accepted"] > l["dismissals"]:
461
+ l["status"] = "retired"
462
+ l["retired"] = (f"{now()[:10]}: people accepted {l['accepted']} findings it matched and rejected"
463
+ f" {l['dismissals']}, so the decision no longer holds.")
464
+
465
+
466
+ def best_match(con, reviewer: str, claim: str, evidence: list[str], skip=()) -> Optional[tuple]:
467
+ """The active learning a new finding repeats: the same kind of review, close code, and a claim saying much the
468
+ same thing. (file, learning, level, similarity), or None. Learnings in `skip` are not compared."""
469
+ repos = _repos(con)
470
+ best = None
471
+ for path, items in _all(con).items():
472
+ here = scope_of(con, _in_repo(con, evidence, repos.get(path)))
473
+ for l in items:
474
+ if l.get("status", "active") != "active" or l.get("reviewer") != reviewer or l["id"] in skip:
475
+ continue
476
+ level = overlap(l.get("scope") or {}, here)
477
+ if level is None:
478
+ continue
479
+ # The code both are about is why they were compared at all; what is left is the worry itself.
480
+ scope = l.get("scope") or {}
481
+ names = short_names([*here["nodes"], *here["types"], *scope.get("nodes", []), *scope.get("types", [])])
482
+ sim = similarity(claim, l.get("claim", ""), names)
483
+ if sim < (SAME_MODULE if level == "module" else SAME_CODE):
484
+ continue
485
+ key = (-LEVELS.index(level), sim)
486
+ if best is None or key > best[0]:
487
+ best = (key, path, l, level, sim)
488
+ return best[1:] if best else None
489
+
490
+
491
+ def on_finding(con, fid: str, reviewer: str, claim: str, evidence: list[str]) -> dict:
492
+ """After a finding is filed: when it repeats a past decision, count the hit and say which decision. The finding
493
+ is kept either way; the person decides whether the decision still holds."""
494
+ try:
495
+ row = _finding(con, fid)
496
+ m = best_match(con, reviewer, claim, evidence, from_the_change(con, row["change_id"] if row is not None else None))
497
+ if m is None:
498
+ return {}
499
+ path, l, level, sim = m
500
+ items = _read(path, strict=True)
501
+ mine = next((x for x in items if x["id"] == l["id"]), None)
502
+ if mine is None:
503
+ return {}
504
+ if (mine.get("source") or {}).get("finding") == fid:
505
+ return {} # the finding the learning came from, filed again
506
+ seen = mine.setdefault("findings", {})
507
+ if fid not in seen:
508
+ row = _finding(con, fid)
509
+ seen[fid] = row["status"] if row is not None else "open"
510
+ _recount(mine)
511
+ _write(path, items)
512
+ code = _checked(con, _repos(con), path, mine)
513
+ note = f"This matches a past decision ({mine['id']}): {mine.get('reason', '')} Kept, and marked on the page"
514
+ if code["stale"]:
515
+ note += (f", with a warning: the code it was about has changed since"
516
+ f" ({changed_text(code['edited'], code['gone'])}). Whether the decision still holds is a question"
517
+ " for the person: do not drop or refile the finding for it.")
518
+ else:
519
+ note += "; refile only if the code changed in a way that decision did not cover."
520
+ if code["code"] == "unknown":
521
+ note += " Whether its code changed since is not known: it was kept before Leyline recorded that."
522
+ return {"learned": {"id": mine["id"], "reason": mine.get("reason", ""), "claim": mine.get("claim", ""),
523
+ "close_on": level, "similarity": round(sim, 2), **code, "note": note}}
524
+ except (OSError, store.UntrustedStore) as e:
525
+ return {"learned_error": f"could not read or write the learnings file: {e}"}
526
+
527
+
528
+ def on_resolve(con, finding_id: str, status: str, resolution: str) -> dict:
529
+ """After a person decides a finding: count the decision against any learning the finding matched, and keep a
530
+ new learning when it is rejected with a reason and repeats none."""
531
+ row = _finding(con, finding_id)
532
+ if row is None:
533
+ return {}
534
+ out = {}
535
+ matched = False
536
+ try:
537
+ for path, items in _all(con).items():
538
+ changed = False
539
+ for l in items:
540
+ if finding_id in (l.get("findings") or {}):
541
+ l["findings"][finding_id] = status
542
+ _recount(l)
543
+ changed = matched = True
544
+ out["learning"] = l["id"]
545
+ elif (l.get("source") or {}).get("finding") == finding_id:
546
+ matched = changed = True
547
+ out["learning"] = l["id"]
548
+ if status == "rejected":
549
+ if resolution.strip():
550
+ l["reason"] = resolution.strip()
551
+ if l.get("status") == "retired" and FOR_ITS_FINDING in (l.get("retired") or ""):
552
+ l["status"] = "active" # rejected again after being reopened or accepted for a while
553
+ del l["retired"]
554
+ elif status == "accepted" and l.get("status", "active") == "active":
555
+ l["status"] = "retired"
556
+ l["retired"] = f"{now()[:10]}: {FOR_ITS_FINDING} {status} instead."
557
+ # reopened or deferred: no decision yet, so the learning stands as it is
558
+ if changed:
559
+ _write(path, items)
560
+ if matched or status != "rejected":
561
+ return out
562
+ if not resolution.strip():
563
+ return {"learning_note": "No learning was kept: give a reason with the rejection, so later reviews can"
564
+ " use it."}
565
+ evidence = json.loads(row["evidence"] or "[]")
566
+ repos = {r["repo_id"] for r in con.execute(
567
+ f"SELECT repo_id FROM nodes WHERE id IN ({','.join('?' * len(evidence))})", evidence)} if evidence else set()
568
+ roots = _roots(con)
569
+ root = next((roots[r] for r in sorted(repos) if r in roots), None)
570
+ if root is None:
571
+ return {"learning_note": "No learning was kept: the repository the evidence is in was not found."}
572
+ path = path_for(root)
573
+ items = _read(path, strict=True)
574
+ lid = "l-" + hashlib.sha1(f"{finding_id}|{row['claim']}".encode()).hexdigest()[:6]
575
+ items = [x for x in items if x["id"] != lid]
576
+ scope = scope_of(con, evidence)
577
+ repo = next((r for r in sorted(repos) if roots.get(r) == root), None)
578
+ items.append({"id": lid, "status": "active", "created": now(), "reviewer": row["reviewer"],
579
+ "claim": row["claim"], "reason": resolution.strip(), "scope": scope,
580
+ "fingerprint": fingerprint(con, repo, scope["nodes"]),
581
+ "source": {"change": row["change_id"], "finding": finding_id},
582
+ "hits": 0, "dismissals": 0, "accepted": 0, "findings": {}})
583
+ _write(path, items)
584
+ return {"learning": lid, "learning_file": str(path)}
585
+ except (OSError, store.UntrustedStore) as e:
586
+ return {"learning_note": f"No learning was kept: could not write the learnings file ({e})."}
587
+
588
+
589
+ def by_finding(con, change_id: Optional[str] = None) -> dict:
590
+ """{finding id: the learning it matched}, for the pages; for a pull request, not the learnings it brings."""
591
+ out, repos = {}, _repos(con)
592
+ skip = from_the_change(con, change_id)
593
+ for path, items in _all(con).items():
594
+ for l in items:
595
+ if not l.get("findings") or l["id"] in skip:
596
+ continue
597
+ code = _checked(con, repos, path, l)
598
+ for fid in l["findings"]:
599
+ out[fid] = {"id": l["id"], "reason": l.get("reason", ""), "status": l.get("status", "active"), **code}
600
+ return out
601
+
602
+
603
+ def _marks(con, change_id: str) -> list[str]:
604
+ row = con.execute("SELECT spec FROM views WHERE id = ?", ("view-" + change_id,)).fetchone()
605
+ return [m["id"] for m in json.loads(row[0] or "{}").get("marks", [])] if row else []
606
+
607
+
608
+ def applying(con, change_id: str, nodes: Optional[list[str]] = None, limit: int = 20) -> list[dict]:
609
+ """Active learnings about code the change touches or reaches, closest first: what reviewers read before filing."""
610
+ try:
611
+ ids = list(dict.fromkeys([*(nodes or []), *_marks(con, change_id)]))
612
+ found, repos = [], _repos(con)
613
+ skip = from_the_change(con, change_id) # a pull request's own learnings: listed apart, not applied
614
+ for path, items in _all(con).items():
615
+ here = scope_of(con, _in_repo(con, ids, repos.get(path)))
616
+ for l in items:
617
+ if l.get("status", "active") != "active" or l["id"] in skip:
618
+ continue
619
+ level = overlap(l.get("scope") or {}, here)
620
+ if level:
621
+ found.append({"id": l["id"], "reviewer": l.get("reviewer"), "claim": l.get("claim"),
622
+ "reason": l.get("reason"), "close_on": level,
623
+ "where": (l.get("scope") or {}).get("paths", []),
624
+ "matched_since": l.get("hits", 0), "rejected_again": l.get("dismissals", 0),
625
+ **_checked(con, repos, path, l)})
626
+ found.sort(key=lambda x: (LEVELS.index(x["close_on"]), x["id"]))
627
+ return found[:limit]
628
+ except OSError:
629
+ return []
630
+
631
+
632
+ # -- listing and retiring ------------------------------------------------------------------------
633
+ def listing(con) -> dict:
634
+ files, repos = _all(con), _repos(con)
635
+ items = [{**l, "file": str(p), **_checked(con, repos, p, l)} for p, xs in files.items() for l in xs]
636
+ items.sort(key=lambda l: (l.get("status", "active") != "active", l.get("created", ""), l["id"]))
637
+ where = [str(path_for(r)) for r in _roots(con).values()]
638
+ active = [l for l in items if l.get("status", "active") == "active"]
639
+ return {"active": len(active), "stale": sum(l["stale"] for l in active), "learnings": items,
640
+ "files": [str(p) for p in files] or where, "problems": unreadable(con)}
641
+
642
+
643
+ def confirm(con, lid: str) -> dict:
644
+ """A person's call that a learning still holds for the code as it is now: its fingerprint is taken again, so it
645
+ is no longer stale. Its status is left as it is."""
646
+ repos = _repos(con)
647
+ for path, items in _all(con).items():
648
+ for l in items:
649
+ if l["id"] == lid:
650
+ was = _checked(con, repos, path, l)["code"]
651
+ l["fingerprint"] = fingerprint(con, repos.get(path), (l.get("scope") or {}).get("nodes", []))
652
+ l["confirmed"] = now()
653
+ if (problem := _write_back(path, items)):
654
+ return problem
655
+ gone = sorted(n for n, h in l["fingerprint"].items() if h is None)
656
+ return {"id": lid, "status": l.get("status", "active"), "file": str(path), "was": was,
657
+ **({"not_in_map": gone} if gone else {})}
658
+ return _not_found(con, lid)
659
+
660
+
661
+ def _write_back(path: Path, items: list[dict]) -> Optional[dict]:
662
+ """Write a decision into its file, or say why it could not be (a folder linked outside the repository, a file
663
+ that cannot be written): an error, not a traceback, for the command and the tool."""
664
+ try:
665
+ _write(path, items)
666
+ except (OSError, store.UntrustedStore) as e:
667
+ return {"error": f"could not write the learnings file: {e}"}
668
+ return None
669
+
670
+
671
+ def _not_found(con, lid: str) -> dict:
672
+ problems = unreadable(con)
673
+ return {"error": f"no learning {lid!r}" + (f" in the files that can be read: {'; '.join(problems)}" if problems else
674
+ "; `leyline learnings` lists them")}
675
+
676
+
677
+ def retire(con, lid: str, why: str = "") -> dict:
678
+ """A person's call that a learning no longer holds. It stays in the file, marked retired, and stops applying."""
679
+ for path, items in _all(con).items():
680
+ for l in items:
681
+ if l["id"] == lid:
682
+ l["status"] = "retired"
683
+ l["retired"] = f"{now()[:10]}: retired by hand" + (f": {why.strip()}" if why.strip() else ".")
684
+ if (problem := _write_back(path, items)):
685
+ return problem
686
+ return {"id": lid, "status": "retired", "file": str(path)}
687
+ return _not_found(con, lid)
688
+
689
+
690
+ def text(r: dict) -> str:
691
+ problems = [f"Left out: {p}." for p in r.get("problems") or []]
692
+ if problems and not r["learnings"]:
693
+ return "\n".join(problems)
694
+ if not r["learnings"]:
695
+ return ("No learnings yet. One is kept when a person rejects a review finding with a reason"
696
+ " (`leyline spec resolve <finding> rejected \"why\"`). They go in " + " or ".join(r["files"] or [FILE_AT_ROOT]) + ".")
697
+ L = [f"{r['active']} active of {len(r['learnings'])}"
698
+ + (f" ({r['stale']} about code that has changed since)" if r.get("stale") else "")
699
+ + ", in " + ", ".join(r["files"]) + ".", *problems, ""]
700
+ for l in r["learnings"]:
701
+ where = ", ".join((l.get("scope") or {}).get("paths", [])[:3]) or "?"
702
+ L.append(f"{l['id']} {l.get('status', 'active'):<7} {l.get('reviewer', '')}, {where}")
703
+ L.append(f" Claim: {l.get('claim', '')}")
704
+ L.append(f" Decided: {l.get('reason', '')}")
705
+ L.append(f" Matched {l.get('hits', 0)} later findings: {l.get('dismissals', 0)} rejected, {l.get('accepted', 0)} accepted.")
706
+ if l.get("retired"):
707
+ L.append(f" Retired {l['retired']}")
708
+ elif l.get("code") == "changed":
709
+ L.append(f" Stale: the code it was about has changed since: {changed_text(l['edited'], l['gone'])}."
710
+ f" Ask whether it still holds: `leyline learnings confirm {l['id']}` if it does, retire it if not.")
711
+ elif l.get("code") == "unknown":
712
+ L.append(" Code: not recorded (kept before Leyline did that), so whether it changed is not known."
713
+ f" `leyline learnings confirm {l['id']}` records it now.")
714
+ return "\n".join(L)
715
+
716
+
717
+ def cli(con, args) -> int:
718
+ """`leyline learnings` lists them; `leyline learnings retire <id> ["why"]` retires one; `leyline learnings
719
+ confirm <id>` says one still holds for the code as it is now."""
720
+ if args.action == "confirm":
721
+ if not args.id:
722
+ print("leyline: say which learning: leyline learnings confirm <id>")
723
+ return 2
724
+ r = confirm(con, args.id)
725
+ if "error" in r:
726
+ print(r["error"])
727
+ return 1
728
+ print(f"Confirmed {r['id']} against the code as it is now, in {r['file']}."
729
+ + (f" Not in the map now: {', '.join(r['not_in_map'])}." if r.get("not_in_map") else ""))
730
+ return 0
731
+ if args.action == "retire":
732
+ if not args.id:
733
+ print("leyline: say which learning: leyline learnings retire <id> \"why\"")
734
+ return 2
735
+ r = retire(con, args.id, args.why or "")
736
+ print(r.get("error") or f"Retired {r['id']} in {r['file']}.")
737
+ return 1 if "error" in r else 0
738
+ r = listing(con)
739
+ if args.json:
740
+ print(json.dumps(r, indent=2, ensure_ascii=False))
741
+ else:
742
+ print(text(r))
743
+ return 1 if r["problems"] else 0 # a file left out is a failure to read, not an empty list