gitfence 0.1.0__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.
- fence/__init__.py +3 -0
- fence/anchors/__init__.py +0 -0
- fence/anchors/fingerprint.py +69 -0
- fence/check/__init__.py +0 -0
- fence/check/classify.py +84 -0
- fence/check/diff.py +127 -0
- fence/check/engine.py +263 -0
- fence/cli.py +576 -0
- fence/config.py +209 -0
- fence/context.py +77 -0
- fence/decisions/__init__.py +0 -0
- fence/decisions/build.py +579 -0
- fence/decisions/model.py +117 -0
- fence/decisions/obsolete.py +121 -0
- fence/decisions/reasons.py +150 -0
- fence/decisions/relevance.py +146 -0
- fence/diffing/__init__.py +0 -0
- fence/diffing/ranges.py +25 -0
- fence/diffing/symbols.py +21 -0
- fence/eval/__init__.py +0 -0
- fence/eval/backtest.py +271 -0
- fence/git/__init__.py +0 -0
- fence/git/blame.py +219 -0
- fence/git/history.py +171 -0
- fence/git/log.py +60 -0
- fence/git/runner.py +248 -0
- fence/github/__init__.py +0 -0
- fence/github/client.py +122 -0
- fence/github/comment.py +50 -0
- fence/github/sync.py +123 -0
- fence/globs.py +43 -0
- fence/index/__init__.py +0 -0
- fence/index/formatting.py +53 -0
- fence/index/ghosts.py +143 -0
- fence/index/indexer.py +219 -0
- fence/index/store.py +389 -0
- fence/lang/__init__.py +118 -0
- fence/lang/python.py +67 -0
- fence/lang/treesitter.py +165 -0
- fence/mcp/__init__.py +0 -0
- fence/mcp/server.py +306 -0
- fence/policy/__init__.py +0 -0
- fence/policy/acks.py +88 -0
- fence/render.py +287 -0
- fence/report.py +103 -0
- fence/scan.py +50 -0
- fence/scoring/__init__.py +0 -0
- fence/scoring/calibration.py +133 -0
- fence/scoring/strength.py +198 -0
- fence/signals/__init__.py +0 -0
- fence/signals/catalog.py +161 -0
- fence/signals/code.py +69 -0
- fence/text.py +43 -0
- fence/trace.py +65 -0
- gitfence-0.1.0.dist-info/METADATA +389 -0
- gitfence-0.1.0.dist-info/RECORD +59 -0
- gitfence-0.1.0.dist-info/WHEEL +4 -0
- gitfence-0.1.0.dist-info/entry_points.txt +3 -0
- gitfence-0.1.0.dist-info/licenses/LICENSE +21 -0
fence/index/store.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""The persistent per-repo index: one SQLite file under the cache directory.
|
|
2
|
+
|
|
3
|
+
Crash safety comes from SQLite itself: WAL journaling, and every indexing batch is
|
|
4
|
+
one transaction, so a killed run leaves the last complete batch and nothing half
|
|
5
|
+
written. Writes are idempotent upserts, so two concurrent `fence index` runs only
|
|
6
|
+
duplicate work; they cannot corrupt each other.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import sqlite3
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Iterable, Iterator
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = 1
|
|
21
|
+
|
|
22
|
+
# Each entry upgrades the schema by one version. Never edit a shipped entry;
|
|
23
|
+
# append a new one.
|
|
24
|
+
MIGRATIONS: tuple[str, ...] = (
|
|
25
|
+
"""
|
|
26
|
+
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
27
|
+
CREATE TABLE commits (
|
|
28
|
+
sha TEXT PRIMARY KEY,
|
|
29
|
+
parents TEXT NOT NULL, -- space-separated
|
|
30
|
+
author_name TEXT NOT NULL,
|
|
31
|
+
author_email TEXT NOT NULL,
|
|
32
|
+
author_time INTEGER NOT NULL,
|
|
33
|
+
committer_time INTEGER NOT NULL,
|
|
34
|
+
subject TEXT NOT NULL,
|
|
35
|
+
body TEXT NOT NULL,
|
|
36
|
+
pr_number INTEGER,
|
|
37
|
+
pr_source TEXT, -- 'merge-subject' | 'squash-subject' | 'github'
|
|
38
|
+
files_changed INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
format_only INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
bulk INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
orphaned INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
seq INTEGER NOT NULL -- indexing order, oldest first
|
|
43
|
+
);
|
|
44
|
+
CREATE INDEX commits_pr ON commits(pr_number);
|
|
45
|
+
CREATE TABLE file_changes (
|
|
46
|
+
sha TEXT NOT NULL,
|
|
47
|
+
path TEXT NOT NULL,
|
|
48
|
+
old_path TEXT,
|
|
49
|
+
status TEXT NOT NULL, -- A M D R T (git --raw letters)
|
|
50
|
+
similarity INTEGER,
|
|
51
|
+
added INTEGER, -- NULL for binary
|
|
52
|
+
deleted INTEGER,
|
|
53
|
+
PRIMARY KEY (sha, path)
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX file_changes_path ON file_changes(path);
|
|
56
|
+
CREATE INDEX file_changes_old_path ON file_changes(old_path);
|
|
57
|
+
CREATE TABLE signals (
|
|
58
|
+
sha TEXT NOT NULL,
|
|
59
|
+
signal_id TEXT NOT NULL,
|
|
60
|
+
detail TEXT NOT NULL, -- the matched text, verbatim
|
|
61
|
+
PRIMARY KEY (sha, signal_id, detail)
|
|
62
|
+
);
|
|
63
|
+
CREATE TABLE refs (name TEXT PRIMARY KEY, sha TEXT NOT NULL);
|
|
64
|
+
CREATE TABLE reverts (sha TEXT PRIMARY KEY, reverted_sha TEXT NOT NULL);
|
|
65
|
+
CREATE INDEX reverts_target ON reverts(reverted_sha);
|
|
66
|
+
CREATE TABLE prs (
|
|
67
|
+
number INTEGER PRIMARY KEY,
|
|
68
|
+
title TEXT, body TEXT, labels TEXT, state TEXT, merged_at TEXT,
|
|
69
|
+
merge_sha TEXT, url TEXT, author TEXT, reviewers TEXT, fetched_at INTEGER
|
|
70
|
+
);
|
|
71
|
+
CREATE TABLE issues (
|
|
72
|
+
number INTEGER PRIMARY KEY,
|
|
73
|
+
title TEXT, body TEXT, labels TEXT, state TEXT, closed_at TEXT, url TEXT,
|
|
74
|
+
fetched_at INTEGER, retrieval_status TEXT NOT NULL DEFAULT 'ok'
|
|
75
|
+
);
|
|
76
|
+
CREATE TABLE pr_issue_links (
|
|
77
|
+
pr_number INTEGER NOT NULL, issue_number INTEGER NOT NULL, kind TEXT NOT NULL,
|
|
78
|
+
PRIMARY KEY (pr_number, issue_number)
|
|
79
|
+
);
|
|
80
|
+
CREATE TABLE review_comments (
|
|
81
|
+
id INTEGER PRIMARY KEY, pr_number INTEGER NOT NULL, path TEXT, line INTEGER,
|
|
82
|
+
body TEXT, author TEXT, url TEXT, created_at TEXT
|
|
83
|
+
);
|
|
84
|
+
CREATE INDEX review_comments_pr ON review_comments(pr_number);
|
|
85
|
+
CREATE TABLE http_cache (url TEXT PRIMARY KEY, etag TEXT, body TEXT, fetched_at INTEGER);
|
|
86
|
+
CREATE TABLE query_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at INTEGER);
|
|
87
|
+
CREATE TABLE tombstones (
|
|
88
|
+
id TEXT PRIMARY KEY, sha TEXT NOT NULL, path TEXT NOT NULL, symbol TEXT,
|
|
89
|
+
fingerprint TEXT NOT NULL, shingles TEXT NOT NULL, record TEXT NOT NULL
|
|
90
|
+
);
|
|
91
|
+
""",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class IndexUnavailable(Exception):
|
|
96
|
+
"""The index is missing, corrupt, or from a newer Fence."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class CommitRow:
|
|
101
|
+
sha: str
|
|
102
|
+
parents: tuple[str, ...]
|
|
103
|
+
author_name: str
|
|
104
|
+
author_email: str
|
|
105
|
+
author_time: int
|
|
106
|
+
committer_time: int
|
|
107
|
+
subject: str
|
|
108
|
+
body: str
|
|
109
|
+
pr_number: int | None = None
|
|
110
|
+
pr_source: str | None = None
|
|
111
|
+
files_changed: int = 0
|
|
112
|
+
format_only: bool = False
|
|
113
|
+
bulk: bool = False
|
|
114
|
+
orphaned: bool = False
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def message(self) -> str:
|
|
118
|
+
return f"{self.subject}\n\n{self.body}".strip() if self.body else self.subject
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class FileChange:
|
|
123
|
+
sha: str
|
|
124
|
+
path: str
|
|
125
|
+
old_path: str | None
|
|
126
|
+
status: str
|
|
127
|
+
similarity: int | None
|
|
128
|
+
added: int | None
|
|
129
|
+
deleted: int | None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def db_path_for(cache_dir: Path, repo_root: Path) -> Path:
|
|
133
|
+
key = hashlib.sha256(str(repo_root.resolve()).lower().encode()).hexdigest()[:16]
|
|
134
|
+
return cache_dir / "index" / f"{repo_root.name[:40]}-{key}.sqlite3"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Store:
|
|
138
|
+
def __init__(self, conn: sqlite3.Connection, path: Path | None) -> None:
|
|
139
|
+
self.conn = conn
|
|
140
|
+
self.path = path
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def open(cls, path: Path | str, *, create: bool = True) -> Store:
|
|
144
|
+
if str(path) != ":memory:":
|
|
145
|
+
p = Path(path)
|
|
146
|
+
if not p.exists() and not create:
|
|
147
|
+
raise IndexUnavailable(f"no index at {p}; run `fence index` first")
|
|
148
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
try:
|
|
150
|
+
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
|
|
151
|
+
conn.row_factory = sqlite3.Row
|
|
152
|
+
conn.execute("PRAGMA busy_timeout = 30000")
|
|
153
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
154
|
+
conn.execute("PRAGMA synchronous = NORMAL")
|
|
155
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
156
|
+
store = cls(conn, None if str(path) == ":memory:" else Path(path))
|
|
157
|
+
store._migrate()
|
|
158
|
+
except sqlite3.DatabaseError as e:
|
|
159
|
+
raise IndexUnavailable(
|
|
160
|
+
f"index at {path} is unreadable ({e}); run `fence reindex`"
|
|
161
|
+
) from None
|
|
162
|
+
return store
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
self.conn.close()
|
|
166
|
+
|
|
167
|
+
def _migrate(self) -> None:
|
|
168
|
+
has_meta = self.conn.execute(
|
|
169
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta'"
|
|
170
|
+
).fetchone()
|
|
171
|
+
version = int(self.get_meta("schema_version") or 0) if has_meta else 0
|
|
172
|
+
if version > SCHEMA_VERSION:
|
|
173
|
+
raise IndexUnavailable(
|
|
174
|
+
f"index schema v{version} is newer than this Fence (v{SCHEMA_VERSION}); upgrade"
|
|
175
|
+
)
|
|
176
|
+
for target in range(version + 1, SCHEMA_VERSION + 1):
|
|
177
|
+
with self.transaction():
|
|
178
|
+
for stmt in _split_sql(MIGRATIONS[target - 1]):
|
|
179
|
+
self.conn.execute(stmt)
|
|
180
|
+
self.set_meta("schema_version", str(target))
|
|
181
|
+
|
|
182
|
+
@contextmanager
|
|
183
|
+
def transaction(self) -> Iterator[sqlite3.Connection]:
|
|
184
|
+
"""BEGIN IMMEDIATE ... COMMIT, rolled back on any exception."""
|
|
185
|
+
self.conn.execute("BEGIN IMMEDIATE")
|
|
186
|
+
try:
|
|
187
|
+
yield self.conn
|
|
188
|
+
except BaseException:
|
|
189
|
+
self.conn.execute("ROLLBACK")
|
|
190
|
+
raise
|
|
191
|
+
self.conn.execute("COMMIT")
|
|
192
|
+
|
|
193
|
+
# meta -------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def get_meta(self, key: str) -> str | None:
|
|
196
|
+
row = self.conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
|
197
|
+
return row["value"] if row else None
|
|
198
|
+
|
|
199
|
+
def set_meta(self, key: str, value: str) -> None:
|
|
200
|
+
self.conn.execute(
|
|
201
|
+
"INSERT INTO meta(key, value) VALUES(?, ?) "
|
|
202
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
203
|
+
(key, value),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# commits ----------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def next_seq(self) -> int:
|
|
209
|
+
row = self.conn.execute("SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM commits").fetchone()
|
|
210
|
+
return int(row["n"])
|
|
211
|
+
|
|
212
|
+
def put_commits(self, commits: Iterable[CommitRow], changes: Iterable[FileChange]) -> None:
|
|
213
|
+
"""Upsert a batch. Call inside transaction()."""
|
|
214
|
+
seq = self.next_seq()
|
|
215
|
+
for c in commits:
|
|
216
|
+
self.conn.execute(
|
|
217
|
+
"INSERT INTO commits(sha, parents, author_name, author_email, author_time,"
|
|
218
|
+
" committer_time, subject, body, pr_number, pr_source, files_changed,"
|
|
219
|
+
" format_only, bulk, orphaned, seq) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)"
|
|
220
|
+
" ON CONFLICT(sha) DO UPDATE SET orphaned = 0",
|
|
221
|
+
(
|
|
222
|
+
c.sha, " ".join(c.parents), c.author_name, c.author_email, c.author_time,
|
|
223
|
+
c.committer_time, c.subject, c.body, c.pr_number, c.pr_source,
|
|
224
|
+
c.files_changed, int(c.format_only), int(c.bulk), seq,
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
seq += 1
|
|
228
|
+
self.conn.executemany(
|
|
229
|
+
"INSERT OR REPLACE INTO file_changes VALUES(?,?,?,?,?,?,?)",
|
|
230
|
+
[
|
|
231
|
+
(f.sha, f.path, f.old_path, f.status, f.similarity, f.added, f.deleted)
|
|
232
|
+
for f in changes
|
|
233
|
+
],
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def put_signals(self, rows: Iterable[tuple[str, str, str]]) -> None:
|
|
237
|
+
self.conn.executemany("INSERT OR IGNORE INTO signals VALUES(?,?,?)", list(rows))
|
|
238
|
+
|
|
239
|
+
def put_revert(self, sha: str, reverted_sha: str) -> None:
|
|
240
|
+
self.conn.execute("INSERT OR REPLACE INTO reverts VALUES(?,?)", (sha, reverted_sha))
|
|
241
|
+
|
|
242
|
+
def has_commit(self, sha: str) -> bool:
|
|
243
|
+
return self.conn.execute("SELECT 1 FROM commits WHERE sha=?", (sha,)).fetchone() is not None
|
|
244
|
+
|
|
245
|
+
def commit(self, sha: str) -> CommitRow | None:
|
|
246
|
+
row = self.conn.execute("SELECT * FROM commits WHERE sha = ?", (sha,)).fetchone()
|
|
247
|
+
return _commit_row(row) if row else None
|
|
248
|
+
|
|
249
|
+
def commits(self, shas: Iterable[str]) -> dict[str, CommitRow]:
|
|
250
|
+
shas = list(dict.fromkeys(shas))
|
|
251
|
+
out: dict[str, CommitRow] = {}
|
|
252
|
+
for chunk in _chunks(shas, 500):
|
|
253
|
+
marks = ",".join("?" * len(chunk))
|
|
254
|
+
for row in self.conn.execute(f"SELECT * FROM commits WHERE sha IN ({marks})", chunk): # noqa: S608
|
|
255
|
+
out[row["sha"]] = _commit_row(row)
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
def commit_count(self, *, include_orphaned: bool = False) -> int:
|
|
259
|
+
sql = "SELECT COUNT(*) AS n FROM commits"
|
|
260
|
+
if not include_orphaned:
|
|
261
|
+
sql += " WHERE orphaned = 0"
|
|
262
|
+
return int(self.conn.execute(sql).fetchone()["n"])
|
|
263
|
+
|
|
264
|
+
def signals_for(self, sha: str) -> list[tuple[str, str]]:
|
|
265
|
+
return [
|
|
266
|
+
(r["signal_id"], r["detail"])
|
|
267
|
+
for r in self.conn.execute(
|
|
268
|
+
"SELECT signal_id, detail FROM signals WHERE sha = ? ORDER BY signal_id, detail",
|
|
269
|
+
(sha,),
|
|
270
|
+
)
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
def reverts_of(self, sha: str, *, as_of: int | None = None) -> list[str]:
|
|
274
|
+
"""Commits that revert `sha` (committed at or before `as_of`, if given)."""
|
|
275
|
+
return [
|
|
276
|
+
r["sha"]
|
|
277
|
+
for r in self.conn.execute(
|
|
278
|
+
"SELECT r.sha FROM reverts r JOIN commits c ON c.sha = r.sha "
|
|
279
|
+
"WHERE r.reverted_sha = ? AND c.committer_time <= ? ORDER BY c.seq",
|
|
280
|
+
(sha, as_of if as_of is not None else 2**62),
|
|
281
|
+
)
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
def reverted_by(self, sha: str) -> str | None:
|
|
285
|
+
row = self.conn.execute("SELECT reverted_sha FROM reverts WHERE sha = ?", (sha,)).fetchone()
|
|
286
|
+
return row["reverted_sha"] if row else None
|
|
287
|
+
|
|
288
|
+
def changes_for(self, sha: str) -> list[FileChange]:
|
|
289
|
+
return [
|
|
290
|
+
FileChange(**dict(r))
|
|
291
|
+
for r in self.conn.execute(
|
|
292
|
+
"SELECT * FROM file_changes WHERE sha = ? ORDER BY path", (sha,)
|
|
293
|
+
)
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
def path_history(self, path: str, *, limit: int = 50) -> list[str]:
|
|
297
|
+
"""Earlier names of `path`, following renames backwards, oldest last."""
|
|
298
|
+
names = [path]
|
|
299
|
+
seen = {path}
|
|
300
|
+
current = path
|
|
301
|
+
for _ in range(limit):
|
|
302
|
+
row = self.conn.execute(
|
|
303
|
+
"SELECT fc.old_path FROM file_changes fc JOIN commits c ON c.sha = fc.sha"
|
|
304
|
+
" WHERE fc.path = ? AND fc.status = 'R' AND fc.old_path IS NOT NULL"
|
|
305
|
+
" AND c.orphaned = 0 ORDER BY c.seq DESC LIMIT 1",
|
|
306
|
+
(current,),
|
|
307
|
+
).fetchone()
|
|
308
|
+
if not row or row["old_path"] in seen:
|
|
309
|
+
break
|
|
310
|
+
current = row["old_path"]
|
|
311
|
+
names.append(current)
|
|
312
|
+
seen.add(current)
|
|
313
|
+
return names
|
|
314
|
+
|
|
315
|
+
# refs and orphan detection ------------------------------------------------
|
|
316
|
+
|
|
317
|
+
def refs(self) -> dict[str, str]:
|
|
318
|
+
return {r["name"]: r["sha"] for r in self.conn.execute("SELECT name, sha FROM refs")}
|
|
319
|
+
|
|
320
|
+
def set_ref(self, name: str, sha: str) -> None:
|
|
321
|
+
self.conn.execute(
|
|
322
|
+
"INSERT INTO refs VALUES(?, ?) ON CONFLICT(name) DO UPDATE SET sha = excluded.sha",
|
|
323
|
+
(name, sha),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def mark_orphans(self, reachable: Iterable[str]) -> int:
|
|
327
|
+
"""Mark every commit not in `reachable` as orphaned. Returns how many changed."""
|
|
328
|
+
self.conn.execute("CREATE TEMP TABLE IF NOT EXISTS reach (sha TEXT PRIMARY KEY)")
|
|
329
|
+
self.conn.execute("DELETE FROM reach")
|
|
330
|
+
for chunk in _chunks(list(reachable), 5000):
|
|
331
|
+
self.conn.executemany("INSERT OR IGNORE INTO reach VALUES(?)", [(s,) for s in chunk])
|
|
332
|
+
cur = self.conn.execute(
|
|
333
|
+
"UPDATE commits SET orphaned = 1 WHERE orphaned = 0 "
|
|
334
|
+
"AND sha NOT IN (SELECT sha FROM reach)"
|
|
335
|
+
)
|
|
336
|
+
self.conn.execute("UPDATE commits SET orphaned = 0 WHERE sha IN (SELECT sha FROM reach)")
|
|
337
|
+
return cur.rowcount
|
|
338
|
+
|
|
339
|
+
# query cache ------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def cache_get(self, key: str) -> object | None:
|
|
342
|
+
row = self.conn.execute("SELECT value FROM query_cache WHERE key = ?", (key,)).fetchone()
|
|
343
|
+
return json.loads(row["value"]) if row else None
|
|
344
|
+
|
|
345
|
+
def cache_put(self, key: str, value: object) -> None:
|
|
346
|
+
self.conn.execute(
|
|
347
|
+
"INSERT OR REPLACE INTO query_cache VALUES(?, ?, ?)",
|
|
348
|
+
(key, json.dumps(value, sort_keys=True), int(time.time())),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def clear_query_cache(self) -> None:
|
|
352
|
+
self.conn.execute("DELETE FROM query_cache")
|
|
353
|
+
|
|
354
|
+
# health -----------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def integrity_ok(self) -> bool:
|
|
357
|
+
row = self.conn.execute("PRAGMA quick_check").fetchone()
|
|
358
|
+
return bool(row) and row[0] == "ok"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _commit_row(row: sqlite3.Row) -> CommitRow:
|
|
362
|
+
return CommitRow(
|
|
363
|
+
sha=row["sha"],
|
|
364
|
+
parents=tuple(p for p in row["parents"].split(" ") if p),
|
|
365
|
+
author_name=row["author_name"],
|
|
366
|
+
author_email=row["author_email"],
|
|
367
|
+
author_time=row["author_time"],
|
|
368
|
+
committer_time=row["committer_time"],
|
|
369
|
+
subject=row["subject"],
|
|
370
|
+
body=row["body"],
|
|
371
|
+
pr_number=row["pr_number"],
|
|
372
|
+
pr_source=row["pr_source"],
|
|
373
|
+
files_changed=row["files_changed"],
|
|
374
|
+
format_only=bool(row["format_only"]),
|
|
375
|
+
bulk=bool(row["bulk"]),
|
|
376
|
+
orphaned=bool(row["orphaned"]),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _split_sql(script: str) -> list[str]:
|
|
381
|
+
# Migration scripts are ours and contain no ';' inside literals, so a plain
|
|
382
|
+
# split is enough, and it lets each statement run inside our transaction
|
|
383
|
+
# (executescript would commit implicitly).
|
|
384
|
+
return [s.strip() for s in script.split(";") if s.strip()]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _chunks(items: list, size: int) -> Iterator[list]:
|
|
388
|
+
for i in range(0, len(items), size):
|
|
389
|
+
yield items[i : i + size]
|
fence/lang/__init__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Pluggable language layer: symbols and normalized tokens per language.
|
|
2
|
+
|
|
3
|
+
A plugin answers two questions about source text: which named scopes
|
|
4
|
+
(functions, methods, classes) exist and where, and what the code is once
|
|
5
|
+
whitespace, comments and quote style are stripped away. The first gives symbol
|
|
6
|
+
anchors; the second gives fingerprints for move detection and format-only
|
|
7
|
+
commit detection. Unsupported languages fall back to file and line anchors,
|
|
8
|
+
and `fallback_tokens` gives a whitespace-insensitive token stream.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import PurePosixPath
|
|
16
|
+
from typing import Literal, Protocol
|
|
17
|
+
|
|
18
|
+
SymbolKind = Literal["function", "class", "module"]
|
|
19
|
+
MODULE = "<module>"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Symbol:
|
|
24
|
+
qualname: str
|
|
25
|
+
kind: SymbolKind
|
|
26
|
+
start_line: int # 1-based, first decorator/annotation line if any
|
|
27
|
+
end_line: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LanguagePlugin(Protocol):
|
|
31
|
+
name: str
|
|
32
|
+
extensions: tuple[str, ...]
|
|
33
|
+
|
|
34
|
+
def symbols(self, source: str) -> list[Symbol] | None:
|
|
35
|
+
"""All named scopes, parents before children. None if the source cannot be parsed."""
|
|
36
|
+
|
|
37
|
+
def tokens(self, source: str) -> list[str]:
|
|
38
|
+
"""Code tokens with comments and layout removed and string quoting normalized."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_REGISTRY: dict[str, LanguagePlugin] = {}
|
|
42
|
+
_BY_EXT: dict[str, str] = {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def register(plugin: LanguagePlugin) -> None:
|
|
46
|
+
_REGISTRY[plugin.name] = plugin
|
|
47
|
+
for ext in plugin.extensions:
|
|
48
|
+
_BY_EXT[ext] = plugin.name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ensure_loaded() -> None:
|
|
52
|
+
if _REGISTRY:
|
|
53
|
+
return
|
|
54
|
+
from fence.lang import python, treesitter
|
|
55
|
+
|
|
56
|
+
register(python.PythonPlugin())
|
|
57
|
+
for plugin in treesitter.plugins():
|
|
58
|
+
register(plugin)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def plugin_for(path: str, enabled: list[str] | None = None) -> LanguagePlugin | None:
|
|
62
|
+
_ensure_loaded()
|
|
63
|
+
name = _BY_EXT.get(PurePosixPath(path).suffix.lower())
|
|
64
|
+
if name is None or (enabled is not None and _family(name) not in enabled):
|
|
65
|
+
return None
|
|
66
|
+
return _REGISTRY[name]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _family(name: str) -> str:
|
|
70
|
+
return "typescript" if name == "tsx" else name
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def supported_languages() -> list[str]:
|
|
74
|
+
_ensure_loaded()
|
|
75
|
+
return sorted({_family(n) for n in _REGISTRY})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def enclosing_symbol(path: str, source: str, line: int) -> Symbol | None:
|
|
79
|
+
"""Innermost symbol containing `line`; `<module>` outside any scope; None if unparsable
|
|
80
|
+
or unsupported."""
|
|
81
|
+
if line < 1:
|
|
82
|
+
raise ValueError(f"line must be >= 1, got {line}")
|
|
83
|
+
total = len(source.splitlines())
|
|
84
|
+
if line > total:
|
|
85
|
+
return None
|
|
86
|
+
plugin = plugin_for(path)
|
|
87
|
+
if plugin is None:
|
|
88
|
+
return None
|
|
89
|
+
syms = plugin.symbols(source)
|
|
90
|
+
if syms is None:
|
|
91
|
+
return None
|
|
92
|
+
best: Symbol | None = None
|
|
93
|
+
for s in syms: # parents come before children, so the last match is the innermost
|
|
94
|
+
if s.start_line <= line <= s.end_line:
|
|
95
|
+
best = s
|
|
96
|
+
return best or Symbol(MODULE, "module", 1, total)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_FALLBACK_TOKEN = re.compile(r"\w+|[^\w\s]")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fallback_tokens(source: str) -> list[str]:
|
|
103
|
+
return _FALLBACK_TOKEN.findall(source)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def tokens_for(path: str, source: str) -> list[str]:
|
|
107
|
+
plugin = plugin_for(path)
|
|
108
|
+
return plugin.tokens(source) if plugin else fallback_tokens(source)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def normalize_string_literal(text: str) -> str:
|
|
112
|
+
"""'x', "x", '''x''', r"x", `x` -> a single canonical token, so quote style is ignored."""
|
|
113
|
+
body = text.lstrip("rRbBuUfF")
|
|
114
|
+
for q in ('"""', "'''", '"', "'", "`"):
|
|
115
|
+
if body.startswith(q) and body.endswith(q) and len(body) >= 2 * len(q):
|
|
116
|
+
body = body[len(q) : -len(q)]
|
|
117
|
+
break
|
|
118
|
+
return "STR:" + body
|
fence/lang/python.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Python plugin: the stdlib `ast` and `tokenize` modules, which implement the exact
|
|
2
|
+
grammar of the running interpreter. No third-party parser is needed."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
import io
|
|
8
|
+
import tokenize
|
|
9
|
+
import warnings
|
|
10
|
+
|
|
11
|
+
from fence.lang import Symbol, normalize_string_literal
|
|
12
|
+
|
|
13
|
+
_Scope = ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef
|
|
14
|
+
_SKIP = {
|
|
15
|
+
tokenize.COMMENT, tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT,
|
|
16
|
+
tokenize.ENCODING, tokenize.ENDMARKER,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PythonPlugin:
|
|
21
|
+
name = "python"
|
|
22
|
+
extensions = (".py", ".pyi")
|
|
23
|
+
|
|
24
|
+
def symbols(self, source: str) -> list[Symbol] | None:
|
|
25
|
+
try:
|
|
26
|
+
# Old code is often valid but deprecated (invalid escapes): parsing it must
|
|
27
|
+
# not print SyntaxWarnings into Fence's output.
|
|
28
|
+
with warnings.catch_warnings():
|
|
29
|
+
warnings.simplefilter("ignore", SyntaxWarning)
|
|
30
|
+
tree = ast.parse(source)
|
|
31
|
+
except (SyntaxError, ValueError):
|
|
32
|
+
return None
|
|
33
|
+
out: list[Symbol] = []
|
|
34
|
+
for node, names in _walk_scopes(tree, ()):
|
|
35
|
+
start = min([node.lineno, *(d.lineno for d in node.decorator_list)])
|
|
36
|
+
kind = "class" if isinstance(node, ast.ClassDef) else "function"
|
|
37
|
+
out.append(Symbol(".".join(names), kind, start, node.end_lineno or node.lineno))
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
def tokens(self, source: str) -> list[str]:
|
|
41
|
+
out: list[str] = []
|
|
42
|
+
try:
|
|
43
|
+
for tok in tokenize.generate_tokens(io.StringIO(source).readline):
|
|
44
|
+
if tok.type in _SKIP:
|
|
45
|
+
continue
|
|
46
|
+
if tok.type == tokenize.STRING:
|
|
47
|
+
out.append(normalize_string_literal(tok.string))
|
|
48
|
+
else:
|
|
49
|
+
out.append(tok.string)
|
|
50
|
+
except (tokenize.TokenError, SyntaxError, IndentationError):
|
|
51
|
+
from fence.lang import fallback_tokens
|
|
52
|
+
|
|
53
|
+
return fallback_tokens(source)
|
|
54
|
+
# A trailing comma is layout in Python (black adds and removes them).
|
|
55
|
+
return [t for i, t in enumerate(out) if not (t == "," and i + 1 < len(out)
|
|
56
|
+
and out[i + 1] in (")", "]", "}"))]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _walk_scopes(node: ast.AST, prefix: tuple[str, ...]):
|
|
60
|
+
for child in ast.iter_child_nodes(node):
|
|
61
|
+
if isinstance(child, _Scope):
|
|
62
|
+
names = (*prefix, child.name)
|
|
63
|
+
yield child, names
|
|
64
|
+
yield from _walk_scopes(child, names)
|
|
65
|
+
else:
|
|
66
|
+
# Scopes can sit inside if/try/with blocks; keep looking with the same prefix.
|
|
67
|
+
yield from _walk_scopes(child, prefix)
|