leyline-code 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/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())