chad-code 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.
- chad/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
chad/repomap.py
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
"""Tree-sitter repo map + multi-language symbol intelligence for chad.
|
|
2
|
+
|
|
3
|
+
The dominant cost of a local coding model is *prefill*: every token of context the
|
|
4
|
+
model has never seen must be encoded before it can answer, and on Ornith's
|
|
5
|
+
non-trimmable cache a bloated transcript is expensive forever. The cheapest way to
|
|
6
|
+
keep prefills small is to never put a whole file in the transcript — give the model
|
|
7
|
+
a ranked *skeleton* (signatures only) to navigate by, and let it pull the one
|
|
8
|
+
symbol it actually needs.
|
|
9
|
+
|
|
10
|
+
This is the aider "repo map" idea, built in-process on tree-sitter:
|
|
11
|
+
|
|
12
|
+
* `tree-sitter-language-pack` ships ~300 grammars (downloaded + cached on first use)
|
|
13
|
+
AND the `tags.scm` queries that mark every definition/reference — so symbol
|
|
14
|
+
extraction is **language-agnostic** with no language-server subprocess to install
|
|
15
|
+
(that precision layer comes later, behind this same surface, via solidlsp).
|
|
16
|
+
* `repo_map()` ranks definitions with personalized PageRank (rustworkx) over the
|
|
17
|
+
file→symbol reference graph and renders the most central ones as elided signatures
|
|
18
|
+
within a token budget. Whole-repo tag extraction is mtime-cached on disk per repo
|
|
19
|
+
and sharded across subprocess workers on a cold scan (see `_extract_all`).
|
|
20
|
+
* `overview` / `find_symbol` / `view_symbol` / `find_refs` are the per-symbol read
|
|
21
|
+
tools, now multi-language (they replace the Python-only jedi backend in
|
|
22
|
+
`symbols.py`; jedi remains the editor for `replace_symbol`/`insert_symbol`).
|
|
23
|
+
|
|
24
|
+
API mirrors `symbols.SymbolService` so `tools.py` can route to either backend.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import pickle
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from collections import Counter, defaultdict, namedtuple
|
|
36
|
+
|
|
37
|
+
import rustworkx as rx
|
|
38
|
+
import tree_sitter_language_pack as tlp
|
|
39
|
+
from tree_sitter import Parser, Query, QueryCursor
|
|
40
|
+
|
|
41
|
+
from . import config
|
|
42
|
+
from .ignore import IGNORE_DIRS, REPOMAP_EXTRA
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger("chad")
|
|
45
|
+
|
|
46
|
+
# repomap indexes the whole repo, so it skips the base set PLUS model weights, installed
|
|
47
|
+
# packages, and caches (REPOMAP_EXTRA) — a symbol editor doesn't need those exclusions.
|
|
48
|
+
_SKIP_NAMES = frozenset(IGNORE_DIRS + REPOMAP_EXTRA)
|
|
49
|
+
|
|
50
|
+
_MAX_FILE_BYTES = 1_000_000 # skip anything bigger than ~1MB (generated/minified)
|
|
51
|
+
_MAX_FILES = 20000
|
|
52
|
+
_CHARS_PER_TOK = 4 # rough token estimate without coupling to the tokenizer
|
|
53
|
+
|
|
54
|
+
# Total wall-clock allowed for the decorative "used by …" annotations in one
|
|
55
|
+
# disambiguation listing; candidates past the deadline render un-annotated.
|
|
56
|
+
_DISAMBIG_BUDGET_S = 3.0
|
|
57
|
+
|
|
58
|
+
# An identifier defined in more files than this (__init__, get, main, forward, …) says
|
|
59
|
+
# nothing about which file matters, so it's excluded from the rank graph. Without the
|
|
60
|
+
# cutoff a generic name fans out definers × referencers: on a 11k-file repo `__init__`
|
|
61
|
+
# alone produced 11M edges of a 32M-edge/10GB graph — enough to stall the tool for
|
|
62
|
+
# minutes and push a machine already holding model weights into Metal OOM.
|
|
63
|
+
_MAX_DEFINERS = 16
|
|
64
|
+
|
|
65
|
+
# -- whole-repo extraction scaling -----------------------------------------------
|
|
66
|
+
# The tree-sitter parse loop is the dominant cost of a cold scan (measured 17.5s of a
|
|
67
|
+
# 19s repo_map on an 11k-file repo) and py-tree-sitter never releases the GIL, so
|
|
68
|
+
# threads don't help (measured 1.0x). Two levers instead:
|
|
69
|
+
# 1. a per-repo on-disk tags cache keyed by mtime, so a warm scan parses only what
|
|
70
|
+
# changed (measured 0.34s to load 11k files' tags vs 17.5s to re-parse), and
|
|
71
|
+
# 2. subprocess workers for cold scans (measured 5.5x on 8 workers). Workers run
|
|
72
|
+
# `python -c` importing ONLY chad.repomap (0.02s, no mlx) — never fork, and never
|
|
73
|
+
# re-import chad's entry point, which would drag the whole MLX engine into each.
|
|
74
|
+
_PARALLEL_MIN_FILES = 200 # below this, worker startup costs more than it saves
|
|
75
|
+
_CACHE_SAVE_MIN = 32 # don't persist a cache for tiny repos (or tiny test fixtures)
|
|
76
|
+
_CACHE_VERSION = 1 # bump when the entry shape or tags queries change
|
|
77
|
+
_CACHE_DIR = os.path.expanduser("~/.chad/cache/repomap")
|
|
78
|
+
|
|
79
|
+
_WORKER_SRC = """\
|
|
80
|
+
import pickle, sys
|
|
81
|
+
from chad.repomap import RepoMap
|
|
82
|
+
root, paths = pickle.load(sys.stdin.buffer)
|
|
83
|
+
rm = RepoMap(root)
|
|
84
|
+
for p in paths:
|
|
85
|
+
rm._extract(p)
|
|
86
|
+
sys.stdout.buffer.write(pickle.dumps(rm._cache, protocol=pickle.HIGHEST_PROTOCOL))
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _worker_count() -> int:
|
|
91
|
+
n = config.env_int("CHAD_REPOMAP_WORKERS", 0) or 0
|
|
92
|
+
if n > 0:
|
|
93
|
+
return n
|
|
94
|
+
return max(1, min(8, (os.cpu_count() or 4) - 2))
|
|
95
|
+
|
|
96
|
+
# A definition discovered by tree-sitter. `kind` is the tag suffix (function,
|
|
97
|
+
# class, method, constant, ...); `sig` is the collapsed header line(s). `name_row`
|
|
98
|
+
# and `name_col` are the 0-based position of the identifier itself (what an LSP
|
|
99
|
+
# wants for go-to-def / find-references).
|
|
100
|
+
Tag = namedtuple("Tag", "rel path name kind line end_line sig name_row name_col")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _estimate_tokens(text: str) -> int:
|
|
104
|
+
return max(1, len(text) // _CHARS_PER_TOK)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _build_edges(defines, references):
|
|
108
|
+
"""The rank graph's edge weights: {(referencer rel, definer rel): weight}, ONE
|
|
109
|
+
aggregated edge per file pair. The naive form (an edge object per raw reference)
|
|
110
|
+
is definers × references per ident — measured 32M edges / 10 GB on an 11k-file
|
|
111
|
+
repo, which OOMs the machine out from under the model. sqrt damps mega-callers
|
|
112
|
+
so one hub file doesn't drown the ranking, and idents defined in more than
|
|
113
|
+
_MAX_DEFINERS files are excluded (no ranking signal, all of the blowup)."""
|
|
114
|
+
edge_w = defaultdict(float)
|
|
115
|
+
for ident, definers in defines.items():
|
|
116
|
+
refcounts = references.get(ident)
|
|
117
|
+
if not refcounts or len(definers) > _MAX_DEFINERS:
|
|
118
|
+
continue
|
|
119
|
+
for referencer, cnt in refcounts.items():
|
|
120
|
+
w = cnt ** 0.5
|
|
121
|
+
for definer in definers:
|
|
122
|
+
if referencer != definer:
|
|
123
|
+
edge_w[(referencer, definer)] += w
|
|
124
|
+
return edge_w
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _rank_files(rels, edge_w, seeds):
|
|
128
|
+
"""Personalized PageRank over the file graph via rustworkx — a native, maintained
|
|
129
|
+
implementation in place of the hand-rolled power iteration it replaced (validated
|
|
130
|
+
on a real 421k-edge graph: identical top-50, spearman 0.999, 0.79s -> 0.04s)."""
|
|
131
|
+
g = rx.PyDiGraph()
|
|
132
|
+
idx = dict(zip(rels, g.add_nodes_from(list(rels))))
|
|
133
|
+
g.add_edges_from([(idx[u], idx[v], w) for (u, v), w in edge_w.items()])
|
|
134
|
+
pers = {idx[r]: w for r, w in seeds.items()} if seeds else None
|
|
135
|
+
ranks = rx.pagerank(g, alpha=0.85, weight_fn=float, personalization=pers,
|
|
136
|
+
tol=1.0e-6, max_iter=100)
|
|
137
|
+
return {rel: ranks[i] for rel, i in idx.items()}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class RepoMap:
|
|
141
|
+
"""Tree-sitter symbol intelligence rooted at a project directory."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, root: str = "."):
|
|
144
|
+
self.root = os.path.abspath(root)
|
|
145
|
+
self._tooling = {} # lang -> (Parser, Query) | None
|
|
146
|
+
self._cache = {} # path -> (mtime, [defs], [(refname, rel, line)])
|
|
147
|
+
self._files = None # memoized completed _code_files() result; None = uncomputed
|
|
148
|
+
self._disk_checked = False # the on-disk tags cache is loaded at most once
|
|
149
|
+
self._agg = None # incremental rank-graph tables; see _aggregates()
|
|
150
|
+
self._refsum_cache = {} # (path, row, col, mtime) -> disambig note
|
|
151
|
+
|
|
152
|
+
# -- persistent tags cache ---------------------------------------------
|
|
153
|
+
# Parsing is the dominant cost of a whole-repo scan and the in-memory cache dies
|
|
154
|
+
# with the process, so every new session used to re-pay it in full. Entries are
|
|
155
|
+
# mtime-validated per file on use (`_extract`'s existing contract), so a stale
|
|
156
|
+
# disk entry is re-parsed, never trusted. Pickle is fine here trust-wise: the
|
|
157
|
+
# cache lives under ~/.chad (0700/0600) — the same trust domain as sessions.
|
|
158
|
+
|
|
159
|
+
def _cache_file(self) -> str:
|
|
160
|
+
return os.path.join(
|
|
161
|
+
_CACHE_DIR, hashlib.sha256(self.root.encode()).hexdigest()[:16] + ".pkl")
|
|
162
|
+
|
|
163
|
+
def _load_disk_cache(self):
|
|
164
|
+
if self._disk_checked:
|
|
165
|
+
return
|
|
166
|
+
self._disk_checked = True
|
|
167
|
+
try:
|
|
168
|
+
with open(self._cache_file(), "rb") as f:
|
|
169
|
+
data = pickle.load(f)
|
|
170
|
+
if (data.get("v") == _CACHE_VERSION
|
|
171
|
+
and data.get("tag_fields") == list(Tag._fields)
|
|
172
|
+
and data.get("root") == self.root):
|
|
173
|
+
# in-memory (this session, freshest) entries win over disk ones
|
|
174
|
+
self._cache = {**data["files"], **self._cache}
|
|
175
|
+
except Exception: # noqa: BLE001 - absent/corrupt/foreign cache: parse fresh
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
def _save_disk_cache(self, keep):
|
|
179
|
+
keep = set(keep)
|
|
180
|
+
try:
|
|
181
|
+
os.makedirs(_CACHE_DIR, mode=0o700, exist_ok=True)
|
|
182
|
+
blob = pickle.dumps(
|
|
183
|
+
{"v": _CACHE_VERSION, "tag_fields": list(Tag._fields), "root": self.root,
|
|
184
|
+
"files": {p: e for p, e in self._cache.items() if p in keep}},
|
|
185
|
+
protocol=pickle.HIGHEST_PROTOCOL)
|
|
186
|
+
tmp = self._cache_file() + ".tmp"
|
|
187
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
188
|
+
with os.fdopen(fd, "wb") as f:
|
|
189
|
+
f.write(blob)
|
|
190
|
+
os.replace(tmp, self._cache_file())
|
|
191
|
+
except Exception: # noqa: BLE001 - cache is an optimization, never a failure
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
# -- whole-repo extraction ---------------------------------------------
|
|
195
|
+
|
|
196
|
+
def _extract_all(self, files, should_stop=None):
|
|
197
|
+
"""Ensure every file's tags are cached: disk cache first, then subprocess
|
|
198
|
+
workers for a large cold miss set, else the plain serial loop. Callers keep
|
|
199
|
+
their per-file `_extract` loops — after this they're cache hits, and any
|
|
200
|
+
shard a failed/killed worker didn't return degrades to a serial parse there."""
|
|
201
|
+
self._load_disk_cache()
|
|
202
|
+
misses = []
|
|
203
|
+
for f in files:
|
|
204
|
+
c = self._cache.get(f)
|
|
205
|
+
try:
|
|
206
|
+
if not c or c[0] != os.path.getmtime(f):
|
|
207
|
+
misses.append(f)
|
|
208
|
+
except OSError:
|
|
209
|
+
continue
|
|
210
|
+
if not misses:
|
|
211
|
+
return
|
|
212
|
+
if len(misses) >= _PARALLEL_MIN_FILES and _worker_count() > 1:
|
|
213
|
+
self._extract_parallel(misses, should_stop)
|
|
214
|
+
else:
|
|
215
|
+
for f in misses:
|
|
216
|
+
if should_stop and should_stop():
|
|
217
|
+
return
|
|
218
|
+
self._extract(f)
|
|
219
|
+
parsed = sum(1 for f in misses if f in self._cache)
|
|
220
|
+
if parsed >= _CACHE_SAVE_MIN and not (should_stop and should_stop()):
|
|
221
|
+
self._save_disk_cache(files)
|
|
222
|
+
|
|
223
|
+
def _extract_parallel(self, misses, should_stop=None):
|
|
224
|
+
"""Shard `misses` across `python -c` workers (size-sorted round-robin for
|
|
225
|
+
balance) and merge their tag caches. Workers import only chad.repomap —
|
|
226
|
+
never chad's entry point, which would pull the MLX engine into each. One
|
|
227
|
+
reader thread per worker (communicate) so a big result can't deadlock the
|
|
228
|
+
pipe; should_stop kills the lot."""
|
|
229
|
+
def size_of(f):
|
|
230
|
+
try:
|
|
231
|
+
return os.path.getsize(f)
|
|
232
|
+
except OSError:
|
|
233
|
+
return 0
|
|
234
|
+
nw = min(_worker_count(), max(1, len(misses) // 50))
|
|
235
|
+
by_size = sorted(misses, key=size_of, reverse=True)
|
|
236
|
+
shards = [by_size[i::nw] for i in range(nw)]
|
|
237
|
+
procs, results, threads = [], [b""] * nw, []
|
|
238
|
+
try:
|
|
239
|
+
for i, shard in enumerate(shards):
|
|
240
|
+
p = subprocess.Popen([sys.executable, "-c", _WORKER_SRC],
|
|
241
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
242
|
+
stderr=subprocess.DEVNULL)
|
|
243
|
+
procs.append(p)
|
|
244
|
+
|
|
245
|
+
def pump(p=p, i=i, payload=pickle.dumps((self.root, shard))):
|
|
246
|
+
try:
|
|
247
|
+
results[i] = p.communicate(payload)[0]
|
|
248
|
+
except Exception: # noqa: BLE001 - a dead worker just loses its shard
|
|
249
|
+
results[i] = b""
|
|
250
|
+
t = threading.Thread(target=pump, daemon=True)
|
|
251
|
+
t.start()
|
|
252
|
+
threads.append(t)
|
|
253
|
+
while any(t.is_alive() for t in threads):
|
|
254
|
+
if should_stop and should_stop():
|
|
255
|
+
for p in procs:
|
|
256
|
+
p.kill()
|
|
257
|
+
for t in threads:
|
|
258
|
+
t.join(timeout=0.05)
|
|
259
|
+
finally:
|
|
260
|
+
for p in procs:
|
|
261
|
+
if p.poll() is None:
|
|
262
|
+
p.kill()
|
|
263
|
+
allowed = set(misses)
|
|
264
|
+
for blob in results:
|
|
265
|
+
if not blob:
|
|
266
|
+
continue
|
|
267
|
+
try:
|
|
268
|
+
part = pickle.loads(blob)
|
|
269
|
+
except Exception: # noqa: BLE001 - partial write from a killed worker
|
|
270
|
+
continue
|
|
271
|
+
self._cache.update({p: e for p, e in part.items() if p in allowed})
|
|
272
|
+
|
|
273
|
+
# -- tree-sitter plumbing --------------------------------------------
|
|
274
|
+
|
|
275
|
+
def lang_for(self, path):
|
|
276
|
+
try:
|
|
277
|
+
return tlp.detect_language_from_path(path)
|
|
278
|
+
except Exception:
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
def _lang_tools(self, lang):
|
|
282
|
+
"""(Parser, tags-Query) for a language, lazily built and cached. Grammars
|
|
283
|
+
download on first use; a language without a tags query yields None."""
|
|
284
|
+
if lang not in self._tooling:
|
|
285
|
+
try:
|
|
286
|
+
language = tlp.get_language(lang)
|
|
287
|
+
qsrc = tlp.get_tags_query(lang)
|
|
288
|
+
if not qsrc:
|
|
289
|
+
self._tooling[lang] = None
|
|
290
|
+
else:
|
|
291
|
+
self._tooling[lang] = (Parser(language), Query(language, qsrc))
|
|
292
|
+
except Exception:
|
|
293
|
+
self._tooling[lang] = None
|
|
294
|
+
return self._tooling[lang]
|
|
295
|
+
|
|
296
|
+
def _rel(self, p):
|
|
297
|
+
try:
|
|
298
|
+
return os.path.relpath(p, self.root)
|
|
299
|
+
except ValueError:
|
|
300
|
+
return p
|
|
301
|
+
|
|
302
|
+
def _code_files(self, should_stop=None):
|
|
303
|
+
# Memoized on the instance: service() is a cwd-cached singleton living for the
|
|
304
|
+
# whole session, so once the tree is walked every later symbol lookup reuses the
|
|
305
|
+
# list instead of re-walking the repo mid-turn. A scan interrupted by should_stop
|
|
306
|
+
# is returned but NOT cached — caching a partial walk would hide real files.
|
|
307
|
+
if self._files is not None:
|
|
308
|
+
return self._files
|
|
309
|
+
out = []
|
|
310
|
+
interrupted = False
|
|
311
|
+
# os.walk with in-place pruning: an ignored or hidden directory is never even
|
|
312
|
+
# entered, where the old glob("**") enumerated every path under node_modules/
|
|
313
|
+
# models/ before filtering (and followed symlinked dirs, risking cycles).
|
|
314
|
+
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
315
|
+
if should_stop and should_stop():
|
|
316
|
+
interrupted = True
|
|
317
|
+
break
|
|
318
|
+
dirnames[:] = [d for d in dirnames
|
|
319
|
+
if not d.startswith(".") and d not in _SKIP_NAMES]
|
|
320
|
+
for name in filenames:
|
|
321
|
+
if name.startswith("."):
|
|
322
|
+
continue
|
|
323
|
+
f = os.path.join(dirpath, name)
|
|
324
|
+
try:
|
|
325
|
+
if not os.path.isfile(f) or os.path.getsize(f) > _MAX_FILE_BYTES:
|
|
326
|
+
continue
|
|
327
|
+
except OSError:
|
|
328
|
+
continue
|
|
329
|
+
if self.lang_for(f):
|
|
330
|
+
out.append(f)
|
|
331
|
+
result = sorted(out)[:_MAX_FILES]
|
|
332
|
+
if not interrupted: # never cache a partial scan
|
|
333
|
+
self._files = result
|
|
334
|
+
return result
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _header(src: bytes, node) -> str:
|
|
338
|
+
"""The definition's header line(s) — up to the line that closes the
|
|
339
|
+
signature (ends in ':' or '{'), collapsed to one line. Language-agnostic."""
|
|
340
|
+
seg = src[node.start_byte:node.end_byte]
|
|
341
|
+
out = []
|
|
342
|
+
for raw in seg.split(b"\n")[:4]:
|
|
343
|
+
line = raw.decode("utf-8", "replace").strip()
|
|
344
|
+
if line:
|
|
345
|
+
out.append(line)
|
|
346
|
+
if line.endswith((":", "{")):
|
|
347
|
+
break
|
|
348
|
+
return " ".join(out)[:200]
|
|
349
|
+
|
|
350
|
+
def _extract(self, path):
|
|
351
|
+
"""(defs, refs) for one file, cached on mtime. defs: [Tag]; refs:
|
|
352
|
+
[(name, rel, line)]. Pairs each `@name` with the `@definition.*`/
|
|
353
|
+
`@reference.*` it was captured with via per-pattern matches()."""
|
|
354
|
+
try:
|
|
355
|
+
mtime = os.path.getmtime(path)
|
|
356
|
+
except OSError:
|
|
357
|
+
# gone/unreadable: drop the stale entry so _aggregates unfolds its old
|
|
358
|
+
# contribution instead of keeping a deleted file's symbols in the map
|
|
359
|
+
self._cache.pop(path, None)
|
|
360
|
+
return [], []
|
|
361
|
+
cached = self._cache.get(path)
|
|
362
|
+
if cached and cached[0] == mtime:
|
|
363
|
+
return cached[1], cached[2]
|
|
364
|
+
|
|
365
|
+
defs, refs = [], []
|
|
366
|
+
tools = self._lang_tools(self.lang_for(path))
|
|
367
|
+
if tools:
|
|
368
|
+
parser, query = tools
|
|
369
|
+
try:
|
|
370
|
+
with open(path, "rb") as f:
|
|
371
|
+
src = f.read()
|
|
372
|
+
tree = parser.parse(src)
|
|
373
|
+
matches = QueryCursor(query).matches(tree.root_node)
|
|
374
|
+
except Exception:
|
|
375
|
+
matches = []
|
|
376
|
+
src = b""
|
|
377
|
+
rel = self._rel(path)
|
|
378
|
+
for _pat, caps in matches:
|
|
379
|
+
name_nodes = caps.get("name")
|
|
380
|
+
name = (src[name_nodes[0].start_byte:name_nodes[0].end_byte]
|
|
381
|
+
.decode("utf-8", "replace")) if name_nodes else None
|
|
382
|
+
nrow = name_nodes[0].start_point[0] if name_nodes else 0
|
|
383
|
+
ncol = name_nodes[0].start_point[1] if name_nodes else 0
|
|
384
|
+
for cap, nodes in caps.items():
|
|
385
|
+
if cap.startswith("definition") and name:
|
|
386
|
+
kind = cap.split(".", 1)[1] if "." in cap else "def"
|
|
387
|
+
for dn in nodes:
|
|
388
|
+
defs.append(Tag(rel, path, name, kind,
|
|
389
|
+
dn.start_point[0] + 1, dn.end_point[0] + 1,
|
|
390
|
+
self._header(src, dn), nrow, ncol))
|
|
391
|
+
elif cap.startswith("reference") and name:
|
|
392
|
+
for rn in nodes:
|
|
393
|
+
refs.append((name, rel, rn.start_point[0] + 1))
|
|
394
|
+
self._cache[path] = (mtime, defs, refs)
|
|
395
|
+
return defs, refs
|
|
396
|
+
|
|
397
|
+
# -- repo map (ranked skeleton) --------------------------------------
|
|
398
|
+
|
|
399
|
+
def _aggregates(self, files, should_stop=None):
|
|
400
|
+
"""(file_defs, defines, references) for the rank graph, maintained
|
|
401
|
+
incrementally: only files whose cached tags changed since the last call are
|
|
402
|
+
re-folded. Rebuilding from scratch was the warm-path majority (measured
|
|
403
|
+
0.39s per repo_map over 1.2M raw references on an 11k-file repo) and it
|
|
404
|
+
re-ran on every call even when nothing had changed. Interruption is safe:
|
|
405
|
+
contributions fold per file, so a stopped pass just leaves the remaining
|
|
406
|
+
files for the next call."""
|
|
407
|
+
if self._agg is None:
|
|
408
|
+
# contrib: path -> (mtime folded in, [def names], {name: ref count})
|
|
409
|
+
self._agg = ({}, {}, defaultdict(set), defaultdict(Counter))
|
|
410
|
+
contrib, file_defs, defines, references = self._agg
|
|
411
|
+
for f in files:
|
|
412
|
+
if should_stop and should_stop():
|
|
413
|
+
break
|
|
414
|
+
defs, refs = self._extract(f)
|
|
415
|
+
mtime = self._cache.get(f, (None,))[0]
|
|
416
|
+
old = contrib.get(f)
|
|
417
|
+
if old and old[0] == mtime:
|
|
418
|
+
continue
|
|
419
|
+
rel = self._rel(f)
|
|
420
|
+
if old: # subtract the file's previous contribution before re-folding
|
|
421
|
+
for name in old[1]:
|
|
422
|
+
s = defines.get(name)
|
|
423
|
+
if s:
|
|
424
|
+
s.discard(rel)
|
|
425
|
+
if not s:
|
|
426
|
+
del defines[name]
|
|
427
|
+
for name in old[2]:
|
|
428
|
+
c = references.get(name)
|
|
429
|
+
if c:
|
|
430
|
+
c.pop(rel, None)
|
|
431
|
+
if not c:
|
|
432
|
+
del references[name]
|
|
433
|
+
def_names = []
|
|
434
|
+
for d in defs:
|
|
435
|
+
if d.name:
|
|
436
|
+
defines[d.name].add(rel)
|
|
437
|
+
def_names.append(d.name)
|
|
438
|
+
ref_counts = Counter()
|
|
439
|
+
for name, _rrel, _ln in refs: # every ref in `refs` is from this file
|
|
440
|
+
ref_counts[name] += 1
|
|
441
|
+
for name, cnt in ref_counts.items():
|
|
442
|
+
references[name][rel] += cnt
|
|
443
|
+
file_defs[rel] = defs
|
|
444
|
+
contrib[f] = (mtime, def_names, ref_counts)
|
|
445
|
+
return file_defs, defines, references
|
|
446
|
+
|
|
447
|
+
def repo_map(self, budget_tokens: int = 1500, focus=None, should_stop=None) -> str:
|
|
448
|
+
"""A PageRank-ranked, signature-only map of the codebase within a token
|
|
449
|
+
budget. `focus` (list of path substrings) personalizes the ranking toward
|
|
450
|
+
the files the agent is working on, the way aider biases toward chat files."""
|
|
451
|
+
files = self._code_files(should_stop)
|
|
452
|
+
if not files:
|
|
453
|
+
return "[no source files found]"
|
|
454
|
+
self._extract_all(files, should_stop)
|
|
455
|
+
file_defs, defines, references = self._aggregates(files, should_stop)
|
|
456
|
+
if should_stop and should_stop():
|
|
457
|
+
return "[interrupted]"
|
|
458
|
+
|
|
459
|
+
# Personalize ranking toward the files the agent is working on (focus) and, by
|
|
460
|
+
# default, likely entrypoints (check.py / main / conftest / app). Tasks usually
|
|
461
|
+
# live in test-reachable code, so a seed on the entrypoint flows rank along its
|
|
462
|
+
# imports to the module under test — surfacing it even in a big, noisy repo.
|
|
463
|
+
seeds = {}
|
|
464
|
+
for rel in file_defs:
|
|
465
|
+
base = os.path.basename(rel).lower()
|
|
466
|
+
if base.startswith(("check", "main", "conftest", "__main__")) or base == "app.py":
|
|
467
|
+
seeds[rel] = 1.0
|
|
468
|
+
if focus:
|
|
469
|
+
for rel in file_defs:
|
|
470
|
+
if any(fp in rel for fp in focus):
|
|
471
|
+
seeds[rel] = 1.0
|
|
472
|
+
try:
|
|
473
|
+
ranks = _rank_files(file_defs, _build_edges(defines, references), seeds)
|
|
474
|
+
except Exception: # noqa: BLE001 - rank failure degrades to def-count order
|
|
475
|
+
ranks = {}
|
|
476
|
+
# Files with no edges still deserve a spot; rank them by definition count.
|
|
477
|
+
ordered = sorted(file_defs,
|
|
478
|
+
key=lambda r: (ranks.get(r, 0.0), len(file_defs[r])),
|
|
479
|
+
reverse=True)
|
|
480
|
+
|
|
481
|
+
out, used = [], 0
|
|
482
|
+
header = (f"repo map ({len(files)} files, ranked by reference centrality; "
|
|
483
|
+
f"signatures only — use view_symbol/read to see bodies)\n")
|
|
484
|
+
used += _estimate_tokens(header)
|
|
485
|
+
for rel in ordered:
|
|
486
|
+
defs = file_defs[rel]
|
|
487
|
+
if not defs:
|
|
488
|
+
continue
|
|
489
|
+
block = [f"\n{rel}"]
|
|
490
|
+
for d in sorted(defs, key=lambda t: t.line):
|
|
491
|
+
indent = " " if d.kind in ("method",) else " "
|
|
492
|
+
block.append(f"{indent}{d.line}: {d.sig}")
|
|
493
|
+
chunk = "\n".join(block)
|
|
494
|
+
cost = _estimate_tokens(chunk)
|
|
495
|
+
if used + cost > budget_tokens and out:
|
|
496
|
+
# Name the most-central omitted files (rank-ordered) so the model can
|
|
497
|
+
# find_symbol/overview into them — but only a handful: dumping every
|
|
498
|
+
# path on a big repo is itself a prefill cost we measured biting back.
|
|
499
|
+
remaining = [r for r in ordered[ordered.index(rel):] if file_defs[r]]
|
|
500
|
+
listed = ", ".join(remaining[:12])
|
|
501
|
+
tail = f" (+{len(remaining) - 12} more)" if len(remaining) > 12 else ""
|
|
502
|
+
out.append(f"\n[{len(remaining)} more files omitted to fit budget="
|
|
503
|
+
f"{budget_tokens}; raise budget= or find_symbol/overview into "
|
|
504
|
+
f"one. Top: {listed}{tail}]")
|
|
505
|
+
break
|
|
506
|
+
out.append(chunk)
|
|
507
|
+
used += cost
|
|
508
|
+
return header + "".join(out)
|
|
509
|
+
|
|
510
|
+
# -- per-symbol reads (multi-language) -------------------------------
|
|
511
|
+
|
|
512
|
+
def _find_defs(self, name, path=None, should_stop=None):
|
|
513
|
+
target = name.replace(".", "/").split("/")[-1]
|
|
514
|
+
files = [os.path.join(self.root, path)] if path and not os.path.isabs(path) \
|
|
515
|
+
else ([path] if path else self._code_files(should_stop))
|
|
516
|
+
if not path:
|
|
517
|
+
self._extract_all(files, should_stop)
|
|
518
|
+
hits = []
|
|
519
|
+
for f in files:
|
|
520
|
+
if should_stop and should_stop():
|
|
521
|
+
break
|
|
522
|
+
defs, _ = self._extract(f)
|
|
523
|
+
for d in defs:
|
|
524
|
+
if d.name == target:
|
|
525
|
+
hits.append(d)
|
|
526
|
+
return hits
|
|
527
|
+
|
|
528
|
+
def _refsum_key(self, h):
|
|
529
|
+
return (h.path, h.name_row, h.name_col, self._cache.get(h.path, (None,))[0])
|
|
530
|
+
|
|
531
|
+
def _ref_summary(self, h, timeout=None):
|
|
532
|
+
"""A short 'used by …' hint for one definition, so the model can tell same-named
|
|
533
|
+
candidates apart by who calls them (e.g. the validate on the signup path vs the
|
|
534
|
+
widely-used schema validator). Precise via the language server; silent if it's
|
|
535
|
+
unavailable (we don't want to imply precision we don't have). Decorative, so it
|
|
536
|
+
goes through `references_decorative` (never starts a non-Python server) and is
|
|
537
|
+
cached per (definition site, file mtime) — a repeated view_symbol on the same
|
|
538
|
+
ambiguous name used to re-pay every LSP lookup (measured 79s on a repeat)."""
|
|
539
|
+
key = self._refsum_key(h)
|
|
540
|
+
cached = self._refsum_cache.get(key)
|
|
541
|
+
if cached is not None:
|
|
542
|
+
return cached
|
|
543
|
+
try:
|
|
544
|
+
from . import lsp
|
|
545
|
+
locs = lsp.service().references_decorative(h.rel, h.name_row, h.name_col,
|
|
546
|
+
timeout=timeout)
|
|
547
|
+
except Exception:
|
|
548
|
+
locs = None
|
|
549
|
+
if not locs:
|
|
550
|
+
note = ""
|
|
551
|
+
else:
|
|
552
|
+
files = []
|
|
553
|
+
for rel, _ln, _col in locs:
|
|
554
|
+
if rel != h.rel and rel not in files:
|
|
555
|
+
files.append(rel)
|
|
556
|
+
if not files:
|
|
557
|
+
note = " — no external refs"
|
|
558
|
+
else:
|
|
559
|
+
more = f" (+{len(files) - 1} more)" if len(files) > 1 else ""
|
|
560
|
+
note = f" — used by {files[0]}{more}"
|
|
561
|
+
if len(self._refsum_cache) > 512: # bound a very long session's cache
|
|
562
|
+
self._refsum_cache.clear()
|
|
563
|
+
self._refsum_cache[key] = note
|
|
564
|
+
return note
|
|
565
|
+
|
|
566
|
+
def _disambig(self, name, hits):
|
|
567
|
+
# Annotate the first few candidates with who references them, under a shared
|
|
568
|
+
# wall-clock budget: per-candidate LSP lookups are cheap warm, but a cold or
|
|
569
|
+
# slow server must not turn a disambiguation list into a minutes-long stall
|
|
570
|
+
# (measured 86s on an 11k-file mixed-language repo before the budget). Each
|
|
571
|
+
# lookup gets only the budget's remaining time, and candidates the budget
|
|
572
|
+
# never reached are cached blank for this mtime generation — otherwise a
|
|
573
|
+
# repeated lookup re-pays the slow pass one candidate at a time.
|
|
574
|
+
rows = []
|
|
575
|
+
deadline = time.monotonic() + _DISAMBIG_BUDGET_S
|
|
576
|
+
for i, h in enumerate(hits[:50]):
|
|
577
|
+
note = ""
|
|
578
|
+
if i < 8:
|
|
579
|
+
remaining = deadline - time.monotonic()
|
|
580
|
+
if remaining > 0.25:
|
|
581
|
+
note = self._ref_summary(h, timeout=remaining)
|
|
582
|
+
else:
|
|
583
|
+
self._refsum_cache.setdefault(self._refsum_key(h), "")
|
|
584
|
+
rows.append(f" {h.rel}:{h.line} {h.kind} {h.sig}{note}")
|
|
585
|
+
return (f"[{len(hits)} symbols named '{name}'; pass path= to disambiguate "
|
|
586
|
+
f"(pick by who uses each):]\n" + "\n".join(rows))
|
|
587
|
+
|
|
588
|
+
def overview(self, path: str, should_stop=None) -> str:
|
|
589
|
+
if not os.path.isfile(path):
|
|
590
|
+
return f"[no such file: {path}]"
|
|
591
|
+
defs, _ = self._extract(path)
|
|
592
|
+
if not defs:
|
|
593
|
+
if not self.lang_for(path):
|
|
594
|
+
return "[no tree-sitter grammar for this file type; use read]"
|
|
595
|
+
return "[no functions or classes]"
|
|
596
|
+
lines = []
|
|
597
|
+
for d in sorted(defs, key=lambda t: t.line):
|
|
598
|
+
indent = " " if d.kind == "method" else ""
|
|
599
|
+
lines.append(f"{d.line}: {indent}{d.sig}")
|
|
600
|
+
return "\n".join(lines)
|
|
601
|
+
|
|
602
|
+
def find_symbol(self, name: str, should_stop=None) -> str:
|
|
603
|
+
hits = self._find_defs(name, None, should_stop)
|
|
604
|
+
if not hits:
|
|
605
|
+
return f"[no definition found for '{name}']"
|
|
606
|
+
out = [f"{h.rel}:{h.line} {h.kind} {h.sig}" for h in hits]
|
|
607
|
+
return "\n".join(out[:100])
|
|
608
|
+
|
|
609
|
+
def view_symbol(self, name: str, path=None, should_stop=None) -> str:
|
|
610
|
+
hits = self._find_defs(name, path, should_stop)
|
|
611
|
+
if not hits:
|
|
612
|
+
return f"[symbol not found: {name}]"
|
|
613
|
+
if len(hits) > 1 and path is None:
|
|
614
|
+
return self._disambig(name, hits)
|
|
615
|
+
h = hits[0]
|
|
616
|
+
try:
|
|
617
|
+
with open(h.path, errors="replace") as f:
|
|
618
|
+
lines = f.read().splitlines()
|
|
619
|
+
except OSError:
|
|
620
|
+
return f"[could not read {h.rel}]"
|
|
621
|
+
a, b = h.line, h.end_line
|
|
622
|
+
width = len(str(b))
|
|
623
|
+
body = "\n".join(f"{i:>{width}} {lines[i-1]}"
|
|
624
|
+
for i in range(a, b + 1) if 0 < i <= len(lines))
|
|
625
|
+
return f"{h.rel}:{a}-{b}\n{body}"
|
|
626
|
+
|
|
627
|
+
def find_refs(self, name: str, path=None, should_stop=None) -> str:
|
|
628
|
+
"""Every USE of a symbol across the project. Precise when a language server
|
|
629
|
+
is available (follows imports, respects scope, won't confuse same-named
|
|
630
|
+
symbols); falls back to tree-sitter name-matching otherwise."""
|
|
631
|
+
# Locate the definition first — gives the exact identifier position the LSP
|
|
632
|
+
# needs, and reuses disambiguation when several symbols share a name.
|
|
633
|
+
hits = self._find_defs(name, path, should_stop)
|
|
634
|
+
if hits and not (len(hits) > 1 and path is None):
|
|
635
|
+
h = hits[0]
|
|
636
|
+
from . import lsp # lazy: only pay the solidlsp import when refs are requested
|
|
637
|
+
locs = lsp.service().references(h.rel, h.name_row, h.name_col)
|
|
638
|
+
if locs is not None:
|
|
639
|
+
if not locs:
|
|
640
|
+
return "[no references]"
|
|
641
|
+
return self._format_refs([(rel, ln) for rel, ln, _col in locs],
|
|
642
|
+
tag="precise")
|
|
643
|
+
elif len(hits) > 1 and path is None:
|
|
644
|
+
return self._disambig(name, hits)
|
|
645
|
+
# fallback: tree-sitter name match across the project
|
|
646
|
+
return self._find_refs_treesitter(name, should_stop)
|
|
647
|
+
|
|
648
|
+
def _find_refs_treesitter(self, name: str, should_stop=None) -> str:
|
|
649
|
+
target = name.replace(".", "/").split("/")[-1]
|
|
650
|
+
out = []
|
|
651
|
+
self._extract_all(self._code_files(should_stop), should_stop)
|
|
652
|
+
for f in self._code_files(should_stop):
|
|
653
|
+
if should_stop and should_stop():
|
|
654
|
+
return "[interrupted by user]"
|
|
655
|
+
_defs, refs = self._extract(f)
|
|
656
|
+
for rname, rel, ln in refs:
|
|
657
|
+
if rname == target:
|
|
658
|
+
out.append((rel, ln))
|
|
659
|
+
if not out:
|
|
660
|
+
return "[no references]"
|
|
661
|
+
return self._format_refs(out, tag="name-match")
|
|
662
|
+
|
|
663
|
+
def _format_refs(self, pairs, tag="") -> str:
|
|
664
|
+
by_file = defaultdict(list)
|
|
665
|
+
for rel, ln in pairs:
|
|
666
|
+
by_file[rel].append(ln)
|
|
667
|
+
lines_out = []
|
|
668
|
+
for rel in sorted(by_file):
|
|
669
|
+
try:
|
|
670
|
+
with open(os.path.join(self.root, rel), errors="replace") as f:
|
|
671
|
+
src_lines = f.read().splitlines()
|
|
672
|
+
except OSError:
|
|
673
|
+
src_lines = []
|
|
674
|
+
for ln in sorted(set(by_file[rel])):
|
|
675
|
+
code = src_lines[ln - 1].strip() if 0 < ln <= len(src_lines) else ""
|
|
676
|
+
lines_out.append(f"{rel}:{ln}: {code}")
|
|
677
|
+
n = len(lines_out)
|
|
678
|
+
if tag == "name-match":
|
|
679
|
+
# No language server: these are bare name matches and MAY include unrelated
|
|
680
|
+
# same-named symbols. Make the degraded precision visible, not silent.
|
|
681
|
+
head = (f"[{n} reference{'s' if n != 1 else ''} — NAME-MATCH ONLY (no language "
|
|
682
|
+
f"server); may include unrelated same-named symbols, verify before "
|
|
683
|
+
f"editing]\n")
|
|
684
|
+
else:
|
|
685
|
+
head = f"[{n} reference{'s' if n != 1 else ''}{', ' + tag if tag else ''}]\n"
|
|
686
|
+
return head + "\n".join(lines_out[:200])
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# -- precise rename (refs-driven, position-safe) ---------------------
|
|
690
|
+
|
|
691
|
+
def rename_symbol(self, name: str, new_name: str, path=None, should_stop=None) -> str:
|
|
692
|
+
"""Rename a symbol and every precise reference to it in one shot. Sites come
|
|
693
|
+
from the language server's find-all-references (follows imports, respects scope),
|
|
694
|
+
so an unrelated same-named method/function is never touched — and we rewrite
|
|
695
|
+
ONLY the exact identifier token at each (line, col), never a blind text replace.
|
|
696
|
+
Refuses (returns the disambiguation list) when the name is ambiguous, and refuses
|
|
697
|
+
rather than guess when no precise language server is available."""
|
|
698
|
+
hits = self._find_defs(name, path, should_stop)
|
|
699
|
+
if not hits:
|
|
700
|
+
return f"[no definition found for '{name}']"
|
|
701
|
+
if len(hits) > 1 and path is None:
|
|
702
|
+
return (f"[ambiguous: {len(hits)} symbols named '{name}'. Pass path= to choose "
|
|
703
|
+
f"one before renaming:]\n{self._disambig(name, hits)}")
|
|
704
|
+
h = hits[0]
|
|
705
|
+
target = h.name
|
|
706
|
+
from . import lsp # lazy: only pay the solidlsp import when a rename is requested
|
|
707
|
+
locs = lsp.service().references(h.rel, h.name_row, h.name_col)
|
|
708
|
+
if locs is None:
|
|
709
|
+
return ("[rename needs the precise language server (find-all-references), which "
|
|
710
|
+
"is unavailable for this file type here. Use find_refs to locate the "
|
|
711
|
+
"sites and edit each, or rename with edit.]")
|
|
712
|
+
# Always include the definition's own identifier; dedupe across refs.
|
|
713
|
+
sites = {(h.rel, h.name_row + 1, h.name_col + 1)}
|
|
714
|
+
for rel, ln, col in locs:
|
|
715
|
+
sites.add((rel, ln, col))
|
|
716
|
+
|
|
717
|
+
by_file = defaultdict(list)
|
|
718
|
+
for rel, ln, col in sites:
|
|
719
|
+
by_file[rel].append((ln, col))
|
|
720
|
+
|
|
721
|
+
changed, total, skipped = [], 0, 0
|
|
722
|
+
for rel in sorted(by_file):
|
|
723
|
+
p = os.path.join(self.root, rel)
|
|
724
|
+
try:
|
|
725
|
+
with open(p, errors="replace") as f:
|
|
726
|
+
lines = f.read().splitlines(keepends=True)
|
|
727
|
+
except OSError:
|
|
728
|
+
continue
|
|
729
|
+
n_here = 0
|
|
730
|
+
# right-to-left within the file so earlier offsets stay valid after splicing
|
|
731
|
+
for ln, col in sorted(by_file[rel], reverse=True):
|
|
732
|
+
if not (0 < ln <= len(lines)):
|
|
733
|
+
continue
|
|
734
|
+
c = col - 1
|
|
735
|
+
line = lines[ln - 1]
|
|
736
|
+
if line[c:c + len(target)] != target:
|
|
737
|
+
skipped += 1 # position didn't land on the identifier — skip safely
|
|
738
|
+
continue
|
|
739
|
+
lines[ln - 1] = line[:c] + new_name + line[c + len(target):]
|
|
740
|
+
n_here += 1
|
|
741
|
+
if n_here:
|
|
742
|
+
try:
|
|
743
|
+
with open(p, "w") as f:
|
|
744
|
+
f.write("".join(lines))
|
|
745
|
+
except OSError:
|
|
746
|
+
continue
|
|
747
|
+
self._cache.pop(p, None) # force re-extract on next read
|
|
748
|
+
changed.append((rel, n_here))
|
|
749
|
+
total += n_here
|
|
750
|
+
|
|
751
|
+
if not total:
|
|
752
|
+
return f"[no occurrences of '{target}' rewritten]"
|
|
753
|
+
detail = ", ".join(f"{rel} ×{n}" for rel, n in changed)
|
|
754
|
+
note = f" ({skipped} stale position(s) skipped)" if skipped else ""
|
|
755
|
+
return (f"[renamed '{target}' → '{new_name}' at {total} site(s) across "
|
|
756
|
+
f"{len(changed)} file(s){note}: {detail}]")
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
_SERVICE = None
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def service() -> RepoMap:
|
|
763
|
+
"""Lazily-bound, cwd-rooted repo map (the project the agent is operating in)."""
|
|
764
|
+
global _SERVICE
|
|
765
|
+
if _SERVICE is None or _SERVICE.root != os.path.abspath(os.getcwd()):
|
|
766
|
+
_SERVICE = RepoMap(os.getcwd())
|
|
767
|
+
return _SERVICE
|