leyline-code 0.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/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
|