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/cli.py
ADDED
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
"""Command line: map, plan and check first; the rest (index, query, serve, the spec steps) after."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from . import __version__, query, store
|
|
14
|
+
from .diff import READS
|
|
15
|
+
from .indexer import index
|
|
16
|
+
|
|
17
|
+
DEFAULT_DB = ".leyline/leyline.db"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _spec(con, args) -> int:
|
|
21
|
+
from . import loop, spec
|
|
22
|
+
if args.action != "resolve" and args.target.startswith(("pr-", "quick-")) and not Path(args.target).is_dir():
|
|
23
|
+
return _pr_spec(con, args) # a pull request (`leyline pr`) or a quick change (`leyline quick`): no folder
|
|
24
|
+
if args.action != "resolve": # a change folder or its id, as plan and check take it
|
|
25
|
+
folder = loop.find_change(args.target, args.db)
|
|
26
|
+
if folder is None and args.action in ("brief", "verify", "facts"):
|
|
27
|
+
print(f"leyline: no change folder {args.target!r}: looked for it as a path and under openspec/changes/.",
|
|
28
|
+
file=sys.stderr)
|
|
29
|
+
return 2
|
|
30
|
+
args.target = str(folder) if folder is not None else args.target
|
|
31
|
+
if args.action == "brief":
|
|
32
|
+
r = spec.brief(con, args.target, new_baseline=args.new_baseline)
|
|
33
|
+
if "error" in r:
|
|
34
|
+
_print(r)
|
|
35
|
+
return 1
|
|
36
|
+
print(spec.brief_text(r))
|
|
37
|
+
if r.get("baseline") == "kept":
|
|
38
|
+
print("The code has changed since the first brief; verify will still compare with the code as it was then.")
|
|
39
|
+
print(f"written to {r['written']}")
|
|
40
|
+
return 0 if r["ready"] else 1
|
|
41
|
+
if args.action == "verify":
|
|
42
|
+
r = spec.verify(con, args.target, args.before, args.after)
|
|
43
|
+
if "error" in r:
|
|
44
|
+
_print(r)
|
|
45
|
+
return 1
|
|
46
|
+
print(spec.verify_text(r))
|
|
47
|
+
return 0 if r["done_as_agreed"] else 1
|
|
48
|
+
if args.action == "facts":
|
|
49
|
+
_print(spec.review_facts(con, args.target, args.reviewer))
|
|
50
|
+
elif args.action == "findings":
|
|
51
|
+
_findings(con, "spec-" + Path(args.target).name)
|
|
52
|
+
elif args.action in ("finding", "file"): # `file` is the older name
|
|
53
|
+
r = spec.add_finding(con, "spec-" + Path(args.target).name, args.reviewer or "", args.severity or "", args.claim or "",
|
|
54
|
+
args.evidence, args.proposal)
|
|
55
|
+
_print(r)
|
|
56
|
+
return 1 if "error" in r else 0
|
|
57
|
+
elif args.action == "forget": # the copy of the code from before the change, kept to compare with
|
|
58
|
+
from . import diff
|
|
59
|
+
cid = "spec-" + Path(args.target).name
|
|
60
|
+
print(f"Deleted the baseline of {cid}." if diff.drop_snapshot(con, cid) else f"No baseline is kept for {cid}.")
|
|
61
|
+
elif args.action == "resolve":
|
|
62
|
+
_print(spec.resolve_finding(con, args.target, args.status, args.reason or ""))
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _findings(con, cid: str) -> None:
|
|
67
|
+
from . import spec
|
|
68
|
+
found = spec.findings(con, cid)["findings"]
|
|
69
|
+
for f in found:
|
|
70
|
+
print(f"{f['id']} {f['status']:<9} {f['severity']:<6} {f['reviewer']}: {f['claim']}")
|
|
71
|
+
if not found:
|
|
72
|
+
print(f"No findings filed for {cid}.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _pr_spec(con, args) -> int:
|
|
76
|
+
"""The spec steps that make sense for a pull request: its reviewers' facts, their findings, and forgetting it."""
|
|
77
|
+
from . import diff, pr, spec
|
|
78
|
+
cid = args.target
|
|
79
|
+
if args.action == "facts":
|
|
80
|
+
if cid.startswith("quick-"):
|
|
81
|
+
from . import quick
|
|
82
|
+
r = quick.review_facts(con, cid, args.reviewer)
|
|
83
|
+
else:
|
|
84
|
+
r = pr.review_facts(con, cid, args.reviewer)
|
|
85
|
+
_print(r)
|
|
86
|
+
return 1 if "error" in r else 0
|
|
87
|
+
if args.action == "findings":
|
|
88
|
+
_findings(con, cid)
|
|
89
|
+
return 0
|
|
90
|
+
if args.action in ("finding", "file"):
|
|
91
|
+
r = spec.add_finding(con, cid, args.reviewer or "", args.severity or "", args.claim or "", args.evidence, args.proposal)
|
|
92
|
+
_print(r)
|
|
93
|
+
return 1 if "error" in r else 0
|
|
94
|
+
if args.action == "forget" and cid.startswith("quick-"):
|
|
95
|
+
from . import quick
|
|
96
|
+
print(f"Deleted the baseline of {cid}." if quick.forget(con, cid) else f"No baseline is kept for {cid}.")
|
|
97
|
+
return 0
|
|
98
|
+
if args.action == "forget":
|
|
99
|
+
gone = diff.drop_snapshot(con, cid)
|
|
100
|
+
diff.snapshot_path(con, cid).with_suffix(".base").unlink(missing_ok=True)
|
|
101
|
+
from . import rereview # and the maps of the heads it was reviewed at
|
|
102
|
+
rereview.forget(con, cid)
|
|
103
|
+
print(f"Deleted the map of {cid}'s base." if gone else f"No base is kept for {cid}.")
|
|
104
|
+
return 0
|
|
105
|
+
print(f"leyline: {args.action} is for a spec folder; a pull request has `leyline pr`, then facts, finding, findings,"
|
|
106
|
+
" resolve and forget", file=sys.stderr)
|
|
107
|
+
return 2
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _pr(args) -> int:
|
|
111
|
+
from . import pr
|
|
112
|
+
about = args.about or ""
|
|
113
|
+
if about == "-":
|
|
114
|
+
about = sys.stdin.read()
|
|
115
|
+
elif args.about_file:
|
|
116
|
+
about = Path(args.about_file).read_text(encoding="utf-8", errors="replace")
|
|
117
|
+
root = Path(args.path)
|
|
118
|
+
try:
|
|
119
|
+
top = pr.git_root(root.resolve())
|
|
120
|
+
except pr.GitError as e:
|
|
121
|
+
print(f"leyline: {args.path} is not inside a git repository ({e})", file=sys.stderr)
|
|
122
|
+
return 2
|
|
123
|
+
db = args.db or str(top / DEFAULT_DB)
|
|
124
|
+
try:
|
|
125
|
+
r = pr.review(db, top, args.base, about, args.github, args.id)
|
|
126
|
+
except pr.GitError as e:
|
|
127
|
+
print(f"leyline: {e}", file=sys.stderr)
|
|
128
|
+
return 2
|
|
129
|
+
if "error" in r:
|
|
130
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
g = r["gate"]
|
|
133
|
+
code = 1 if args.gate and not g["passed"] else 0 # without --gate a review never fails the command
|
|
134
|
+
if args.json:
|
|
135
|
+
_print({k: v for k, v in r.items()})
|
|
136
|
+
return code
|
|
137
|
+
print(pr.text(r))
|
|
138
|
+
print(f"written to {r['page']}")
|
|
139
|
+
if not g["passed"]:
|
|
140
|
+
print(f"Next: {g['next']}.")
|
|
141
|
+
else:
|
|
142
|
+
print(f"Next: have it reviewed (`leyline spec facts {r['change_id']} --reviewer logic`, then `performance`;"
|
|
143
|
+
" the leyline-adversarial-review skill runs both).")
|
|
144
|
+
return code
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _quick(args) -> int:
|
|
148
|
+
"""`leyline quick "<what>" --about <names>` before a small change, `leyline quick --done quick-<slug>` after it."""
|
|
149
|
+
from . import loop, quick
|
|
150
|
+
db = args.db or loop.find_store(Path(args.path)) or str(Path(args.path) / DEFAULT_DB)
|
|
151
|
+
if args.to_spec:
|
|
152
|
+
target = args.what or args.done
|
|
153
|
+
if not target or not Path(db).exists():
|
|
154
|
+
print("leyline: --to-spec <spec id> needs a quick change started here: `leyline quick --to-spec <id> quick-<slug>`",
|
|
155
|
+
file=sys.stderr)
|
|
156
|
+
return 2
|
|
157
|
+
con = store.connect(db)
|
|
158
|
+
try:
|
|
159
|
+
r = quick.to_spec(con, target, args.to_spec)
|
|
160
|
+
finally:
|
|
161
|
+
con.close()
|
|
162
|
+
if "error" in r:
|
|
163
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
164
|
+
return 1
|
|
165
|
+
sid = args.to_spec.removeprefix("spec-")
|
|
166
|
+
print(f"{r['change_id']} now starts from {r['from']}'s baseline ({r['tests']} test results from before).\n"
|
|
167
|
+
f"Next: run `leyline plan {sid}` (it keeps this baseline), then `<your test command> | leyline check {sid} --tests -`.")
|
|
168
|
+
return 0
|
|
169
|
+
try:
|
|
170
|
+
results = _tests_arg(args.tests)
|
|
171
|
+
except OSError as e:
|
|
172
|
+
print(f"leyline: cannot read the test output: {e}", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
if results == []:
|
|
175
|
+
print(f"leyline: found no test results in that output. It reads {READS}.", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
if args.done:
|
|
178
|
+
r = quick.done(db, args.done if args.done.startswith("quick-") else "quick-" + args.done, results, args.coverage,
|
|
179
|
+
args.about)
|
|
180
|
+
if "error" in r:
|
|
181
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
182
|
+
return 1
|
|
183
|
+
_print(r) if args.json else print(quick.done_text(r), end="")
|
|
184
|
+
return 0 if r["done"] else 1
|
|
185
|
+
if not args.what:
|
|
186
|
+
print("leyline: say what the change is (`leyline quick \"make the retry count 3\" --about RETRIES`), or pass"
|
|
187
|
+
" --done quick-<slug> after it", file=sys.stderr)
|
|
188
|
+
return 2
|
|
189
|
+
if _not_dirs([args.path]):
|
|
190
|
+
return 2
|
|
191
|
+
r = quick.start(db, args.what, args.about, results, args.path, args.id, args.new_baseline)
|
|
192
|
+
if "error" in r:
|
|
193
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
194
|
+
return 1
|
|
195
|
+
_print(r) if args.json else print(quick.start_text(r), end="")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _affected(args) -> int:
|
|
200
|
+
from . import affected, loop
|
|
201
|
+
folder = loop.find_change(args.change, args.db or DEFAULT_DB)
|
|
202
|
+
db = args.db or (loop.find_store(folder) if folder else None) or DEFAULT_DB
|
|
203
|
+
if not Path(db).exists():
|
|
204
|
+
print("leyline: no map of this code yet. Run `leyline map <repo>` first.", file=sys.stderr)
|
|
205
|
+
return 2
|
|
206
|
+
loop.refresh(db) # new code since the plan counts as changed
|
|
207
|
+
con = store.connect(db)
|
|
208
|
+
try:
|
|
209
|
+
r = affected.select(con, affected.change_id_for(con, args.change, folder))
|
|
210
|
+
finally:
|
|
211
|
+
con.close()
|
|
212
|
+
if "error" in r:
|
|
213
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
214
|
+
return 1
|
|
215
|
+
_print(r) if args.json else print(affected.text(r))
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _drift(args) -> int:
|
|
220
|
+
from . import drift, loop
|
|
221
|
+
db = args.db or loop.find_store(Path(args.path)) or DEFAULT_DB
|
|
222
|
+
if not Path(db).exists():
|
|
223
|
+
print("leyline: no map of this code yet. Run `leyline map <repo>` first.", file=sys.stderr)
|
|
224
|
+
return 2
|
|
225
|
+
r = drift.run(db, args.path, args.accept)
|
|
226
|
+
if args.json:
|
|
227
|
+
_print(r)
|
|
228
|
+
else:
|
|
229
|
+
print(drift.text(r) + "\n".join(drift.next_steps(r)))
|
|
230
|
+
return 1 if r["fails"] else 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _short(con, i: str) -> str:
|
|
234
|
+
"""A node as a person reads it: its name, after its owner's when it has one that is not a file."""
|
|
235
|
+
n = query._node(con, i)
|
|
236
|
+
if n is None:
|
|
237
|
+
return i
|
|
238
|
+
up = query._node(con, n["parent_id"]) if n["parent_id"] else None
|
|
239
|
+
return f"{up['name']}.{n['name']}" if up is not None and up["kind"] in ("type", "callable") else n["name"]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _impact(con, args) -> int:
|
|
243
|
+
"""`leyline impact <name or id>`: the CLI side of the MCP `impact` tool."""
|
|
244
|
+
found = query.resolve(con, args.node)
|
|
245
|
+
if "error" in found:
|
|
246
|
+
print(f"leyline: {found['error']}", file=sys.stderr)
|
|
247
|
+
for c in found.get("candidates", [])[:15]:
|
|
248
|
+
print(f" {c['id']} ({c['kind']}{', ' + c['path'] if c.get('path') else ''})", file=sys.stderr)
|
|
249
|
+
return 1
|
|
250
|
+
r = query.impact(con, found["id"], args.depth)
|
|
251
|
+
if args.json:
|
|
252
|
+
_print(r)
|
|
253
|
+
return 0
|
|
254
|
+
print(f"{found['id']}\nreached by {_n(r['reached_by'], 'place')} within {_n(r['depth_limit'], 'call')}"
|
|
255
|
+
+ (", across modules" if r["crosses_module_boundary"] else "")
|
|
256
|
+
+ (", across repositories" if r["crosses_repo_boundary"] else ""))
|
|
257
|
+
verb = "used" if (query._node(con, found["id"]) or {"kind": ""})["kind"] == "field" else "called"
|
|
258
|
+
for m in r["by_module"]:
|
|
259
|
+
near = [_short(con, d) for d in m["direct"]]
|
|
260
|
+
print(f" {m['module']:<28} {m['count']:>5}"
|
|
261
|
+
+ (f" {verb} directly by {', '.join(near[:5])}"
|
|
262
|
+
f"{' ...' if len(near) > 5 else ''}" if near else ""))
|
|
263
|
+
t = r["flows_through"]
|
|
264
|
+
if t["total"]:
|
|
265
|
+
print(f"flows through it: {t['total']}")
|
|
266
|
+
for f in t["items"][:10]:
|
|
267
|
+
print(f" {f['name']}")
|
|
268
|
+
if t["total"] > 10:
|
|
269
|
+
print(f" and {t['total'] - 10} more (--json lists 40)")
|
|
270
|
+
print(r["note"])
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _n(n: int, word: str, plural: str = "") -> str:
|
|
275
|
+
"""A count and its noun: 1 file, 2 files."""
|
|
276
|
+
return f"{n:,} {word if n == 1 else plural or word + 's'}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _print(obj) -> None:
|
|
280
|
+
try:
|
|
281
|
+
json.dump(obj, sys.stdout, indent=2)
|
|
282
|
+
except BrokenPipeError: # the reader (head, a closed pager) went away; that is not an error
|
|
283
|
+
try:
|
|
284
|
+
sys.stdout.close()
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
return
|
|
288
|
+
sys.stdout.write("\n")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _summary(o: dict) -> str:
|
|
292
|
+
lines = []
|
|
293
|
+
for repo in o["repos"]:
|
|
294
|
+
lines.append(f"{repo['id']} ({(repo.get('commit') or 'no commit')[:10]})")
|
|
295
|
+
for m in repo["modules"]:
|
|
296
|
+
langs = ", ".join(f"{k} {v}" for k, v in m["languages"].items())
|
|
297
|
+
lines.append(f" {m['path']:<22} {m['files']:>3} files {m['loc']:>6} loc "
|
|
298
|
+
f"{m['types']:>4} types {m['callables']:>4} callables [{langs}]"
|
|
299
|
+
+ (f" {m['title']}" if m.get("title") else ""))
|
|
300
|
+
multi = len(o["repos"]) > 1
|
|
301
|
+
name = lambda i: i.replace(":module:", "/") if multi else i.split(":module:")[-1]
|
|
302
|
+
if o.get("workspace"):
|
|
303
|
+
lines.append("\nlinks between repositories")
|
|
304
|
+
for e in o["workspace"]["links"]:
|
|
305
|
+
kinds = ", ".join(f"{k} {v}" for k, v in e.items() if k not in ("from", "to", "total"))
|
|
306
|
+
lines.append(f" {e['from']:<18} -> {e['to']:<18} {kinds}")
|
|
307
|
+
lines.append("\nmodule dependencies")
|
|
308
|
+
for e in o["module_edges"]:
|
|
309
|
+
kinds = ", ".join(f"{k} {v}" for k, v in e.items() if k not in ("from", "to", "total"))
|
|
310
|
+
lines.append(f" {name(e['from']):<18} -> {name(e['to']):<18} {kinds}")
|
|
311
|
+
lines.append("\nextractors")
|
|
312
|
+
for c in o["coverage"]:
|
|
313
|
+
lines.append(f" {(c['repo'] + ' ') if multi else ''}{c['extractor']:<24} {c['status']}")
|
|
314
|
+
return "\n".join(lines)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
ABOUT = """Leyline maps a codebase so that a person can plan a change, have an agent write the code, and check
|
|
318
|
+
that it was done as agreed. The usual path is three commands:
|
|
319
|
+
|
|
320
|
+
leyline map [repo ...] index the code; prints a short overview and where the map page is
|
|
321
|
+
leyline plan <change> write and print the one-page plan for an OpenSpec change folder
|
|
322
|
+
(openspec/changes/<id>/, or just <id>); says what is still needed
|
|
323
|
+
leyline check <change> --tests FILE after the change: re-map, read the test output, and say whether
|
|
324
|
+
it was done as agreed
|
|
325
|
+
|
|
326
|
+
Each command ends with the next step. To review a change someone else wrote, with no spec:
|
|
327
|
+
|
|
328
|
+
leyline pr [base] what the checkout's change reaches and did not change, for review
|
|
329
|
+
leyline pr [base] --gate the same, exiting 1 while something that blocks is left (for CI)
|
|
330
|
+
|
|
331
|
+
A small change needs no spec folder:
|
|
332
|
+
|
|
333
|
+
leyline quick "<what>" --about NAMES before: what it touches and reaches, the tests that run it
|
|
334
|
+
leyline quick --done quick-<slug> after: one verdict, and whether it grew into something to spec
|
|
335
|
+
|
|
336
|
+
To work through a coding agent, give it Leyline's skills:
|
|
337
|
+
|
|
338
|
+
leyline skills install copy them into this repository's .claude/skills (and .agents/skills)
|
|
339
|
+
leyline skills list what each one is for"""
|
|
340
|
+
|
|
341
|
+
ADVANCED = """advanced commands (leyline <command> -h for each):
|
|
342
|
+
index index without the summary; prints the full statistics
|
|
343
|
+
overview the module map
|
|
344
|
+
search find nodes by name
|
|
345
|
+
expand one node in detail
|
|
346
|
+
neighbors edges around a node
|
|
347
|
+
impact what can reach a function, a type or a field: its callers near and far, and the flows through it
|
|
348
|
+
source source text of a node
|
|
349
|
+
context a short ranked outline of the code around a focus, cut to a token budget, for an agent
|
|
350
|
+
outline a large module split into parts (folders, or groups of files): what each holds and how they link
|
|
351
|
+
name-part give a part from the outline a name and a one-line summary
|
|
352
|
+
find-flows where a behavior described in words could start: entry points, handlers, tests, ranked
|
|
353
|
+
explain-path an ordered walk across calls and channels from one of them, with the lines that make each step
|
|
354
|
+
diagram a Mermaid sequence diagram of how execution reaches some functions and what they call
|
|
355
|
+
state fields assigned from outside the type that declares them
|
|
356
|
+
coupling files that usually change together, from git history
|
|
357
|
+
patterns design patterns found by their shape
|
|
358
|
+
tour a guided walk through the repository
|
|
359
|
+
coverage import measured test coverage, or show it
|
|
360
|
+
rules check the architecture rules
|
|
361
|
+
affected-tests the tests to run for a change, as a command
|
|
362
|
+
spec the spec loop step by step: brief, facts, finding (or file), findings, resolve, verify
|
|
363
|
+
drift code the specs name that has moved, changed signature or gone since they were written
|
|
364
|
+
learnings past decisions on review findings, which later reviews read first; retire or confirm one
|
|
365
|
+
record-tests store a test run under a label
|
|
366
|
+
review compare an implemented change with a proposal made through MCP
|
|
367
|
+
view serve the map on localhost
|
|
368
|
+
export write the map as one self-contained HTML page
|
|
369
|
+
serve serve the store over MCP (stdio), for a coding agent
|
|
370
|
+
grade measure the call links found against a compiler's"""
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _tests_arg(value: Optional[str]) -> Optional[list[dict]]:
|
|
374
|
+
if value is None:
|
|
375
|
+
return None
|
|
376
|
+
from . import diff
|
|
377
|
+
if value == "-":
|
|
378
|
+
buffer = getattr(sys.stdin, "buffer", None) # a text-only stdin (a test's StringIO) has no bytes under it
|
|
379
|
+
data = buffer.read() if buffer is not None else sys.stdin.read()
|
|
380
|
+
else:
|
|
381
|
+
data = Path(value).read_bytes()
|
|
382
|
+
# A byte-order mark is not part of the first result's name: PowerShell's `>` writes UTF-16 with one, and some
|
|
383
|
+
# editors UTF-8 with one.
|
|
384
|
+
if isinstance(data, bytes):
|
|
385
|
+
if data.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
386
|
+
data = data.decode("utf-16", errors="replace")
|
|
387
|
+
else:
|
|
388
|
+
data = data.decode("utf-8-sig", errors="replace")
|
|
389
|
+
return diff.parse_test_output(data.lstrip("\ufeff"))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _not_dirs(paths: list[str]) -> bool:
|
|
393
|
+
"""Say so, and return True, when a path to map is not a directory: mapping it would add it to the store as a
|
|
394
|
+
repository with nothing in it (a typo), or with one file."""
|
|
395
|
+
bad = [p for p in paths if not Path(p).is_dir()]
|
|
396
|
+
for p in bad:
|
|
397
|
+
what = "is a file" if Path(p).is_file() else "does not exist"
|
|
398
|
+
print(f"leyline: {p} {what}. Name the repository's directory (relative paths are from {Path.cwd()}).",
|
|
399
|
+
file=sys.stderr)
|
|
400
|
+
return bool(bad)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _workspace_elsewhere(db: str) -> bool:
|
|
404
|
+
"""Whether the store is a workspace of several repositories, none of them the current directory."""
|
|
405
|
+
if not Path(db).is_file():
|
|
406
|
+
return False
|
|
407
|
+
con = store.connect(db)
|
|
408
|
+
try:
|
|
409
|
+
if con.execute("SELECT 1 FROM meta WHERE key = 'workspace'").fetchone() is None:
|
|
410
|
+
return False
|
|
411
|
+
here = Path.cwd().resolve()
|
|
412
|
+
return all(Path(p).resolve() != here for p in store.roots(con).values())
|
|
413
|
+
finally:
|
|
414
|
+
con.close()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _loop(args) -> int:
|
|
418
|
+
"""map, plan and check: the short path."""
|
|
419
|
+
from . import loop
|
|
420
|
+
if args.cmd == "map":
|
|
421
|
+
if len(args.path) > 1 and args.repo:
|
|
422
|
+
print("leyline: --repo names one repository; a workspace takes its ids from the directory names", file=sys.stderr)
|
|
423
|
+
return 2
|
|
424
|
+
if args.forget:
|
|
425
|
+
if args.path:
|
|
426
|
+
print("leyline: --forget takes no repository to map: forget first, then map", file=sys.stderr)
|
|
427
|
+
return 2
|
|
428
|
+
db = args.db or str(loop.find_store(Path.cwd()) or DEFAULT_DB)
|
|
429
|
+
r = loop.forget(db, args.forget)
|
|
430
|
+
if "error" in r:
|
|
431
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
432
|
+
return 1
|
|
433
|
+
print(r["said"] + ("\n" + loop.map_text(r) if r.get("repos") else ""))
|
|
434
|
+
return 0
|
|
435
|
+
paths = args.path or ["."]
|
|
436
|
+
if _not_dirs(paths):
|
|
437
|
+
return 2
|
|
438
|
+
db = args.db or (str(Path(paths[0]) / DEFAULT_DB) if len(paths) == 1 else DEFAULT_DB)
|
|
439
|
+
if not args.path and not args.repo and _workspace_elsewhere(db):
|
|
440
|
+
paths = None # the folder a workspace was mapped from: map its members again, not the folder itself
|
|
441
|
+
r = loop.map_repos(paths, db, args.repo, args.exact, args.scip, full=args.full)
|
|
442
|
+
if "error" in r:
|
|
443
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
444
|
+
return 1
|
|
445
|
+
print(loop.map_text(r))
|
|
446
|
+
return 0
|
|
447
|
+
change = loop.find_change(args.change, args.db or DEFAULT_DB)
|
|
448
|
+
if change is None:
|
|
449
|
+
print(f"leyline: no change folder {args.change!r}: looked for it as a path and under openspec/changes/.\n"
|
|
450
|
+
"Write the change first (ask your agent; the leyline-spec skill says how).", file=sys.stderr)
|
|
451
|
+
return 2
|
|
452
|
+
db = args.db or loop.find_store(change) or DEFAULT_DB
|
|
453
|
+
if not Path(db).exists():
|
|
454
|
+
print("leyline: no map of this code yet. Run `leyline map <repo>` first.", file=sys.stderr)
|
|
455
|
+
return 2
|
|
456
|
+
try:
|
|
457
|
+
results = _tests_arg(args.tests)
|
|
458
|
+
except OSError as e:
|
|
459
|
+
print(f"leyline: cannot read the test output: {e}", file=sys.stderr)
|
|
460
|
+
return 2
|
|
461
|
+
if results == []:
|
|
462
|
+
print(f"leyline: found no test results in that output. It reads {READS}; other formats can go through the"
|
|
463
|
+
" record_test_run MCP tool.", file=sys.stderr)
|
|
464
|
+
return 2
|
|
465
|
+
name = args.change
|
|
466
|
+
if args.cmd == "plan":
|
|
467
|
+
r = loop.plan(db, change, results, args.new_baseline)
|
|
468
|
+
if "error" in r:
|
|
469
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
470
|
+
return 1
|
|
471
|
+
print(loop.plan_text(r, name))
|
|
472
|
+
from . import spec
|
|
473
|
+
return 0 if spec.brief_status(r)["ready"] else 1
|
|
474
|
+
r = loop.check(db, change, results, args.coverage)
|
|
475
|
+
if "error" in r:
|
|
476
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
477
|
+
for line in r.get("next") or []:
|
|
478
|
+
print(line, file=sys.stderr)
|
|
479
|
+
return 1
|
|
480
|
+
print(loop.check_text(r, name))
|
|
481
|
+
return 0 if r["done_as_agreed"] else 1
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def main(argv=None) -> int:
|
|
485
|
+
for stream in (sys.stdout, sys.stderr):
|
|
486
|
+
# A console or pipe in a legacy encoding (Windows) cannot print every file name or arrow: replace, not fail.
|
|
487
|
+
if (getattr(stream, "encoding", None) or "").lower().replace("-", "") != "utf8":
|
|
488
|
+
try:
|
|
489
|
+
stream.reconfigure(errors="replace")
|
|
490
|
+
except (AttributeError, ValueError):
|
|
491
|
+
pass
|
|
492
|
+
from . import indexer
|
|
493
|
+
indexer.REPORTED = set() # one command that maps twice (a pull request's base and head) says what it left out once
|
|
494
|
+
try:
|
|
495
|
+
return _main(argv)
|
|
496
|
+
except KeyboardInterrupt:
|
|
497
|
+
print("\nleyline: stopped", file=sys.stderr)
|
|
498
|
+
return 130
|
|
499
|
+
except BrokenPipeError: # the reader (head, a closed pager) went away; that is not an error
|
|
500
|
+
try:
|
|
501
|
+
sys.stdout = open(os.devnull, "w")
|
|
502
|
+
except OSError:
|
|
503
|
+
pass
|
|
504
|
+
return 0
|
|
505
|
+
except store.UntrustedStore as e:
|
|
506
|
+
print(f"leyline: {e}", file=sys.stderr)
|
|
507
|
+
return 2
|
|
508
|
+
except sqlite3.DatabaseError as e:
|
|
509
|
+
print(f"leyline: {_store_problem(e)}", file=sys.stderr)
|
|
510
|
+
return 2
|
|
511
|
+
except OSError as e:
|
|
512
|
+
import errno
|
|
513
|
+
if e.errno not in (errno.EACCES, errno.EPERM, errno.EROFS):
|
|
514
|
+
raise
|
|
515
|
+
# a repository that cannot be written to (read-only, or someone else's) can still be mapped and reported on
|
|
516
|
+
print(f"leyline: cannot write {e.filename or 'the store'} ({e.strerror}). To map a folder you cannot write to,"
|
|
517
|
+
" keep the store elsewhere: `leyline --db <writable folder>/leyline.db ...`, or set LEYLINE_DB.",
|
|
518
|
+
file=sys.stderr)
|
|
519
|
+
return 2
|
|
520
|
+
except MemoryError:
|
|
521
|
+
print("leyline: ran out of memory. A repository this large needs more than this machine has free: close other"
|
|
522
|
+
" programs, or map one part of it (a subdirectory) at a time.", file=sys.stderr)
|
|
523
|
+
return 2
|
|
524
|
+
finally:
|
|
525
|
+
indexer.REPORTED = None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _store_problem(e: Exception) -> str:
|
|
529
|
+
"""What a person can do about an error from the store, in place of the traceback."""
|
|
530
|
+
from .diff import DamagedBaseline
|
|
531
|
+
msg = str(e)
|
|
532
|
+
if isinstance(e, DamagedBaseline):
|
|
533
|
+
return msg
|
|
534
|
+
if "locked" in msg or "busy" in msg:
|
|
535
|
+
return ("the store is in use: another leyline run (a map, plan or check) is writing it. Try again when that"
|
|
536
|
+
" run finishes.")
|
|
537
|
+
if "not a database" in msg or "malformed" in msg or "corrupt" in msg:
|
|
538
|
+
return (f"the store is damaged or is not a Leyline store ({msg}). Delete it (and the leyline.cache.db"
|
|
539
|
+
" beside it) and run `leyline map` again.")
|
|
540
|
+
if "readonly" in msg or "unable to open" in msg or "disk I/O" in msg or "full" in msg:
|
|
541
|
+
return f"cannot write the store ({msg}): check the folder's permissions and free disk space."
|
|
542
|
+
return f"the store could not be read ({type(e).__name__}: {msg}). Running `leyline map --full` rebuilds it."
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _main(argv=None) -> int:
|
|
546
|
+
ap = argparse.ArgumentParser(prog="leyline", usage="%(prog)s [-h] [--version] [--db DB] command ...",
|
|
547
|
+
description=ABOUT, epilog=ADVANCED,
|
|
548
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
549
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
550
|
+
ap.add_argument("--db", default=os.environ.get("LEYLINE_DB"),
|
|
551
|
+
help="path to the store (default: .leyline/leyline.db in the repository)")
|
|
552
|
+
# The commands are listed by ABOUT and ADVANCED, so the usual three come first.
|
|
553
|
+
sub = ap.add_subparsers(dest="cmd", required=True, metavar="command", help=argparse.SUPPRESS)
|
|
554
|
+
p = sub.add_parser("map", description="Index one or more repositories, print a short overview, and write the map page.")
|
|
555
|
+
p.add_argument("path", nargs="*", default=[], help="the repository (default: the current directory, or the workspace"
|
|
556
|
+
" mapped from it); name several to map them together")
|
|
557
|
+
p.add_argument("--repo", help="repo id (defaults to the directory name; one repository only)")
|
|
558
|
+
p.add_argument("--exact", choices=["auto", "off", "roslyn", "scip"], default="auto",
|
|
559
|
+
help="let a compiler overrule the syntax-based links (default: auto, whatever is available)")
|
|
560
|
+
p.add_argument("--scip", action="append", default=[], metavar="FILE", help="a SCIP index to read (repeatable)")
|
|
561
|
+
p.add_argument("--full", action="store_true", help="index everything again, not only what changed since the last map")
|
|
562
|
+
p.add_argument("--forget", action="append", metavar="ID", help="drop a repository from the store (a workspace member"
|
|
563
|
+
" that is gone or no longer wanted) and map the rest again;"
|
|
564
|
+
" repeatable")
|
|
565
|
+
p = sub.add_parser("plan", description="Write and print the one-page plan (leyline.md) for an OpenSpec change folder,"
|
|
566
|
+
" re-mapping first if the code changed. Exits 1 while something blocks implementation.")
|
|
567
|
+
p.add_argument("change", help="the change folder, or its id under openspec/changes/")
|
|
568
|
+
p.add_argument("--tests", metavar="FILE", help="test runner output from before the change (- for stdin), kept to compare"
|
|
569
|
+
" with after; " + READS)
|
|
570
|
+
p.add_argument("--new-baseline", action="store_true",
|
|
571
|
+
help="compare from the code as it is now, forgetting the picture kept from the first plan")
|
|
572
|
+
p = sub.add_parser("check", description="After the change is made: re-map, record the test output, and say whether the"
|
|
573
|
+
" change was done as agreed. Exits 0 only when it was.")
|
|
574
|
+
p.add_argument("change", help="the change folder, or its id under openspec/changes/")
|
|
575
|
+
p.add_argument("--tests", metavar="FILE", help="test runner output from after the change (- for stdin); " + READS)
|
|
576
|
+
p.add_argument("--coverage", metavar="FILE", help="coverage measured on that same run (pytest --cov=<package>"
|
|
577
|
+
" --cov-context=test writes .coverage): says whether each scenario's"
|
|
578
|
+
" test ran the changed code")
|
|
579
|
+
p = sub.add_parser("affected-tests", description="The tests to run for a change, and a command that runs them: the"
|
|
580
|
+
" tests measured running the changed code when per-test coverage is"
|
|
581
|
+
" imported, else those whose path on the map passes through it.")
|
|
582
|
+
p.add_argument("change", help="the change folder or its id, or a review id such as pr-123")
|
|
583
|
+
p.add_argument("--json", action="store_true")
|
|
584
|
+
p = sub.add_parser("pr", description="Review a branch or pull request someone else wrote: map the commit it left"
|
|
585
|
+
" its base at, compare it with the checkout, and print what the change reaches"
|
|
586
|
+
" and did not change, with no spec needed. Findings are filed against pr-<id>.")
|
|
587
|
+
p.add_argument("base", nargs="?", help="the branch it will merge into (default: origin's default branch, else main, else master)")
|
|
588
|
+
p.add_argument("--about", help="what the change says it does (its title and description); - reads stdin")
|
|
589
|
+
p.add_argument("--about-file", metavar="FILE", help="the same, from a file")
|
|
590
|
+
p.add_argument("--github", metavar="NUMBER", help="take the base, title and description from this GitHub pull request"
|
|
591
|
+
" (needs gh; check the pull request out first)")
|
|
592
|
+
p.add_argument("--id", help="name the review pr-<id> (default: the PR number, else the branch name)")
|
|
593
|
+
p.add_argument("--path", default=".", help="the checkout (default: here)")
|
|
594
|
+
p.add_argument("--gate", action="store_true", help="exit 1 when anything that blocks is left (set under [pr] in"
|
|
595
|
+
" openspec/leyline.toml); without it the review exits 0")
|
|
596
|
+
p.add_argument("--json", action="store_true")
|
|
597
|
+
p = sub.add_parser("quick", description="A small change with no spec folder. Before editing: name the code (--about,"
|
|
598
|
+
" or in backticks), and see what it touches, what must change with it and the"
|
|
599
|
+
" tests that run it. After: --done quick-<slug> compares with the baseline and"
|
|
600
|
+
" gives one verdict. Exits 1 after while the change is not done.")
|
|
601
|
+
p.add_argument("what", nargs="?", help="the change in a sentence, such as \"make the retry count 3\"")
|
|
602
|
+
p.add_argument("--about", nargs="+", metavar="NAME", help="the code it touches: `Owner.name`, `module.func`, a constant")
|
|
603
|
+
p.add_argument("--done", metavar="ID", help="after the change: the quick-<slug> the first run printed")
|
|
604
|
+
p.add_argument("--tests", metavar="FILE", help="test runner output (- for stdin): before the edit with the sentence,"
|
|
605
|
+
" after it with --done; " + READS)
|
|
606
|
+
p.add_argument("--coverage", metavar="FILE", help="with --done: coverage measured on that test run")
|
|
607
|
+
p.add_argument("--id", help="name it quick-<id> (default: from the sentence)")
|
|
608
|
+
p.add_argument("--to-spec", metavar="SPEC_ID", help="it grew: hand its baseline and test run to openspec/changes/<SPEC_ID>"
|
|
609
|
+
" (`leyline quick --to-spec <id> quick-<slug>`)")
|
|
610
|
+
p.add_argument("--new-baseline", action="store_true", help="start over from the code as it is now")
|
|
611
|
+
p.add_argument("--path", default=".", help="the repository (default: here)")
|
|
612
|
+
p.add_argument("--json", action="store_true")
|
|
613
|
+
p = sub.add_parser("skills", description="The skills that ship with Leyline: instructions a coding agent follows to"
|
|
614
|
+
" answer questions about the code, plan, make and review changes with the map.")
|
|
615
|
+
p.add_argument("action", choices=["list", "show", "install"], help="list them, show one, or install them")
|
|
616
|
+
p.add_argument("names", nargs="*", help="show: the skill; install: only these (default: all)")
|
|
617
|
+
p.add_argument("--to", metavar="DIR", help="install: the repository (default: the one around this directory)")
|
|
618
|
+
where = p.add_mutually_exclusive_group()
|
|
619
|
+
where.add_argument("--claude", dest="where", action="store_const", const="claude", help="install into .claude/skills")
|
|
620
|
+
where.add_argument("--agents", dest="where", action="store_const", const="agents", help="install into .agents/skills")
|
|
621
|
+
where.add_argument("--both", dest="where", action="store_const", const="both",
|
|
622
|
+
help="both (the default when the repository has an .agents folder; otherwise .claude only)")
|
|
623
|
+
p.add_argument("--force", action="store_true", help="install: replace a copy that was edited here")
|
|
624
|
+
# Advanced commands: no help= keeps them out of the list at the top of --help; ADVANCED lists them.
|
|
625
|
+
p = sub.add_parser("drift", description="Compare the code that the living specs (openspec/specs/) and finished changes"
|
|
626
|
+
" name in backticks with the map: what is gone, renamed, has moved, has changed"
|
|
627
|
+
" signature or could now be several things. Exits 1 when something is gone,"
|
|
628
|
+
" renamed or changed signature.")
|
|
629
|
+
p.add_argument("path", nargs="?", default=".", help="the repository, or its openspec/ folder (default: here)")
|
|
630
|
+
p.add_argument("--accept", action="store_true", help="the specs and the code agree as they are now: record that, in"
|
|
631
|
+
" openspec/leyline-anchors.json, to compare with later")
|
|
632
|
+
p.add_argument("--json", action="store_true")
|
|
633
|
+
p = sub.add_parser("grade", description="measure the call links found against a compiler's (a SCIP index, or roslyn for C#)")
|
|
634
|
+
p.add_argument("root", help="the repository")
|
|
635
|
+
p.add_argument("compiler", help="a .scip file, or roslyn")
|
|
636
|
+
p.add_argument("--prefix", default="", help="the folder the SCIP index's paths are relative to, inside the repository")
|
|
637
|
+
p = sub.add_parser("index", description="index a repository into the store, or several as one workspace")
|
|
638
|
+
p.add_argument("path", nargs="*", default=["."],
|
|
639
|
+
help="the repository; name several to index them together, so calls between them are linked")
|
|
640
|
+
p.add_argument("--repo", help="repo id (defaults to the directory name; one repository only)")
|
|
641
|
+
p.add_argument("--exact", choices=["auto", "off", "roslyn", "scip"], default="auto",
|
|
642
|
+
help="let a compiler overrule the syntax-based links: the .NET SDK's for C#, a SCIP index for"
|
|
643
|
+
" other languages. auto (the default) uses whatever is available")
|
|
644
|
+
p.add_argument("--scip", action="append", default=[], metavar="FILE", help="a SCIP index to read (repeatable)")
|
|
645
|
+
p.add_argument("--full", action="store_true", help="index everything again, not only what changed since the last index")
|
|
646
|
+
p = sub.add_parser("overview", description="the module map")
|
|
647
|
+
p.add_argument("--json", action="store_true")
|
|
648
|
+
p = sub.add_parser("expand", description="one node in detail")
|
|
649
|
+
p.add_argument("node_id")
|
|
650
|
+
p.add_argument("--limit", type=int, default=50)
|
|
651
|
+
p = sub.add_parser("search", description="find nodes by name")
|
|
652
|
+
p.add_argument("text")
|
|
653
|
+
p.add_argument("--kind")
|
|
654
|
+
p.add_argument("--limit", type=int, default=20)
|
|
655
|
+
p = sub.add_parser("neighbors", description="edges around a node")
|
|
656
|
+
p.add_argument("node_id")
|
|
657
|
+
p.add_argument("--direction", default="both", choices=["in", "out", "both"])
|
|
658
|
+
p.add_argument("--kinds", nargs="*")
|
|
659
|
+
p = sub.add_parser("impact", description="what can reach a node through calls and channels, grouped by module,"
|
|
660
|
+
" and the flows that pass through it")
|
|
661
|
+
p.add_argument("node", help="a node id, or a name such as Owner.method or a file path")
|
|
662
|
+
p.add_argument("--depth", type=int, default=6, help="how many calls back to follow (default 6)")
|
|
663
|
+
p.add_argument("--json", action="store_true")
|
|
664
|
+
p = sub.add_parser("source", description="source text of a node")
|
|
665
|
+
p.add_argument("node_id")
|
|
666
|
+
p = sub.add_parser("context", description="a short outline of the code around a focus, most related first, cut to a"
|
|
667
|
+
" token budget: declaration lines file by file, the focus marked >")
|
|
668
|
+
p.add_argument("focus", nargs="+", help="node ids, names (Owner.method), file paths, a change id (spec-<id>,"
|
|
669
|
+
" pr-<id>) or words to search for")
|
|
670
|
+
p.add_argument("--tokens", type=int, default=2000, help="how long the outline may be, in tokens (characters / 4;"
|
|
671
|
+
" default 2000, from 200 to 8000)")
|
|
672
|
+
p.add_argument("--json", action="store_true")
|
|
673
|
+
p = sub.add_parser("outline", description="one level of a module split into at most 12 parts (folders, or groups of"
|
|
674
|
+
" files where a folder is flat): size, entry points, links in and out,"
|
|
675
|
+
" channels, busiest functions, risks and names")
|
|
676
|
+
p.add_argument("module", nargs="?", help="a module's path (editor/core), or a part id from an outline; none lists"
|
|
677
|
+
" the modules")
|
|
678
|
+
p.add_argument("--depth", type=int, default=2, help="1: the parts; 2: and the parts inside each (default); 3: one"
|
|
679
|
+
" more level")
|
|
680
|
+
p.add_argument("--json", action="store_true")
|
|
681
|
+
p = sub.add_parser("name-part", description="name a part from the outline, with a one-line summary")
|
|
682
|
+
p.add_argument("part_id")
|
|
683
|
+
p.add_argument("name")
|
|
684
|
+
p.add_argument("--summary", default="", help="one line on what the part does")
|
|
685
|
+
p.add_argument("--evidence", nargs="*", default=[], help="node ids of the code read to name it")
|
|
686
|
+
p.add_argument("--intent", action="store_true", help="the person said it (no evidence needed)")
|
|
687
|
+
p = sub.add_parser("find-flows", description="where a behavior described in words could start, best first: entry"
|
|
688
|
+
" points, route, UI and message handlers, tests named for it, functions")
|
|
689
|
+
p.add_argument("description", help="what happens, such as \"what happens when a writer saves a dialogue\"")
|
|
690
|
+
p.add_argument("--limit", type=int, default=10)
|
|
691
|
+
p.add_argument("--json", action="store_true")
|
|
692
|
+
p = sub.add_parser("explain-path", description="an ordered walk across calls and channels from a function: its main"
|
|
693
|
+
" flow, the shortest path to --to, or the path through --through")
|
|
694
|
+
p.add_argument("start", help="a node id, a name (Owner.method), a route (\"PUT /api/x\") or a flow id")
|
|
695
|
+
p.add_argument("--to", help="end here: the shortest path")
|
|
696
|
+
p.add_argument("--through", help="pass this node, then go on from it")
|
|
697
|
+
p.add_argument("--steps", type=int, default=40, help="most steps to show (default 40)")
|
|
698
|
+
p.add_argument("--json", action="store_true")
|
|
699
|
+
p = sub.add_parser("diagram", description="a Mermaid sequence diagram of how execution reaches some functions or"
|
|
700
|
+
" types and what they call")
|
|
701
|
+
p.add_argument("ids", nargs="+", help="node ids or names")
|
|
702
|
+
p.add_argument("--json", action="store_true")
|
|
703
|
+
sub.add_parser("serve", description="serve the store over MCP (stdio)")
|
|
704
|
+
p = sub.add_parser("export", description="write the map as one self-contained HTML page")
|
|
705
|
+
p.add_argument("-o", "--out", default="leyline-map.html")
|
|
706
|
+
p.add_argument("--fragment", action="store_true", help="omit the html/head/body wrapper")
|
|
707
|
+
p.add_argument("--no-sources", action="store_true", help="leave source text out of the page")
|
|
708
|
+
p = sub.add_parser("record-tests", description="store a test run read from a test runner's output")
|
|
709
|
+
p.add_argument("run", help="a label for the run, such as before or after")
|
|
710
|
+
p.add_argument("file", help="runner output (" + READS + "); - for stdin")
|
|
711
|
+
p = sub.add_parser("rules", description="check the architecture rules")
|
|
712
|
+
p.add_argument("--confirm", type=int, metavar="ID", help="confirm a suggested rule")
|
|
713
|
+
p = sub.add_parser("review", description="compare an implemented change with its proposal")
|
|
714
|
+
p.add_argument("change_id")
|
|
715
|
+
p.add_argument("--before", help="label of the test run recorded before the change")
|
|
716
|
+
p.add_argument("--after", help="label of the test run recorded after it")
|
|
717
|
+
p.add_argument("--json", action="store_true")
|
|
718
|
+
p = sub.add_parser("patterns", description="design patterns found by their shape")
|
|
719
|
+
p.add_argument("pattern", nargs="?", help="only this pattern, such as strategy")
|
|
720
|
+
p.add_argument("--tests", action="store_true", help="include patterns inside test code")
|
|
721
|
+
p = sub.add_parser("spec", description="a change stated as an OpenSpec folder, or a pull request reviewed with"
|
|
722
|
+
" `leyline pr` (pr-<id>): brief it, review it, verify it",
|
|
723
|
+
formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""examples:
|
|
724
|
+
leyline spec facts <change> --reviewer logic the facts a logic reviewer works from (JSON)
|
|
725
|
+
leyline spec finding <change> --reviewer logic --severity high \\
|
|
726
|
+
--claim "one sentence a person can check" --evidence <node id> ... --proposal "what to change"
|
|
727
|
+
leyline spec findings <change> the findings, one line each
|
|
728
|
+
leyline spec resolve <finding id> accepted|rejected|deferred "why"
|
|
729
|
+
leyline spec forget <change> delete the change's baseline (a pull request: its base's map)""")
|
|
730
|
+
p.add_argument("action", choices=["brief", "verify", "facts", "finding", "file", "findings", "resolve", "forget"],
|
|
731
|
+
help="finding files one review finding (file is the same); forget deletes a change's baseline")
|
|
732
|
+
p.add_argument("--new-baseline", action="store_true",
|
|
733
|
+
help="brief: compare from the code as it is now, forgetting the picture kept from the first brief")
|
|
734
|
+
p.add_argument("--reviewer", help="finding, facts: logic or performance")
|
|
735
|
+
p.add_argument("--severity", choices=["high", "medium", "low"], help="finding")
|
|
736
|
+
p.add_argument("--claim", help="finding: one sentence a person can check")
|
|
737
|
+
p.add_argument("--evidence", nargs="*", default=[], help="finding: node ids that show it")
|
|
738
|
+
p.add_argument("--proposal", default="", help="finding: the change to the spec")
|
|
739
|
+
p.add_argument("target", help="the change folder (openspec/changes/<id>) or its id, or a finding id for resolve")
|
|
740
|
+
p.add_argument("status", nargs="?", choices=["accepted", "rejected", "deferred", "open"], help="for resolve")
|
|
741
|
+
p.add_argument("reason", nargs="?", help="for resolve: why")
|
|
742
|
+
p.add_argument("--before", help="verify: label of the test run recorded before the change")
|
|
743
|
+
p.add_argument("--after", help="verify: label of the test run recorded after it")
|
|
744
|
+
p = sub.add_parser("learnings", description="past decisions on review findings: each finding a person rejected,"
|
|
745
|
+
" with the reason. Later reviews read them first.")
|
|
746
|
+
p.add_argument("action", nargs="?", choices=["list", "retire", "confirm"], default="list",
|
|
747
|
+
help="retire: it no longer holds; confirm: it still holds for the code as it is now")
|
|
748
|
+
p.add_argument("id", nargs="?", help="retire or confirm: the learning's id")
|
|
749
|
+
p.add_argument("why", nargs="?", help="retire: why it no longer holds")
|
|
750
|
+
p.add_argument("--json", action="store_true")
|
|
751
|
+
p = sub.add_parser("coverage", description="import a coverage file, or show what was measured")
|
|
752
|
+
p.add_argument("file", nargs="?", help="a coverage.py data file (.coverage), a Cobertura XML report, Istanbul's"
|
|
753
|
+
" coverage-final.json or an lcov file (lcov.info)")
|
|
754
|
+
p.add_argument("--run", default="default", help="a name for this import")
|
|
755
|
+
p.add_argument("--test", metavar="PATH", help="the one test file that ran, for a report with no per-test detail"
|
|
756
|
+
" (Istanbul, Cobertura, lcov with no TN: names): ties what ran to that file")
|
|
757
|
+
p = sub.add_parser("state", description="fields assigned from outside the type that declares them")
|
|
758
|
+
p.add_argument("scope", nargs="?", help="a module id or an id prefix")
|
|
759
|
+
p = sub.add_parser("coupling", description="files that usually change in the same commits as a file (or, with no file,"
|
|
760
|
+
" the most coupled pairs), read from git history: links the map cannot see")
|
|
761
|
+
p.add_argument("path", nargs="?", help="a file, relative to here or to its repository, or the end of its path")
|
|
762
|
+
p.add_argument("--min-together", type=int, default=3, help="commits the two changed in together (default 3, at least 2)")
|
|
763
|
+
p.add_argument("--min-confidence", type=float, default=0.5,
|
|
764
|
+
help="share of the file's commits that changed the other too (default 0.5)")
|
|
765
|
+
p.add_argument("--limit", type=int, default=20)
|
|
766
|
+
p.add_argument("--json", action="store_true")
|
|
767
|
+
p = sub.add_parser("tour", description="print a tour of the repository")
|
|
768
|
+
p.add_argument("tour_id", nargs="?", help="a tour id; the orientation tour when left out")
|
|
769
|
+
p = sub.add_parser("view", description="serve the map on localhost")
|
|
770
|
+
p.add_argument("--port", type=int, default=8765)
|
|
771
|
+
args, extra = ap.parse_known_args(argv)
|
|
772
|
+
# Python before 3.12 stops filling a list of names once an option follows the first of them
|
|
773
|
+
# (`skills install --to repo ask`): the names after the options come back here.
|
|
774
|
+
if extra and getattr(args, "names", None) is not None and not any(e.startswith("-") for e in extra):
|
|
775
|
+
args.names = list(args.names) + extra
|
|
776
|
+
elif extra:
|
|
777
|
+
ap.error("unrecognized arguments: " + " ".join(extra))
|
|
778
|
+
|
|
779
|
+
if args.cmd in ("map", "plan", "check"):
|
|
780
|
+
return _loop(args)
|
|
781
|
+
if args.cmd == "pr":
|
|
782
|
+
return _pr(args)
|
|
783
|
+
if args.cmd == "quick":
|
|
784
|
+
return _quick(args)
|
|
785
|
+
if args.cmd == "affected-tests":
|
|
786
|
+
return _affected(args)
|
|
787
|
+
if args.cmd == "drift":
|
|
788
|
+
return _drift(args)
|
|
789
|
+
if args.cmd == "skills":
|
|
790
|
+
from . import agent_skills
|
|
791
|
+
return agent_skills.cli(args)
|
|
792
|
+
explicit, args.db = args.db is not None, args.db or DEFAULT_DB
|
|
793
|
+
if args.cmd == "index":
|
|
794
|
+
if len(args.path) > 1 and args.repo:
|
|
795
|
+
print("leyline: --repo names one repository; a workspace takes its ids from the directory names", file=sys.stderr)
|
|
796
|
+
return 2
|
|
797
|
+
if _not_dirs(args.path):
|
|
798
|
+
return 2
|
|
799
|
+
# One repository keeps its store inside it; a workspace's store is in the current directory.
|
|
800
|
+
db = args.db if explicit else str(Path(args.path[0]) / DEFAULT_DB) if len(args.path) == 1 else DEFAULT_DB
|
|
801
|
+
stats = index(args.path if len(args.path) > 1 else args.path[0], db, args.repo, args.exact, args.scip, full=args.full)
|
|
802
|
+
t = stats.get("timing", {})
|
|
803
|
+
print(f"indexed into {db}: {_n(t.get('files', 0), 'file')}, {_n(t.get('lines', 0), 'line')} in {t.get('total_seconds', 0)} s"
|
|
804
|
+
f" ({t.get('lines_per_second', 0):,} lines/s)")
|
|
805
|
+
_print(stats)
|
|
806
|
+
return 0
|
|
807
|
+
if args.cmd == "serve":
|
|
808
|
+
if not explicit: # started in a folder inside the repository: the repository's store, as other commands
|
|
809
|
+
from .loop import find_store, repository_of
|
|
810
|
+
found = find_store(Path.cwd())
|
|
811
|
+
top = None if found else repository_of(Path.cwd()) # no store yet: the first map puts it at the top
|
|
812
|
+
args.db = str(found or (top / DEFAULT_DB if top else DEFAULT_DB))
|
|
813
|
+
os.environ["LEYLINE_DB"] = args.db
|
|
814
|
+
from .server import main as serve
|
|
815
|
+
serve()
|
|
816
|
+
return 0
|
|
817
|
+
if args.cmd == "grade":
|
|
818
|
+
from . import grade
|
|
819
|
+
if args.compiler != "roslyn" and not Path(args.compiler).is_file(): # before indexing the whole repository
|
|
820
|
+
print(f"leyline: no SCIP index at {args.compiler}: give a .scip file, or roslyn for C#.", file=sys.stderr)
|
|
821
|
+
return 2
|
|
822
|
+
g = grade.grade(args.root, args.compiler, args.prefix, db=str(Path(args.db).with_suffix(".grade.db")))
|
|
823
|
+
g.pop("_samples", None)
|
|
824
|
+
_print(g)
|
|
825
|
+
return 0
|
|
826
|
+
if not explicit: # the repository's store, as --help says, from anywhere inside the repository
|
|
827
|
+
from .loop import find_store
|
|
828
|
+
args.db = str(find_store(Path.cwd()) or DEFAULT_DB)
|
|
829
|
+
if not Path(args.db).exists():
|
|
830
|
+
print(f"leyline: no store at {args.db}. Run `leyline map` first.", file=sys.stderr)
|
|
831
|
+
return 2
|
|
832
|
+
con = store.connect(args.db)
|
|
833
|
+
try:
|
|
834
|
+
return _with_store(args, con)
|
|
835
|
+
finally:
|
|
836
|
+
con.close()
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _with_store(args, con) -> int:
|
|
840
|
+
"""The commands that read or write one store, given open."""
|
|
841
|
+
if args.cmd == "export":
|
|
842
|
+
from . import export
|
|
843
|
+
text = (export.fragment if args.fragment else export.page)(con, not args.no_sources)
|
|
844
|
+
Path(args.out).write_text(text, encoding="utf-8")
|
|
845
|
+
print(f"wrote {args.out} ({len(text) // 1024} KB)")
|
|
846
|
+
return 0
|
|
847
|
+
if args.cmd == "view":
|
|
848
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
849
|
+
from . import export
|
|
850
|
+
|
|
851
|
+
class Handler(BaseHTTPRequestHandler):
|
|
852
|
+
def do_GET(self): # re-read the store on every load, so a re-index shows up on refresh
|
|
853
|
+
# Only a page opened as this machine: a web page whose name an attacker points at 127.0.0.1 (DNS
|
|
854
|
+
# rebinding) would otherwise be served the map, with every source file in it, as its own origin.
|
|
855
|
+
host = self.headers.get("Host") or ""
|
|
856
|
+
if host.rsplit(":", 1)[0] not in ("127.0.0.1", "localhost"):
|
|
857
|
+
self.send_error(403, "the map is served to http://127.0.0.1 only")
|
|
858
|
+
return
|
|
859
|
+
con = store.connect(args.db)
|
|
860
|
+
try:
|
|
861
|
+
body = export.page(con).encode()
|
|
862
|
+
finally:
|
|
863
|
+
con.close()
|
|
864
|
+
self.send_response(200)
|
|
865
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
866
|
+
self.send_header("Content-Length", str(len(body)))
|
|
867
|
+
self.end_headers()
|
|
868
|
+
self.wfile.write(body)
|
|
869
|
+
|
|
870
|
+
def log_message(self, *a):
|
|
871
|
+
pass
|
|
872
|
+
|
|
873
|
+
print(f"Leyline map at http://127.0.0.1:{args.port} (Ctrl+C to stop)")
|
|
874
|
+
try:
|
|
875
|
+
# A thread per request: a browser that keeps a connection open, or a slow page, must not hold up the next load.
|
|
876
|
+
server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
|
877
|
+
server.daemon_threads = True
|
|
878
|
+
server.serve_forever()
|
|
879
|
+
except KeyboardInterrupt:
|
|
880
|
+
pass
|
|
881
|
+
return 0
|
|
882
|
+
if args.cmd == "record-tests":
|
|
883
|
+
from . import diff
|
|
884
|
+
text = sys.stdin.read() if args.file == "-" else Path(args.file).read_text(encoding="utf-8", errors="replace")
|
|
885
|
+
_print(diff.record_tests(con, args.run, diff.parse_test_output(text)))
|
|
886
|
+
elif args.cmd == "patterns":
|
|
887
|
+
from . import patterns
|
|
888
|
+
r = patterns.listing(con, args.pattern, None, args.tests, limit=500)
|
|
889
|
+
print(", ".join(f"{k} {v}" for k, v in sorted(r["by_pattern"].items())) or "no patterns found")
|
|
890
|
+
for x in r["patterns"]:
|
|
891
|
+
print(f"\n[{x['pattern']} {x['confidence']:.2f}{' stale' if x['stale'] else ''}] {x['rationale']}")
|
|
892
|
+
elif args.cmd == "spec":
|
|
893
|
+
return _spec(con, args)
|
|
894
|
+
elif args.cmd == "learnings":
|
|
895
|
+
from . import learnings
|
|
896
|
+
return learnings.cli(con, args)
|
|
897
|
+
elif args.cmd == "coverage":
|
|
898
|
+
from . import coverage
|
|
899
|
+
if args.file:
|
|
900
|
+
_print(coverage.import_file(con, args.file, args.run, args.test))
|
|
901
|
+
r = coverage.summary(con)
|
|
902
|
+
if not r["imported"]:
|
|
903
|
+
print(r["how"])
|
|
904
|
+
return 0
|
|
905
|
+
print(f"{'module':<28}{'functions':>10}{'ran':>8}{'on a path, never ran':>24}{'ran off every path':>22}")
|
|
906
|
+
for m in r["modules"]:
|
|
907
|
+
print(f"{m['module']:<28}{m['functions']:>10}{m['ran']:>8}{m['path_but_never_ran']:>24}{m['ran_off_every_path']:>22}")
|
|
908
|
+
for run in r["runs"]:
|
|
909
|
+
if run["stale"]:
|
|
910
|
+
print(f"\nrun {run['run']!r} was measured at another commit; import a fresh file")
|
|
911
|
+
print("\n" + r["note"])
|
|
912
|
+
elif args.cmd == "state":
|
|
913
|
+
r = query.shared_state(con, args.scope, 60)
|
|
914
|
+
print(f"{_n(r['total'], 'field is', 'fields are')} assigned from outside their own type\n")
|
|
915
|
+
for f in r["fields"]:
|
|
916
|
+
print(f"{f['name']:<40} {f['writers']:>3} writers in {', '.join(f['written_from'][:5])}"
|
|
917
|
+
f"{' ...' if len(f['written_from']) > 5 else ''}; {f['readers']} readers")
|
|
918
|
+
print("\n" + r["note"])
|
|
919
|
+
elif args.cmd == "coupling":
|
|
920
|
+
from . import coupling
|
|
921
|
+
r = coupling.query(con, args.path, max(args.min_together, 2), args.min_confidence, args.limit)
|
|
922
|
+
if args.json:
|
|
923
|
+
_print(r)
|
|
924
|
+
else:
|
|
925
|
+
print(coupling.text(r), file=sys.stderr if "error" in r else sys.stdout)
|
|
926
|
+
return 1 if "error" in r else 0
|
|
927
|
+
elif args.cmd == "tour":
|
|
928
|
+
from . import tours
|
|
929
|
+
listed = tours.listing(con)["tours"]
|
|
930
|
+
t = tours.get(con, args.tour_id or (listed[0]["id"] if listed else ""))
|
|
931
|
+
if "error" in t:
|
|
932
|
+
_print(t)
|
|
933
|
+
return 1
|
|
934
|
+
print(f"{t['title']}\n")
|
|
935
|
+
for st in t["stops"]:
|
|
936
|
+
print(f"{st['seq']}. {st['title']} [{st['kind']}: {st['ref']}]\n {st['narrative']}\n")
|
|
937
|
+
if len(listed) > 1:
|
|
938
|
+
print("other tours: " + ", ".join(x["id"] for x in listed if x["id"] != t["id"]))
|
|
939
|
+
elif args.cmd == "rules":
|
|
940
|
+
from . import rules
|
|
941
|
+
if args.confirm:
|
|
942
|
+
_print(rules.confirm_rule(con, args.confirm))
|
|
943
|
+
r = rules.check(con)
|
|
944
|
+
if not r["rules"]:
|
|
945
|
+
print("No rules yet; an agent adds one with the add_rule MCP tool, and `leyline rules --confirm ID` confirms it.")
|
|
946
|
+
for x in r["rules"]:
|
|
947
|
+
scope = x["from"] + (" -> " + x["to"] if x["to"] else "")
|
|
948
|
+
print(f"{'ok ' if x['passes'] else 'FAIL'} #{x['id']} {x['kind']} {scope} [{x['status']}]"
|
|
949
|
+
+ ("" if x["passes"] else f" {x['violations']} violations"))
|
|
950
|
+
return 1 if any(not x["passes"] and x["status"] == "confirmed" and x["severity"] == "error" for x in r["rules"]) else 0
|
|
951
|
+
elif args.cmd == "review":
|
|
952
|
+
from . import diff
|
|
953
|
+
r = diff.review(con, args.change_id, args.before, args.after)
|
|
954
|
+
if args.json or "error" in r:
|
|
955
|
+
_print(r)
|
|
956
|
+
else:
|
|
957
|
+
print(diff.review_text(r))
|
|
958
|
+
return 1 if "error" in r else 0
|
|
959
|
+
elif args.cmd == "overview":
|
|
960
|
+
o = query.overview(con)
|
|
961
|
+
_print(o) if args.json else print(_summary(o))
|
|
962
|
+
elif args.cmd == "expand":
|
|
963
|
+
r = query.expand(con, args.node_id, args.limit)
|
|
964
|
+
_print(r)
|
|
965
|
+
return 1 if "error" in r else 0
|
|
966
|
+
elif args.cmd == "search":
|
|
967
|
+
_print(query.search(con, args.text, args.kind, args.limit))
|
|
968
|
+
elif args.cmd == "impact":
|
|
969
|
+
return _impact(con, args)
|
|
970
|
+
elif args.cmd == "neighbors":
|
|
971
|
+
r = query.neighbors(con, args.node_id, args.direction, args.kinds)
|
|
972
|
+
_print(r)
|
|
973
|
+
return 1 if "error" in r else 0
|
|
974
|
+
elif args.cmd == "source":
|
|
975
|
+
r = query.source(con, args.node_id)
|
|
976
|
+
if "error" in r:
|
|
977
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
978
|
+
return 1
|
|
979
|
+
print(r["text"])
|
|
980
|
+
elif args.cmd in ("find-flows", "explain-path", "diagram"):
|
|
981
|
+
from . import explain
|
|
982
|
+
if args.cmd == "find-flows":
|
|
983
|
+
r = explain.find_flows(con, args.description, args.limit)
|
|
984
|
+
text = explain.find_text(r)
|
|
985
|
+
elif args.cmd == "explain-path":
|
|
986
|
+
r = explain.explain_path(con, args.start, args.to, args.through, args.steps)
|
|
987
|
+
text = (r.get("text", "") + ("\n\n```mermaid\n" + r["mermaid"] + "\n```" if r.get("mermaid") else "")
|
|
988
|
+
if "error" not in r else r["error"])
|
|
989
|
+
if not text and r.get("found") is False: # no path between the two: say so, as the JSON does
|
|
990
|
+
text = r.get("note", "") + (f"\nNext: {r['next']}" if r.get("next") else "")
|
|
991
|
+
if "candidates" in r and r["candidates"]:
|
|
992
|
+
text += "\nDid you mean: " + ", ".join(c["id"] for c in r["candidates"][:5])
|
|
993
|
+
else:
|
|
994
|
+
r = explain.diagram(con, args.ids)
|
|
995
|
+
text = r.get("error") or "```mermaid\n" + r["mermaid"] + "\n```\n\n" + r["legend"]
|
|
996
|
+
if args.json:
|
|
997
|
+
_print(r)
|
|
998
|
+
else:
|
|
999
|
+
print(text, file=sys.stderr if "error" in r else sys.stdout)
|
|
1000
|
+
return 1 if "error" in r else 0
|
|
1001
|
+
elif args.cmd in ("outline", "name-part"):
|
|
1002
|
+
from . import outline
|
|
1003
|
+
if args.cmd == "outline":
|
|
1004
|
+
r = outline.outline(con, args.module, args.depth)
|
|
1005
|
+
if args.json and "error" not in r:
|
|
1006
|
+
_print(r)
|
|
1007
|
+
else:
|
|
1008
|
+
print(outline.text(r), file=sys.stderr if "error" in r else sys.stdout)
|
|
1009
|
+
else:
|
|
1010
|
+
r = outline.name_part(con, args.part_id, args.name, args.summary, args.evidence,
|
|
1011
|
+
"intent" if args.intent else "inferred", "cli")
|
|
1012
|
+
if "error" in r:
|
|
1013
|
+
print(f"leyline: {r['error']}", file=sys.stderr)
|
|
1014
|
+
else:
|
|
1015
|
+
print(f"Named {r['part_id']}: {r['name']}." + (f" {r['summary']}" if r["summary"] else ""))
|
|
1016
|
+
return 1 if "error" in r else 0
|
|
1017
|
+
elif args.cmd == "context":
|
|
1018
|
+
from . import context
|
|
1019
|
+
r = context.build(con, args.focus, args.tokens)
|
|
1020
|
+
if args.json or "error" in r:
|
|
1021
|
+
_print(r)
|
|
1022
|
+
else:
|
|
1023
|
+
print(r["text"], end="")
|
|
1024
|
+
return 1 if "error" in r else 0
|
|
1025
|
+
return 0
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
if __name__ == "__main__":
|
|
1029
|
+
raise SystemExit(main())
|