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,395 @@
|
|
|
1
|
+
"""The engine behind the MCP tools: index repositories, search them, and gather cited evidence for questions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from . import analysis, history, sources
|
|
15
|
+
from .analysis import DriftReport, ImpactReport, definition_span
|
|
16
|
+
from .chunking import chunk_file
|
|
17
|
+
from .config import Settings
|
|
18
|
+
from .embeddings import Embedder, make_embedder
|
|
19
|
+
from .history import BlameShare, Commit, LineChange, PullRequest
|
|
20
|
+
from .store import Store, StoredChunk
|
|
21
|
+
|
|
22
|
+
RRF_K = 60
|
|
23
|
+
"""Reciprocal-rank-fusion constant; 60 is the value from the original paper and works well untuned."""
|
|
24
|
+
CANDIDATES = 50
|
|
25
|
+
EMBED_BATCH_FILES = 16
|
|
26
|
+
HISTORY_LIMIT = 5_000
|
|
27
|
+
"""Commits read per indexing run; enough for the history that matters, bounded for huge repos."""
|
|
28
|
+
MAX_PULL_REQUESTS = 5
|
|
29
|
+
_NAME = re.compile(r"^[A-Za-z0-9._-]{1,100}$")
|
|
30
|
+
|
|
31
|
+
ProgressFn = Callable[[int, int, str], None]
|
|
32
|
+
SearchMode = Literal["hybrid", "keyword", "vector"]
|
|
33
|
+
SEARCH_MODES: tuple[SearchMode, ...] = ("hybrid", "keyword", "vector")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BrainError(ValueError):
|
|
37
|
+
"""A request the brain can't serve, with a message meant for the user."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class IndexReport:
|
|
42
|
+
repo: str
|
|
43
|
+
root: str
|
|
44
|
+
commit: str | None
|
|
45
|
+
files_seen: int
|
|
46
|
+
files_indexed: int
|
|
47
|
+
files_unchanged: int
|
|
48
|
+
files_removed: int
|
|
49
|
+
chunks_total: int
|
|
50
|
+
commits_added: int
|
|
51
|
+
commits_total: int
|
|
52
|
+
seconds: float
|
|
53
|
+
full_rebuild: bool
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Hit:
|
|
58
|
+
chunk: StoredChunk
|
|
59
|
+
score: float
|
|
60
|
+
keyword_rank: int | None
|
|
61
|
+
vector_rank: int | None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class HistoryReport:
|
|
66
|
+
repo: str
|
|
67
|
+
path: str
|
|
68
|
+
start_line: int
|
|
69
|
+
end_line: int
|
|
70
|
+
symbol: str | None
|
|
71
|
+
blame: list[BlameShare]
|
|
72
|
+
changes: list[LineChange]
|
|
73
|
+
pull_requests: list[PullRequest]
|
|
74
|
+
github: str | None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class RepoInfo:
|
|
79
|
+
name: str
|
|
80
|
+
source: str
|
|
81
|
+
root: str
|
|
82
|
+
commit: str | None
|
|
83
|
+
indexed_at: str
|
|
84
|
+
files: int
|
|
85
|
+
chunks: int
|
|
86
|
+
commits: int = 0
|
|
87
|
+
languages: dict[str, int] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Brain:
|
|
91
|
+
def __init__(self, settings: Settings, embedder: Embedder | None = None):
|
|
92
|
+
self.settings = settings
|
|
93
|
+
self.embedder = embedder or make_embedder(settings)
|
|
94
|
+
self._stores: dict[str, Store] = {}
|
|
95
|
+
|
|
96
|
+
# ---- indexing -------------------------------------------------------------------------
|
|
97
|
+
def index(self, source: str, name: str | None = None, progress: ProgressFn | None = None) -> IndexReport:
|
|
98
|
+
"""Index (or re-index) a local directory or an https git URL. Unchanged files are skipped."""
|
|
99
|
+
started = time.monotonic()
|
|
100
|
+
name = self._valid_name(name or sources.default_name(source))
|
|
101
|
+
if not self.settings.allow_local_paths and not sources.is_url(source):
|
|
102
|
+
raise BrainError("This server only indexes public https git URLs, not local paths.")
|
|
103
|
+
root = sources.resolve(source, self.settings.clone_dir)
|
|
104
|
+
files = sources.iter_files(root, self.settings.max_file_bytes)
|
|
105
|
+
if len(files) > self.settings.max_files:
|
|
106
|
+
raise BrainError(
|
|
107
|
+
f"{source} has {len(files)} indexable files, over this server's limit of {self.settings.max_files}."
|
|
108
|
+
)
|
|
109
|
+
store = self._store(name)
|
|
110
|
+
|
|
111
|
+
meta = store.get_meta()
|
|
112
|
+
full_rebuild = (
|
|
113
|
+
meta.get("embed_model") != self.embedder.model_id or meta.get("schema_version") != store.schema_version
|
|
114
|
+
)
|
|
115
|
+
if full_rebuild:
|
|
116
|
+
store.clear()
|
|
117
|
+
known = store.file_hashes()
|
|
118
|
+
current = {f.path: f for f in files}
|
|
119
|
+
|
|
120
|
+
removed = [path for path in known if path not in current]
|
|
121
|
+
for path in removed:
|
|
122
|
+
store.remove_file(path)
|
|
123
|
+
|
|
124
|
+
changed = [f for f in files if known.get(f.path) != _sha(f.text)]
|
|
125
|
+
notify = progress or (lambda done, total, message: None)
|
|
126
|
+
notify(0, len(changed), f"{len(changed)} of {len(files)} files need indexing")
|
|
127
|
+
for start in range(0, len(changed), EMBED_BATCH_FILES):
|
|
128
|
+
batch = changed[start : start + EMBED_BATCH_FILES]
|
|
129
|
+
per_file = [chunk_file(f.path, f.language, f.text) for f in batch]
|
|
130
|
+
vectors = self.embedder.embed_documents([_embedding_text(c) for chunks in per_file for c in chunks])
|
|
131
|
+
offset = 0
|
|
132
|
+
for file, chunks in zip(batch, per_file, strict=True):
|
|
133
|
+
store.replace_file(
|
|
134
|
+
file.path, _sha(file.text), file.language, chunks, vectors[offset : offset + len(chunks)]
|
|
135
|
+
)
|
|
136
|
+
offset += len(chunks)
|
|
137
|
+
notify(min(start + len(batch), len(changed)), len(changed), batch[-1].path)
|
|
138
|
+
|
|
139
|
+
commit = sources.head_commit(root)
|
|
140
|
+
commits_added = 0
|
|
141
|
+
if history.is_git_repo(root):
|
|
142
|
+
notify(len(changed), len(changed), "reading git history")
|
|
143
|
+
since = None if full_rebuild else meta.get("history_head") or None
|
|
144
|
+
commits_added = store.add_commits(history.commits(root, since, HISTORY_LIMIT))
|
|
145
|
+
store.set_meta(
|
|
146
|
+
source=source,
|
|
147
|
+
root=str(root),
|
|
148
|
+
commit=commit or "",
|
|
149
|
+
embed_model=self.embedder.model_id,
|
|
150
|
+
history_head=commit or "",
|
|
151
|
+
schema_version=store.schema_version,
|
|
152
|
+
indexed_at=datetime.now(UTC).isoformat(timespec="seconds"),
|
|
153
|
+
)
|
|
154
|
+
return IndexReport(
|
|
155
|
+
repo=name,
|
|
156
|
+
root=str(root),
|
|
157
|
+
commit=commit,
|
|
158
|
+
files_seen=len(files),
|
|
159
|
+
files_indexed=len(changed),
|
|
160
|
+
files_unchanged=len(files) - len(changed),
|
|
161
|
+
files_removed=len(removed),
|
|
162
|
+
chunks_total=store.counts()["chunks"],
|
|
163
|
+
commits_added=commits_added,
|
|
164
|
+
commits_total=store.commit_count(),
|
|
165
|
+
seconds=round(time.monotonic() - started, 2),
|
|
166
|
+
full_rebuild=full_rebuild,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# ---- retrieval ------------------------------------------------------------------------
|
|
170
|
+
def search(
|
|
171
|
+
self,
|
|
172
|
+
query: str,
|
|
173
|
+
repo: str | None = None,
|
|
174
|
+
k: int = 8,
|
|
175
|
+
path_prefix: str | None = None,
|
|
176
|
+
kinds: list[str] | None = None,
|
|
177
|
+
mode: SearchMode = "hybrid",
|
|
178
|
+
) -> list[Hit]:
|
|
179
|
+
"""Hybrid search: BM25 keywords and vector similarity, merged with reciprocal-rank fusion.
|
|
180
|
+
``mode`` can switch to one retriever alone; the evals use that to measure what each adds."""
|
|
181
|
+
query = query.strip()
|
|
182
|
+
if not query:
|
|
183
|
+
raise BrainError("The query is empty.")
|
|
184
|
+
if mode not in SEARCH_MODES:
|
|
185
|
+
raise BrainError(f"mode must be one of {', '.join(SEARCH_MODES)}.")
|
|
186
|
+
store = self._store(self.resolve_repo(repo))
|
|
187
|
+
keyword_ids = store.keyword_search(query, CANDIDATES) if mode != "vector" else []
|
|
188
|
+
vector_ids = store.vector_search(self.embedder.embed_query(query), CANDIDATES) if mode != "keyword" else []
|
|
189
|
+
|
|
190
|
+
scores: dict[int, float] = {}
|
|
191
|
+
for ranked in (keyword_ids, vector_ids):
|
|
192
|
+
for rank, chunk_id in enumerate(ranked, start=1):
|
|
193
|
+
scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (RRF_K + rank)
|
|
194
|
+
chunks = store.get_chunks(list(scores))
|
|
195
|
+
keyword_rank = {cid: r for r, cid in enumerate(keyword_ids, start=1)}
|
|
196
|
+
vector_rank = {cid: r for r, cid in enumerate(vector_ids, start=1)}
|
|
197
|
+
|
|
198
|
+
hits = []
|
|
199
|
+
for chunk_id in sorted(scores, key=scores.__getitem__, reverse=True):
|
|
200
|
+
chunk = chunks[chunk_id]
|
|
201
|
+
if path_prefix and not chunk.path.startswith(path_prefix):
|
|
202
|
+
continue
|
|
203
|
+
if kinds and chunk.kind not in kinds:
|
|
204
|
+
continue
|
|
205
|
+
hits.append(
|
|
206
|
+
Hit(
|
|
207
|
+
chunk=chunk,
|
|
208
|
+
score=round(scores[chunk_id], 5),
|
|
209
|
+
keyword_rank=keyword_rank.get(chunk_id),
|
|
210
|
+
vector_rank=vector_rank.get(chunk_id),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
if len(hits) == k:
|
|
214
|
+
break
|
|
215
|
+
return hits
|
|
216
|
+
|
|
217
|
+
# ---- history --------------------------------------------------------------------------
|
|
218
|
+
def explain_history(
|
|
219
|
+
self,
|
|
220
|
+
repo: str | None,
|
|
221
|
+
path: str | None = None,
|
|
222
|
+
symbol: str | None = None,
|
|
223
|
+
start: int | None = None,
|
|
224
|
+
end: int | None = None,
|
|
225
|
+
max_commits: int = 8,
|
|
226
|
+
) -> HistoryReport:
|
|
227
|
+
"""How a function, a line range or a whole file came to be: blame shares, the commits that
|
|
228
|
+
changed it (with the relevant diff), and the pull requests those commits came from."""
|
|
229
|
+
name = self.resolve_repo(repo)
|
|
230
|
+
store = self._store(name)
|
|
231
|
+
root = Path(store.get_meta()["root"])
|
|
232
|
+
if symbol:
|
|
233
|
+
matches = store.find_symbol(symbol, path)
|
|
234
|
+
if not matches:
|
|
235
|
+
where = f" in {path}" if path else ""
|
|
236
|
+
raise BrainError(f"No function, class or section called {symbol!r}{where}. Try search first.")
|
|
237
|
+
path, start, end, symbol = definition_span(matches)
|
|
238
|
+
if not path:
|
|
239
|
+
raise BrainError("Give a path (optionally with start and end lines) or a symbol.")
|
|
240
|
+
target = self._file_in(root, path)
|
|
241
|
+
if not history.is_git_repo(root):
|
|
242
|
+
raise BrainError(f"{name} isn't a git repository, so it has no history to explain.")
|
|
243
|
+
total = len(target.read_text(encoding="utf-8", errors="replace").splitlines())
|
|
244
|
+
start, end = max(1, start or 1), min(total, end or total)
|
|
245
|
+
if start > end:
|
|
246
|
+
raise BrainError(f"{path} has {total} lines.")
|
|
247
|
+
try:
|
|
248
|
+
changes = history.line_history(root, path, start, end, max(1, min(max_commits, 30)))
|
|
249
|
+
shares = history.blame(root, path, start, end)[:5]
|
|
250
|
+
except sources.SourceError as exc:
|
|
251
|
+
raise BrainError(f"git couldn't read the history of {path}: {exc}") from exc
|
|
252
|
+
slug = history.github_slug(root)
|
|
253
|
+
return HistoryReport(
|
|
254
|
+
repo=name,
|
|
255
|
+
path=path,
|
|
256
|
+
start_line=start,
|
|
257
|
+
end_line=end,
|
|
258
|
+
symbol=symbol,
|
|
259
|
+
blame=shares,
|
|
260
|
+
changes=changes,
|
|
261
|
+
pull_requests=self._pull_requests(store, slug, changes),
|
|
262
|
+
github=slug,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def search_history(self, query: str, repo: str | None = None, k: int = 5) -> list[Commit]:
|
|
266
|
+
"""Commits whose message matches the query (BM25)."""
|
|
267
|
+
if not query.strip():
|
|
268
|
+
raise BrainError("The query is empty.")
|
|
269
|
+
return self._store(self.resolve_repo(repo)).search_commits(query, k)
|
|
270
|
+
|
|
271
|
+
def _pull_requests(self, store: Store, slug: str | None, changes: list[LineChange]) -> list[PullRequest]:
|
|
272
|
+
numbers = list(dict.fromkeys(n for change in changes for n in change.commit.prs))[:MAX_PULL_REQUESTS]
|
|
273
|
+
if not slug:
|
|
274
|
+
return []
|
|
275
|
+
prs = []
|
|
276
|
+
for number in numbers:
|
|
277
|
+
pr = store.cached_pull_request(number)
|
|
278
|
+
if pr is None:
|
|
279
|
+
if self.settings.fetch_pull_requests:
|
|
280
|
+
pr = history.fetch_pull_request(slug, number, self.settings.github_token)
|
|
281
|
+
store.cache_pull_request(pr)
|
|
282
|
+
else:
|
|
283
|
+
pr = PullRequest(number=number, url=f"https://github.com/{slug}/pull/{number}")
|
|
284
|
+
prs.append(pr)
|
|
285
|
+
return prs
|
|
286
|
+
|
|
287
|
+
# ---- structure ------------------------------------------------------------------------
|
|
288
|
+
def impact_of_change(self, repo: str | None, symbol: str, path: str | None = None, depth: int = 2) -> ImpactReport:
|
|
289
|
+
"""What uses ``symbol``, directly and transitively, including the tests that exercise it."""
|
|
290
|
+
store = self._store(self.resolve_repo(repo))
|
|
291
|
+
matches = store.find_symbol(symbol, path)
|
|
292
|
+
if not matches:
|
|
293
|
+
where = f" in {path}" if path else ""
|
|
294
|
+
raise BrainError(f"No function, class or method called {symbol!r}{where}. Try search first.")
|
|
295
|
+
target_path, start, end, _ = definition_span(matches)
|
|
296
|
+
target = next(c for c in matches if c.path == target_path)
|
|
297
|
+
return analysis.impact_of_change(store, target, (start, end), depth)
|
|
298
|
+
|
|
299
|
+
def find_doc_drift(
|
|
300
|
+
self, repo: str | None, path_prefix: str | None = None, check_history: bool = True, limit: int = 50
|
|
301
|
+
) -> DriftReport:
|
|
302
|
+
"""Documentation that no longer matches the code."""
|
|
303
|
+
store = self._store(self.resolve_repo(repo))
|
|
304
|
+
return analysis.find_doc_drift(store, Path(store.get_meta()["root"]), path_prefix, check_history, limit)
|
|
305
|
+
|
|
306
|
+
# ---- repositories ---------------------------------------------------------------------
|
|
307
|
+
def repos(self) -> list[RepoInfo]:
|
|
308
|
+
index_dir = self.settings.index_dir
|
|
309
|
+
names = sorted(p.stem for p in index_dir.glob("*.db")) if index_dir.is_dir() else []
|
|
310
|
+
return [info for info in (self.repo_info(n) for n in names) if info is not None]
|
|
311
|
+
|
|
312
|
+
def repo_info(self, name: str) -> RepoInfo | None:
|
|
313
|
+
store = self._store(name)
|
|
314
|
+
meta = store.get_meta()
|
|
315
|
+
if "indexed_at" not in meta:
|
|
316
|
+
return None
|
|
317
|
+
counts = store.counts()
|
|
318
|
+
return RepoInfo(
|
|
319
|
+
name=name,
|
|
320
|
+
source=meta.get("source", ""),
|
|
321
|
+
root=meta.get("root", ""),
|
|
322
|
+
commit=meta.get("commit") or None,
|
|
323
|
+
indexed_at=meta["indexed_at"],
|
|
324
|
+
files=counts["files"],
|
|
325
|
+
chunks=counts["chunks"],
|
|
326
|
+
commits=store.commit_count(),
|
|
327
|
+
languages=store.languages(),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def resolve_repo(self, repo: str | None) -> str:
|
|
331
|
+
"""The repo to use: the one named, or the only one indexed."""
|
|
332
|
+
if repo:
|
|
333
|
+
name = self._valid_name(repo)
|
|
334
|
+
if not (self.settings.index_dir / f"{name}.db").exists():
|
|
335
|
+
raise BrainError(f"No index named {name!r}. Run index_repo first.")
|
|
336
|
+
return name
|
|
337
|
+
names = [info.name for info in self.repos()]
|
|
338
|
+
if len(names) == 1:
|
|
339
|
+
return names[0]
|
|
340
|
+
if not names:
|
|
341
|
+
raise BrainError("Nothing is indexed yet. Run index_repo with a local path or an https git URL.")
|
|
342
|
+
raise BrainError(f"Several repos are indexed ({', '.join(names)}); say which one with repo=.")
|
|
343
|
+
|
|
344
|
+
def read_lines(self, repo: str | None, path: str, start: int, end: int) -> str:
|
|
345
|
+
"""Exact lines from an indexed file, for following up on a citation."""
|
|
346
|
+
info = self.repo_info(self.resolve_repo(repo))
|
|
347
|
+
if info is None:
|
|
348
|
+
raise BrainError("That repo isn't indexed.")
|
|
349
|
+
target = self._file_in(Path(info.root), path)
|
|
350
|
+
lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
351
|
+
start, end = max(1, start), min(len(lines), end)
|
|
352
|
+
if start > end:
|
|
353
|
+
raise BrainError(f"{path} has {len(lines)} lines.")
|
|
354
|
+
return "\n".join(f"{n:>5} {lines[n - 1]}" for n in range(start, end + 1))
|
|
355
|
+
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _file_in(root: Path, path: str) -> Path:
|
|
358
|
+
"""``path`` inside ``root``, refusing anything that escapes it."""
|
|
359
|
+
root = root.resolve()
|
|
360
|
+
target = (root / path).resolve()
|
|
361
|
+
if not target.is_relative_to(root) or not target.is_file():
|
|
362
|
+
raise BrainError(f"{path!r} isn't a file in this repository.")
|
|
363
|
+
return target
|
|
364
|
+
|
|
365
|
+
def close(self) -> None:
|
|
366
|
+
for store in self._stores.values():
|
|
367
|
+
store.close()
|
|
368
|
+
self._stores.clear()
|
|
369
|
+
|
|
370
|
+
def store_for(self, repo: str | None) -> Store:
|
|
371
|
+
"""The index of an indexed repo, for read-only analysis such as evals."""
|
|
372
|
+
return self._store(self.resolve_repo(repo))
|
|
373
|
+
|
|
374
|
+
def _store(self, name: str) -> Store:
|
|
375
|
+
if name not in self._stores:
|
|
376
|
+
self._stores[name] = Store(self.settings.index_dir / f"{name}.db")
|
|
377
|
+
return self._stores[name]
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _valid_name(name: str) -> str:
|
|
381
|
+
if not _NAME.match(name) or name.startswith("."):
|
|
382
|
+
raise BrainError("Repo names may use letters, digits, '.', '_' and '-' only.")
|
|
383
|
+
return name
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _sha(text: str) -> str:
|
|
387
|
+
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _embedding_text(chunk) -> str:
|
|
391
|
+
"""What the model sees: where the chunk lives and what it's called, then the code itself."""
|
|
392
|
+
header = f"File: {chunk.path}"
|
|
393
|
+
if chunk.symbol:
|
|
394
|
+
header += f"\n{chunk.kind.capitalize()}: {chunk.symbol}"
|
|
395
|
+
return f"{header}\n\n{chunk.text}"
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Split files into chunks that follow the code's own structure.
|
|
2
|
+
|
|
3
|
+
Python is split by function and class (with exact line numbers, from ``ast``), Markdown by heading,
|
|
4
|
+
and everything else into overlapping line windows. Chunks that are still too long are windowed
|
|
5
|
+
again but keep their symbol, so a citation always says which function it came from.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import builtins
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
|
|
15
|
+
WINDOW_LINES = 60
|
|
16
|
+
WINDOW_OVERLAP = 10
|
|
17
|
+
MAX_CHUNK_LINES = 120
|
|
18
|
+
_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
|
|
19
|
+
_IGNORED_NAMES = set(dir(builtins)) | {"self", "cls", "super", "typing", "t", "_"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Chunk:
|
|
24
|
+
path: str
|
|
25
|
+
start_line: int
|
|
26
|
+
"""1-based, inclusive."""
|
|
27
|
+
end_line: int
|
|
28
|
+
"""1-based, inclusive."""
|
|
29
|
+
kind: str
|
|
30
|
+
"""function, class, method, module, section, or code."""
|
|
31
|
+
symbol: str | None
|
|
32
|
+
text: str
|
|
33
|
+
refs: tuple[str, ...] = ()
|
|
34
|
+
"""Names this chunk uses: calls, attributes and imports (Python only). Feeds impact_of_change."""
|
|
35
|
+
params: tuple[str, ...] | None = None
|
|
36
|
+
"""Parameter names when the chunk is a function or method; None otherwise. Feeds find_doc_drift."""
|
|
37
|
+
accepts_any_keyword: bool = False
|
|
38
|
+
"""True when the function takes ``**kwargs``, so any keyword argument is valid."""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def citation(self) -> str:
|
|
42
|
+
return f"{self.path}:{self.start_line}-{self.end_line}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def chunk_file(path: str, language: str, text: str) -> list[Chunk]:
|
|
46
|
+
lines = text.splitlines()
|
|
47
|
+
if not any(line.strip() for line in lines):
|
|
48
|
+
return []
|
|
49
|
+
if language == "python":
|
|
50
|
+
try:
|
|
51
|
+
tree = ast.parse(text)
|
|
52
|
+
except (SyntaxError, ValueError):
|
|
53
|
+
return _windows(path, lines, 1, len(lines), "code", None)
|
|
54
|
+
chunks = [piece for chunk in _python_chunks(path, lines, tree) for piece in _split_long(chunk, lines)]
|
|
55
|
+
return _with_python_facts(chunks, tree)
|
|
56
|
+
if language == "markdown":
|
|
57
|
+
chunks = _markdown_chunks(path, lines)
|
|
58
|
+
else:
|
|
59
|
+
chunks = _windows(path, lines, 1, len(lines), "code", None)
|
|
60
|
+
return [piece for chunk in chunks for piece in _split_long(chunk, lines)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _python_chunks(path: str, lines: list[str], tree: ast.Module) -> list[Chunk]:
|
|
64
|
+
chunks: list[Chunk] = []
|
|
65
|
+
covered: list[tuple[int, int]] = []
|
|
66
|
+
for node in tree.body:
|
|
67
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
68
|
+
continue
|
|
69
|
+
start, end = _span(node)
|
|
70
|
+
covered.append((start, end))
|
|
71
|
+
if isinstance(node, ast.ClassDef):
|
|
72
|
+
chunks.extend(_class_chunks(path, lines, node, start, end))
|
|
73
|
+
else:
|
|
74
|
+
chunks.append(_make(path, lines, start, end, "function", node.name))
|
|
75
|
+
# Whatever sits between definitions (imports, constants, module docstring) is one "module" chunk each.
|
|
76
|
+
cursor = 1
|
|
77
|
+
for start, end in [*sorted(covered), (len(lines) + 1, len(lines) + 1)]:
|
|
78
|
+
gap_start, gap_end = cursor, start - 1
|
|
79
|
+
while gap_start <= gap_end and not lines[gap_start - 1].strip():
|
|
80
|
+
gap_start += 1
|
|
81
|
+
while gap_end >= gap_start and not lines[gap_end - 1].strip():
|
|
82
|
+
gap_end -= 1
|
|
83
|
+
if gap_start <= gap_end:
|
|
84
|
+
chunks.append(_make(path, lines, gap_start, gap_end, "module", None))
|
|
85
|
+
cursor = max(cursor, end + 1)
|
|
86
|
+
return sorted(chunks, key=lambda c: c.start_line)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _with_python_facts(chunks: list[Chunk], tree: ast.Module) -> list[Chunk]:
|
|
90
|
+
"""Attach the names each chunk uses, and the parameters of each function chunk."""
|
|
91
|
+
uses: list[tuple[int, str]] = []
|
|
92
|
+
signatures: dict[int, tuple[tuple[str, ...], bool]] = {}
|
|
93
|
+
for node in ast.walk(tree):
|
|
94
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
|
|
95
|
+
uses.append((node.lineno, node.id))
|
|
96
|
+
elif isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load):
|
|
97
|
+
uses.append((node.lineno, node.attr))
|
|
98
|
+
elif isinstance(node, ast.ImportFrom):
|
|
99
|
+
uses.extend((node.lineno, alias.name) for alias in node.names)
|
|
100
|
+
if node.module:
|
|
101
|
+
uses.extend((node.lineno, part) for part in node.module.split("."))
|
|
102
|
+
elif isinstance(node, ast.Import):
|
|
103
|
+
uses.extend((node.lineno, part) for alias in node.names for part in alias.name.split("."))
|
|
104
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
105
|
+
args = node.args
|
|
106
|
+
names = [a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)]
|
|
107
|
+
signatures[_span(node)[0]] = (tuple(n for n in names if n not in ("self", "cls")), args.kwarg is not None)
|
|
108
|
+
result = []
|
|
109
|
+
for chunk in chunks:
|
|
110
|
+
own = chunk.symbol.rsplit(".", 1)[-1] if chunk.symbol else None
|
|
111
|
+
refs = sorted(
|
|
112
|
+
{
|
|
113
|
+
name
|
|
114
|
+
for line, name in uses
|
|
115
|
+
if chunk.start_line <= line <= chunk.end_line
|
|
116
|
+
and name not in _IGNORED_NAMES
|
|
117
|
+
and name != own
|
|
118
|
+
and len(name) > 2
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
signature = signatures.get(chunk.start_line) if chunk.kind in ("function", "method") else None
|
|
122
|
+
result.append(
|
|
123
|
+
replace(
|
|
124
|
+
chunk,
|
|
125
|
+
refs=tuple(refs),
|
|
126
|
+
params=signature[0] if signature else None,
|
|
127
|
+
accepts_any_keyword=bool(signature and signature[1]),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _class_chunks(path: str, lines: list[str], node: ast.ClassDef, start: int, end: int) -> list[Chunk]:
|
|
134
|
+
if end - start + 1 <= MAX_CHUNK_LINES:
|
|
135
|
+
return [_make(path, lines, start, end, "class", node.name)]
|
|
136
|
+
# A big class: its header (up to the first method) plus one chunk per method.
|
|
137
|
+
methods = [m for m in node.body if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))]
|
|
138
|
+
header_end = (_span(methods[0])[0] - 1) if methods else end
|
|
139
|
+
chunks = [_make(path, lines, start, header_end, "class", node.name)]
|
|
140
|
+
for method in methods:
|
|
141
|
+
m_start, m_end = _span(method)
|
|
142
|
+
chunks.append(_make(path, lines, m_start, m_end, "method", f"{node.name}.{method.name}"))
|
|
143
|
+
return chunks
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _span(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> tuple[int, int]:
|
|
147
|
+
decorators = node.decorator_list
|
|
148
|
+
start = min([node.lineno, *(d.lineno for d in decorators)])
|
|
149
|
+
return start, node.end_lineno or node.lineno
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _markdown_chunks(path: str, lines: list[str]) -> list[Chunk]:
|
|
153
|
+
chunks: list[Chunk] = []
|
|
154
|
+
trail: list[str] = []
|
|
155
|
+
section_start, section_symbol = 1, None
|
|
156
|
+
in_fence = False
|
|
157
|
+
for number, line in enumerate(lines, start=1):
|
|
158
|
+
if line.lstrip().startswith(("```", "~~~")):
|
|
159
|
+
in_fence = not in_fence
|
|
160
|
+
match = None if in_fence else _HEADING.match(line)
|
|
161
|
+
if not match:
|
|
162
|
+
continue
|
|
163
|
+
if number > section_start:
|
|
164
|
+
chunks.append(_make(path, lines, section_start, number - 1, "section", section_symbol))
|
|
165
|
+
level = len(match.group(1))
|
|
166
|
+
trail = [*trail[: level - 1], match.group(2)]
|
|
167
|
+
section_start, section_symbol = number, " > ".join(trail)
|
|
168
|
+
chunks.append(_make(path, lines, section_start, len(lines), "section", section_symbol))
|
|
169
|
+
return [c for c in chunks if c.text.strip()]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _windows(path: str, lines: list[str], start: int, end: int, kind: str, symbol: str | None) -> list[Chunk]:
|
|
173
|
+
chunks = []
|
|
174
|
+
step = WINDOW_LINES - WINDOW_OVERLAP
|
|
175
|
+
cursor = start
|
|
176
|
+
while cursor <= end:
|
|
177
|
+
stop = min(cursor + WINDOW_LINES - 1, end)
|
|
178
|
+
chunk = _make(path, lines, cursor, stop, kind, symbol)
|
|
179
|
+
if chunk.text.strip():
|
|
180
|
+
chunks.append(chunk)
|
|
181
|
+
if stop == end:
|
|
182
|
+
break
|
|
183
|
+
cursor += step
|
|
184
|
+
return chunks
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _split_long(chunk: Chunk, lines: list[str]) -> list[Chunk]:
|
|
188
|
+
if chunk.end_line - chunk.start_line + 1 <= MAX_CHUNK_LINES:
|
|
189
|
+
return [chunk]
|
|
190
|
+
return _windows(chunk.path, lines, chunk.start_line, chunk.end_line, chunk.kind, chunk.symbol)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _make(path: str, lines: list[str], start: int, end: int, kind: str, symbol: str | None) -> Chunk:
|
|
194
|
+
return Chunk(
|
|
195
|
+
path=path, start_line=start, end_line=end, kind=kind, symbol=symbol, text="\n".join(lines[start - 1 : end])
|
|
196
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Settings, read once from the environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
DEFAULT_EMBED_MODEL = "BAAI/bge-small-en-v1.5"
|
|
10
|
+
DEFAULT_MAX_FILE_BYTES = 512_000
|
|
11
|
+
DEFAULT_MAX_FILES = 20_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Settings:
|
|
16
|
+
home: Path
|
|
17
|
+
"""Where indexes and cloned repositories live."""
|
|
18
|
+
|
|
19
|
+
embedder: str
|
|
20
|
+
"""``fastembed`` (real local model) or ``hash`` (offline, for tests)."""
|
|
21
|
+
|
|
22
|
+
embed_model: str
|
|
23
|
+
max_file_bytes: int
|
|
24
|
+
"""Files larger than this are skipped; they are almost always generated or data."""
|
|
25
|
+
|
|
26
|
+
github_token: str | None = None
|
|
27
|
+
"""Optional. Only used to read PR titles and descriptions (raises the API rate limit)."""
|
|
28
|
+
|
|
29
|
+
fetch_pull_requests: bool = True
|
|
30
|
+
|
|
31
|
+
allow_local_paths: bool = True
|
|
32
|
+
"""Whether index_repo may read local directories. Off by default when serving over HTTP, so remote
|
|
33
|
+
clients can only index public git URLs, never the server's own files."""
|
|
34
|
+
|
|
35
|
+
max_files: int = DEFAULT_MAX_FILES
|
|
36
|
+
"""Repositories with more indexable files than this are refused, to bound time and disk."""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def index_dir(self) -> Path:
|
|
40
|
+
return self.home / "indexes"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def clone_dir(self) -> Path:
|
|
44
|
+
return self.home / "clones"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_settings() -> Settings:
|
|
48
|
+
home = Path(os.environ.get("CODEBASE_BRAIN_HOME", Path.home() / ".codebase-brain")).expanduser()
|
|
49
|
+
return Settings(
|
|
50
|
+
home=home,
|
|
51
|
+
embedder=os.environ.get("CODEBASE_BRAIN_EMBEDDER", "fastembed"),
|
|
52
|
+
embed_model=os.environ.get("CODEBASE_BRAIN_EMBED_MODEL", DEFAULT_EMBED_MODEL),
|
|
53
|
+
max_file_bytes=int(os.environ.get("CODEBASE_BRAIN_MAX_FILE_BYTES", DEFAULT_MAX_FILE_BYTES)),
|
|
54
|
+
github_token=os.environ.get("GITHUB_TOKEN") or None,
|
|
55
|
+
fetch_pull_requests=os.environ.get("CODEBASE_BRAIN_FETCH_PRS", "1") != "0",
|
|
56
|
+
allow_local_paths=os.environ.get("CODEBASE_BRAIN_ALLOW_LOCAL_PATHS", "1") != "0",
|
|
57
|
+
max_files=int(os.environ.get("CODEBASE_BRAIN_MAX_FILES", DEFAULT_MAX_FILES)),
|
|
58
|
+
)
|