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/store.py ADDED
@@ -0,0 +1,560 @@
1
+ """The SQLite store. Everything else reads and writes through this module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import array
6
+ import hashlib
7
+ import itertools
8
+ import json
9
+ import re
10
+ import sqlite3
11
+ from importlib import resources
12
+ from pathlib import Path
13
+ from typing import Iterable, Optional
14
+
15
+ from .model import Edge, Node
16
+
17
+ SCHEMA_VERSION = "0"
18
+
19
+
20
+ BUSY_SECONDS = 60
21
+
22
+
23
+ def _current(con) -> bool:
24
+ """Whether the store is already in this version's shape: the three views in place and the version noted."""
25
+ kinds = {r[0]: r[1] for r in con.execute("SELECT name, type FROM sqlite_master WHERE name IN ('flow_steps', 'calls', 'edges')")}
26
+ if kinds != {"flow_steps": "view", "calls": "view", "edges": "view"}:
27
+ return False
28
+ row = con.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
29
+ return row is not None and row[0] == SCHEMA_VERSION
30
+
31
+
32
+ def write_file(path: str | Path, data: str | bytes) -> None:
33
+ """Write a file Leyline makes inside a repository (a page, the store's folder, a skill) without following a link
34
+ at its name. The repository may be someone else's, and a link committed where Leyline writes (`.leyline/map.html`
35
+ pointing at ~/.bashrc) would otherwise have Leyline write over the file it points at. The text goes to a new
36
+ file beside it, which then takes the name's place: a link there is replaced, not written through."""
37
+ import os
38
+ import tempfile
39
+ path = Path(path)
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ # The mode of the file it replaces, unless that is a link: a temporary file is private; the file it becomes is not.
42
+ mode = path.stat().st_mode & 0o777 if path.is_file() and not path.is_symlink() else 0o644
43
+ fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", suffix=".tmp", dir=path.parent)
44
+ try:
45
+ # Also whole or not at all: a write that fails part way (a full disk, a killed process) leaves the file as it was.
46
+ with os.fdopen(fd, "wb" if isinstance(data, bytes) else "w",
47
+ **({} if isinstance(data, bytes) else {"encoding": "utf-8", "newline": "\n"})) as f:
48
+ f.write(data)
49
+ os.chmod(tmp, mode)
50
+ os.replace(tmp, path)
51
+ except BaseException:
52
+ Path(tmp).unlink(missing_ok=True)
53
+ raise
54
+
55
+
56
+ def inside(path: str | Path, root: str | Path, resolved: bool = False) -> bool:
57
+ """Whether `path`, links followed, is `root` or under it: a file Leyline reads from a repository into a page or an
58
+ answer (a README, a change's proposal) must not be a link to one outside it, such as ~/.aws/credentials.
59
+ `resolved`: root is already the real path (a caller checking many files under it resolves it once)."""
60
+ try:
61
+ real, top = Path(path).resolve(), (Path(root) if resolved else Path(root).resolve())
62
+ except (OSError, RuntimeError):
63
+ return False
64
+ return real == top or top in real.parents
65
+
66
+
67
+ class UntrustedStore(RuntimeError):
68
+ """A store folder Leyline will not use: the repository tracks files in it, or it links outside the repository."""
69
+
70
+
71
+ TRUST_ENV = "LEYLINE_TRUST_STORE"
72
+ _TRACKED: dict = {} # (folder, the git index's mtime) -> the files git tracks in the folder
73
+
74
+
75
+ def check_folder(folder: str | Path) -> None:
76
+ """Refuse a .leyline/ folder that is not Leyline's own. Its files are local and rebuilt by `leyline map`, so a
77
+ repository that tracks some (a pull request can commit a store with annotations, resolved findings and rules of
78
+ its making, or a snapshot or review page, and checking it out writes over the ignored copy) is supplying them,
79
+ and a .leyline that is a link to a folder outside the repository would have Leyline write there.
80
+ LEYLINE_TRUST_STORE=1 uses the folder anyway. A store kept anywhere else (--db) is the person's choice."""
81
+ import os
82
+ import subprocess
83
+ folder = Path(folder).absolute()
84
+ if folder.name != ".leyline" or os.environ.get(TRUST_ENV) == "1":
85
+ return
86
+ if folder.is_symlink() and not inside(folder, folder.parent):
87
+ raise UntrustedStore(f"{folder} is a link to {folder.resolve()}, outside the repository: Leyline does not write"
88
+ f" its store there. Remove the link (or keep the store elsewhere with --db or LEYLINE_DB),"
89
+ f" or set {TRUST_ENV}=1 if you made it.")
90
+ top = next((d for d in folder.parents if (d / ".git").exists()), None)
91
+ if top is None or not folder.is_dir():
92
+ return
93
+ try:
94
+ stamp = (top / ".git" / "index").stat().st_mtime_ns if (top / ".git").is_dir() else (top / ".git").stat().st_mtime_ns
95
+ except OSError:
96
+ stamp = None
97
+ key = (str(folder), stamp)
98
+ if key not in _TRACKED:
99
+ try:
100
+ run = subprocess.run(["git", "-C", str(folder), "ls-files", "-z", "--full-name", "--", "."],
101
+ capture_output=True, timeout=60, stdin=subprocess.DEVNULL)
102
+ listed = [n for n in run.stdout.decode("utf-8", "replace").split("\0") if n] if run.returncode == 0 else []
103
+ except (OSError, subprocess.SubprocessError):
104
+ listed = []
105
+ _TRACKED.clear() if len(_TRACKED) > 100 else None
106
+ _TRACKED[key] = listed
107
+ tracked = _TRACKED[key]
108
+ if tracked:
109
+ shown = ", ".join(tracked[:5]) + (f" and {len(tracked) - 5} more" if len(tracked) > 5 else "")
110
+ raise UntrustedStore(f"the repository at {top} tracks files in Leyline's store folder ({shown}). They are not"
111
+ " Leyline's: a store, snapshot or review page committed there may say whatever its author"
112
+ " wanted. Stop tracking them (`git rm -r --cached .leyline`, then map again), or keep the"
113
+ " store outside the repository (--db /elsewhere/leyline.db, or LEYLINE_DB), or set"
114
+ f" {TRUST_ENV}=1 to use them as they are.")
115
+
116
+
117
+ def escapes(folder: str | Path) -> Optional[str]:
118
+ """Why Leyline writes nothing into `folder` (a change folder, openspec/), or None: it is a link, or under one, to a
119
+ place outside the repository it sits in (the folder above openspec/, else the nearest with .git). A repository
120
+ can commit openspec/changes/x as a link to any folder, and the page written there would land outside it."""
121
+ folder = Path(folder).absolute()
122
+ chain = [folder, *folder.parents]
123
+ top = (next((p.parent for p in chain if p.name == "openspec"), None)
124
+ or next((p for p in chain if (p / ".git").exists()), None))
125
+ if top is None or inside(folder, top):
126
+ return None
127
+ return (f"{folder} is a link to {folder.resolve()}, outside the repository at {top}: Leyline writes nothing there"
128
+ " (a repository can commit such a link to have files written anywhere)")
129
+
130
+
131
+ def connect(db_path: str | Path) -> sqlite3.Connection:
132
+ db_path = Path(db_path)
133
+ check_folder(db_path.parent)
134
+ db_path.parent.mkdir(parents=True, exist_ok=True)
135
+ # Another leyline run may be writing (a map while a plan starts): wait for it rather than fail at once.
136
+ con = sqlite3.connect(str(db_path), timeout=BUSY_SECONDS)
137
+ try:
138
+ return _prepare(con)
139
+ except BaseException: # a store another run holds, or one that is not a store: the caller gets no connection
140
+ con.close()
141
+ raise
142
+
143
+
144
+ def _prepare(con: sqlite3.Connection) -> sqlite3.Connection:
145
+ con.row_factory = sqlite3.Row
146
+ con.execute("PRAGMA journal_mode=WAL")
147
+ con.execute("PRAGMA foreign_keys=OFF") # edges may point at nodes written later in a run
148
+ con.executescript(resources.files("leyline").joinpath("schema.sql").read_text(encoding="utf-8"))
149
+ have = {r[1] for r in con.execute("PRAGMA table_info(rules)")}
150
+ for col in ("status", "source", "created"):
151
+ if col not in have:
152
+ con.execute(f"ALTER TABLE rules ADD COLUMN {col} TEXT")
153
+ for table, cols in (("tours", ("repo_id", "created")), ("tour_stops", ("title",)), ("pattern_instances", ("attrs",))):
154
+ have = {r[1] for r in con.execute(f"PRAGMA table_info({table})")}
155
+ for col in cols:
156
+ if col not in have:
157
+ con.execute(f"ALTER TABLE {table} ADD COLUMN {col} TEXT")
158
+ if _current(con):
159
+ return con # nothing to move: a reader takes no write lock, so it is not held up by a run that is writing
160
+ # One transaction, committed here: moving an older store's rows drops the old table, and a caller that only
161
+ # reads and never commits would otherwise roll back the moved rows and lose them. IMMEDIATE takes the write
162
+ # lock first: a deferred one that read first could not wait for a writer and would fail at once.
163
+ con.execute("BEGIN IMMEDIATE")
164
+ try:
165
+ _flow_steps_view(con)
166
+ _calls_view(con)
167
+ _edges_view(con)
168
+ con.execute("INSERT OR REPLACE INTO meta VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
169
+ con.commit()
170
+ except BaseException:
171
+ con.rollback()
172
+ raise
173
+ return con
174
+
175
+
176
+ # Every callable on some test's path, as `SELECT DISTINCT callable_id FROM flow_steps` over the test flows
177
+ # would give it. Done on the keyed table: the flows are filtered first (reading the kind out of each flow's
178
+ # attrs once per step was most of the cost on a large repo), and only the distinct callables are turned
179
+ # back into ids.
180
+ TESTED = ("SELECT id AS callable_id FROM keys WHERE k IN (SELECT DISTINCT callable FROM steps WHERE flow IN"
181
+ " (SELECT k FROM keys WHERE id IN (SELECT id FROM flows WHERE json_extract(attrs, '$.kind') = 'test')))")
182
+
183
+
184
+ def flow_callables(con, least: int, most: int) -> dict:
185
+ """flow id -> the callable ids of its steps in order, for every flow of least..most steps. One pass over the
186
+ keyed table: a query per flow cost seconds on a large repo, and through the view every step of every flow
187
+ would be joined to its ids just to count them."""
188
+ out: dict[str, list] = {}
189
+ for f, c in con.execute(
190
+ "SELECT fk.id, ck.id FROM steps s JOIN keys fk ON fk.k = s.flow JOIN keys ck ON ck.k = s.callable"
191
+ " WHERE s.flow IN (SELECT flow FROM steps GROUP BY flow HAVING COUNT(*) BETWEEN ? AND ?)"
192
+ " ORDER BY s.flow, s.seq", (least, most)):
193
+ out.setdefault(f, []).append(c)
194
+ return out
195
+
196
+
197
+ VIA = ("start", "calls", "runs", "dispatch", "event", "process", "http", "file", "channel", "di", "queue", "rpc", "db")
198
+ _VIA_CASE = "CASE s.via " + " ".join(f"WHEN {i} THEN '{v}'" for i, v in enumerate(VIA)) + " END"
199
+
200
+
201
+ def _flow_steps_view(con) -> None:
202
+ """`flow_steps` as readers know it, over the integer tables. A store written before the change has a
203
+ real `flow_steps` table: its rows are moved across once."""
204
+ kind = con.execute("SELECT type FROM sqlite_master WHERE name = 'flow_steps'").fetchone()
205
+ if kind is not None and kind[0] == "table":
206
+ rows = con.execute("SELECT flow_id, seq, depth, callable_id, via, site_line, parent_seq FROM flow_steps").fetchall()
207
+ con.execute("DROP TABLE flow_steps")
208
+ _insert_steps(con, rows)
209
+ kind = None
210
+ if kind is None:
211
+ con.execute(f"""CREATE VIEW flow_steps AS
212
+ SELECT fk.id AS flow_id, s.seq, s.depth, ck.id AS callable_id, NULL AS edge_id, {_VIA_CASE} AS via,
213
+ s.site_line, s.parent_seq
214
+ FROM steps s JOIN keys fk ON fk.k = s.flow JOIN keys ck ON ck.k = s.callable""")
215
+
216
+
217
+ def _calls_view(con) -> None:
218
+ """`calls` as readers know it, over `call_sites`, which holds the two ends as keys. A store written before
219
+ the change has a real `calls` table: its rows are moved across once. The old table's index happened to
220
+ return whole-table groups sorted by callee, then caller; through the view a reader that needs that order
221
+ asks for it."""
222
+ kind = con.execute("SELECT type FROM sqlite_master WHERE name = 'calls'").fetchone()
223
+ if kind is not None and kind[0] == "table":
224
+ rows = con.execute("SELECT src_id, dst_id, dispatch, precision, site_start, site_end, hit_count, commit_sha"
225
+ " FROM calls ORDER BY rowid").fetchall()
226
+ con.execute("DROP TABLE calls")
227
+ _insert_calls(con, rows)
228
+ kind = None
229
+ if kind is None:
230
+ con.execute("""CREATE VIEW calls AS
231
+ SELECT sk.id AS src_id, dk.id AS dst_id, c.dispatch, c.precision, c.site_start, c.site_end, c.hit_count,
232
+ c.commit_sha
233
+ FROM call_sites c JOIN keys sk ON sk.k = c.src JOIN keys dk ON dk.k = c.dst""")
234
+
235
+
236
+ EDGE_COLUMNS = "id, kind, src_id, dst_id, precision, layer, source, commit_sha, attrs"
237
+
238
+
239
+ def _edges_view(con) -> None:
240
+ """`edges` as readers and writers know it, over `links`, which holds the two ends as keys. A store written
241
+ before the change has a real `edges` table: its rows are moved across once, keeping their ids. Inserts
242
+ into and deletes from the view go to `links` through triggers."""
243
+ kind = con.execute("SELECT type FROM sqlite_master WHERE name = 'edges'").fetchone()
244
+ if kind is not None and kind[0] == "table":
245
+ rows = con.execute(f"SELECT {EDGE_COLUMNS} FROM edges ORDER BY id").fetchall()
246
+ con.execute("DROP TABLE edges")
247
+ insert_edges(con, rows)
248
+ kind = None
249
+ if kind is None:
250
+ con.execute("""CREATE VIEW edges AS
251
+ SELECT l.id, l.kind, sk.id AS src_id, dk.id AS dst_id, l.precision, l.layer, l.source, l.commit_sha, l.attrs
252
+ FROM links l JOIN keys sk ON sk.k = l.src JOIN keys dk ON dk.k = l.dst""")
253
+ con.execute("""CREATE TRIGGER IF NOT EXISTS edges_insert INSTEAD OF INSERT ON edges BEGIN
254
+ INSERT OR IGNORE INTO keys (id) VALUES (NEW.src_id);
255
+ INSERT OR IGNORE INTO keys (id) VALUES (NEW.dst_id);
256
+ INSERT INTO links (id, kind, src, dst, precision, layer, source, commit_sha, attrs)
257
+ VALUES (NEW.id, NEW.kind, (SELECT k FROM keys WHERE id = NEW.src_id), (SELECT k FROM keys WHERE id = NEW.dst_id),
258
+ NEW.precision, COALESCE(NEW.layer, 'fact'), NEW.source, NEW.commit_sha, NEW.attrs);
259
+ END""")
260
+ con.execute("CREATE TRIGGER IF NOT EXISTS edges_delete INSTEAD OF DELETE ON edges BEGIN"
261
+ " DELETE FROM links WHERE id = OLD.id; END")
262
+
263
+
264
+ def insert_edges(con, rows) -> None:
265
+ """rows: (id or None, kind, src_id, dst_id, precision, layer, source, commit_sha, attrs). Straight into `links`,
266
+ which is quicker for many rows than the view's trigger."""
267
+ key = _keys(con, itertools.chain.from_iterable((r[2], r[3]) for r in rows))
268
+ con.executemany("INSERT INTO links (id, kind, src, dst, precision, layer, source, commit_sha, attrs)"
269
+ " VALUES (?,?,?,?,?,?,?,?,?)", ((r[0], r[1], key[r[2]], key[r[3]], *r[4:]) for r in rows))
270
+
271
+
272
+ def _insert_calls(con, rows) -> None:
273
+ """rows: (src_id, dst_id, dispatch, precision, site_start, site_end, hit_count, commit_sha)"""
274
+ key = _keys(con, itertools.chain.from_iterable((r[0], r[1]) for r in rows))
275
+ con.executemany("INSERT INTO call_sites (src, dst, dispatch, precision, site_start, site_end, hit_count, commit_sha)"
276
+ " VALUES (?,?,?,?,?,?,?,?)", ((key[r[0]], key[r[1]], *r[2:]) for r in rows))
277
+
278
+
279
+ def _keys(con, ids) -> dict:
280
+ ids = list(dict.fromkeys(ids))
281
+ con.executemany("INSERT OR IGNORE INTO keys (id) VALUES (?)", ((i,) for i in ids))
282
+ out = {}
283
+ for i in range(0, len(ids), 900):
284
+ chunk = ids[i:i + 900]
285
+ out.update((r[1], r[0]) for r in con.execute(f"SELECT k, id FROM keys WHERE id IN ({','.join('?' * len(chunk))})", chunk))
286
+ return out
287
+
288
+
289
+ def _insert_steps(con, steps, reindex: bool = True) -> None:
290
+ """steps: (flow id, seq, depth, callable id, via, site line, parent seq) rows. A collection that can list
291
+ the flow ids and then the callable ids itself (indexer.FlowSteps) saves a pass over millions of rows."""
292
+ ids = getattr(steps, "ids", None)
293
+ key = _keys(con, ids() if ids else itertools.chain((s[0] for s in steps), (s[3] for s in steps)))
294
+ via = {v: i for i, v in enumerate(VIA)}
295
+ # Millions of rows on a large repo: the callable index is built once at the end rather than kept up to date
296
+ # row by row, and the rows are made as they are inserted, not held in a second list. A few rows added to a
297
+ # large table (reindex False) go in with the index kept.
298
+ if reindex:
299
+ con.execute("DROP INDEX IF EXISTS steps_callable")
300
+ con.executemany("INSERT OR REPLACE INTO steps (flow, seq, depth, callable, via, site_line, parent_seq) VALUES (?,?,?,?,?,?,?)",
301
+ ((key[f], seq, depth, key[c], via.get(v, via["channel"]), line, parent)
302
+ for f, seq, depth, c, v, line, parent in steps))
303
+ con.execute("CREATE INDEX IF NOT EXISTS steps_callable ON steps(callable, flow)")
304
+
305
+
306
+ def clear_facts(con: sqlite3.Connection, repo_id: str) -> None:
307
+ """Drop every fact row for a repo. Inferred and intent rows are left alone."""
308
+ ids = "SELECT id FROM nodes WHERE repo_id = ? AND layer = 'fact'"
309
+ con.execute(f"DELETE FROM call_sites WHERE src IN (SELECT k FROM keys WHERE id IN ({ids}))", (repo_id,))
310
+ con.execute(f"DELETE FROM links WHERE layer = 'fact' AND src IN (SELECT k FROM keys WHERE id IN ({ids}))", (repo_id,))
311
+ con.execute(f"DELETE FROM ancestry WHERE node_id IN ({ids})", (repo_id,))
312
+ con.execute(f"DELETE FROM search WHERE node_id IN ({ids})", (repo_id,))
313
+ con.execute(f"DELETE FROM steps WHERE flow IN (SELECT k FROM keys WHERE id IN (SELECT id FROM flows WHERE entry_id IN ({ids})))",
314
+ (repo_id,))
315
+ con.execute(f"DELETE FROM flows WHERE layer = 'fact' AND entry_id IN ({ids})", (repo_id,))
316
+ con.execute("DELETE FROM nodes WHERE repo_id = ? AND layer = 'fact'", (repo_id,))
317
+ con.execute("DELETE FROM extractor_coverage WHERE repo_id = ?", (repo_id,))
318
+
319
+
320
+ def write_nodes(con, nodes: Iterable[Node], repo_id: str, source: str, commit: str | None) -> None:
321
+ # Rows are made as they are inserted, not held in a list: on a large repository that list was hundreds of MB.
322
+ con.executemany(
323
+ "INSERT OR IGNORE INTO nodes (id, kind, name, parent_id, repo_id, language, path,"
324
+ " span_start, span_end, content_hash, layer, source, commit_sha, attrs)"
325
+ " VALUES (?,?,?,?,?,?,?,?,?,?,'fact',?,?,?)",
326
+ ((n.id, n.kind, n.name, n.parent_id, repo_id, n.language, n.path, n.span_start,
327
+ n.span_end, n.content_hash, source, commit, json.dumps(n.attrs) if n.attrs else None)
328
+ for n in nodes))
329
+
330
+
331
+ def write_edges(con, edges: Iterable[Edge], source: str, commit: str | None) -> None:
332
+ edges = edges if isinstance(edges, list) else list(edges)
333
+ key = _keys(con, itertools.chain.from_iterable((e.src_id, e.dst_id) for e in edges))
334
+ con.executemany("INSERT INTO links (kind, src, dst, precision, layer, source, commit_sha, attrs) VALUES (?,?,?,?,'fact',?,?,?)",
335
+ ((e.kind, key[e.src_id], key[e.dst_id], e.precision, source, commit,
336
+ json.dumps(e.attrs) if e.attrs else None) for e in edges))
337
+
338
+
339
+ def write_calls(con, rows: Iterable[tuple], commit: str | None) -> None:
340
+ """rows: (src_id, dst_id, dispatch, precision, line)"""
341
+ rows = rows if isinstance(rows, list) else list(rows)
342
+ key = _keys(con, itertools.chain.from_iterable((r[0], r[1]) for r in rows))
343
+ con.executemany("INSERT INTO call_sites (src, dst, dispatch, precision, site_start, site_end, hit_count, commit_sha)"
344
+ " VALUES (?,?,?,?,?,?,0,?)", ((key[s], key[d], disp, prec, line, line, commit) for s, d, disp, prec, line in rows))
345
+
346
+
347
+ def write_flows(con, repo_id: str, flows, steps, reindex: bool = True) -> None:
348
+ con.executemany(
349
+ "INSERT OR REPLACE INTO flows (id, name, origin, entry_id, weight, group_id, layer, source, attrs)"
350
+ " VALUES (?,?,?,?,?,?,?,?,?)",
351
+ [(f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7], json.dumps(f[8])) for f in flows])
352
+ _insert_steps(con, steps, reindex)
353
+
354
+
355
+ def evidence_hash(con, evidence: Iterable[str]) -> str:
356
+ """A hash of the files the evidence nodes live in. It changes when any of them is edited."""
357
+ parts = []
358
+ for node_id in sorted(set(evidence)):
359
+ row = con.execute(
360
+ "SELECT COALESCE(f.content_hash, n.content_hash, '') FROM nodes n"
361
+ " LEFT JOIN ancestry a ON a.node_id = n.id LEFT JOIN nodes f ON f.id = a.file_id WHERE n.id = ?",
362
+ (node_id,)).fetchone()
363
+ parts.append(f"{node_id}:{row[0] if row else 'missing'}")
364
+ return hashlib.sha1("|".join(parts).encode()).hexdigest()
365
+
366
+
367
+ def annotate(con, node_id: str, key: str, value: str, layer: str, source: str,
368
+ confidence: float | None, evidence: list[str]) -> dict:
369
+ """Write one inferred or intent statement about a node, replacing an earlier one with the same key."""
370
+ if layer not in ("inferred", "intent"):
371
+ raise ValueError("layer must be 'inferred' or 'intent'; facts come only from extractors")
372
+ if con.execute("SELECT 1 FROM nodes WHERE id = ?", (node_id,)).fetchone() is None:
373
+ raise ValueError(f"no node with id {node_id!r}")
374
+ if layer == "inferred":
375
+ if not evidence:
376
+ raise ValueError("an inferred annotation needs evidence: the node ids it is based on")
377
+ missing = [e for e in evidence if con.execute("SELECT 1 FROM nodes WHERE id = ?", (e,)).fetchone() is None]
378
+ if missing:
379
+ raise ValueError(f"evidence ids not in the store: {missing[:3]}")
380
+ with con:
381
+ con.execute("DELETE FROM annotations WHERE node_id = ? AND key = ? AND layer = ?", (node_id, key, layer))
382
+ con.execute(
383
+ "INSERT INTO annotations (node_id, key, value, layer, source, confidence, evidence, evidence_hash, stale)"
384
+ " VALUES (?,?,?,?,?,?,?,?,0)",
385
+ (node_id, key, value, layer, source, confidence, json.dumps(evidence or []),
386
+ evidence_hash(con, evidence or [])))
387
+ return {"node_id": node_id, "key": key, "layer": layer, "evidence": len(evidence or [])}
388
+
389
+
390
+ def refresh_stale(con) -> int:
391
+ """Flag annotations whose evidence changed or whose node is gone. Returns how many are stale."""
392
+ stale = 0
393
+ with con:
394
+ for a in con.execute("SELECT id, node_id, evidence, evidence_hash FROM annotations").fetchall():
395
+ gone = con.execute("SELECT 1 FROM nodes WHERE id = ?", (a["node_id"],)).fetchone() is None
396
+ changed = evidence_hash(con, json.loads(a["evidence"] or "[]")) != a["evidence_hash"]
397
+ flag = 1 if gone or (changed and a["evidence"] not in (None, "[]")) else 0
398
+ con.execute("UPDATE annotations SET stale = ? WHERE id = ?", (flag, a["id"]))
399
+ stale += flag
400
+ return stale
401
+
402
+
403
+ def write_coverage(con, repo_id, extractor, version, status, commit, stats: dict) -> None:
404
+ # Keys sorted: the counts are added up in another order by an incremental run than by a full one.
405
+ con.execute(
406
+ "INSERT OR REPLACE INTO extractor_coverage VALUES (?,?,?,?,?,?)",
407
+ (repo_id, extractor, version, status, commit, json.dumps(stats, sort_keys=True)),
408
+ )
409
+
410
+
411
+ def rebuild_derived(con: sqlite3.Connection, systems_of: Optional[str] = None) -> None:
412
+ """Recompute the ancestry cache and the search index from the node table. `systems_of` is for after
413
+ clustering a repo, which removes and adds only that repo's system nodes, the new ones after every other
414
+ node: the rows of other nodes stay, which gives the same tables as a full rebuild without rewriting every row."""
415
+ have: set = set()
416
+ if systems_of is not None:
417
+ kept = "SELECT id FROM nodes WHERE NOT (kind = 'system' AND repo_id IS ?)"
418
+ con.execute(f"DELETE FROM ancestry WHERE node_id NOT IN ({kept})", (systems_of,))
419
+ con.execute(f"DELETE FROM search WHERE node_id NOT IN ({kept})", (systems_of,))
420
+ have = {r[0] for r in con.execute("SELECT node_id FROM ancestry")}
421
+ else:
422
+ con.execute("DELETE FROM ancestry")
423
+ con.execute("DELETE FROM search")
424
+ # The node table is read three times, as rows come, and in between only each node's kind and parent is held
425
+ # (by number): every row held at once, and every row made before inserting, was most of a GB on a large
426
+ # repository. The same query each time, so the rows come in the same order.
427
+ q = "SELECT id, kind, name, parent_id, path FROM nodes"
428
+ share = {}.setdefault # one string per kind
429
+ index: dict[str, int] = {}
430
+ ids: list[str] = []
431
+ kinds: list[str] = []
432
+ parents: list = []
433
+ for r in con.execute(q):
434
+ index[r[0]] = len(ids)
435
+ ids.append(r[0])
436
+ kinds.append(share(r[1], r[1]))
437
+ parents.append(r[3])
438
+ up = array.array("i", (index.get(p, -1) if p else -1 for p in parents))
439
+ del parents
440
+
441
+ def ancestry():
442
+ for r in con.execute(q):
443
+ if r[0] in have:
444
+ continue
445
+ file_id = module_id = None
446
+ k, seen = index[r[0]], 0
447
+ while k >= 0 and seen < 64:
448
+ kind = kinds[k]
449
+ if kind == "file" and file_id is None:
450
+ file_id = ids[k]
451
+ if kind == "module" and module_id is None:
452
+ module_id = ids[k]
453
+ k = up[k]
454
+ seen += 1
455
+ yield r[0], file_id, module_id
456
+
457
+ con.executemany("INSERT INTO ancestry VALUES (?,?,?)", ancestry())
458
+ con.executemany("INSERT INTO search (node_id, name, qualified, path, kind) VALUES (?,?,?,?,?)",
459
+ ((r[0], r[2], _searchable(r[0]), r[4] or "", r[1]) for r in con.execute(q)
460
+ if r[0] not in have and r[1] not in ("repo",)))
461
+
462
+
463
+ def derive_some(con, gone: Iterable[str], ids: Iterable[str]) -> None:
464
+ """rebuild_derived for a few nodes: drop the ancestry and search rows of `gone` and of `ids`, and make them again
465
+ for `ids` from the node table."""
466
+ ids = list(dict.fromkeys(ids))
467
+ drop = list(dict.fromkeys(itertools.chain(gone, ids)))
468
+ for i in range(0, len(drop), 900):
469
+ chunk = drop[i:i + 900]
470
+ marks = ",".join("?" * len(chunk))
471
+ con.execute(f"DELETE FROM ancestry WHERE node_id IN ({marks})", chunk)
472
+ con.execute(f"DELETE FROM search WHERE node_id IN ({marks})", chunk)
473
+ row = lambda i: con.execute("SELECT id, kind, name, parent_id, path FROM nodes WHERE id = ?", (i,)).fetchone()
474
+ for i in ids:
475
+ r = row(i)
476
+ if r is None:
477
+ continue
478
+ file_id = module_id = None
479
+ cur, seen = r, 0
480
+ while cur is not None and seen < 64:
481
+ if cur["kind"] == "file" and file_id is None:
482
+ file_id = cur["id"]
483
+ if cur["kind"] == "module" and module_id is None:
484
+ module_id = cur["id"]
485
+ cur = row(cur["parent_id"]) if cur["parent_id"] else None
486
+ seen += 1
487
+ con.execute("INSERT INTO ancestry VALUES (?,?,?)", (r["id"], file_id, module_id))
488
+ if r["kind"] != "repo":
489
+ con.execute("INSERT INTO search (node_id, name, qualified, path, kind) VALUES (?,?,?,?,?)",
490
+ (r["id"], r["name"], _searchable(r["id"]), r["path"] or "", r["kind"]))
491
+
492
+
493
+ def _searchable(node_id: str) -> str:
494
+ # Split the qualified part of an id into words so "Simulation Step" finds Simulation.Step.
495
+ tail = node_id.split(":", 2)[-1]
496
+ for ch in ".()/,<>:_":
497
+ tail = tail.replace(ch, " ")
498
+ return tail
499
+
500
+
501
+ def store_file(con) -> Optional[Path]:
502
+ """The file this connection's store lives in, or None for an in-memory store."""
503
+ row = con.execute("PRAGMA database_list").fetchone()
504
+ return Path(row[2]).resolve() if row and row[2] else None
505
+
506
+
507
+ def set_root(con, repo: str, root) -> None:
508
+ """Record where a repository is: as given, and relative to the store, so a store that moves with its
509
+ repository (a copied or renamed checkout, a store kept in .leyline/) still finds the code."""
510
+ root = Path(root).resolve()
511
+ con.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", (f"root:{repo}", str(root)))
512
+ here = store_file(con)
513
+ if here is not None:
514
+ import os
515
+ try:
516
+ con.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", (f"rel:{repo}", os.path.relpath(root, here.parent)))
517
+ except ValueError: # another drive on Windows: no relative path exists
518
+ pass
519
+
520
+
521
+ def roots(con) -> dict:
522
+ """{repo id: the directory its code is in now}. The path relative to the store wins when it leads to a
523
+ directory; a store written before that was recorded, sitting in X/.leyline/ for a single repository,
524
+ belongs to X. Otherwise the path recorded when it was indexed."""
525
+ here = store_file(con)
526
+ meta = {r[0]: r[1] for r in con.execute("SELECT key, value FROM meta WHERE key LIKE 'root:%' OR key LIKE 'rel:%'")}
527
+ out = {}
528
+ repos = [k.split(":", 1)[1] for k in meta if k.startswith("root:")]
529
+ for repo in repos:
530
+ path = Path(meta[f"root:{repo}"])
531
+ rel = meta.get(f"rel:{repo}")
532
+ if here is not None and rel is not None and (here.parent / rel).is_dir():
533
+ path = (here.parent / rel).resolve()
534
+ elif here is not None and rel is None and len(repos) == 1 and here.parent.name == ".leyline" \
535
+ and here.parent.parent.is_dir() and here.parent.parent.resolve() != path:
536
+ path = here.parent.parent.resolve()
537
+ out[repo] = path
538
+ return out
539
+
540
+
541
+ def test_places(con) -> tuple[set, set]:
542
+ """Where the tests are: the files that hold a test (a test node, or the entry of a test flow), and the modules
543
+ that are mostly such files (a test project). A module that holds a few test files beside its product code (a
544
+ TypeScript package with its *.test.ts next to the source) is not a test module: its other files are product."""
545
+ files_of = {}
546
+ for r in con.execute("SELECT n.id, n.path, a.module_id FROM nodes n LEFT JOIN ancestry a ON a.node_id = n.id"
547
+ " WHERE n.kind = 'file' AND n.layer = 'fact'"):
548
+ files_of[r[1]] = r[2]
549
+ tests = {r[0] for r in con.execute("SELECT path FROM nodes WHERE kind = 'test' AND path IS NOT NULL")}
550
+ tests |= {r[0] for r in con.execute("SELECT n.path FROM flows f JOIN nodes n ON n.id = f.entry_id"
551
+ " WHERE json_extract(f.attrs, '$.kind') = 'test' AND n.path IS NOT NULL")}
552
+ per = {}
553
+ for path, mod in files_of.items():
554
+ t, n = per.get(mod, (0, 0))
555
+ per[mod] = (t + (path in tests), n + 1)
556
+ # A test project is named for it (tests/, Foo.Tests, spec), or is nearly all test files: a package with its
557
+ # own test folder can hold as many test files as source files and still be the product.
558
+ named = re.compile(r"(^|[/.:_-])(tests?|specs?|__tests__|testing|e2e)$", re.I)
559
+ modules = {m for m, (t, n) in per.items() if m and n and t and (named.search(m) or t * 10 >= n * 8)}
560
+ return tests, modules