codebase-brain-mcp 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.
- codebase_brain_mcp/__init__.py +8 -0
- codebase_brain_mcp/__main__.py +118 -0
- codebase_brain_mcp/analysis.py +427 -0
- codebase_brain_mcp/brain.py +395 -0
- codebase_brain_mcp/chunking.py +196 -0
- codebase_brain_mcp/config.py +58 -0
- codebase_brain_mcp/embeddings.py +79 -0
- codebase_brain_mcp/evals.py +372 -0
- codebase_brain_mcp/history.py +214 -0
- codebase_brain_mcp/py.typed +0 -0
- codebase_brain_mcp/remote.py +112 -0
- codebase_brain_mcp/server.py +415 -0
- codebase_brain_mcp/sources.py +186 -0
- codebase_brain_mcp/store.py +349 -0
- codebase_brain_mcp-0.1.0.dist-info/METADATA +293 -0
- codebase_brain_mcp-0.1.0.dist-info/RECORD +19 -0
- codebase_brain_mcp-0.1.0.dist-info/WHEEL +4 -0
- codebase_brain_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- codebase_brain_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Find a repository on disk (cloning it first if it's a URL) and list the files worth indexing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
LANGUAGES = {
|
|
12
|
+
".py": "python",
|
|
13
|
+
".pyi": "python",
|
|
14
|
+
".md": "markdown",
|
|
15
|
+
".mdx": "markdown",
|
|
16
|
+
".rst": "text",
|
|
17
|
+
".txt": "text",
|
|
18
|
+
".js": "javascript",
|
|
19
|
+
".jsx": "javascript",
|
|
20
|
+
".mjs": "javascript",
|
|
21
|
+
".cjs": "javascript",
|
|
22
|
+
".ts": "typescript",
|
|
23
|
+
".tsx": "typescript",
|
|
24
|
+
".go": "go",
|
|
25
|
+
".rs": "rust",
|
|
26
|
+
".java": "java",
|
|
27
|
+
".kt": "kotlin",
|
|
28
|
+
".rb": "ruby",
|
|
29
|
+
".php": "php",
|
|
30
|
+
".c": "c",
|
|
31
|
+
".h": "c",
|
|
32
|
+
".cc": "cpp",
|
|
33
|
+
".cpp": "cpp",
|
|
34
|
+
".hpp": "cpp",
|
|
35
|
+
".cs": "csharp",
|
|
36
|
+
".swift": "swift",
|
|
37
|
+
".sh": "shell",
|
|
38
|
+
".bash": "shell",
|
|
39
|
+
".sql": "sql",
|
|
40
|
+
".html": "html",
|
|
41
|
+
".css": "css",
|
|
42
|
+
".scss": "css",
|
|
43
|
+
".toml": "config",
|
|
44
|
+
".yaml": "config",
|
|
45
|
+
".yml": "config",
|
|
46
|
+
".json": "config",
|
|
47
|
+
".ini": "config",
|
|
48
|
+
".cfg": "config",
|
|
49
|
+
}
|
|
50
|
+
NAMED_FILES = {"Dockerfile": "config", "Makefile": "config", "README": "text", "LICENSE": "text"}
|
|
51
|
+
SKIP_DIRS = {
|
|
52
|
+
".git",
|
|
53
|
+
"node_modules",
|
|
54
|
+
".venv",
|
|
55
|
+
"venv",
|
|
56
|
+
"__pycache__",
|
|
57
|
+
"dist",
|
|
58
|
+
"build",
|
|
59
|
+
".mypy_cache",
|
|
60
|
+
".pytest_cache",
|
|
61
|
+
".tox",
|
|
62
|
+
".next",
|
|
63
|
+
"target",
|
|
64
|
+
"vendor",
|
|
65
|
+
".idea",
|
|
66
|
+
".vscode",
|
|
67
|
+
}
|
|
68
|
+
SKIP_FILES = {"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "uv.lock", "Cargo.lock"}
|
|
69
|
+
GIT_TIMEOUT_SECONDS = 300
|
|
70
|
+
_URL = re.compile(r"^https://[A-Za-z0-9.-]+/[A-Za-z0-9._/-]+?(\.git)?/?$")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SourceError(ValueError):
|
|
74
|
+
"""The source can't be used: missing path, bad URL, or a failed clone."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class SourceFile:
|
|
79
|
+
path: str
|
|
80
|
+
"""Path relative to the repository root, always with forward slashes."""
|
|
81
|
+
language: str
|
|
82
|
+
text: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_url(source: str) -> bool:
|
|
86
|
+
return source.startswith(("https://", "http://", "git@", "ssh://"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def default_name(source: str) -> str:
|
|
90
|
+
"""``https://github.com/tiangolo/fastapi.git`` and ``~/code/fastapi`` both become ``fastapi``."""
|
|
91
|
+
tail = source.rstrip("/").rsplit("/", 1)[-1]
|
|
92
|
+
tail = tail.removesuffix(".git")
|
|
93
|
+
name = re.sub(r"[^A-Za-z0-9._-]", "-", tail).strip("-.")
|
|
94
|
+
if not name:
|
|
95
|
+
raise SourceError(f"Can't work out a name from {source!r}; pass one explicitly.")
|
|
96
|
+
return name
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve(source: str, clone_dir: Path) -> Path:
|
|
100
|
+
"""The repository's root on disk. URLs are shallow-cloned (or refreshed) under ``clone_dir``."""
|
|
101
|
+
if not is_url(source):
|
|
102
|
+
root = Path(source).expanduser().resolve()
|
|
103
|
+
if not root.is_dir():
|
|
104
|
+
raise SourceError(f"{source!r} is not a directory.")
|
|
105
|
+
return root
|
|
106
|
+
if not _URL.match(source):
|
|
107
|
+
# Only plain https URLs: no ssh, no git's ext:: transports, no options smuggled into the URL.
|
|
108
|
+
raise SourceError("Only https:// repository URLs are supported, e.g. https://github.com/owner/repo.")
|
|
109
|
+
target = clone_dir / default_name(source)
|
|
110
|
+
# Blobless clones keep the full commit history (needed for explain_history) but fetch file
|
|
111
|
+
# contents only for the checkout and for the commits we actually inspect.
|
|
112
|
+
if (target / ".git").is_dir():
|
|
113
|
+
if run_git(target, "rev-parse", "--is-shallow-repository").strip() == "true":
|
|
114
|
+
run_git(target, "fetch", "--unshallow", "--filter=blob:none", "origin")
|
|
115
|
+
else:
|
|
116
|
+
run_git(target, "fetch", "--filter=blob:none", "origin")
|
|
117
|
+
run_git(target, "reset", "--hard", "FETCH_HEAD")
|
|
118
|
+
else:
|
|
119
|
+
clone_dir.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
run_git(clone_dir, "clone", "--filter=blob:none", "--", source, str(target))
|
|
121
|
+
return target
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def head_commit(root: Path) -> str | None:
|
|
125
|
+
try:
|
|
126
|
+
return run_git(root, "rev-parse", "HEAD").strip() or None
|
|
127
|
+
except SourceError:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def iter_files(root: Path, max_bytes: int) -> list[SourceFile]:
|
|
132
|
+
"""Text files worth indexing, sorted by path. Honours .gitignore when ``root`` is a git checkout."""
|
|
133
|
+
files = []
|
|
134
|
+
for rel in _candidate_paths(root):
|
|
135
|
+
language = language_for(rel)
|
|
136
|
+
if language is None:
|
|
137
|
+
continue
|
|
138
|
+
full = root / rel
|
|
139
|
+
try:
|
|
140
|
+
if not full.is_file() or full.is_symlink() or full.stat().st_size > max_bytes:
|
|
141
|
+
continue
|
|
142
|
+
raw = full.read_bytes()
|
|
143
|
+
except OSError:
|
|
144
|
+
continue
|
|
145
|
+
if b"\x00" in raw[:8192]:
|
|
146
|
+
continue # binary
|
|
147
|
+
files.append(SourceFile(path=rel, language=language, text=raw.decode("utf-8", errors="replace")))
|
|
148
|
+
return sorted(files, key=lambda f: f.path)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def language_for(rel: str) -> str | None:
|
|
152
|
+
name = rel.rsplit("/", 1)[-1]
|
|
153
|
+
if name in SKIP_FILES or name.endswith((".min.js", ".min.css", ".map")):
|
|
154
|
+
return None
|
|
155
|
+
if name in NAMED_FILES:
|
|
156
|
+
return NAMED_FILES[name]
|
|
157
|
+
return LANGUAGES.get(os.path.splitext(name)[1].lower())
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _candidate_paths(root: Path) -> list[str]:
|
|
161
|
+
try:
|
|
162
|
+
out = run_git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard")
|
|
163
|
+
paths = [p for p in out.split("\0") if p]
|
|
164
|
+
return [p for p in paths if not SKIP_DIRS.intersection(p.split("/")[:-1])]
|
|
165
|
+
except SourceError:
|
|
166
|
+
pass
|
|
167
|
+
paths = []
|
|
168
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
169
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
|
|
170
|
+
rel_dir = Path(dirpath).relative_to(root).as_posix()
|
|
171
|
+
for filename in filenames:
|
|
172
|
+
paths.append(filename if rel_dir == "." else f"{rel_dir}/{filename}")
|
|
173
|
+
return paths
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run_git(cwd: Path, *args: str) -> str:
|
|
177
|
+
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ALLOW_PROTOCOL": "https:file"}
|
|
178
|
+
try:
|
|
179
|
+
done = subprocess.run(
|
|
180
|
+
["git", *args], cwd=cwd, env=env, capture_output=True, text=True, timeout=GIT_TIMEOUT_SECONDS, check=False
|
|
181
|
+
)
|
|
182
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
183
|
+
raise SourceError(f"git {args[0]} failed: {exc}") from exc
|
|
184
|
+
if done.returncode != 0:
|
|
185
|
+
raise SourceError(f"git {args[0]} failed: {done.stderr.strip()[:300]}")
|
|
186
|
+
return done.stdout
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""One SQLite file per indexed repository: files, chunks, their vectors, and an FTS5 keyword index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
import threading
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .chunking import Chunk
|
|
15
|
+
from .history import Commit, PullRequest
|
|
16
|
+
|
|
17
|
+
SCHEMA = """
|
|
18
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
19
|
+
CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, sha TEXT NOT NULL, language TEXT NOT NULL);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
21
|
+
id INTEGER PRIMARY KEY,
|
|
22
|
+
path TEXT NOT NULL,
|
|
23
|
+
start_line INTEGER NOT NULL,
|
|
24
|
+
end_line INTEGER NOT NULL,
|
|
25
|
+
kind TEXT NOT NULL,
|
|
26
|
+
symbol TEXT,
|
|
27
|
+
text TEXT NOT NULL,
|
|
28
|
+
vector BLOB NOT NULL,
|
|
29
|
+
params TEXT,
|
|
30
|
+
accepts_any_keyword INTEGER NOT NULL DEFAULT 0
|
|
31
|
+
);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS chunks_path ON chunks (path);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS chunks_symbol ON chunks (symbol);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS refs (chunk_id INTEGER NOT NULL, name TEXT NOT NULL);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS refs_name ON refs (name);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS refs_chunk ON refs (chunk_id);
|
|
37
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5 (body, symbol, path, tokenize = 'porter unicode61');
|
|
38
|
+
CREATE TABLE IF NOT EXISTS commits (
|
|
39
|
+
sha TEXT PRIMARY KEY,
|
|
40
|
+
author TEXT NOT NULL,
|
|
41
|
+
date TEXT NOT NULL,
|
|
42
|
+
subject TEXT NOT NULL,
|
|
43
|
+
body TEXT NOT NULL,
|
|
44
|
+
prs TEXT NOT NULL
|
|
45
|
+
);
|
|
46
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS commits_fts USING fts5 (subject, body, sha UNINDEXED, tokenize = 'porter unicode61');
|
|
47
|
+
CREATE TABLE IF NOT EXISTS pull_requests (number INTEGER PRIMARY KEY, data TEXT NOT NULL);
|
|
48
|
+
"""
|
|
49
|
+
SCHEMA_VERSION = "3"
|
|
50
|
+
"""Bump when the index layout changes; older indexes are rebuilt automatically."""
|
|
51
|
+
_WORD = re.compile(r"[A-Za-z0-9_]+")
|
|
52
|
+
_CAMEL = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class StoredChunk:
|
|
57
|
+
id: int
|
|
58
|
+
path: str
|
|
59
|
+
start_line: int
|
|
60
|
+
end_line: int
|
|
61
|
+
kind: str
|
|
62
|
+
symbol: str | None
|
|
63
|
+
text: str
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def citation(self) -> str:
|
|
67
|
+
return f"{self.path}:{self.start_line}-{self.end_line}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def identifier_terms(text: str) -> str:
|
|
71
|
+
"""``getUserById`` and ``get_user_by_id`` both also yield ``get user by id``, so keywords match either style."""
|
|
72
|
+
extra = []
|
|
73
|
+
for word in _WORD.findall(text):
|
|
74
|
+
parts = [p.lower() for piece in word.split("_") for p in _CAMEL.findall(piece)]
|
|
75
|
+
if len(parts) > 1:
|
|
76
|
+
extra.extend(parts)
|
|
77
|
+
return " ".join(extra)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _glob_literal(text: str) -> str:
|
|
81
|
+
"""Escape GLOB wildcards. GLOB, unlike LIKE, is case-sensitive, which symbol names need."""
|
|
82
|
+
return re.sub(r"([*?\[])", r"[\1]", text)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _match_expression(query: str) -> str:
|
|
86
|
+
"""An FTS5 OR-query of the query's words and identifier parts, each quoted so nothing is FTS syntax."""
|
|
87
|
+
terms = {t.lower() for t in _WORD.findall(query)} | set(identifier_terms(query).split())
|
|
88
|
+
return " OR ".join(f'"{t}"' for t in sorted(t for t in terms if len(t) > 1))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Store:
|
|
92
|
+
def __init__(self, path: Path):
|
|
93
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
self.path = path
|
|
95
|
+
self._db = sqlite3.connect(path, check_same_thread=False)
|
|
96
|
+
self._db.row_factory = sqlite3.Row
|
|
97
|
+
self._upgrade()
|
|
98
|
+
self._db.executescript(SCHEMA)
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
self._matrix: tuple[np.ndarray, np.ndarray] | None = None
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
self._db.close()
|
|
104
|
+
|
|
105
|
+
def _upgrade(self) -> None:
|
|
106
|
+
"""Indexes from older versions are dropped (they're rebuilt on the next index run)."""
|
|
107
|
+
tables = {r[0] for r in self._db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
|
108
|
+
if "meta" not in tables:
|
|
109
|
+
return
|
|
110
|
+
row = self._db.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
|
|
111
|
+
if row and row[0] == SCHEMA_VERSION:
|
|
112
|
+
return
|
|
113
|
+
with self._db:
|
|
114
|
+
for table in ("chunks_fts", "chunks", "files", "refs", "commits_fts", "commits"):
|
|
115
|
+
self._db.execute(f"DROP TABLE IF EXISTS {table}")
|
|
116
|
+
self._db.execute("DELETE FROM meta WHERE key IN ('embed_model', 'history_head')")
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def schema_version(self) -> str:
|
|
120
|
+
return SCHEMA_VERSION
|
|
121
|
+
|
|
122
|
+
# ---- metadata -------------------------------------------------------------------------
|
|
123
|
+
def get_meta(self) -> dict[str, str]:
|
|
124
|
+
return {row["key"]: row["value"] for row in self._db.execute("SELECT key, value FROM meta")}
|
|
125
|
+
|
|
126
|
+
def set_meta(self, **values: str) -> None:
|
|
127
|
+
with self._lock, self._db:
|
|
128
|
+
self._db.executemany("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", values.items())
|
|
129
|
+
|
|
130
|
+
# ---- files and chunks -----------------------------------------------------------------
|
|
131
|
+
def file_hashes(self) -> dict[str, str]:
|
|
132
|
+
return {row["path"]: row["sha"] for row in self._db.execute("SELECT path, sha FROM files")}
|
|
133
|
+
|
|
134
|
+
def replace_file(self, path: str, sha: str, language: str, chunks: list[Chunk], vectors: np.ndarray) -> None:
|
|
135
|
+
with self._lock, self._db:
|
|
136
|
+
self._delete_chunks(path)
|
|
137
|
+
for chunk, vector in zip(chunks, vectors, strict=True):
|
|
138
|
+
cur = self._db.execute(
|
|
139
|
+
"INSERT INTO chunks (path, start_line, end_line, kind, symbol, text, vector, params,"
|
|
140
|
+
" accepts_any_keyword) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
141
|
+
(
|
|
142
|
+
chunk.path,
|
|
143
|
+
chunk.start_line,
|
|
144
|
+
chunk.end_line,
|
|
145
|
+
chunk.kind,
|
|
146
|
+
chunk.symbol,
|
|
147
|
+
chunk.text,
|
|
148
|
+
np.asarray(vector, dtype=np.float32).tobytes(),
|
|
149
|
+
None if chunk.params is None else json.dumps(list(chunk.params)),
|
|
150
|
+
int(chunk.accepts_any_keyword),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
self._db.executemany(
|
|
154
|
+
"INSERT INTO refs (chunk_id, name) VALUES (?, ?)", [(cur.lastrowid, name) for name in chunk.refs]
|
|
155
|
+
)
|
|
156
|
+
body = f"{chunk.text}\n{identifier_terms(chunk.text)}"
|
|
157
|
+
self._db.execute(
|
|
158
|
+
"INSERT INTO chunks_fts (rowid, body, symbol, path) VALUES (?, ?, ?, ?)",
|
|
159
|
+
(cur.lastrowid, body, chunk.symbol or "", f"{path} {identifier_terms(path)}"),
|
|
160
|
+
)
|
|
161
|
+
self._db.execute(
|
|
162
|
+
"INSERT OR REPLACE INTO files (path, sha, language) VALUES (?, ?, ?)", (path, sha, language)
|
|
163
|
+
)
|
|
164
|
+
self._matrix = None
|
|
165
|
+
|
|
166
|
+
def remove_file(self, path: str) -> None:
|
|
167
|
+
with self._lock, self._db:
|
|
168
|
+
self._delete_chunks(path)
|
|
169
|
+
self._db.execute("DELETE FROM files WHERE path = ?", (path,))
|
|
170
|
+
self._matrix = None
|
|
171
|
+
|
|
172
|
+
def clear(self) -> None:
|
|
173
|
+
with self._lock, self._db:
|
|
174
|
+
self._db.execute("DELETE FROM chunks_fts")
|
|
175
|
+
self._db.execute("DELETE FROM refs")
|
|
176
|
+
self._db.execute("DELETE FROM chunks")
|
|
177
|
+
self._db.execute("DELETE FROM files")
|
|
178
|
+
self._db.execute("DELETE FROM commits_fts")
|
|
179
|
+
self._db.execute("DELETE FROM commits")
|
|
180
|
+
self._matrix = None
|
|
181
|
+
|
|
182
|
+
def _delete_chunks(self, path: str) -> None:
|
|
183
|
+
self._db.execute("DELETE FROM chunks_fts WHERE rowid IN (SELECT id FROM chunks WHERE path = ?)", (path,))
|
|
184
|
+
self._db.execute("DELETE FROM refs WHERE chunk_id IN (SELECT id FROM chunks WHERE path = ?)", (path,))
|
|
185
|
+
self._db.execute("DELETE FROM chunks WHERE path = ?", (path,))
|
|
186
|
+
|
|
187
|
+
def counts(self) -> dict[str, int]:
|
|
188
|
+
files = self._db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
|
189
|
+
chunks = self._db.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
|
190
|
+
return {"files": files, "chunks": chunks}
|
|
191
|
+
|
|
192
|
+
def languages(self) -> dict[str, int]:
|
|
193
|
+
rows = self._db.execute("SELECT language, COUNT(*) AS n FROM files GROUP BY language ORDER BY n DESC")
|
|
194
|
+
return {row["language"]: row["n"] for row in rows}
|
|
195
|
+
|
|
196
|
+
def get_chunks(self, ids: list[int]) -> dict[int, StoredChunk]:
|
|
197
|
+
if not ids:
|
|
198
|
+
return {}
|
|
199
|
+
marks = ",".join("?" * len(ids))
|
|
200
|
+
rows = self._db.execute(
|
|
201
|
+
f"SELECT id, path, start_line, end_line, kind, symbol, text FROM chunks WHERE id IN ({marks})", # noqa: S608 (only "?" placeholders)
|
|
202
|
+
ids,
|
|
203
|
+
)
|
|
204
|
+
return {row["id"]: StoredChunk(**dict(row)) for row in rows}
|
|
205
|
+
|
|
206
|
+
def find_symbol(self, symbol: str, path: str | None = None) -> list[StoredChunk]:
|
|
207
|
+
"""Chunks whose symbol is ``symbol`` or ends with ``.symbol`` (so ``unsign`` finds ``Signer.unsign``)."""
|
|
208
|
+
query = (
|
|
209
|
+
"SELECT id, path, start_line, end_line, kind, symbol, text FROM chunks" # noqa: S608 (fixed SQL)
|
|
210
|
+
" WHERE (symbol = ? OR symbol GLOB ?)" + (" AND path = ?" if path else "") + " ORDER BY start_line"
|
|
211
|
+
)
|
|
212
|
+
params = [symbol, f"*.{_glob_literal(symbol)}"] + ([path] if path else [])
|
|
213
|
+
return [StoredChunk(**dict(row)) for row in self._db.execute(query, params)]
|
|
214
|
+
|
|
215
|
+
# ---- structure (impact and drift) -----------------------------------------------------
|
|
216
|
+
def chunks_referencing(self, name: str, limit: int = 200) -> list[StoredChunk]:
|
|
217
|
+
rows = self._db.execute(
|
|
218
|
+
"SELECT DISTINCT c.id, c.path, c.start_line, c.end_line, c.kind, c.symbol, c.text FROM refs r"
|
|
219
|
+
" JOIN chunks c ON c.id = r.chunk_id WHERE r.name = ? ORDER BY c.path, c.start_line LIMIT ?",
|
|
220
|
+
(name, limit),
|
|
221
|
+
)
|
|
222
|
+
return [StoredChunk(**dict(row)) for row in rows]
|
|
223
|
+
|
|
224
|
+
def symbols(self) -> list[tuple[str, str]]:
|
|
225
|
+
"""Every defined (symbol, path) pair."""
|
|
226
|
+
rows = self._db.execute(
|
|
227
|
+
"SELECT DISTINCT symbol, path FROM chunks WHERE symbol IS NOT NULL"
|
|
228
|
+
" AND kind IN ('function', 'class', 'method')"
|
|
229
|
+
)
|
|
230
|
+
return [(row["symbol"], row["path"]) for row in rows]
|
|
231
|
+
|
|
232
|
+
def signatures(self, short_name: str) -> list[tuple[str, str, list[str], bool]]:
|
|
233
|
+
"""(symbol, path, params, accepts_any_keyword) for functions named ``short_name``."""
|
|
234
|
+
rows = self._db.execute(
|
|
235
|
+
"SELECT symbol, path, params, accepts_any_keyword FROM chunks WHERE params IS NOT NULL"
|
|
236
|
+
" AND (symbol = ? OR symbol GLOB ?)",
|
|
237
|
+
(short_name, f"*.{_glob_literal(short_name)}"),
|
|
238
|
+
)
|
|
239
|
+
return [(r["symbol"], r["path"], json.loads(r["params"]), bool(r["accepts_any_keyword"])) for r in rows]
|
|
240
|
+
|
|
241
|
+
def file_paths(self) -> set[str]:
|
|
242
|
+
return {row["path"] for row in self._db.execute("SELECT path FROM files")}
|
|
243
|
+
|
|
244
|
+
def doc_chunks(self, path_prefix: str | None = None) -> list[StoredChunk]:
|
|
245
|
+
query = (
|
|
246
|
+
"SELECT c.id, c.path, c.start_line, c.end_line, c.kind, c.symbol, c.text FROM chunks c"
|
|
247
|
+
" JOIN files f ON f.path = c.path WHERE f.language IN ('markdown', 'text')"
|
|
248
|
+
)
|
|
249
|
+
params: list[str] = []
|
|
250
|
+
if path_prefix:
|
|
251
|
+
query += " AND c.path LIKE ?"
|
|
252
|
+
params.append(path_prefix.replace("%", "") + "%")
|
|
253
|
+
return [StoredChunk(**dict(row)) for row in self._db.execute(query + " ORDER BY c.path, c.start_line", params)]
|
|
254
|
+
|
|
255
|
+
def code_mentions(self, term: str) -> bool:
|
|
256
|
+
"""Whether ``term`` appears anywhere in non-documentation files (a plain substring check)."""
|
|
257
|
+
row = self._db.execute(
|
|
258
|
+
"SELECT 1 FROM chunks c JOIN files f ON f.path = c.path"
|
|
259
|
+
" WHERE f.language NOT IN ('markdown', 'text') AND instr(c.text, ?) > 0 LIMIT 1",
|
|
260
|
+
(term,),
|
|
261
|
+
).fetchone()
|
|
262
|
+
return row is not None
|
|
263
|
+
|
|
264
|
+
# ---- history --------------------------------------------------------------------------
|
|
265
|
+
def add_commits(self, commits: list[Commit]) -> int:
|
|
266
|
+
added = 0
|
|
267
|
+
with self._lock, self._db:
|
|
268
|
+
for commit in commits:
|
|
269
|
+
cur = self._db.execute(
|
|
270
|
+
"INSERT OR IGNORE INTO commits (sha, author, date, subject, body, prs) VALUES (?, ?, ?, ?, ?, ?)",
|
|
271
|
+
(commit.sha, commit.author, commit.date, commit.subject, commit.body, json.dumps(commit.prs)),
|
|
272
|
+
)
|
|
273
|
+
if cur.rowcount:
|
|
274
|
+
added += 1
|
|
275
|
+
self._db.execute(
|
|
276
|
+
"INSERT INTO commits_fts (subject, body, sha) VALUES (?, ?, ?)",
|
|
277
|
+
(f"{commit.subject} {identifier_terms(commit.subject)}", commit.body, commit.sha),
|
|
278
|
+
)
|
|
279
|
+
return added
|
|
280
|
+
|
|
281
|
+
def commit_count(self) -> int:
|
|
282
|
+
return self._db.execute("SELECT COUNT(*) FROM commits").fetchone()[0]
|
|
283
|
+
|
|
284
|
+
def search_commits(self, query: str, limit: int) -> list[Commit]:
|
|
285
|
+
match = _match_expression(query)
|
|
286
|
+
if not match:
|
|
287
|
+
return []
|
|
288
|
+
rows = self._db.execute(
|
|
289
|
+
"SELECT c.sha, c.author, c.date, c.subject, c.body, c.prs FROM commits_fts f"
|
|
290
|
+
" JOIN commits c ON c.sha = f.sha WHERE commits_fts MATCH ?"
|
|
291
|
+
" ORDER BY bm25(commits_fts, 3.0, 1.0) LIMIT ?",
|
|
292
|
+
(match, limit),
|
|
293
|
+
)
|
|
294
|
+
return [
|
|
295
|
+
Commit(
|
|
296
|
+
sha=r["sha"],
|
|
297
|
+
author=r["author"],
|
|
298
|
+
date=r["date"],
|
|
299
|
+
subject=r["subject"],
|
|
300
|
+
body=r["body"],
|
|
301
|
+
prs=tuple(json.loads(r["prs"])),
|
|
302
|
+
)
|
|
303
|
+
for r in rows
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
def cached_pull_request(self, number: int) -> PullRequest | None:
|
|
307
|
+
row = self._db.execute("SELECT data FROM pull_requests WHERE number = ?", (number,)).fetchone()
|
|
308
|
+
return PullRequest(**json.loads(row["data"])) if row else None
|
|
309
|
+
|
|
310
|
+
def cache_pull_request(self, pr: PullRequest) -> None:
|
|
311
|
+
if pr.title is None:
|
|
312
|
+
return # don't cache failed lookups; they may work next time
|
|
313
|
+
with self._lock, self._db:
|
|
314
|
+
self._db.execute(
|
|
315
|
+
"INSERT OR REPLACE INTO pull_requests (number, data) VALUES (?, ?)",
|
|
316
|
+
(pr.number, json.dumps(pr.__dict__)),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# ---- retrieval ------------------------------------------------------------------------
|
|
320
|
+
def keyword_search(self, query: str, limit: int) -> list[int]:
|
|
321
|
+
"""Chunk ids ranked by BM25. Symbol and path matches count more than body matches."""
|
|
322
|
+
match = _match_expression(query)
|
|
323
|
+
if not match:
|
|
324
|
+
return []
|
|
325
|
+
rows = self._db.execute(
|
|
326
|
+
"SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY bm25(chunks_fts, 1.0, 4.0, 2.0) LIMIT ?",
|
|
327
|
+
(match, limit),
|
|
328
|
+
)
|
|
329
|
+
return [row[0] for row in rows]
|
|
330
|
+
|
|
331
|
+
def vector_search(self, query_vector: np.ndarray, limit: int) -> list[int]:
|
|
332
|
+
"""Chunk ids ranked by cosine similarity (vectors are normalised, so a dot product)."""
|
|
333
|
+
ids, matrix = self._vectors()
|
|
334
|
+
if not len(ids):
|
|
335
|
+
return []
|
|
336
|
+
scores = matrix @ query_vector.astype(np.float32)
|
|
337
|
+
top = np.argsort(-scores)[:limit]
|
|
338
|
+
return [int(ids[i]) for i in top]
|
|
339
|
+
|
|
340
|
+
def _vectors(self) -> tuple[np.ndarray, np.ndarray]:
|
|
341
|
+
if self._matrix is None:
|
|
342
|
+
rows = self._db.execute("SELECT id, vector FROM chunks ORDER BY id").fetchall()
|
|
343
|
+
if rows:
|
|
344
|
+
ids = np.array([row["id"] for row in rows])
|
|
345
|
+
matrix = np.stack([np.frombuffer(row["vector"], dtype=np.float32) for row in rows])
|
|
346
|
+
else:
|
|
347
|
+
ids, matrix = np.array([], dtype=int), np.zeros((0, 0), dtype=np.float32)
|
|
348
|
+
self._matrix = (ids, matrix)
|
|
349
|
+
return self._matrix
|