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