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/tours.py ADDED
@@ -0,0 +1,352 @@
1
+ """Tours: an ordered walk through a codebase, each stop pointing at something on the map.
2
+
3
+ The orientation tour is generated from the graph alone, so every sentence in it is a count or a name
4
+ the store can back. An agent or the user can write further tours for a purpose (a feature end to end,
5
+ what a new contributor to one module needs) through `save`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import hashlib
12
+ import json
13
+ import re
14
+ from collections import Counter, defaultdict
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from . import store
19
+
20
+ SOURCE = "leyline-tour/0.1"
21
+ REF_KINDS = ("repo", "node", "flow", "pattern", "view")
22
+ SIDE_DIRS = ("sample", "samples", "example", "examples", "bench", "benchmarks", "docs", "doc", "demo", "demos", "snippets")
23
+
24
+
25
+ def _plural(n: int, word: str) -> str:
26
+ return f"{n:,} {word}{'' if n == 1 else 's'}"
27
+
28
+
29
+ def _names(items, limit=4) -> str:
30
+ items = list(items)
31
+ return ", ".join(items[:limit]) + (f" and {len(items) - limit} more" if len(items) > limit else "")
32
+
33
+
34
+ def _readme(con, repo_id: str) -> Optional[str]:
35
+ here = store.roots(con).get(repo_id)
36
+ row = (str(here),) if here is not None else None
37
+ if not row:
38
+ return None
39
+ for name in ("README.md", "README.rst", "README.txt", "README"):
40
+ p = Path(row[0]) / name
41
+ if p.is_file() and store.inside(p, row[0]):
42
+ para: list[str] = []
43
+ try:
44
+ text = p.read_text(encoding="utf-8", errors="replace")
45
+ except OSError:
46
+ continue
47
+ for line in text.splitlines():
48
+ s = line.strip()
49
+ skip = (not s or s.startswith(("#", "=", "-", "!", "[", "<", "|", "..", "```", ">")) or set(s) <= set("=-~"))
50
+ if skip and para:
51
+ break
52
+ if not skip:
53
+ para.append(s)
54
+ text = re.sub(r"\[([^\]]+)\](\([^)]*\)|\[[^\]]*\])?", r"\1", " ".join(para)).replace("**", "").replace("`", "")
55
+ if len(text) > 40:
56
+ return text[:420].rsplit(" ", 1)[0] + ("..." if len(text) > 420 else "")
57
+ return None
58
+
59
+
60
+ # How each language is written in a sentence: one name per language, as its own community writes it.
61
+ LANGUAGE_NAMES = {"csharp": "C#", "python": "Python", "typescript": "TypeScript", "javascript": "JavaScript",
62
+ "go": "Go", "rust": "Rust", "java": "Java", "kotlin": "Kotlin", "swift": "Swift", "c": "C",
63
+ "cpp": "C++", "ruby": "Ruby", "php": "PHP", "scala": "Scala", "lua": "Lua", "elixir": "Elixir",
64
+ "dart": "Dart", "zig": "Zig", "haskell": "Haskell", "ocaml": "OCaml", "julia": "Julia",
65
+ "bash": "Bash", "gdscript": "GDScript"}
66
+
67
+
68
+ def generate(con, repo_id: str) -> dict:
69
+ """Write the orientation tour for a repo, replacing the previous one."""
70
+ q = lambda sql, *a: con.execute(sql, a).fetchall()
71
+ repo = q("SELECT id, name FROM nodes WHERE id = ? AND kind = 'repo'", repo_id)
72
+ if not repo:
73
+ return {"error": f"no repo {repo_id!r}"}
74
+ # In id order, not the order the rows were written in: a module an incremental run adds is written last, and a
75
+ # tie between modules must be settled as a full run would settle it.
76
+ mods = {r["id"]: dict(r) for r in q("SELECT id, name, path FROM nodes WHERE kind = 'module' AND repo_id = ? ORDER BY id", repo_id)}
77
+ named = Counter(m["name"] for m in mods.values())
78
+ for m in mods.values(): # two modules called lib (src/lib, scripts/lib) are told apart by their paths
79
+ if named[m["name"]] > 1 and m["path"]:
80
+ m["name"] = m["path"]
81
+ if not mods:
82
+ with con: # nothing to tour: the tour of an earlier map, which names modules that are gone, goes too
83
+ con.execute("DELETE FROM tour_stops WHERE tour_id = ?", (f"tour:orientation:{repo_id}",))
84
+ con.execute("DELETE FROM tours WHERE id = ?", (f"tour:orientation:{repo_id}",))
85
+ return {"stops": 0}
86
+ # The few module ids and kinds are one string each: every row read gives its own copy, and on a large
87
+ # repository the copies were hundreds of MB.
88
+ share = {}
89
+ share = share.setdefault
90
+ module = {r[0]: share(r[1], r[1]) for r in con.execute("SELECT node_id, module_id FROM ancestry")}
91
+ names, kind_of, parent = {}, {}, {}
92
+ for r in con.execute("SELECT id, name, kind, parent_id FROM nodes WHERE repo_id = ?", (repo_id,)): # one pass, not three
93
+ names[r[0]], kind_of[r[0]], parent[r[0]] = r[1], share(r[2], r[2]), r[3]
94
+
95
+ def qual(i):
96
+ p = parent.get(i)
97
+ return f"{names[p]}.{names[i]}" if kind_of.get(p) == "type" and kind_of.get(i) in ("callable", "field") else names.get(i, i)
98
+ # Only each flow's kind is read from its attrs, which list every module the flow passes (hundreds of MB in all).
99
+ flows = q("SELECT id, name, entry_id, json_extract(attrs, '$.kind') AS kind FROM flows"
100
+ " WHERE entry_id IN (SELECT id FROM nodes WHERE repo_id = ?)", repo_id)
101
+ flow_kind = {f["id"]: f["kind"] for f in flows}
102
+ test_mods = {module.get(f["entry_id"]) for f in flows if flow_kind[f["id"]] == "test"} - {None}
103
+
104
+ # Tests kept beside the code they test (grocery.test.ts next to grocery.ts) do not make that code a test module.
105
+ test_files, mostly_tests = store.test_places(con)
106
+
107
+ def side(m): # samples, docs, benchmarks and test helpers: real code, but not the product
108
+ return any(part.lower() in SIDE_DIRS or part.lower().startswith("test") for part in (mods[m]["path"] or "").split("/"))
109
+ core = [m for m in mods if m not in mostly_tests and not side(m)] or list(mods)
110
+ size = {r["m"]: (r["files"], r["loc"]) for r in q(
111
+ "SELECT a.module_id AS m, COUNT(*) AS files, SUM(COALESCE(n.span_end, 0)) AS loc FROM nodes n"
112
+ " JOIN ancestry a ON a.node_id = n.id WHERE n.kind = 'file' GROUP BY a.module_id")}
113
+ deps, users = defaultdict(set), defaultdict(set)
114
+ for sql in ("SELECT src_id, dst_id FROM calls",
115
+ "SELECT src_id, dst_id FROM edges WHERE kind IN ('imports','uses_type','instantiates','extends','implements')"):
116
+ for r in con.execute(sql): # read as they come: held all at once, millions of rows were GBs
117
+ a, b = module.get(r[0], r[0] if r[0] in mods else None), module.get(r[1], r[1] if r[1] in mods else None)
118
+ if a and b and a != b and a in mods and b in mods:
119
+ deps[a].add(b)
120
+ users[b].add(a)
121
+ langs = [r[0] for r in q("SELECT language FROM nodes WHERE kind = 'file' AND repo_id = ? AND language IS NOT NULL"
122
+ " GROUP BY language ORDER BY COUNT(*) DESC", repo_id)]
123
+ n_files = q("SELECT COUNT(*) FROM nodes WHERE kind = 'file' AND repo_id = ?", repo_id)[0][0]
124
+ stops: list[dict] = []
125
+
126
+ def stop(title, kind, ref, text):
127
+ stops.append({"title": title, "ref_kind": kind, "ref_id": ref, "narrative": " ".join(text.split())})
128
+
129
+ # 1. What it is.
130
+ readme = _readme(con, repo_id)
131
+ lang_names = LANGUAGE_NAMES
132
+ stop("What this repository is", "repo", repo_id,
133
+ f"{repo[0]['name']} has {_plural(len(mods), 'module')} and {_plural(n_files, 'source file')}, written in "
134
+ f"{_names([lang_names.get(x, x) for x in langs])}. "
135
+ + (f"{len(test_mods)} of the modules hold tests. " if test_mods else "No tests were found. ")
136
+ + (f"Its README begins: \"{readme}\"" if readme else ""))
137
+
138
+ # 2. Where it starts.
139
+ entries = q("SELECT e.dst_id AS target, n.attrs FROM edges e JOIN nodes n ON n.id = e.src_id"
140
+ " WHERE e.kind = 'exposes' AND n.kind = 'entry_point' AND n.repo_id = ?", repo_id)
141
+ reach = {}
142
+ for f in flows:
143
+ if flow_kind[f["id"]] != "test":
144
+ steps = [r[0] for r in q("SELECT callable_id FROM flow_steps WHERE flow_id = ?", f["id"])]
145
+ reach[f["entry_id"]] = (f["id"], len(steps), {module.get(s) for s in steps} - {None})
146
+ path_of = {r["id"]: r["path"] for r in q("SELECT id, path FROM nodes WHERE repo_id = ? AND kind IN ('callable', 'test')", repo_id)}
147
+ all_starts = [e["target"] for e in entries if e["target"] in reach]
148
+ # An entry that reaches almost nothing (a build script, a one-line sample) teaches little.
149
+ starts = sorted((t for t in all_starts if reach[t][1] > 8),
150
+ key=lambda t: (module.get(t) not in core, -len(reach[t][2]), -reach[t][1], t))
151
+ if not starts and core:
152
+ lib = max(core, key=lambda m: len(users[m]))
153
+ stop("No single place to start", "node", lib,
154
+ "No entry point in the product code leads far, which is what a library looks like: it is entered through "
155
+ "whatever its callers use. "
156
+ + (f"The {_plural(len(all_starts), 'entry point')} found {'is a sample, benchmark or build script' if len(all_starts) == 1 else 'are samples, benchmarks or build scripts'}. " if all_starts else "")
157
+ + "The tests are the best record of how it is meant to be called.")
158
+ picked = []
159
+ for t in starts: # at most two, from different modules
160
+ if len(picked) < 2 and module.get(t) not in {module.get(x) for x in picked}:
161
+ picked.append(t)
162
+ for t in picked:
163
+ fid, n, ms = reach[t]
164
+ m = module.get(t)
165
+ script = names.get(t, "").startswith("<") or "top-level" in names.get(t, "")
166
+ body = f"The top-level code of {path_of.get(t) or 'this file'} in" if script else f"{qual(t)} in"
167
+ stop(f"Where {(path_of.get(t) or '').rsplit('/', 1)[-1] if script else qual(t)} starts", "node", t,
168
+ f"{body} {mods[m]['name'] if m in mods else 'this module'} is a program entry point. From here the code can reach "
169
+ f"{_plural(n - 1, 'function')} in {_plural(len(ms), 'module')}"
170
+ + (f": {_names(sorted(mods[x]['name'] for x in ms if x in mods))}." if ms else ".")
171
+ + " Open Flows to walk that path in order.")
172
+ if len(all_starts) > len(picked) and picked:
173
+ stops[-1]["narrative"] += f" There are {len(all_starts) - len(picked)} more entry points; the repository panel lists them."
174
+
175
+ # 3. The modules, most depended-on first.
176
+ order = sorted(core, key=lambda m: (-len(users[m] & set(core)), -len(users[m]), -(size.get(m, (0, 0))[1] or 0), m))
177
+ top_types = lambda m: list(dict.fromkeys(r["name"] for r in q(
178
+ "SELECT n.name, COUNT(*) AS c FROM edges e JOIN nodes n ON n.id = e.dst_id JOIN ancestry a ON a.node_id = n.id"
179
+ " WHERE a.module_id = ? AND n.kind = 'type' AND e.kind IN ('uses_type','instantiates','extends','implements')"
180
+ " GROUP BY n.id ORDER BY c DESC, n.name LIMIT 8", m)))[:4]
181
+ systems = defaultdict(list)
182
+ for r in q("SELECT n.id, n.parent_id, n.name, (SELECT value FROM annotations a WHERE a.node_id = n.id AND a.key = 'name'"
183
+ " ORDER BY layer = 'intent' DESC LIMIT 1) AS given FROM nodes n WHERE n.kind = 'system'"):
184
+ systems[r["parent_id"]].append(r["given"] or r["name"])
185
+ for k, m in enumerate(order[:5]):
186
+ files, loc = size.get(m, (0, 0))
187
+ d, u = sorted(mods[x]["name"] for x in deps[m]), sorted(mods[x]["name"] for x in users[m])
188
+ role = ("Nothing else in the repository is needed to read it, so it is the place to start." if not d and u else
189
+ "Nothing depends on it, so it is an end point: an application, a tool or a host for the code below it." if d and not u else
190
+ "It stands alone: no other module uses it and it uses none." if not d and not u else "")
191
+ tt = top_types(m)
192
+ stop(("The foundation: " if k == 0 and u else "Module: ") + mods[m]["name"], "node", m,
193
+ f"{mods[m]['name']} has {_plural(files, 'file')} and about {loc:,} lines. "
194
+ + (f"It is used by {_names(u)}. " if u else "")
195
+ + (f"It depends on {_names(d)}. " if d else "")
196
+ + role + " "
197
+ + (f"Its most used types are {_names(tt)}. " if tt else "")
198
+ + (f"Clustering splits it into {len(systems[m])} groups: {_names(sorted(systems[m]), 5)}." if systems.get(m) else ""))
199
+ if len(order) > 5:
200
+ stops[-1]["narrative"] += f" {len(order) - 5} smaller modules are left out of this tour."
201
+
202
+ # 4. The main abstractions and boundaries, from the pattern labels.
203
+ try:
204
+ from . import patterns
205
+ labels = patterns.listing(con, limit=500)["patterns"]
206
+ except Exception:
207
+ labels = []
208
+ weight = lambda p: (p["confidence"], sum(len(v) for v in p["roles"].values()))
209
+ shapes = sorted((p for p in labels if p["pattern"] in ("strategy", "composite", "decorator", "template method", "builder")
210
+ and p["confidence"] >= 0.7), key=weight, reverse=True)
211
+ seen_kinds = set()
212
+ for p in shapes:
213
+ if p["pattern"] in seen_kinds or len(seen_kinds) >= 3:
214
+ continue
215
+ seen_kinds.add(p["pattern"])
216
+ stop(f"A {p['pattern']} to know", "pattern", p["id"], f"{p['rationale']} {p['about']}")
217
+ edges_ = sorted((x for x in labels if x["pattern"] in ("process boundary", "observer")),
218
+ key=lambda x: (x["pattern"] != "process boundary", "standard input" not in x["rationale"]))
219
+ shown = set()
220
+ for p in edges_:
221
+ if p["pattern"] in shown:
222
+ continue # one of each kind; the rest are in the pattern list
223
+ shown.add(p["pattern"])
224
+ stop("A boundary" if p["pattern"] == "process boundary" else "An event", "pattern", p["id"],
225
+ f"{p['rationale']} A change on one side of this needs a matching change on the other, and the compiler will not say so.")
226
+
227
+ # State with more than one writer.
228
+ try:
229
+ from . import query
230
+ shared = [f for f in query.shared_state(con, limit=100000)["fields"] if len(f["written_from"]) >= 2]
231
+ except Exception:
232
+ shared = []
233
+ if shared:
234
+ f = shared[0]
235
+ stop("State changed from several places", "node", f["id"],
236
+ f"{f['name']} is assigned by {_plural(f['writers'], 'function')} in {_names(f['written_from'])}, none of them the type "
237
+ f"that declares it, and read by {_plural(f['readers'], 'function')}. No single piece of code keeps it valid, so a "
238
+ f"change to what it means has to be checked at every one of those places. "
239
+ + (f"{len(shared) - 1} more fields are assigned from two or more other types; `leyline state` lists them." if len(shared) > 1 else ""))
240
+
241
+ # 5. A path worth tracing: a test that crosses the most modules without being huge.
242
+ best = None
243
+ sized = store.flow_callables(con, 6, 80)
244
+ core_set = set(core)
245
+ for f in flows:
246
+ if flow_kind[f["id"]] != "test" or f["id"] not in sized:
247
+ continue
248
+ steps = sized[f["id"]]
249
+ if 6 <= len(steps) <= 80:
250
+ ms = {module.get(s) for s in steps} - {None}
251
+ score = (len(ms & core_set), -abs(len(steps) - 25))
252
+ if best is None or score > best[0]:
253
+ best = (score, f, len(steps), ms)
254
+ if best:
255
+ _, f, n, ms = best
256
+ stop("A path worth tracing", "flow", f["id"],
257
+ f"The test \"{f['name']}\" passes through {_plural(n, 'function')} in {_names(sorted(mods[x]['name'] for x in ms if x in mods))}. "
258
+ f"Reading it top to bottom shows how those modules work together on one concrete case.")
259
+
260
+ # 6. Tests, and what the map does not show.
261
+ n_tests = sum(1 for f in flows if flow_kind[f["id"]] == "test")
262
+ if n_tests:
263
+ tested = {r[0] for r in q(store.TESTED)}
264
+ fns = [r[0] for r in q("SELECT id, path FROM nodes WHERE kind = 'callable' AND repo_id = ?", repo_id)
265
+ if module.get(r[0]) in core and r[1] not in test_files]
266
+ on = sum(1 for i in fns if i in tested)
267
+ home = sorted(test_mods, key=lambda m: -(size.get(m, (0, 0))[1] or 0))
268
+ from . import coverage as measured
269
+ m = measured.summary(con)
270
+ watched = sum(x["functions"] for x in m.get("modules", []))
271
+ if m.get("imported") and watched:
272
+ did = sum(x["ran"] for x in m["modules"])
273
+ off = sum(x["ran_off_every_path"] for x in m["modules"])
274
+ never = sum(x["path_but_never_ran"] for x in m["modules"])
275
+ tail = (f"Coverage was measured: {did:,} of the {watched:,} functions in the watched files ran ({round(100 * did / watched)}%). "
276
+ f"{off:,} of those ran without being on any test's path on the map, so they were reached through links the map "
277
+ f"does not have; {never:,} are on a path and never ran.")
278
+ else:
279
+ tail = ("That is reachability read from the source, not measured coverage: a function on a path may sit behind a "
280
+ "branch the test never takes. Import a coverage file to see what ran.")
281
+ stop("How it is tested", "node", home[0],
282
+ f"There are {_plural(n_tests, 'test')} in {_names([mods[m_]['name'] for m_ in home if m_ in mods])}. "
283
+ f"{on:,} of the {len(fns):,} functions outside test code are on some test's path ({round(100 * on / max(1, len(fns)))}%). " + tail)
284
+ cov = q("SELECT extractor, status, stats FROM extractor_coverage WHERE repo_id = ?", repo_id)
285
+ blind = sorted(c["extractor"].split(":", 1)[-1] for c in cov if c["status"] == "not_analyzed" and c["extractor"].startswith("communicates"))
286
+ guess = q("SELECT COUNT(*) FROM calls WHERE precision = 'guess'")[0][0]
287
+ total = q("SELECT COUNT(*) FROM calls")[0][0]
288
+ open_calls = sum((json.loads(c["stats"] or "{}").get("calls_unresolved", 0) + json.loads(c["stats"] or "{}").get("calls_guess_declined", 0))
289
+ for c in cov if c["extractor"].startswith(("tree-sitter", "generic")))
290
+ stop("What this map cannot see", "repo", repo_id,
291
+ f"Links come from reading syntax, not from a compiler. Of {total:,} call links, {guess:,} are guesses by name, and "
292
+ f"{open_calls:,} call sites could not be tied to a function at all. "
293
+ + (f"Connections through {_names(blind, 6)} are not analyzed, so two parts that talk that way look unconnected. " if blind else "")
294
+ + "Flows show what can run, not what did run.")
295
+
296
+ tid = f"tour:orientation:{repo_id}"
297
+ _write(con, tid, f"Orientation: {repo[0]['name']}", "someone new to the repository", "fact", SOURCE, repo_id, stops)
298
+ return {"id": tid, "stops": len(stops)}
299
+
300
+
301
+ def _write(con, tid, title, audience, layer, source, repo_id, stops) -> None:
302
+ now = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
303
+ with con:
304
+ con.execute("DELETE FROM tour_stops WHERE tour_id = ?", (tid,))
305
+ con.execute("INSERT OR REPLACE INTO tours (id, title, audience, layer, source, repo_id, created) VALUES (?,?,?,?,?,?,?)",
306
+ (tid, title, audience, layer, source, repo_id, now))
307
+ con.executemany("INSERT INTO tour_stops (tour_id, seq, ref_kind, ref_id, narrative, title) VALUES (?,?,?,?,?,?)",
308
+ [(tid, k, s["ref_kind"], s["ref_id"], s["narrative"], s.get("title") or "") for k, s in enumerate(stops)])
309
+
310
+
311
+ def _exists(con, kind: str, ref: str) -> bool:
312
+ table = {"node": "nodes", "repo": "nodes", "flow": "flows", "pattern": "pattern_instances", "view": "views"}[kind]
313
+ return con.execute(f"SELECT 1 FROM {table} WHERE id = ?", (ref,)).fetchone() is not None
314
+
315
+
316
+ def save(con, title: str, stops: list[dict], audience: str = "", source: str = "mcp", layer: str = "inferred") -> dict:
317
+ """Save an authored tour. Each stop is {"title", "ref": id, "kind": node|flow|pattern|view|repo, "narrative"}."""
318
+ clean, missing = [], []
319
+ for s in stops:
320
+ kind = s.get("kind") or s.get("ref_kind") or "node"
321
+ ref = s.get("ref") or s.get("ref_id") or s.get("id")
322
+ if kind not in REF_KINDS:
323
+ return {"error": f"stop kind must be one of {REF_KINDS}"}
324
+ if not ref or not _exists(con, kind, ref):
325
+ missing.append(ref)
326
+ continue
327
+ if not (s.get("narrative") or "").strip():
328
+ return {"error": "every stop needs a narrative: what to notice here and why it matters"}
329
+ clean.append({"title": s.get("title") or "", "ref_kind": kind, "ref_id": ref, "narrative": s["narrative"].strip()})
330
+ if not clean:
331
+ return {"error": "no stop refers to something in the store", "missing": missing[:5]}
332
+ tid = "tour:" + hashlib.sha1(title.encode()).hexdigest()[:8]
333
+ repo = con.execute("SELECT id FROM nodes WHERE kind = 'repo' LIMIT 1").fetchone()
334
+ _write(con, tid, title, audience, layer, source, repo[0] if repo else None, clean)
335
+ return {"id": tid, "title": title, "stops": len(clean), "missing": missing[:10]}
336
+
337
+
338
+ def listing(con) -> dict:
339
+ out = [{"id": t["id"], "title": t["title"], "audience": t["audience"], "source": t["source"], "layer": t["layer"],
340
+ "stops": con.execute("SELECT COUNT(*) FROM tour_stops WHERE tour_id = ?", (t["id"],)).fetchone()[0]}
341
+ for t in con.execute("SELECT * FROM tours ORDER BY source = ? DESC, created DESC", (SOURCE,))]
342
+ return {"total": len(out), "tours": out}
343
+
344
+
345
+ def get(con, tour_id: str) -> dict:
346
+ t = con.execute("SELECT * FROM tours WHERE id = ?", (tour_id,)).fetchone()
347
+ if t is None:
348
+ return {"error": f"No tour {tour_id!r}. Use `tours` to list them."}
349
+ stops = [{"seq": s["seq"] + 1, "title": s["title"], "kind": s["ref_kind"], "ref": s["ref_id"], "narrative": s["narrative"],
350
+ "exists": _exists(con, s["ref_kind"], s["ref_id"])}
351
+ for s in con.execute("SELECT * FROM tour_stops WHERE tour_id = ? ORDER BY seq", (tour_id,))]
352
+ return {"id": t["id"], "title": t["title"], "audience": t["audience"], "source": t["source"], "layer": t["layer"], "stops": stops}