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