repograph-codeintel 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.
- repograph/__init__.py +3 -0
- repograph/__main__.py +3 -0
- repograph/agent_setup.py +224 -0
- repograph/cli.py +476 -0
- repograph/config.py +145 -0
- repograph/extract.py +616 -0
- repograph/impact.py +558 -0
- repograph/indexer.py +184 -0
- repograph/mcp_server.py +130 -0
- repograph/query.py +485 -0
- repograph/resolve.py +755 -0
- repograph/scanner.py +107 -0
- repograph/store.py +287 -0
- repograph/test_gate.py +169 -0
- repograph_codeintel-0.1.0.dist-info/METADATA +295 -0
- repograph_codeintel-0.1.0.dist-info/RECORD +20 -0
- repograph_codeintel-0.1.0.dist-info/WHEEL +5 -0
- repograph_codeintel-0.1.0.dist-info/entry_points.txt +2 -0
- repograph_codeintel-0.1.0.dist-info/licenses/LICENSE +202 -0
- repograph_codeintel-0.1.0.dist-info/top_level.txt +1 -0
repograph/scanner.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Repository file discovery with exclusions and resource limits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .config import Settings
|
|
11
|
+
|
|
12
|
+
LANGUAGES = {
|
|
13
|
+
".py": "python",
|
|
14
|
+
".pyi": "python",
|
|
15
|
+
".ts": "typescript",
|
|
16
|
+
".tsx": "tsx",
|
|
17
|
+
".mts": "typescript",
|
|
18
|
+
".cts": "typescript",
|
|
19
|
+
".js": "javascript",
|
|
20
|
+
".jsx": "jsx",
|
|
21
|
+
".mjs": "javascript",
|
|
22
|
+
".cjs": "javascript",
|
|
23
|
+
".prisma": "prisma",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
SUPPORTED_LANGUAGES = ("python", "typescript", "tsx", "javascript", "jsx", "prisma")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class FileEntry:
|
|
31
|
+
path: str # repo-relative POSIX path
|
|
32
|
+
lang: str
|
|
33
|
+
size: int
|
|
34
|
+
mtime: float
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ScanResult:
|
|
39
|
+
files: list[FileEntry]
|
|
40
|
+
skipped_large: int = 0
|
|
41
|
+
skipped_symlink: int = 0
|
|
42
|
+
truncated: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def detect_language(path: str) -> str | None:
|
|
46
|
+
return LANGUAGES.get(Path(path).suffix.lower())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scan(root: Path, settings: Settings) -> ScanResult:
|
|
50
|
+
root = root.resolve()
|
|
51
|
+
out: list[FileEntry] = []
|
|
52
|
+
skipped_large = skipped_symlink = 0
|
|
53
|
+
|
|
54
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
55
|
+
dirnames[:] = sorted(
|
|
56
|
+
d
|
|
57
|
+
for d in dirnames
|
|
58
|
+
if not settings.is_excluded(f"{Path(dirpath, d).relative_to(root).as_posix()}")
|
|
59
|
+
and not (Path(dirpath, d) / ".repograph").exists()
|
|
60
|
+
)
|
|
61
|
+
for name in sorted(filenames):
|
|
62
|
+
if len(out) >= settings.max_files:
|
|
63
|
+
return ScanResult(out, skipped_large, skipped_symlink, truncated=True)
|
|
64
|
+
full = Path(dirpath, name)
|
|
65
|
+
rel = full.relative_to(root).as_posix()
|
|
66
|
+
if settings.is_excluded(rel):
|
|
67
|
+
continue
|
|
68
|
+
lang = detect_language(rel)
|
|
69
|
+
if lang is None:
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
st = full.lstat()
|
|
73
|
+
except OSError:
|
|
74
|
+
continue
|
|
75
|
+
if stat.S_ISLNK(st.st_mode):
|
|
76
|
+
# Symlinked files are skipped by default: they can read data
|
|
77
|
+
# outside the repository and double-count sources.
|
|
78
|
+
if not settings.follow_symlinks:
|
|
79
|
+
skipped_symlink += 1
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
resolved = full.resolve(strict=True)
|
|
83
|
+
resolved.relative_to(root)
|
|
84
|
+
st = resolved.stat()
|
|
85
|
+
except (OSError, ValueError):
|
|
86
|
+
skipped_symlink += 1
|
|
87
|
+
continue
|
|
88
|
+
if not stat.S_ISREG(st.st_mode):
|
|
89
|
+
continue
|
|
90
|
+
if st.st_size > settings.max_file_bytes:
|
|
91
|
+
skipped_large += 1
|
|
92
|
+
continue
|
|
93
|
+
out.append(FileEntry(rel, lang, st.st_size, st.st_mtime))
|
|
94
|
+
return ScanResult(out, skipped_large, skipped_symlink)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_source(root: Path, rel: str, settings: Settings) -> bytes | None:
|
|
98
|
+
"""Read a source file without following links outside the root."""
|
|
99
|
+
full = (root / rel)
|
|
100
|
+
try:
|
|
101
|
+
resolved = full.resolve(strict=True)
|
|
102
|
+
resolved.relative_to(root.resolve())
|
|
103
|
+
if not resolved.is_file() or resolved.stat().st_size > settings.max_file_bytes:
|
|
104
|
+
return None
|
|
105
|
+
return resolved.read_bytes()
|
|
106
|
+
except (OSError, ValueError):
|
|
107
|
+
return None
|
repograph/store.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""SQLite persistence.
|
|
2
|
+
|
|
3
|
+
Single local database, WAL mode, relative paths only. All user-controlled
|
|
4
|
+
values are bound parameters; identifiers are never interpolated into SQL.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterator
|
|
15
|
+
|
|
16
|
+
from .config import Settings
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = "1"
|
|
19
|
+
|
|
20
|
+
SCHEMA = """
|
|
21
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
22
|
+
key TEXT PRIMARY KEY,
|
|
23
|
+
value TEXT NOT NULL
|
|
24
|
+
);
|
|
25
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
26
|
+
id INTEGER PRIMARY KEY,
|
|
27
|
+
path TEXT NOT NULL UNIQUE,
|
|
28
|
+
lang TEXT NOT NULL,
|
|
29
|
+
sha256 TEXT NOT NULL,
|
|
30
|
+
size INTEGER NOT NULL,
|
|
31
|
+
mtime REAL NOT NULL,
|
|
32
|
+
parsed INTEGER NOT NULL DEFAULT 1,
|
|
33
|
+
parse_error TEXT,
|
|
34
|
+
is_test INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
indexed_at TEXT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
38
|
+
id INTEGER PRIMARY KEY,
|
|
39
|
+
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
40
|
+
name TEXT NOT NULL,
|
|
41
|
+
qualified_name TEXT NOT NULL,
|
|
42
|
+
kind TEXT NOT NULL,
|
|
43
|
+
start_line INTEGER NOT NULL,
|
|
44
|
+
end_line INTEGER NOT NULL,
|
|
45
|
+
start_col INTEGER NOT NULL,
|
|
46
|
+
end_col INTEGER NOT NULL,
|
|
47
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
48
|
+
is_exported INTEGER NOT NULL DEFAULT 1
|
|
49
|
+
);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS imports (
|
|
51
|
+
id INTEGER PRIMARY KEY,
|
|
52
|
+
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
53
|
+
raw TEXT NOT NULL,
|
|
54
|
+
module TEXT NOT NULL,
|
|
55
|
+
line INTEGER NOT NULL,
|
|
56
|
+
target_file_id INTEGER REFERENCES files(id) ON DELETE SET NULL,
|
|
57
|
+
confidence TEXT NOT NULL,
|
|
58
|
+
method TEXT NOT NULL,
|
|
59
|
+
kind TEXT NOT NULL,
|
|
60
|
+
names TEXT NOT NULL DEFAULT '[]',
|
|
61
|
+
note TEXT NOT NULL DEFAULT ''
|
|
62
|
+
);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS refs (
|
|
64
|
+
id INTEGER PRIMARY KEY,
|
|
65
|
+
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
66
|
+
name TEXT NOT NULL,
|
|
67
|
+
line INTEGER NOT NULL,
|
|
68
|
+
col INTEGER NOT NULL,
|
|
69
|
+
kind TEXT NOT NULL,
|
|
70
|
+
confidence TEXT NOT NULL,
|
|
71
|
+
method TEXT NOT NULL,
|
|
72
|
+
target_file_id INTEGER REFERENCES files(id) ON DELETE SET NULL,
|
|
73
|
+
target_name TEXT
|
|
74
|
+
);
|
|
75
|
+
CREATE TABLE IF NOT EXISTS routes (
|
|
76
|
+
id INTEGER PRIMARY KEY,
|
|
77
|
+
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
78
|
+
method TEXT NOT NULL,
|
|
79
|
+
path TEXT NOT NULL,
|
|
80
|
+
handler TEXT NOT NULL,
|
|
81
|
+
line INTEGER NOT NULL,
|
|
82
|
+
framework TEXT NOT NULL,
|
|
83
|
+
confidence TEXT NOT NULL DEFAULT 'heuristic'
|
|
84
|
+
);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_symbols_qname ON symbols(qualified_name);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_imports_file ON imports(file_id);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_imports_target ON imports(target_file_id);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_refs_file ON refs(file_id);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file_id);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_routes_file ON routes(file_id);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_files_path ON files(path);
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class FileRow:
|
|
99
|
+
id: int
|
|
100
|
+
path: str
|
|
101
|
+
lang: str
|
|
102
|
+
sha256: str
|
|
103
|
+
size: int
|
|
104
|
+
mtime: float
|
|
105
|
+
parsed: int
|
|
106
|
+
parse_error: str | None
|
|
107
|
+
is_test: int
|
|
108
|
+
indexed_at: str = ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Store:
|
|
112
|
+
def __init__(self, settings: Settings) -> None:
|
|
113
|
+
self.settings = settings
|
|
114
|
+
settings.state_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
self.conn = sqlite3.connect(settings.db_path, timeout=30)
|
|
116
|
+
self.conn.row_factory = sqlite3.Row
|
|
117
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
118
|
+
self.conn.execute("PRAGMA foreign_keys=ON")
|
|
119
|
+
self.conn.execute("PRAGMA synchronous=NORMAL")
|
|
120
|
+
|
|
121
|
+
# -- lifecycle ----------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def init_schema(self, reset: bool = False) -> None:
|
|
124
|
+
if reset:
|
|
125
|
+
self.conn.executescript(
|
|
126
|
+
"DROP TABLE IF EXISTS refs; DROP TABLE IF EXISTS imports;"
|
|
127
|
+
"DROP TABLE IF EXISTS symbols; DROP TABLE IF EXISTS files;"
|
|
128
|
+
"DROP TABLE IF EXISTS meta;"
|
|
129
|
+
)
|
|
130
|
+
self.conn.executescript(SCHEMA)
|
|
131
|
+
current = self.get_meta("schema_version")
|
|
132
|
+
if current and current != SCHEMA_VERSION:
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
f"index schema {current} does not match {SCHEMA_VERSION}; run `repograph index --force`"
|
|
135
|
+
)
|
|
136
|
+
self.set_meta("schema_version", SCHEMA_VERSION)
|
|
137
|
+
self.conn.commit()
|
|
138
|
+
|
|
139
|
+
def close(self) -> None:
|
|
140
|
+
self.conn.close()
|
|
141
|
+
|
|
142
|
+
def __enter__(self) -> "Store":
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def __exit__(self, *exc: object) -> None:
|
|
146
|
+
self.close()
|
|
147
|
+
|
|
148
|
+
@contextmanager
|
|
149
|
+
def tx(self) -> Iterator[None]:
|
|
150
|
+
try:
|
|
151
|
+
yield
|
|
152
|
+
self.conn.commit()
|
|
153
|
+
except Exception:
|
|
154
|
+
self.conn.rollback()
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
# -- meta ---------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def get_meta(self, key: str) -> str | None:
|
|
160
|
+
row = self.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
|
|
161
|
+
return row["value"] if row else None
|
|
162
|
+
|
|
163
|
+
def set_meta(self, key: str, value: str) -> None:
|
|
164
|
+
self.conn.execute(
|
|
165
|
+
"INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
166
|
+
(key, value),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# -- files --------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def existing_files(self) -> dict[str, FileRow]:
|
|
172
|
+
rows = self.conn.execute("SELECT * FROM files").fetchall()
|
|
173
|
+
return {r["path"]: FileRow(**dict(r)) for r in rows}
|
|
174
|
+
|
|
175
|
+
def file_id(self, path: str) -> int | None:
|
|
176
|
+
row = self.conn.execute("SELECT id FROM files WHERE path=?", (path,)).fetchone()
|
|
177
|
+
return row["id"] if row else None
|
|
178
|
+
|
|
179
|
+
def file_path(self, file_id: int) -> str | None:
|
|
180
|
+
row = self.conn.execute("SELECT path FROM files WHERE id=?", (file_id,)).fetchone()
|
|
181
|
+
return row["path"] if row else None
|
|
182
|
+
|
|
183
|
+
def upsert_file(self, path: str, lang: str, sha: str, size: int, mtime: float,
|
|
184
|
+
parse_error: str | None, is_test: bool, indexed_at: str) -> int:
|
|
185
|
+
self.conn.execute(
|
|
186
|
+
"""INSERT INTO files(path,lang,sha256,size,mtime,parsed,parse_error,is_test,indexed_at)
|
|
187
|
+
VALUES(?,?,?,?,?,?,?,?,?)
|
|
188
|
+
ON CONFLICT(path) DO UPDATE SET lang=excluded.lang, sha256=excluded.sha256,
|
|
189
|
+
size=excluded.size, mtime=excluded.mtime, parsed=excluded.parsed,
|
|
190
|
+
parse_error=excluded.parse_error, is_test=excluded.is_test,
|
|
191
|
+
indexed_at=excluded.indexed_at""",
|
|
192
|
+
(path, lang, sha, size, mtime, 0 if parse_error else 1, parse_error, int(is_test), indexed_at),
|
|
193
|
+
)
|
|
194
|
+
row = self.conn.execute("SELECT id FROM files WHERE path=?", (path,)).fetchone()
|
|
195
|
+
return int(row["id"])
|
|
196
|
+
|
|
197
|
+
def delete_file(self, path: str) -> None:
|
|
198
|
+
self.conn.execute("DELETE FROM files WHERE path=?", (path,))
|
|
199
|
+
|
|
200
|
+
def clear_children(self, file_id: int) -> None:
|
|
201
|
+
for table in ("symbols", "imports", "refs", "routes"):
|
|
202
|
+
self.conn.execute(f"DELETE FROM {table} WHERE file_id=?", (file_id,))
|
|
203
|
+
|
|
204
|
+
def all_files(self) -> list[sqlite3.Row]:
|
|
205
|
+
return self.conn.execute("SELECT * FROM files ORDER BY path").fetchall()
|
|
206
|
+
|
|
207
|
+
def stats(self) -> dict[str, object]:
|
|
208
|
+
one = lambda sql: self.conn.execute(sql).fetchone()[0] # noqa: E731
|
|
209
|
+
langs = {
|
|
210
|
+
r["lang"]: r["n"]
|
|
211
|
+
for r in self.conn.execute("SELECT lang, COUNT(*) AS n FROM files GROUP BY lang ORDER BY n DESC")
|
|
212
|
+
}
|
|
213
|
+
kinds = {
|
|
214
|
+
r["kind"]: r["n"]
|
|
215
|
+
for r in self.conn.execute("SELECT kind, COUNT(*) AS n FROM symbols GROUP BY kind ORDER BY n DESC")
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
"files": one("SELECT COUNT(*) FROM files"),
|
|
219
|
+
"symbols": one("SELECT COUNT(*) FROM symbols"),
|
|
220
|
+
"imports": one("SELECT COUNT(*) FROM imports"),
|
|
221
|
+
"internal_imports": one("SELECT COUNT(*) FROM imports WHERE target_file_id IS NOT NULL"),
|
|
222
|
+
"refs": one("SELECT COUNT(*) FROM refs"),
|
|
223
|
+
"resolved_refs": one("SELECT COUNT(*) FROM refs WHERE target_file_id IS NOT NULL"),
|
|
224
|
+
"tests": one("SELECT COUNT(*) FROM files WHERE is_test=1"),
|
|
225
|
+
"parse_errors": one("SELECT COUNT(*) FROM files WHERE parsed=0"),
|
|
226
|
+
"languages": langs,
|
|
227
|
+
"symbol_kinds": kinds,
|
|
228
|
+
"indexed_at": self.get_meta("indexed_at"),
|
|
229
|
+
"root": self.get_meta("root"),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# -- symbols ------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def insert_symbol(self, file_id: int, s, is_exported: bool = True) -> int:
|
|
235
|
+
cur = self.conn.execute(
|
|
236
|
+
"""INSERT INTO symbols(file_id,name,qualified_name,kind,start_line,end_line,
|
|
237
|
+
start_col,end_col,tags,is_exported)
|
|
238
|
+
VALUES(?,?,?,?,?,?,?,?,?,?)""",
|
|
239
|
+
(file_id, s.name, s.qualified_name, s.kind, s.start_line, s.end_line,
|
|
240
|
+
s.start_col, s.end_col, ",".join(s.tags), int(is_exported)),
|
|
241
|
+
)
|
|
242
|
+
return int(cur.lastrowid)
|
|
243
|
+
|
|
244
|
+
def insert_import(self, file_id: int, imp, target_file_id: int | None, confidence: str,
|
|
245
|
+
method: str, kind: str, note: str = "") -> None:
|
|
246
|
+
self.conn.execute(
|
|
247
|
+
"""INSERT INTO imports(file_id,raw,module,line,target_file_id,confidence,method,kind,names,note)
|
|
248
|
+
VALUES(?,?,?,?,?,?,?,?,?,?)""",
|
|
249
|
+
(file_id, imp.raw, imp.module, imp.line, target_file_id, confidence, method, kind,
|
|
250
|
+
json.dumps(imp.names), note),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def insert_ref(self, file_id: int, ref, confidence: str, method: str,
|
|
254
|
+
target_file_id: int | None, target_name: str | None) -> None:
|
|
255
|
+
self.conn.execute(
|
|
256
|
+
"""INSERT INTO refs(file_id,name,line,col,kind,confidence,method,target_file_id,target_name)
|
|
257
|
+
VALUES(?,?,?,?,?,?,?,?,?)""",
|
|
258
|
+
(file_id, ref.name, ref.line, ref.col, ref.kind, confidence, method,
|
|
259
|
+
target_file_id, target_name),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def insert_route(self, file_id: int, route, confidence: str = "heuristic") -> None:
|
|
263
|
+
self.conn.execute(
|
|
264
|
+
"""INSERT INTO routes(file_id,method,path,handler,line,framework,confidence)
|
|
265
|
+
VALUES(?,?,?,?,?,?,?)""",
|
|
266
|
+
(file_id, route.method, route.path, route.handler, route.line, route.framework, confidence),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# -- read helpers -------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def file_edges(self) -> list[dict[str, object]]:
|
|
272
|
+
rows = self.conn.execute(
|
|
273
|
+
"""SELECT s.path AS src, t.path AS dst, i.confidence, i.method, i.line, i.module
|
|
274
|
+
FROM imports i
|
|
275
|
+
JOIN files s ON s.id=i.file_id
|
|
276
|
+
JOIN files t ON t.id=i.target_file_id
|
|
277
|
+
WHERE i.target_file_id IS NOT NULL AND s.path != t.path"""
|
|
278
|
+
).fetchall()
|
|
279
|
+
return [dict(r) for r in rows]
|
|
280
|
+
|
|
281
|
+
def symbols_by_name(self, name: str, limit: int = 50) -> list[dict[str, object]]:
|
|
282
|
+
rows = self.conn.execute(
|
|
283
|
+
"""SELECT s.*, f.path FROM symbols s JOIN files f ON f.id=s.file_id
|
|
284
|
+
WHERE s.name=? ORDER BY f.path, s.start_line LIMIT ?""",
|
|
285
|
+
(name, limit),
|
|
286
|
+
).fetchall()
|
|
287
|
+
return [dict(r) for r in rows]
|
repograph/test_gate.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Select the tests a change needs, and say what cannot be ruled out.
|
|
2
|
+
|
|
3
|
+
The value of this command is not the list it produces; it is that the list is
|
|
4
|
+
*checkable*. Run the selection, run the full suite, subtract: the difference is
|
|
5
|
+
the tool's recall, measured by pytest instead of by the author.
|
|
6
|
+
|
|
7
|
+
So the gate fails closed. A changed file missing from the index, a test file
|
|
8
|
+
that failed to parse, a test file with an unresolved internal import, or a
|
|
9
|
+
changed ``conftest.py`` are reported as undetermined rather than assumed
|
|
10
|
+
unrelated. ``--strict`` turns "I cannot prove this" into a nonzero exit.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import posixpath
|
|
16
|
+
import shlex
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .impact import _is_test_module, analyze_impact, git_changes
|
|
21
|
+
from .store import Store
|
|
22
|
+
|
|
23
|
+
# A test whose own imports we could not resolve may reach any file. Listing it
|
|
24
|
+
# as unrelated would be a silent false negative, which is the failure this tool
|
|
25
|
+
# exists to avoid.
|
|
26
|
+
_UNDETERMINED_SQL = """
|
|
27
|
+
SELECT f.path AS path,
|
|
28
|
+
f.parse_error AS parse_error,
|
|
29
|
+
(SELECT COUNT(*) FROM imports i
|
|
30
|
+
WHERE i.file_id=f.id AND i.target_file_id IS NULL AND i.kind != 'external'
|
|
31
|
+
) AS unresolved
|
|
32
|
+
FROM files f
|
|
33
|
+
WHERE f.is_test=1 AND f.path LIKE '%.py'
|
|
34
|
+
AND (f.parsed=0
|
|
35
|
+
OR EXISTS (SELECT 1 FROM imports i
|
|
36
|
+
WHERE i.file_id=f.id AND i.target_file_id IS NULL
|
|
37
|
+
AND i.kind != 'external'))
|
|
38
|
+
ORDER BY f.path
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_pytest_file(path: str) -> bool:
|
|
43
|
+
"""pytest only collects Python; a .test.ts file is a test but not ours."""
|
|
44
|
+
return path.endswith(".py") and _is_test_module(path)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pytest_command(run: list[str]) -> list[str]:
|
|
48
|
+
return [sys.executable, "-m", "pytest", "-q", *run] if run else [sys.executable, "-m", "pytest", "-q"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def select(
|
|
52
|
+
store: Store,
|
|
53
|
+
root: Path,
|
|
54
|
+
*,
|
|
55
|
+
paths: list[str] | None = None,
|
|
56
|
+
diff: bool = False,
|
|
57
|
+
base: str = "HEAD",
|
|
58
|
+
strict: bool = False,
|
|
59
|
+
) -> dict:
|
|
60
|
+
"""Tests to run, tests that cannot be ruled out, and an exit code."""
|
|
61
|
+
changed: list[str] = []
|
|
62
|
+
source = "files"
|
|
63
|
+
if diff:
|
|
64
|
+
source = "git-diff"
|
|
65
|
+
changes = git_changes(root, base=base)
|
|
66
|
+
if not changes["available"]:
|
|
67
|
+
return {"error": changes["error"]}
|
|
68
|
+
changed = list(changes["paths"])
|
|
69
|
+
deleted = set(changes["deleted"])
|
|
70
|
+
else:
|
|
71
|
+
changed = [p.strip("/") for p in (paths or [])]
|
|
72
|
+
deleted = set()
|
|
73
|
+
|
|
74
|
+
indexed = {r["path"] for r in store.all_files()}
|
|
75
|
+
analysable = [p for p in changed if p in indexed]
|
|
76
|
+
unindexed = [p for p in changed if p not in indexed and p not in deleted]
|
|
77
|
+
|
|
78
|
+
total = store.conn.execute(
|
|
79
|
+
"SELECT COUNT(*) AS n FROM files WHERE is_test=1"
|
|
80
|
+
).fetchone()["n"]
|
|
81
|
+
test_paths = [r["path"] for r in store.conn.execute(
|
|
82
|
+
"SELECT path FROM files WHERE is_test=1 ORDER BY path"
|
|
83
|
+
).fetchall() if _is_pytest_file(r["path"])]
|
|
84
|
+
total = len(test_paths)
|
|
85
|
+
|
|
86
|
+
run: list[dict] = []
|
|
87
|
+
caveats: list[str] = []
|
|
88
|
+
reachable: list[str] = []
|
|
89
|
+
if analysable:
|
|
90
|
+
impact = analyze_impact(store, root, paths=analysable, depth=2)
|
|
91
|
+
if "error" in impact:
|
|
92
|
+
return {"error": impact["error"]}
|
|
93
|
+
for t in impact["related_tests"]:
|
|
94
|
+
if not _is_pytest_file(t["path"]):
|
|
95
|
+
continue
|
|
96
|
+
run.append({
|
|
97
|
+
"path": t["path"],
|
|
98
|
+
"confidence": t["confidence"],
|
|
99
|
+
"rules": list(dict.fromkeys(r["rule"] for r in t["rules"])),
|
|
100
|
+
"evidence": t["evidence"],
|
|
101
|
+
})
|
|
102
|
+
for b in impact.get("blind_spots", []):
|
|
103
|
+
if b["kind"] == "test-selection-reachability":
|
|
104
|
+
reachable = [p for p in b.get("items") or [] if _is_pytest_file(p)]
|
|
105
|
+
if reachable:
|
|
106
|
+
caveats.append(
|
|
107
|
+
f"{len(reachable)} more test files reach this change through dependent "
|
|
108
|
+
"modules but are not selected; run them or the full suite"
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
caveats.append("no changed file is in the index; nothing can be selected")
|
|
112
|
+
|
|
113
|
+
conftest_changed = [p for p in changed if posixpath.basename(p) == "conftest.py"]
|
|
114
|
+
if conftest_changed:
|
|
115
|
+
caveats.append(
|
|
116
|
+
"a conftest.py changed: session fixtures can affect any test, so the whole suite is undetermined"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
seen = {r["path"] for r in run}
|
|
120
|
+
undetermined: list[dict] = []
|
|
121
|
+
if conftest_changed:
|
|
122
|
+
undetermined = [
|
|
123
|
+
{"path": p, "reason": "conftest-change", "evidence": {"location": conftest_changed[0]}}
|
|
124
|
+
for p in test_paths if p not in seen
|
|
125
|
+
]
|
|
126
|
+
seen |= {u["path"] for u in undetermined}
|
|
127
|
+
else:
|
|
128
|
+
for row in store.conn.execute(_UNDETERMINED_SQL).fetchall():
|
|
129
|
+
if row["path"] in seen:
|
|
130
|
+
continue
|
|
131
|
+
reason = "parse-error" if row["parse_error"] is not None else "unresolved-import"
|
|
132
|
+
undetermined.append({
|
|
133
|
+
"path": row["path"],
|
|
134
|
+
"reason": reason,
|
|
135
|
+
"evidence": {"location": row["path"], "detail": row["parse_error"]},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
undetermined += [
|
|
139
|
+
{"path": p, "reason": "changed-file-not-indexed", "evidence": {"location": p}}
|
|
140
|
+
for p in unindexed
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
argv = _pytest_command([r["path"] for r in run])
|
|
144
|
+
selected = len(run)
|
|
145
|
+
exit_code = 0
|
|
146
|
+
if strict and (undetermined or not run):
|
|
147
|
+
exit_code = 3
|
|
148
|
+
return {
|
|
149
|
+
"source": source,
|
|
150
|
+
"base": base if diff else None,
|
|
151
|
+
"changed_files": changed,
|
|
152
|
+
"unindexed_changed_files": unindexed,
|
|
153
|
+
"run": run,
|
|
154
|
+
"reachable_not_selected": reachable,
|
|
155
|
+
"undetermined": undetermined,
|
|
156
|
+
"selection": {
|
|
157
|
+
"run": selected,
|
|
158
|
+
"undetermined": len(undetermined),
|
|
159
|
+
"total_tests": total,
|
|
160
|
+
"fraction": round(selected / total, 4) if total else None,
|
|
161
|
+
},
|
|
162
|
+
"command": shlex.join(argv),
|
|
163
|
+
"argv": argv,
|
|
164
|
+
"exit_code": exit_code,
|
|
165
|
+
"caveats": caveats + [
|
|
166
|
+
"selected tests are a lower bound: the static graph cannot see dynamic imports, "
|
|
167
|
+
"fixtures that parameterize modules, or pytest plugins that collect elsewhere",
|
|
168
|
+
],
|
|
169
|
+
}
|