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/incremental.py ADDED
@@ -0,0 +1,931 @@
1
+ """Re-map after an edit: do again only what the edit can have changed.
2
+
3
+ A full index parses every file, resolves names across the whole repository, walks every flow and rewrites every
4
+ row of the store. After a small edit nearly all of that comes out as it did last time. A run made through this
5
+ module keeps, in a cache beside the store (`leyline.db` -> `leyline.cache.db`), what the next run needs to skip
6
+ that work:
7
+
8
+ - each file's parse output, under its content hash and module, so unchanged files are not parsed again;
9
+ - what resolving each file's calls produced (calls, edges, counts), so unaffected files are not resolved again;
10
+ - fingerprints of the last run: every node's shape, each file's imports, the base types, the call graph that
11
+ flows walk, and each file's rows in the store.
12
+
13
+ The next run then parses the changed files, runs the whole-repository passes (indexes, imports, types, fields,
14
+ events, channels), resolves the calls of the files the edit can reach, walks again the flows that pass through a
15
+ node whose calls changed, and rewrites only the rows that differ. The store comes out as a full run would write it.
16
+
17
+ Which files an edit can reach. Calls resolve by name, scope and import, so a file is resolved again when:
18
+ - its content changed;
19
+ - it mentions (anywhere in its parse output) the name or id of a node that was added, removed or changed in a way a
20
+ resolver can read (kind, name, parent, attributes; for a type also its text, which the generic resolver reads
21
+ declarations from), or a name whose "seen on an outside type" standing changed, or a test whose fixture types
22
+ changed, or a module-level variable whose stated type changed, or a type whose constructor (its own or one it
23
+ inherits) was added, removed or changed;
24
+ - it imports, directly or through other files, a file whose import resolution changed.
25
+ A change to a type's base types, to a project file (package.json, .csproj, pyproject.toml, setup.py), or to which
26
+ directories are modules makes every file's calls be resolved again (still without parsing them again). With
27
+ LEYLINE_VERIFY=1 every incremental run is followed by a full one into a scratch store, and any row that differs is
28
+ reported (stats["incremental"]["differs"], and on stderr).
29
+
30
+ Systems are clustered again only in the modules whose rows changed; clustering is per module, so the rest are as a
31
+ full run would make them. Patterns and the orientation tour are always made again from the whole store.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import hashlib
37
+ import itertools
38
+ import json
39
+ import marshal
40
+ import os
41
+ import pickle
42
+ import platform
43
+ import re
44
+ import sqlite3
45
+ import sys
46
+ import time
47
+ import uuid
48
+ import zlib
49
+ from array import array
50
+ from collections import defaultdict
51
+ from pathlib import Path
52
+ from typing import Optional
53
+
54
+ from . import store
55
+
56
+ CACHE_VERSION = "2"
57
+ POSITIONS = ("loc", "body_line") # node attributes that only say where it is, as its span does
58
+ _TOKEN = re.compile(r"[A-Za-z_$][\w$]*")
59
+
60
+
61
+ # -- what a file mentions -----------------------------------------------------------------------------------
62
+ def mentions(res, decls) -> bytes:
63
+ """Every string in a file's parse output, and every identifier inside those strings, as sorted crc32 values.
64
+ Read field by field from whatever the adapter produced, so a new field on a record is covered without a change
65
+ here. Collisions only make a file look affected when it is not."""
66
+ strings: set = set()
67
+ stack = [res, decls]
68
+ seen: set = set() # a record reachable twice (a call shared as the receiver of the next in a.b().c()) is read once
69
+ while stack:
70
+ o = stack.pop()
71
+ if o is None or isinstance(o, (int, float, bool)):
72
+ continue
73
+ if isinstance(o, str):
74
+ strings.add(o)
75
+ continue
76
+ if id(o) in seen:
77
+ continue
78
+ seen.add(id(o))
79
+ if isinstance(o, dict):
80
+ stack.extend(o.keys())
81
+ stack.extend(o.values())
82
+ elif isinstance(o, (list, tuple, set, frozenset)):
83
+ stack.extend(o)
84
+ else:
85
+ for slot in getattr(type(o), "__slots__", ()):
86
+ stack.append(getattr(o, slot, None))
87
+ toks = set()
88
+ for s in strings:
89
+ toks.add(zlib.crc32(s.encode()))
90
+ for m in _TOKEN.findall(s):
91
+ toks.add(zlib.crc32(m.encode()))
92
+ return array("I", sorted(toks)).tobytes()
93
+
94
+
95
+ def _crc(obj) -> int:
96
+ # marshal version 0 writes equal values as equal bytes (no references, no interning), so the hash is stable
97
+ return zlib.crc32(marshal.dumps(obj, 0))
98
+
99
+
100
+ def code_version() -> str:
101
+ """Changes whenever the leyline source does: cached parse and resolve output is only valid for the code that
102
+ made it."""
103
+ h = hashlib.sha1(f"{CACHE_VERSION}|{sys.version}".encode())
104
+ root = Path(__file__).parent
105
+ for p in sorted(root.rglob("*")):
106
+ if p.suffix in (".py", ".sql") and "__pycache__" not in p.parts:
107
+ st = p.stat()
108
+ h.update(f"{p.relative_to(root)}|{st.st_size}|{st.st_mtime_ns}\n".encode())
109
+ return h.hexdigest()
110
+
111
+
112
+ def cache_path(db_path) -> Path:
113
+ p = Path(db_path)
114
+ return p.with_name(p.stem + ".cache" + (p.suffix or ".db"))
115
+
116
+
117
+ # -- the cache file -----------------------------------------------------------------------------------------
118
+ class _Cache:
119
+ def __init__(self, path: Path):
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+ try:
122
+ self._open(path)
123
+ except sqlite3.DatabaseError as e:
124
+ # Damaged (a disk error, a copy cut off): it only saves work, so it is started again and the run is a full
125
+ # one. A cache another run holds is busy, not damaged, and is waited for (see Run._take).
126
+ if isinstance(e, sqlite3.OperationalError) or not ("not a database" in str(e) or "malformed" in str(e)):
127
+ raise
128
+ for p in (path, Path(str(path) + "-wal"), Path(str(path) + "-shm")):
129
+ p.unlink(missing_ok=True)
130
+ self._open(path)
131
+
132
+ def _open(self, path: Path) -> None:
133
+ self.con = sqlite3.connect(str(path), timeout=store.BUSY_SECONDS) # another run may be writing it: wait
134
+ try:
135
+ self.con.execute("PRAGMA journal_mode=WAL")
136
+ self.con.executescript("""
137
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB);
138
+ CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY, sha TEXT, module TEXT, loc INTEGER, blob BLOB,
139
+ err TEXT, toks BLOB);
140
+ CREATE TABLE IF NOT EXISTS resolved (id TEXT PRIMARY KEY, outside BLOB, out BLOB);""")
141
+ except BaseException:
142
+ self.con.close()
143
+ raise
144
+
145
+ def get(self, key: str):
146
+ row = self.con.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
147
+ return row[0] if row else None
148
+
149
+
150
+ class _Noting(set):
151
+ """outside_names, noting what each file adds while its turn runs."""
152
+ into: Optional[set] = None
153
+
154
+ def add(self, x) -> None:
155
+ set.add(self, x)
156
+ if self.into is not None:
157
+ self.into.add(x)
158
+
159
+
160
+ class _NotingDict(dict):
161
+ """call_col, noting what each file sets while its turn runs."""
162
+ into: Optional[list] = None
163
+
164
+ def __setitem__(self, k, v) -> None:
165
+ dict.__setitem__(self, k, v)
166
+ if self.into is not None:
167
+ self.into.append((k, v))
168
+
169
+
170
+ def _ancestry(nodes: dict, groups: bool = False) -> dict:
171
+ """node id -> (file id, module id), as store.rebuild_derived works them out. With `groups`, node id -> the
172
+ group its rows are written in (see Run._groups): its file id, or its own id when it is in no file."""
173
+ out = {}
174
+ for i, n in nodes.items():
175
+ file_id = module_id = None
176
+ cur, seen = n, 0
177
+ while cur is not None and seen < 64:
178
+ if cur.kind == "file" and file_id is None:
179
+ file_id = cur.id
180
+ if cur.kind == "module" and module_id is None:
181
+ module_id = cur.id
182
+ cur = nodes.get(cur.parent_id) if cur.parent_id else None
183
+ seen += 1
184
+ out[i] = (file_id or i) if groups else (file_id, module_id)
185
+ return out
186
+
187
+
188
+ def _module_key(mod_dir: str, keep_ext: bool) -> str:
189
+ """What a file's parse output depends on besides its content: its module, and whether its ids keep the file's
190
+ extension (indexer._ext_clashes), which a file added or removed beside it can change."""
191
+ return mod_dir + ("\0keep_ext" if keep_ext else "")
192
+
193
+
194
+ class _Hits(dict):
195
+ """path -> a file whose kept parse output is used, read from the cache when the indexer takes it (pop): held
196
+ all at once, every file's output was in memory for the whole run."""
197
+
198
+ def __init__(self, con, hits: dict):
199
+ super().__init__(hits)
200
+ self.con = con
201
+
202
+ def pop(self, path, default=None):
203
+ if path not in self:
204
+ return default
205
+ f, ext, mod_dir, mod_id, file_id, sha = dict.pop(self, path)
206
+ loc, blob, err, toks = self.con.execute("SELECT loc, blob, err, toks FROM files WHERE id = ?", (file_id,)).fetchone()
207
+ return f, ext, mod_dir, mod_id, loc, sha, blob, err, toks
208
+
209
+
210
+ # -- a run ----------------------------------------------------------------------------------------------------
211
+ class Run:
212
+ """Attach to an Indexer before Indexer.run: decides between a full and an incremental run, and supplies the
213
+ hooks the indexer calls (parse_cache, _calls_pass, _flow_select, and _write for an incremental run)."""
214
+
215
+ def __init__(self, con, db_path, ix, full: bool = False):
216
+ self.ix, self.con = ix, con
217
+ from .indexer import max_file_bytes
218
+ self.cache = _Cache(cache_path(db_path))
219
+ try:
220
+ self._take(con, db_path)
221
+ except BaseException: # waited too long for another run, or stopped: the caller never gets this run to abandon
222
+ self.cache.con.close()
223
+ raise
224
+ self.key = json.dumps([code_version(), sorted((r, str(p)) for r, p in ix.repos.items()),
225
+ ix.exact_mode, sorted(ix.scip_paths), max_file_bytes()])
226
+ gen = con.execute("SELECT value FROM meta WHERE key = 'generation'").fetchone()
227
+ self.prior = None
228
+ if not full and gen is not None and self.cache.get("key") == self.key and self.cache.get("generation") == gen[0]:
229
+ blob = self.cache.get("state")
230
+ self.prior = pickle.loads(blob) if blob else None
231
+ self.full = self.prior is None
232
+ self.why_full = None if not self.full else ("asked" if full else "no usable cache from an earlier run")
233
+ with con: # until finish() writes a new token, the store matches no cache: a run that fails half way is not built on
234
+ con.execute("DELETE FROM meta WHERE key = 'generation'")
235
+ # nor taken as up to date (loop.refresh): index() notes who made the map once all of it is written
236
+ con.execute("DELETE FROM meta WHERE key = 'made_by'")
237
+ self.rows: dict = {}
238
+ # Parse output depends only on a file's content and module, not on where the checkout is or on the rest of
239
+ # the store: a full run that was not asked for (a moved checkout, a store copied to map another commit)
240
+ # still takes it, when the same Leyline made it.
241
+ if not self.full or (not full and self.cache.get("code") == code_version()):
242
+ # Only what tells whether a file changed: its parse output is read when it is used (see _Hits).
243
+ for r in self.cache.con.execute("SELECT id, sha, module FROM files"):
244
+ self.rows[r[0]] = r
245
+ self.toks: dict[str, bytes] = {}
246
+ self.seen: set = set()
247
+ self.reparsed: set = set()
248
+ self.resolved = 0 # files whose resolving was done again and kept (in the cache's `resolved` table)
249
+ self.resolve_all = self.full
250
+ self.flows_all = self.full
251
+ self.affected: Optional[set] = None
252
+ self.dirty_modules: dict[str, set] = {}
253
+ self.systems_changed = False
254
+ self.report: dict = {}
255
+ ix.parse_cache = self
256
+ ix._calls_pass = self._calls_pass
257
+ ix._flow_select = self._flow_select
258
+ if self.full:
259
+ write = ix._write
260
+ ix._write = lambda c: (write(c), self._after_full_write())
261
+ else:
262
+ ix._write = self._write_patch
263
+
264
+ def _take(self, con, db_path) -> None:
265
+ """Take the map for this run. A run holds the cache's write lock from before it reads the cache until finish()
266
+ (a transaction that read first could not wait for another run, and would fail at once), and notes in the
267
+ store which process it is. A run that finds another one in progress (a map while a plan starts) waits for it
268
+ to finish, however long that is, up to LEYLINE_WAIT seconds (an hour by default): it says so on stderr, and
269
+ past the limit it stops, naming the other run. A run that dies lets go of the lock with its process."""
270
+ c = self.cache.con
271
+ c.execute("PRAGMA busy_timeout = 1000") # tried once a second, so the wait can be reported and limited
272
+ try:
273
+ limit = max(0.0, float(os.environ.get("LEYLINE_WAIT", "") or 3600))
274
+ except ValueError:
275
+ limit = 3600.0
276
+ began, said = time.time(), False
277
+ while True:
278
+ try:
279
+ c.execute("BEGIN IMMEDIATE")
280
+ break
281
+ except sqlite3.OperationalError as exc:
282
+ if "locked" not in str(exc) and "busy" not in str(exc):
283
+ raise
284
+ row = con.execute("SELECT value FROM meta WHERE key = 'indexing'").fetchone()
285
+ other = json.loads(row[0]) if row else {}
286
+ who = (f"pid {other.get('pid')} on {other.get('host')}, for {time.time() - other.get('started', began):.0f} s"
287
+ if other else "a run that did not say which process it is")
288
+ if time.time() - began >= limit:
289
+ raise RuntimeError(f"another leyline index of {db_path} is running ({who}) and holds the map until it "
290
+ f"finishes; waited {limit:g} s (LEYLINE_WAIT). Map again when it is done.")
291
+ if not said:
292
+ print(f"leyline: another leyline index of {db_path} is running ({who}); waiting for it to finish",
293
+ file=sys.stderr)
294
+ said = True
295
+ c.execute(f"PRAGMA busy_timeout = {int(store.BUSY_SECONDS * 1000)}")
296
+ with con:
297
+ con.execute("INSERT OR REPLACE INTO meta VALUES ('indexing', ?)",
298
+ (json.dumps({"pid": os.getpid(), "host": platform.node(), "started": time.time()}),))
299
+
300
+ def _let_go(self) -> None:
301
+ """The store no longer says a run is in progress (see _take)."""
302
+ try:
303
+ with self.con:
304
+ self.con.execute("DELETE FROM meta WHERE key = 'indexing'")
305
+ except sqlite3.Error:
306
+ pass
307
+
308
+ # -- parse cache ---------------------------------------------------------------------------------------
309
+ def lookup(self, repo: str, root: Path, work: list, keep_ext: frozenset = frozenset()) -> dict:
310
+ """path -> the parse output kept for it, for the files whose content and module are unchanged."""
311
+ if not self.rows:
312
+ return {}
313
+ out = {}
314
+ from .indexer import read_source
315
+ for f, ext, mod_dir, mod_id in work:
316
+ row = self.rows.get(f"{repo}:file:{f}")
317
+ if row is None:
318
+ continue
319
+ try:
320
+ data = read_source(root / f)
321
+ except OSError: # gone since it was listed: parsed again, which reports it
322
+ continue
323
+ sha = hashlib.sha1(data).hexdigest()
324
+ if row[1] == sha and row[2] == _module_key(mod_dir, f in keep_ext):
325
+ out[f] = (f, ext, mod_dir, mod_id, row[0], sha)
326
+ return _Hits(self.cache.con, out)
327
+
328
+ def keep(self, file_id: str, got: tuple, fresh: bool, keep_ext: bool = False) -> None:
329
+ f, ext, mod_dir, mod_id, loc, sha, blob, failed, toks = got
330
+ self.seen.add(file_id)
331
+ self.toks[file_id] = toks
332
+ if fresh:
333
+ self.reparsed.add(file_id)
334
+ self.cache.con.execute("INSERT OR REPLACE INTO files VALUES (?,?,?,?,?,?,?)",
335
+ (file_id, sha, _module_key(mod_dir, keep_ext), loc, blob, failed, toks))
336
+
337
+ # -- what changed --------------------------------------------------------------------------------------
338
+ def _markers(self) -> list:
339
+ """The directories that are modules, and the content of every project file a resolver reads."""
340
+ from .indexer import _module_dirs
341
+ out = []
342
+ for repo, files in sorted(self.ix.files_of.items()):
343
+ root = self.ix.repos[repo]
344
+ marks = []
345
+ for f in files:
346
+ base = f.rsplit("/", 1)[-1]
347
+ if base in ("package.json", "pyproject.toml", "setup.py", "tsconfig.json", "jsconfig.json") \
348
+ or base.endswith(".csproj"):
349
+ try:
350
+ marks.append((f, hashlib.sha1((root / f).read_bytes()).hexdigest()))
351
+ except OSError:
352
+ marks.append((f, None))
353
+ out.append((repo, sorted(_module_dirs(files)), marks))
354
+ return out
355
+
356
+ def _shapes(self) -> dict:
357
+ """node id -> (name, kind, hash of what a resolver can read of it, the text hash of a type, the type a
358
+ constructor belongs to). Where a node sits (its span, and the attributes that only repeat a line number) is
359
+ left out: an edit above a node moves it without changing what it declares."""
360
+ from .indexer import CTORS
361
+ out = {}
362
+ for i, n in self.ix.nodes.items():
363
+ attrs = n.attrs
364
+ if attrs and any(k in attrs for k in POSITIONS):
365
+ attrs = {k: v for k, v in attrs.items() if k not in POSITIONS}
366
+ out[i] = (n.name, n.kind, _crc((n.kind, n.name, n.parent_id, n.language, n.path, attrs)),
367
+ n.content_hash if n.kind == "type" else None,
368
+ n.parent_id if n.kind == "callable" and n.name in CTORS else None)
369
+ return out
370
+
371
+ def _imports(self) -> dict:
372
+ """file id -> (hash of how its imports resolved, the files it can see through them)."""
373
+ ix, out = self.ix, {}
374
+ g = lambda name: getattr(ix, name, {}).get
375
+ py, targets, star = g("py_names"), g("import_targets"), g("star_exports")
376
+ usings, static, alias, outside = g("cs_usings"), g("cs_static"), g("cs_alias"), g("outside_imports")
377
+ for fid in ix.results:
378
+ names = py(fid) or {}
379
+ state = (sorted(names.items(), key=lambda kv: kv[0]), sorted(targets(fid) or ()), list(star(fid) or ()),
380
+ sorted(usings(fid) or ()), list(static(fid) or ()), sorted((alias(fid) or {}).items()),
381
+ sorted(outside(fid) or ()))
382
+ seen = set(targets(fid) or ()) | {t for t, _ in names.values() if t} | set(star(fid) or ())
383
+ out[fid] = (_crc(repr(state)), tuple(sorted(seen)))
384
+ return out
385
+
386
+ def _mentioning(self, names: set) -> set:
387
+ crcs = {zlib.crc32(n.encode()) for n in names if isinstance(n, str)}
388
+ if not crcs:
389
+ return set()
390
+ return {fid for fid in self.ix.results if self.toks.get(fid) and not crcs.isdisjoint(array("I", self.toks[fid]))}
391
+
392
+ def _plan(self) -> None:
393
+ """Work out which files' calls to resolve again. Runs when _resolve_calls starts, after the passes it
394
+ reads from (indexes, imports, types, overrides, fixtures)."""
395
+ ix = self.ix
396
+ self.markers = self._markers()
397
+ self.shapes = self._shapes()
398
+ self.imports = self._imports()
399
+ self.bases = {k: tuple(v) for k, v in ix.bases.items() if v}
400
+ self.param_types = dict(getattr(ix, "py_param_type", {}))
401
+ self.var_types = {fid: dict(v) for fid, v in getattr(ix, "py_vars", {}).items()}
402
+ self.removed = {f for f in self.rows if f not in self.seen} if not self.full else set()
403
+ if self.full:
404
+ return
405
+ p = self.prior
406
+ if self.markers != p["markers"]:
407
+ self.resolve_all = self.flows_all = True
408
+ self.report["everything_because"] = "a project file or the module layout changed"
409
+ old_shapes = p["shapes"]
410
+ for t in set(self.bases) | set(p["bases"]):
411
+ if t in self.shapes and t in old_shapes and self.bases.get(t) != p["bases"].get(t):
412
+ self.resolve_all = True
413
+ self.report["everything_because"] = f"the base types of {t} changed"
414
+ break
415
+ if self.resolve_all:
416
+ return
417
+ tokens: set = set()
418
+ for i, s in self.shapes.items():
419
+ o = old_shapes.get(i)
420
+ if o == s:
421
+ continue
422
+ if o is not None and o[:3] == s[:3]:
423
+ tokens.add(i) # only a type's text changed: what is declared inside it
424
+ else:
425
+ tokens.update((i, s[0]) if o is None else (i, s[0], o[0]))
426
+ for i, o in old_shapes.items():
427
+ if i not in self.shapes:
428
+ tokens.update((i, o[0]))
429
+ # A constructor is called by its type's name (`Engine()`, `new Shape()`), and by the name of any type that
430
+ # inherits it: when one is added, removed or changed, so are the calls that make such an object.
431
+ owners = {s[4] for i, s in self.shapes.items() if s[4] and old_shapes.get(i) != s}
432
+ owners |= {o[4] for i, o in old_shapes.items() if o[4] and self.shapes.get(i) != o}
433
+ if owners:
434
+ derived = defaultdict(set)
435
+ for bases in (self.bases, p["bases"]):
436
+ for t, bs in bases.items():
437
+ for b in bs:
438
+ derived[b].add(t)
439
+ todo = list(owners)
440
+ while todo:
441
+ for t in derived.get(todo.pop(), ()):
442
+ if t not in owners:
443
+ owners.add(t)
444
+ todo.append(t)
445
+ for t in owners:
446
+ tokens.add(t)
447
+ tokens.update(s[0] for s in (self.shapes.get(t), old_shapes.get(t)) if s)
448
+ for k in set(self.param_types) | set(p["param_types"]):
449
+ if self.param_types.get(k) != p["param_types"].get(k):
450
+ tokens.add(k[0])
451
+ # A module-level variable of a stated type (`current: App = App()`) is not a node: a call made on it
452
+ # elsewhere names the variable, which goes in when the type it is stated with changes.
453
+ for fid in set(self.var_types) | set(p["var_types"]):
454
+ new, old = self.var_types.get(fid, {}), p["var_types"].get(fid, {})
455
+ if new != old:
456
+ tokens.update(n for n in set(new) | set(old) if new.get(n) != old.get(n))
457
+ old_imports = p["imports"]
458
+ moved = {f for f in set(self.imports) | set(old_imports)
459
+ if self.imports.get(f, (None,))[0] != old_imports.get(f, (None,))[0]}
460
+ rev = defaultdict(set)
461
+ for f, (_, seen) in itertools.chain(old_imports.items(), self.imports.items()):
462
+ for t in seen:
463
+ rev[t].add(f)
464
+ reach, todo = set(moved), list(moved)
465
+ while todo:
466
+ for f in rev.get(todo.pop(), ()):
467
+ if f not in reach:
468
+ reach.add(f)
469
+ todo.append(f)
470
+ self.tokens = tokens
471
+ self.import_moved = moved
472
+ self.affected = ((self.reparsed | reach | self._mentioning(tokens)) & set(ix.results))
473
+
474
+ # -- the hooks the indexer calls ------------------------------------------------------------------------
475
+ def _calls_pass(self, phase: int):
476
+ if phase == 1:
477
+ self._plan()
478
+ if self.resolve_all:
479
+ self.affected = None
480
+ return self._pass_outside()
481
+ return self._pass_calls()
482
+
483
+ def _old(self, fid: str):
484
+ if not hasattr(self, "_old_rows"):
485
+ self._old_rows = {r[0]: (r[1], r[2]) for r in self.cache.con.execute("SELECT id, outside, out FROM resolved")} \
486
+ if not self.full else {}
487
+ return self._old_rows.get(fid)
488
+
489
+ # Each file's turn in each pass of _resolve_calls is recorded as what it added: names seen on outside types,
490
+ # calls, call columns, edges and counts. A file that is not resolved again gets its recorded turn replayed.
491
+ def _mark(self):
492
+ ix = self.ix
493
+ ix.outside_names.into, ix.call_col.into = set(), []
494
+ return (len(ix.calls), len(ix.edges), {k: dict(v) for k, v in ix.stats.items()},
495
+ len(getattr(ix, "_arg_log", ())), len(getattr(ix, "_pending", ())))
496
+
497
+ def _since(self, mark) -> bytes:
498
+ ix = self.ix
499
+ n_calls, n_edges, before, n_args, n_pending = mark
500
+ delta = {}
501
+ for k, v in ix.stats.items():
502
+ was = before.get(k, {})
503
+ d = {c: n - was.get(c, 0) for c, n in v.items() if n != was.get(c, 0)}
504
+ if d:
505
+ delta[k] = d
506
+ # The calls the Python argument-flow rule reads (_arg_log, _pending) belong to the file's turn too:
507
+ # the rule runs after every file, over all of them, so a replayed file must still contribute its share.
508
+ got = (ix.outside_names.into, ix.calls[n_calls:], ix.call_col.into, ix.edges[n_edges:], delta,
509
+ list(getattr(ix, "_arg_log", [])[n_args:]), list(getattr(ix, "_pending", [])[n_pending:]))
510
+ ix.outside_names.into = ix.call_col.into = None
511
+ return pickle.dumps(got) # now: the edges' attrs are changed later, when the run writes
512
+
513
+ def _replay(self, turn: tuple) -> None:
514
+ ix = self.ix
515
+ names, calls, cols, edges, delta = turn[:5]
516
+ if len(turn) > 5:
517
+ ix._arg_log.extend(turn[5])
518
+ ix._pending.extend(turn[6])
519
+ set.update(ix.outside_names, names)
520
+ ix.calls.extend(calls)
521
+ for k, v in cols:
522
+ dict.__setitem__(ix.call_col, k, v)
523
+ ix.edges.extend(edges)
524
+ for k, d in delta.items():
525
+ ix.stats[k].update(d)
526
+
527
+ def _pass_outside(self):
528
+ """_resolve_calls' first pass: the names seen on outside types (working out a receiver's type here can add
529
+ edges too). Its outcome decides which files the second pass resolves again."""
530
+ ix = self.ix
531
+ ix.outside_names, ix.call_col = _Noting(ix.outside_names), _NotingDict(ix.call_col)
532
+ self.first: dict[str, bytes] = {}
533
+ todo = self.affected
534
+ order = list(ix.results.items())
535
+
536
+ c = self.cache.con
537
+ if self.full:
538
+ c.execute("DELETE FROM resolved")
539
+
540
+ def turns(which):
541
+ for fid, res in order:
542
+ if which(fid):
543
+ mark = self._mark()
544
+ yield fid, res
545
+ if self.full:
546
+ # kept in the cache as it is made (with the second pass's turn added then), not held
547
+ c.execute("INSERT INTO resolved VALUES (?,?,NULL)", (fid, self._since(mark)))
548
+ self.first[fid] = None
549
+ else:
550
+ self.first[fid] = self._since(mark)
551
+ yield from turns(lambda f: todo is None or f in todo or self._old(f) is None)
552
+ if todo is not None:
553
+ todo |= set(self.first)
554
+ kept = {fid: pickle.loads(self._old(fid)[0]) for fid, _ in order if fid not in self.first}
555
+ names = set(ix.outside_names).union(*(t[0] for t in kept.values()))
556
+ diff = frozenset(names) ^ self.prior["outside"]
557
+ if diff:
558
+ # a name's standing changed: the files that call it are resolved again, both passes
559
+ todo |= self._mentioning({n for _, n in diff})
560
+ yield from turns(lambda f: f in todo and f not in self.first)
561
+ for fid, turn in kept.items():
562
+ if fid not in self.first:
563
+ self._replay(turn)
564
+ self.first[fid] = self._old(fid)[0]
565
+ self.report["files_resolved"] = len(todo)
566
+ self.outside = frozenset(ix.outside_names)
567
+
568
+ def _pass_calls(self):
569
+ ix = self.ix
570
+ todo = self.affected
571
+ c = self.cache.con
572
+ # Each file's turns go to the cache as they are made, not held to the end of the run (finish commits them).
573
+ for fid, res in list(ix.results.items()):
574
+ if todo is None or fid in todo:
575
+ mark = self._mark()
576
+ yield fid, res
577
+ first = self.first.pop(fid)
578
+ if first is None: # a full run: the first pass's turn is in the cache already
579
+ c.execute("UPDATE resolved SET out = ? WHERE id = ?", (self._since(mark), fid))
580
+ else:
581
+ c.execute("INSERT OR REPLACE INTO resolved VALUES (?,?,?)", (fid, first, self._since(mark)))
582
+ self.resolved += 1
583
+ else:
584
+ self._replay(pickle.loads(self._old(fid)[1]))
585
+
586
+ def _flow_select(self, out: dict, starts: list) -> Optional[set]:
587
+ """Hash each node's outgoing steps; walk again the flows that visit a node whose steps changed."""
588
+ ix = self.ix
589
+ nodes = ix.nodes
590
+ self.flow_hashes = {n: _crc([(line, dst, via, sub, dst in nodes) for line, dst, via, sub in lst])
591
+ for n, lst in out.items()}
592
+ self.starts, self.flow_start = {}, {} # start -> [(flow id, what its row says)]; flow id -> start
593
+ for start, kind, detail, fid in starts:
594
+ if start in nodes:
595
+ n = nodes[start]
596
+ name = n.name if n.kind == "test" else start.split(":", 2)[-1].split("::")[-1]
597
+ self.starts.setdefault(start, []).append((fid, (name, kind, detail)))
598
+ self.flow_start[fid] = start
599
+ if self.flows_all:
600
+ return None
601
+ old_hash = self.prior["flow_hashes"]
602
+ changed = {n for n, h in self.flow_hashes.items() if old_hash.get(n) != h}
603
+ changed |= {n for n in old_hash if n not in self.flow_hashes}
604
+ old_flows = {}
605
+ for r in self.con.execute("SELECT id, name, entry_id, attrs FROM flows WHERE layer = 'fact'"):
606
+ if r[2] and r[2].split(":", 1)[0] in ix.repos:
607
+ a = json.loads(r[3] or "{}")
608
+ old_flows[r[0]] = (r[1], a.get("kind"), a.get("detail"))
609
+ dirty = {s for s, metas in self.starts.items() if any(old_flows.get(fid) != meta for fid, meta in metas)}
610
+ ids = list(changed)
611
+ for k in range(0, len(ids), 900):
612
+ chunk = ids[k:k + 900]
613
+ for r in self.con.execute(
614
+ "SELECT DISTINCT fk.id FROM steps s JOIN keys fk ON fk.k = s.flow WHERE s.callable IN"
615
+ f" (SELECT k FROM keys WHERE id IN ({','.join('?' * len(chunk))}))", chunk):
616
+ if r[0] in self.flow_start:
617
+ dirty.add(self.flow_start[r[0]])
618
+ # A C# program's dispatch steps depend on what its module can see, which a C# file's usings can change.
619
+ touched = self.reparsed | self.removed | getattr(self, "import_moved", set())
620
+ if any(f.split(":file:", 1)[-1].endswith(".cs") for f in touched):
621
+ dirty |= {s for s in self.starts if ix.file_lang.get(ix.file_of.get(s)) == "csharp"}
622
+ self.flows_drop = {f for f in old_flows if f not in self.flow_start} | {fid for s in dirty for fid, _ in self.starts[s]}
623
+ self.report["flows_walked"] = len(dirty)
624
+ return dirty
625
+
626
+ # -- writing --------------------------------------------------------------------------------------------
627
+ def _groups(self, ancestry: bool = True) -> tuple[dict, dict, dict]:
628
+ """The rows a run writes, grouped by the file each row's node (for an edge or call, its source) sits in,
629
+ or by the node itself when it is in no file; and a hash of each group. A group lists its nodes' ids, its
630
+ edges and its calls, and _rows makes its rows from them: made for every group at once, the rows were a
631
+ copy of the whole map (GBs on a large repository)."""
632
+ ix = self.ix
633
+ if ancestry:
634
+ anc = self.anc = _ancestry(ix.nodes)
635
+ group = {i: (a[0] or i) for i, a in anc.items()}
636
+ else:
637
+ group = _ancestry(ix.nodes, groups=True)
638
+ members: dict[str, tuple] = defaultdict(lambda: ([], [], []))
639
+ for i in ix.nodes:
640
+ members[group[i]][0].append(i)
641
+ edges, calls = ix._final()
642
+ for e in edges:
643
+ members[group[e.src_id]][1].append(e)
644
+ for c in calls:
645
+ members[group[c[0]]][2].append(c)
646
+ # Edges are hashed in a sorted order: the same edges can come out of a run in another order (one found
647
+ # while resolving a receiver in the first pass of a full run is found in the second pass of a later
648
+ # one), and rewriting them would change nothing.
649
+ hashes, self.pattern_groups = {}, {}
650
+ for g, m in members.items():
651
+ r = self._rows(m)
652
+ edges_ = sorted(marshal.dumps(e, 0) for e in r[1])
653
+ hashes[g] = hashlib.blake2b(marshal.dumps((r[0], r[2], edges_), 0), digest_size=8).digest()
654
+ # what the pattern matchers read of the group besides its nodes: its edges and which functions call which
655
+ self.pattern_groups[g] = hashlib.blake2b(marshal.dumps((edges_, sorted({c[:2] for c in r[2]})), 0),
656
+ digest_size=8).digest()
657
+ return members, hashes, group
658
+
659
+ def _rows(self, members: tuple) -> tuple[list, list, list]:
660
+ """A group's rows: (id, kind, name, parent, repository, language, path, span start, span end, content hash,
661
+ attrs) per node, (kind, source, target, precision, attrs) per edge, and its calls as they are."""
662
+ ix = self.ix
663
+ nodes = ix.nodes
664
+ out = []
665
+ for i in members[0]:
666
+ n = nodes[i]
667
+ out.append((i, n.kind, n.name, n.parent_id, ix._owner(i), n.language, n.path, n.span_start,
668
+ n.span_end, n.content_hash, n.attrs or None))
669
+ return out, [(e.kind, e.src_id, e.dst_id, e.precision, e.attrs or None) for e in members[1]], members[2]
670
+
671
+ def _after_full_write(self) -> None:
672
+ _, self.groups, _ = self._groups(ancestry=False)
673
+ self.flow_sizes = self._sizes_of(self.ix.flows, self.ix.flow_steps, {})
674
+
675
+ @staticmethod
676
+ def _sizes_of(flows, steps, sizes: dict) -> dict:
677
+ """start -> the step count of each flow walked from it, as Indexer.run counts them for the coverage row."""
678
+ starts = [f for f, _ in steps.flows]
679
+ entry = {f[0]: f[3] for f in flows} # flow id -> its start (flow:<start>, or flow:<start>#<kind>)
680
+ ends = [s for _, s in steps.flows[1:]] + [len(steps)]
681
+ out: dict = {}
682
+ for fid, (_, s), e in zip(starts, steps.flows, ends):
683
+ out.setdefault(entry.get(fid, fid[5:]), []).append(e - s)
684
+ sizes = {k: v for k, v in sizes.items() if k not in out}
685
+ sizes.update(out)
686
+ return sizes
687
+
688
+ def _write_patch(self, con) -> None:
689
+ ix = self.ix
690
+ rows, hashes, group = self._groups()
691
+ old = self.prior["groups"]
692
+ changed = sorted(g for g in set(hashes) | set(old) if hashes.get(g) != old.get(g))
693
+ self.groups = hashes
694
+ self.report["groups_written"] = len(changed)
695
+ dirty: dict[str, set] = defaultdict(set)
696
+ with con:
697
+ con.execute("CREATE TEMP TABLE IF NOT EXISTS gone (id TEXT PRIMARY KEY)")
698
+ con.execute("DELETE FROM gone")
699
+ for k in range(0, len(changed), 900):
700
+ chunk = changed[k:k + 900]
701
+ marks = ",".join("?" * len(chunk))
702
+ for r in con.execute(
703
+ f"SELECT a.node_id, a.module_id FROM ancestry a JOIN nodes n ON n.id = a.node_id"
704
+ f" WHERE n.layer = 'fact' AND (a.file_id IN ({marks}) OR (a.file_id IS NULL AND a.node_id IN ({marks})))",
705
+ chunk + chunk):
706
+ con.execute("INSERT OR IGNORE INTO gone VALUES (?)", (r[0],))
707
+ if r[1]:
708
+ dirty[r[1].split(":", 1)[0]].add(r[1])
709
+ gone = "SELECT k FROM keys WHERE id IN (SELECT id FROM gone)"
710
+ con.execute(f"DELETE FROM call_sites WHERE src IN ({gone})")
711
+ con.execute(f"DELETE FROM links WHERE layer = 'fact' AND src IN ({gone})")
712
+ con.execute("DELETE FROM ancestry WHERE node_id IN (SELECT id FROM gone)")
713
+ con.execute("DELETE FROM search WHERE node_id IN (SELECT id FROM gone)")
714
+ con.execute("DELETE FROM nodes WHERE layer = 'fact' AND id IN (SELECT id FROM gone)")
715
+ # The commit each row was read at: if HEAD moved, every row of that repository says so, as after a full run.
716
+ for repo, commit in ix.commits.items():
717
+ if self.prior["commits"].get(repo, commit) != commit:
718
+ con.execute("UPDATE nodes SET commit_sha = ? WHERE repo_id = ? AND layer = 'fact'", (commit, repo))
719
+ mine = "SELECT k FROM keys WHERE id IN (SELECT id FROM nodes WHERE repo_id = ? AND layer = 'fact')"
720
+ con.execute(f"UPDATE links SET commit_sha = ? WHERE layer = 'fact' AND src IN ({mine})", (commit, repo))
721
+ con.execute(f"UPDATE call_sites SET commit_sha = ? WHERE src IN ({mine})", (commit, repo))
722
+ new_nodes, new_edges, new_calls = [], [], []
723
+ for g in changed:
724
+ if g in rows:
725
+ n, e, c = self._rows(rows[g])
726
+ new_nodes += n
727
+ new_edges += e
728
+ new_calls += c
729
+ for repo in ix.repos:
730
+ commit = ix.commits[repo]
731
+ store.write_nodes(con, [ix.nodes[r[0]] for r in new_nodes if r[4] == repo], repo, ix_source(), commit)
732
+ store.set_root(con, repo, ix.repos[repo])
733
+ store.insert_edges(con, [(None, k, s, d, p, "fact", ix_source(), commit, json.dumps(a) if a else None)
734
+ for k, s, d, p, a in new_edges if ix._owner(s) == repo])
735
+ store.write_calls(con, [c for c in new_calls if ix._owner(c[0]) == repo], commit)
736
+ anc = self.anc
737
+ con.executemany("INSERT INTO ancestry VALUES (?,?,?)", [(r[0], *anc[r[0]]) for r in new_nodes])
738
+ con.executemany("INSERT INTO search (node_id, name, qualified, path, kind) VALUES (?,?,?,?,?)",
739
+ [(r[0], r[2], store._searchable(r[0]), r[6] or "", r[1]) for r in new_nodes if r[1] != "repo"])
740
+ for r in new_nodes:
741
+ m = anc[r[0]][1]
742
+ if m:
743
+ dirty[r[4]].add(m)
744
+ for g in changed: # a module node is a group of its own; its contains edges list its files
745
+ if g in ix.nodes and ix.nodes[g].kind == "module":
746
+ dirty[ix._owner(g)].add(g)
747
+ # Flows: the ones walked again replace their old rows; the ones whose start is gone go.
748
+ if self.flows_all:
749
+ drop = [r[0] for r in con.execute("SELECT id, entry_id FROM flows WHERE layer = 'fact'")
750
+ if r[1] and r[1].split(":", 1)[0] in ix.repos]
751
+ else:
752
+ drop = sorted(self.flows_drop)
753
+ con.execute("DELETE FROM gone")
754
+ con.executemany("INSERT OR IGNORE INTO gone VALUES (?)", [(f,) for f in drop])
755
+ con.execute("DELETE FROM steps WHERE flow IN (SELECT k FROM keys WHERE id IN (SELECT id FROM gone))")
756
+ con.execute("DELETE FROM flows WHERE layer = 'fact' AND id IN (SELECT id FROM gone)")
757
+ store.write_flows(con, ix.repo, ix.flows, ix.flow_steps, reindex=False)
758
+ self.flow_sizes = self._sizes_of(ix.flows, ix.flow_steps, {} if self.flows_all else self.prior["flow_sizes"])
759
+ self.flow_sizes = {k: v for k, v in self.flow_sizes.items() if k in self.starts}
760
+ ix._write_coverage(con, sum(len(v) for v in self.flow_sizes.values()),
761
+ sum(sum(v) for v in self.flow_sizes.values()))
762
+ self.dirty_modules = dict(dirty)
763
+ self.report["modules_reclustered"] = sum(len(v) for v in dirty.values())
764
+
765
+ # -- after the run --------------------------------------------------------------------------------------
766
+ def systems(self, con, repo: str) -> dict:
767
+ """Cluster again the modules whose rows changed (every module after a full run)."""
768
+ from . import cluster
769
+ if self.full:
770
+ return cluster.propose(con, repo)
771
+ mods = self.dirty_modules.get(repo, set())
772
+
773
+ def made():
774
+ rows = {r[0]: r[2] for r in con.execute("SELECT id, parent_id, attrs FROM nodes WHERE kind = 'system' AND repo_id = ?",
775
+ (repo,)) if r[1] in mods}
776
+ grouped = sorted(tuple(r) for r in con.execute(
777
+ "SELECT src_id, dst_id FROM edges WHERE kind = 'groups' AND src_id IN (SELECT value FROM json_each(?))",
778
+ (json.dumps(sorted(rows)),)))
779
+ return rows, grouped
780
+ before = made()
781
+ out = cluster.propose(con, repo, modules=mods)
782
+ after = made()
783
+ self.systems_changed = self.systems_changed or before != after
784
+ with con:
785
+ store.derive_some(con, before[0], after[0])
786
+ return out
787
+
788
+ def patterns(self, con, repo: str) -> dict:
789
+ """Find patterns again, unless nothing the matchers read changed: the nodes (where they sit aside), the
790
+ edges, which functions call which, and the systems. Then the matches are the ones already stored, and only
791
+ the hashes of the files behind them are brought up to date, as a fresh pass would write them."""
792
+ from . import patterns
793
+ old = None if self.full else self.prior["shapes"]
794
+ same = (old is not None and len(old) == len(self.shapes)
795
+ and all(old.get(i, ())[:3] == s[:3] for i, s in self.shapes.items()) # a type's text is not read
796
+ and self.pattern_groups == self.prior.get("pattern_groups") and not self.systems_changed)
797
+ self.report["patterns"] = "kept" if same else "found again"
798
+ if not same:
799
+ return patterns.run(con, repo)
800
+ roles = defaultdict(list)
801
+ for r in con.execute("SELECT instance_id, node_id FROM pattern_roles"):
802
+ roles[r[0]].append(r[1])
803
+ with con:
804
+ for r in con.execute("SELECT id, matcher, evidence_hash FROM pattern_instances").fetchall():
805
+ h = store.evidence_hash(con, roles.get(r[0], []))
806
+ if r[1] == patterns.MATCHER:
807
+ con.execute("UPDATE pattern_instances SET evidence_hash = ? WHERE id = ?", (h, r[0]))
808
+ else:
809
+ con.execute("UPDATE pattern_instances SET stale = ? WHERE id = ?", (int(h != r[2]), r[0]))
810
+ row = con.execute("SELECT stats FROM extractor_coverage WHERE repo_id = ? AND extractor = 'patterns:structural'",
811
+ (repo,)).fetchone()
812
+ return json.loads(row[0]) if row and row[0] else {}
813
+
814
+ def finish(self, con) -> None:
815
+ """Save what the next run needs, under a token the store also keeps: a store written by anything else (an
816
+ older leyline, a run that failed) no longer matches the cache, and the next run is a full one."""
817
+ if not hasattr(self, "shapes"): # nothing was resolved (an empty repository): nothing to keep
818
+ self._let_go()
819
+ self.cache.con.rollback()
820
+ return
821
+ token = uuid.uuid4().hex
822
+ state = {"shapes": self.shapes, "imports": self.imports, "bases": self.bases, "param_types": self.param_types,
823
+ "var_types": self.var_types, "outside": self.outside, "markers": self.markers, "flow_hashes": self.flow_hashes,
824
+ "flow_sizes": self.flow_sizes, "groups": self.groups, "pattern_groups": self.pattern_groups,
825
+ "commits": self.commits}
826
+ c = self.cache.con
827
+ if self.full:
828
+ c.execute("DELETE FROM files WHERE id NOT IN (SELECT value FROM json_each(?))", (json.dumps(sorted(self.seen)),))
829
+ else:
830
+ c.executemany("DELETE FROM files WHERE id = ?", [(f,) for f in self.removed])
831
+ c.executemany("DELETE FROM resolved WHERE id = ?", [(f,) for f in self.removed])
832
+ c.execute("INSERT OR REPLACE INTO meta VALUES ('key', ?)", (self.key,))
833
+ c.execute("INSERT OR REPLACE INTO meta VALUES ('code', ?)", (code_version(),))
834
+ c.execute("INSERT OR REPLACE INTO meta VALUES ('state', ?)", (pickle.dumps(state, protocol=pickle.HIGHEST_PROTOCOL),))
835
+ with con:
836
+ con.execute("INSERT OR REPLACE INTO meta VALUES ('generation', ?)", (token,))
837
+ con.execute("DELETE FROM meta WHERE key = 'indexing'")
838
+ c.execute("INSERT OR REPLACE INTO meta VALUES ('generation', ?)", (token,))
839
+ c.commit()
840
+ c.close()
841
+
842
+ def release(self) -> None:
843
+ """Let go of the indexer and of the cached blobs once the run has written the store. The hooks are taken
844
+ off the indexer too: they refer back to it, and the cycle would keep its memory until a garbage collection."""
845
+ ix = self.ix
846
+ self.commits = dict(ix.commits)
847
+ for name in ("_calls_pass", "_flow_select", "_write"):
848
+ ix.__dict__.pop(name, None)
849
+ ix.parse_cache = None
850
+ self.ix = None
851
+ self.rows = self._old_rows = self.anc = None
852
+
853
+ def abandon(self) -> None:
854
+ self._let_go()
855
+ try:
856
+ self.cache.con.rollback()
857
+ self.cache.con.close()
858
+ except sqlite3.Error:
859
+ pass
860
+
861
+ def summary(self) -> dict:
862
+ out = {"mode": "full" if self.full else "incremental", "files_parsed": len(self.reparsed),
863
+ "files_removed": len(self.removed) if hasattr(self, "removed") else 0}
864
+ if self.full:
865
+ out["why"] = self.why_full
866
+ out.update(self.report)
867
+ return out
868
+
869
+
870
+ # The rows an index writes, as two stores are compared: an incremental run must leave them as a full run would.
871
+ COMPARED = {
872
+ "nodes": "SELECT id, kind, name, parent_id, repo_id, language, path, span_start, span_end, content_hash, layer, attrs"
873
+ " FROM nodes",
874
+ "calls": "SELECT src_id, dst_id, dispatch, precision, site_start, site_end FROM calls",
875
+ "edges": "SELECT kind, src_id, dst_id, precision, layer, attrs FROM edges",
876
+ "flows": "SELECT id, name, entry_id, attrs FROM flows",
877
+ "flow_steps": "SELECT flow_id, seq, depth, callable_id, via, site_line, parent_seq FROM flow_steps",
878
+ "patterns": "SELECT id, pattern, rationale, confidence, evidence_hash, stale, attrs FROM pattern_instances",
879
+ "pattern_roles": "SELECT instance_id, role, node_id FROM pattern_roles",
880
+ "tour_stops": "SELECT tour_id, seq, ref_kind, ref_id, narrative, title FROM tour_stops",
881
+ "search": "SELECT node_id, name, qualified, path, kind FROM search",
882
+ "ancestry": "SELECT node_id, file_id, module_id FROM ancestry",
883
+ }
884
+
885
+
886
+ def differences(a, b, tables: Optional[dict] = None) -> dict:
887
+ """table -> (rows in a, rows in b, the first rows that differ), for the tables an index writes; empty when the
888
+ two stores agree. Both sides are read sorted by SQLite and compared as they stream, so two stores of a large
889
+ repository are compared without holding either."""
890
+ out = {}
891
+ ca, cb = sqlite3.connect(str(a)), sqlite3.connect(str(b))
892
+ try:
893
+ for t, sql in (tables or COMPARED).items():
894
+ n = len(ca.execute(sql + " LIMIT 0").description)
895
+ order = f" ORDER BY {', '.join(str(i) for i in range(1, n + 1))}"
896
+ ra, rb = ca.execute(sql + order), cb.execute(sql + order)
897
+ na = nb = 0
898
+ first = []
899
+ for x, y in itertools.zip_longest(ra, rb):
900
+ na += x is not None
901
+ nb += y is not None
902
+ if x != y and len(first) < 3:
903
+ first.append((x, y))
904
+ if first:
905
+ out[t] = (na, nb, first)
906
+ finally:
907
+ ca.close()
908
+ cb.close()
909
+ return out
910
+
911
+
912
+ def verify(db_path, members: list, exact: str, scip, listed=None) -> dict:
913
+ """LEYLINE_VERIFY=1: index the same tree in full into a scratch store and compare it with the store an
914
+ incremental run just wrote. Slow (it is a full run); for checking this module against changes to the resolvers."""
915
+ import tempfile
916
+ from .indexer import index
917
+ with tempfile.TemporaryDirectory() as d:
918
+ other = Path(d) / "full.db"
919
+ if len(members) == 1:
920
+ index(members[0][0], other, members[0][1], exact, scip, full=True, _verify=False, listed=listed)
921
+ else:
922
+ index([r for r, _ in members], other, None, exact, scip, full=True, _verify=False)
923
+ diff = differences(db_path, other)
924
+ if diff:
925
+ print(f"leyline: the incremental index differs from a full one: {diff}", file=sys.stderr)
926
+ return diff
927
+
928
+
929
+ def ix_source() -> str:
930
+ from .indexer import SOURCE
931
+ return SOURCE