leyline-code 0.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/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
|