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/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
+ }