honestcode 0.2.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.

Potentially problematic release.


This version of honestcode might be problematic. Click here for more details.

@@ -0,0 +1,1267 @@
1
+ """
2
+ Tool implementations for the HonestCode MCP server.
3
+
4
+ Each function is decorated with @mcp.tool() in server.py. They provide the
5
+ hallucination-detection capabilities exposed to MCP clients:
6
+ - index_project : build the project symbol index
7
+ - load_project_deps : load dependency APIs from requirements/pyproject
8
+ - check_symbol : verify a symbol is defined
9
+ - check_api : verify a library API call is correct
10
+ - execute_code : run code in a sandbox
11
+ - scan_file : scan a file for potential hallucinations
12
+ - load_package_apis : load a package's API signatures
13
+ - get_project_stats : index statistics
14
+ - validate_types : AST-based type checking
15
+ - find_dead_code : find potentially unused symbols
16
+ - find_similar_code : find similar/duplicate functions
17
+ - explore_call_graph : explore callers/callees of a symbol
18
+ - search_code : search source code with regex
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import ast
24
+ import logging
25
+ import os
26
+ import re
27
+ import sqlite3
28
+ import subprocess
29
+ import threading
30
+ from pathlib import Path
31
+
32
+ from honestcode.graph.graph_store import CallGraph, GraphCache
33
+ from honestcode.graph.watcher import ProjectWatcher
34
+ from honestcode.honest.router import HonestRouter
35
+ from honestcode.honest.symbol_index import ProjectIndex, get_project_index
36
+ from honestcode.mcp.knowledge_base import APIKnowledgeBase
37
+ from honestcode.sandbox import SandboxExecutor
38
+ from honestcode.structure.extractor import StructureExtractor
39
+ from honestcode.structure.relations import ParseResult
40
+ from honestcode.structure.utils import call_base, call_name
41
+
42
+ __all__ = [
43
+ "index_project",
44
+ "load_project_deps",
45
+ "check_symbol",
46
+ "check_api",
47
+ "execute_code",
48
+ "scan_file",
49
+ "load_package_apis",
50
+ "get_project_stats",
51
+ "validate_types",
52
+ "find_dead_code",
53
+ "find_similar_code",
54
+ "explore_call_graph",
55
+ "search_code",
56
+ "choose_tool",
57
+ ]
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+ # ── Global server state ────────────────────────────────────────────────
62
+ _state_lock = threading.RLock()
63
+ _project_index: ProjectIndex | None = None
64
+ _project_root: Path | None = None
65
+ _dep_kb: APIKnowledgeBase = APIKnowledgeBase()
66
+ _sandbox: SandboxExecutor = SandboxExecutor()
67
+ _extractor: StructureExtractor = StructureExtractor()
68
+ # In-memory call-graph cache keyed by resolved project root. The SQLite
69
+ # GraphCache on disk is the source of truth; this is just a hot layer.
70
+ _graph_mem_cache: dict[str, CallGraph] = {}
71
+
72
+
73
+ def _cache_dir() -> Path:
74
+ """Directory for symbol/graph caches (override with HONESTCODE_CACHE_DIR)."""
75
+ return Path(os.environ.get("HONESTCODE_CACHE_DIR", os.path.expanduser("~/.cache/honestcode")))
76
+
77
+
78
+ # ── Internal helpers ───────────────────────────────────────────────────
79
+ def _module_name(p: Path, root: Path) -> str:
80
+ """Infer a dotted module name from *p* relative to *root*.
81
+
82
+ Only strips the ``src`` prefix when it is a standalone directory component
83
+ (i.e. the standard ``src`` layout) and there are further path segments.
84
+ """
85
+ try:
86
+ rel = p.resolve().relative_to(root.resolve())
87
+ except ValueError:
88
+ rel = p
89
+ parts = list(rel.with_suffix("").parts)
90
+ # Only strip "src" if it is a real layout prefix (has sub-packages after it).
91
+ if len(parts) > 1 and parts[0] == "src":
92
+ parts = parts[1:]
93
+ if parts and parts[-1] == "__init__":
94
+ parts.pop()
95
+ return ".".join(parts)
96
+
97
+
98
+ def _with_module_prefix(p: Path, root: Path, res: ParseResult) -> ParseResult:
99
+ """Return a copy of *res* whose symbol names are prefixed by their module."""
100
+ module = _module_name(p, root)
101
+ if not module:
102
+ return res
103
+ prefix = module + "."
104
+
105
+ def prefixed(d: dict[str, tuple[int, int]]) -> dict[str, tuple[int, int]]:
106
+ return {prefix + k if not k.startswith(prefix) else k: v for k, v in d.items()}
107
+
108
+ return ParseResult(
109
+ edges=res.edges,
110
+ func_defs=prefixed(res.func_defs),
111
+ class_defs=prefixed(res.class_defs),
112
+ var_defs=prefixed(res.var_defs),
113
+ imports=res.imports,
114
+ imported_symbols=res.imported_symbols,
115
+ exported_symbols={
116
+ prefix + s if not s.startswith(prefix) else s for s in (res.exported_symbols or set())
117
+ },
118
+ )
119
+
120
+
121
+ def _parse_results_for(root: Path) -> dict[Path, ParseResult]:
122
+ """Parse every *.py file under root and return a path -> ParseResult map."""
123
+ results: dict[Path, ParseResult] = {}
124
+ for p in root.rglob("*.py"):
125
+ if p.name.startswith(".") or "__pycache__" in p.parts:
126
+ continue
127
+ try:
128
+ res = _extractor.parse_file(p)
129
+ results[p] = _with_module_prefix(p, root, res)
130
+ except Exception: # noqa: BLE001
131
+ continue
132
+ return results
133
+
134
+
135
+ def _normalize_code(text: str) -> str:
136
+ """Normalize code for similarity comparison."""
137
+ lines = text.splitlines()
138
+ out: list[str] = []
139
+ for line in lines:
140
+ stripped = line.strip()
141
+ if not stripped or stripped.startswith("#"):
142
+ continue
143
+ out.append(re.sub(r"\s+", " ", stripped))
144
+ return "\n".join(out)
145
+
146
+
147
+ def _build_call_graph(root: Path) -> CallGraph:
148
+ """Build a project-wide definition/reference graph from ASTs.
149
+
150
+ Reference ``context`` is recorded as the **module-qualified** enclosing
151
+ symbol name (e.g. ``pkg.core.main``) when derivable, so that callers of
152
+ ``explore_call_graph`` can match by the same key they pass in. The short
153
+ name (``main``) is still recorded as a secondary key for backwards
154
+ compatibility with short-name lookups.
155
+ """
156
+ graph = CallGraph()
157
+
158
+ for p, res in _parse_results_for(root).items():
159
+ # Build a lineno -> module-qualified enclosing context map for this file.
160
+ # Map from short name -> qualified name using the (already-prefixed)
161
+ # function/class defs returned by _parse_results_for.
162
+ module = _module_name(p, root)
163
+ qualified_by_short: dict[str, str] = {}
164
+ for qname in list(res.func_defs.keys()) + list(res.class_defs.keys()):
165
+ short = qname.split(".")[-1]
166
+ qualified_by_short.setdefault(short, qname)
167
+
168
+ # Definitions
169
+ for name, (start, end) in res.func_defs.items():
170
+ graph.definitions.setdefault(name, []).append((p, start + 1, end + 1, "function"))
171
+ for name, (start, end) in res.class_defs.items():
172
+ graph.definitions.setdefault(name, []).append((p, start + 1, end + 1, "class"))
173
+ for name, (start, end) in res.var_defs.items():
174
+ graph.definitions.setdefault(name, []).append((p, start + 1, end + 1, "variable"))
175
+
176
+ # AST-level references with enclosing context (module-qualified).
177
+ # The extractor's same-scope edges are intentionally not re-recorded
178
+ # here: the AST walk below already captures every Name/Attribute/
179
+ # Call reference (with context), and re-adding edges would duplicate
180
+ # caller entries with wrong (definition) line numbers.
181
+ try:
182
+ tree = ast.parse(p.read_text(encoding="utf-8"), filename=str(p))
183
+ except SyntaxError:
184
+ continue
185
+
186
+ for node in ast.iter_child_nodes(tree):
187
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
188
+ ctx = qualified_by_short.get(node.name, node.name)
189
+ _record_reference(node, p, graph, ctx)
190
+ _collect_refs_in(node, p, graph, ctx, qualified_by_short)
191
+ else:
192
+ _record_reference(node, p, graph, module or None)
193
+ _collect_refs_in(node, p, graph, module or None, qualified_by_short)
194
+
195
+ return graph
196
+
197
+
198
+ def _collect_refs_in(
199
+ node: ast.AST,
200
+ p: Path,
201
+ graph: CallGraph,
202
+ context: str | None,
203
+ qualified_by_short: dict[str, str] | None = None,
204
+ ) -> None:
205
+ """Collect call/name references inside *node*, attaching *context*.
206
+
207
+ When *qualified_by_short* is provided and a nested ``FunctionDef`` /
208
+ ``ClassDef`` is encountered, the context is updated to that node's
209
+ module-qualified name (looked up by its short name) so that references
210
+ inside a class method are attributed to ``pkg.Mod.Class.method`` rather
211
+ than to the enclosing class.
212
+ """
213
+ for child in ast.iter_child_nodes(node):
214
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
215
+ # Recurse with the child's qualified name as the new context.
216
+ if qualified_by_short is not None:
217
+ child_ctx = qualified_by_short.get(child.name)
218
+ if child_ctx is None:
219
+ # Build a dotted path from the current context.
220
+ child_ctx = f"{context}.{child.name}" if context else child.name
221
+ else:
222
+ child_ctx = child.name
223
+ _record_reference(child, p, graph, child_ctx)
224
+ _collect_refs_in(child, p, graph, child_ctx, qualified_by_short)
225
+ else:
226
+ _record_reference(child, p, graph, context)
227
+ _collect_refs_in(child, p, graph, context, qualified_by_short)
228
+
229
+
230
+ def _record_reference(
231
+ child: ast.AST,
232
+ p: Path,
233
+ graph: CallGraph,
234
+ context: str | None,
235
+ ) -> None:
236
+ """Record a single AST node as a reference if it is a call/name/attribute."""
237
+ if isinstance(child, ast.Call):
238
+ name = call_name(child.func)
239
+ if name:
240
+ graph.references.setdefault(name, []).append((p, child.lineno, "call", context))
241
+ elif isinstance(child, ast.Name):
242
+ if isinstance(child.ctx, ast.Store):
243
+ return
244
+ graph.references.setdefault(child.id, []).append((p, child.lineno, "reference", context))
245
+ elif isinstance(child, ast.Attribute):
246
+ if isinstance(child.ctx, ast.Store):
247
+ return
248
+ name = call_name(child)
249
+ if name:
250
+ graph.references.setdefault(name, []).append((p, child.lineno, "attribute", context))
251
+
252
+
253
+ # ── Tools ──────────────────────────────────────────────────────────────
254
+ def _invalidate_graph_cache() -> None:
255
+ """Drop the in-memory call-graph cache (after re-indexing)."""
256
+ with _state_lock:
257
+ _graph_mem_cache.clear()
258
+
259
+
260
+ def _get_call_graph(root: Path) -> CallGraph:
261
+ """Return a fresh call graph, reading from the SQLite cache when possible.
262
+
263
+ Falls back to a full AST rebuild and persists it to disk, so repeated graph
264
+ queries (call-graph, dead-code, impact, affected) stay fast on large repos.
265
+ """
266
+ with _state_lock:
267
+ cached = _graph_mem_cache.get(str(root))
268
+ if cached is not None:
269
+ return cached
270
+
271
+ cache = GraphCache.for_root(root, _cache_dir())
272
+ if cache.is_fresh(root):
273
+ loaded = cache.load()
274
+ if loaded is not None:
275
+ with _state_lock:
276
+ _graph_mem_cache[str(root)] = loaded
277
+ return loaded
278
+
279
+ graph = _build_call_graph(root)
280
+ try:
281
+ cache.save(graph, root)
282
+ except OSError:
283
+ logger.debug("Could not persist graph cache for %s", root)
284
+ with _state_lock:
285
+ _graph_mem_cache[str(root)] = graph
286
+ return graph
287
+
288
+
289
+ _watcher: ProjectWatcher | None = None
290
+
291
+
292
+ def _ensure_watcher(root: Path) -> None:
293
+ """Start a background file watcher that auto-reindexes on changes."""
294
+ global _watcher
295
+ if _watcher is not None:
296
+ return
297
+
298
+ def _on_change(changed: list) -> None:
299
+ global _project_index, _project_root
300
+ idx = get_project_index(root, force_rebuild=False)
301
+ with _state_lock:
302
+ _project_index = idx
303
+ _project_root = root
304
+ _invalidate_graph_cache()
305
+ logger.info("Watcher: re-indexed after %d file changes", len(changed))
306
+
307
+ _watcher = ProjectWatcher(root, _on_change).start()
308
+
309
+
310
+ def stop_watching() -> dict:
311
+ """Stop the background file watcher, if any."""
312
+ global _watcher
313
+ with _state_lock:
314
+ if _watcher is None:
315
+ return {"success": True, "watching": False}
316
+ _watcher.stop()
317
+ _watcher = None
318
+ return {"success": True, "watching": False}
319
+
320
+
321
+ def index_project(root_path: str, force_rebuild: bool = False, watch: bool = False) -> dict:
322
+ """Build (or reuse) the symbol index for a project directory."""
323
+ global _project_index, _project_root
324
+ root = Path(root_path)
325
+ if not root.exists():
326
+ return {"success": False, "error": f"Path does not exist: {root_path}"}
327
+ resolved = root.resolve()
328
+ idx = get_project_index(resolved, force_rebuild=force_rebuild)
329
+ with _state_lock:
330
+ _project_root = resolved
331
+ _project_index = idx
332
+ _invalidate_graph_cache()
333
+ if watch:
334
+ _ensure_watcher(resolved)
335
+ return {
336
+ "success": True,
337
+ "symbols_indexed": len(idx.symbols),
338
+ "root": str(resolved),
339
+ "cached": idx.cached,
340
+ "watching": _watcher is not None,
341
+ }
342
+
343
+
344
+ def load_project_deps(root_path: str) -> dict:
345
+ """Load dependency APIs declared in requirements.txt / pyproject.toml."""
346
+ root = Path(root_path)
347
+ loaded: list[str] = []
348
+ for candidate in ("requirements.txt", "pyproject.toml"):
349
+ f = root / candidate
350
+ if not f.exists():
351
+ continue
352
+ pkgs = _parse_requires(f)
353
+ for pkg in pkgs:
354
+ try:
355
+ n = _dep_kb.load_package(pkg)
356
+ if n:
357
+ loaded.append(pkg)
358
+ except Exception: # noqa: BLE001
359
+ logger.debug("Failed to load package %s", pkg)
360
+ return {"success": True, "packages_loaded": loaded, "total_apis": len(_dep_kb.all_names())}
361
+
362
+
363
+ def _parse_requires(f: Path) -> list[str]:
364
+ text = f.read_text(encoding="utf-8")
365
+ pkgs: list[str] = []
366
+ if f.name == "requirements.txt":
367
+ for line in text.splitlines():
368
+ line = line.strip()
369
+ if not line or line.startswith("#"):
370
+ continue
371
+ # Skip pip options / VCS installs.
372
+ if line.startswith("-") or line.startswith("http://") or line.startswith("https://"):
373
+ continue
374
+ m = re.match(r"^([A-Za-z0-9_.\-]+)", line)
375
+ if m:
376
+ pkgs.append(m.group(1))
377
+ else: # pyproject.toml
378
+ pkgs.extend(_parse_pyproject_deps(text))
379
+ return pkgs
380
+
381
+
382
+ def _parse_pyproject_deps(text: str) -> list[str]:
383
+ """Extract package names from pyproject.toml, preferring TOML parsing."""
384
+ try:
385
+ try:
386
+ import tomllib # Python 3.11+
387
+ except ImportError:
388
+ import tomli as tomllib # type: ignore[no-redef]
389
+ data = tomllib.loads(text)
390
+ deps: list[str] = []
391
+ for key in ("project.dependencies", "tool.poetry.dependencies"):
392
+ parts = key.split(".")
393
+ cur = data
394
+ for part in parts:
395
+ cur = cur.get(part, {})
396
+ if isinstance(cur, list):
397
+ deps.extend(_strip_version_spec(d) for d in cur if isinstance(d, str))
398
+ elif isinstance(cur, dict):
399
+ # Poetry declares dependencies as {name: version} pairs.
400
+ deps.extend(k for k in cur if isinstance(k, str))
401
+ return deps
402
+ except Exception: # noqa: BLE001
403
+ pass
404
+
405
+ # Fallback regex for quoted / unquoted declarations.
406
+ pkgs: list[str] = []
407
+ for line in text.splitlines():
408
+ line = line.strip()
409
+ if not line or line.startswith("#"):
410
+ continue
411
+ # "name>=1.0" or name = ">=1.0"
412
+ m = re.match(r'^["\']?([A-Za-z0-9_.\-]+)["\']?\s*(?:[<>=!~^]=?|$|\s)', line)
413
+ if m:
414
+ pkgs.append(m.group(1))
415
+ return pkgs
416
+
417
+
418
+ def _strip_version_spec(dep: str) -> str:
419
+ """Return the package name part of a dependency specifier."""
420
+ dep = dep.strip()
421
+ m = re.match(r"^([A-Za-z0-9_.\-]+)", dep)
422
+ return m.group(1) if m else dep
423
+
424
+
425
+ def check_symbol(symbol_name: str, file_path: str | None = None) -> dict:
426
+ """Check whether a symbol is defined in the project."""
427
+ with _state_lock:
428
+ idx = _project_index
429
+ if idx is None:
430
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
431
+ info = idx.symbols.get(symbol_name)
432
+ return {
433
+ "success": True,
434
+ "symbol": symbol_name,
435
+ "defined": info is not None,
436
+ "location": {"file": info.file, "line": info.line} if info else None,
437
+ }
438
+
439
+
440
+ def check_api(api_name: str) -> dict:
441
+ """Verify a library API call is correct (e.g. pd.read_exel -> read_excel)."""
442
+ if _dep_kb.has(api_name):
443
+ return {"success": True, "api": api_name, "valid": True}
444
+
445
+ base = api_name.split(".")[0]
446
+ dep_names = _dep_kb.all_names()
447
+ base_known = _dep_kb.has(base) or any(k.startswith(base + ".") for k in dep_names)
448
+ if not base_known:
449
+ return {
450
+ "success": True,
451
+ "api": api_name,
452
+ "valid": False,
453
+ "reason": "unknown module",
454
+ "suggestion": [],
455
+ }
456
+
457
+ members = [k for k in dep_names if k.startswith(base + ".")]
458
+ attr_part = api_name.split(".")[-1]
459
+ exact = [m for m in members if m == api_name]
460
+ if exact:
461
+ return {"success": True, "api": api_name, "valid": True}
462
+
463
+ close = [m for m in members if m.split(".")[-1] == attr_part]
464
+ if close:
465
+ return {
466
+ "success": True,
467
+ "api": api_name,
468
+ "valid": False,
469
+ "reason": "Attribute not found on module",
470
+ "suggestion": close[:3],
471
+ }
472
+
473
+ from difflib import get_close_matches
474
+
475
+ fuzzy = get_close_matches(api_name, members, n=3, cutoff=0.6)
476
+ return {
477
+ "success": True,
478
+ "api": api_name,
479
+ "valid": False,
480
+ "reason": "Attribute not found on module",
481
+ "suggestion": fuzzy if fuzzy else members[:3],
482
+ }
483
+
484
+
485
+ def execute_code(code: str, prelude: str = "", known_names: list[str] | None = None) -> dict:
486
+ """Execute code in a sandboxed subprocess and return structured results."""
487
+ result = _sandbox.execute(code=code, prelude=prelude, known_names=set(known_names or []))
488
+ return result.to_dict()
489
+
490
+
491
+ def _collect_local_defined(tree: ast.AST) -> set[str]:
492
+ """Collect locally defined names (functions, classes, vars, parameters).
493
+
494
+ Includes parameters and assignment targets so that ``self.helper()`` or
495
+ ``client.fetch()`` are not flagged when ``self`` / ``client`` are bound
496
+ in the enclosing scope.
497
+ """
498
+ defined: set[str] = set()
499
+ for node in ast.walk(tree):
500
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
501
+ defined.add(node.name)
502
+ for a in node.args.args + node.args.posonlyargs + node.args.kwonlyargs:
503
+ if a.arg:
504
+ defined.add(a.arg)
505
+ if node.args.vararg:
506
+ defined.add(node.args.vararg.arg)
507
+ if node.args.kwarg:
508
+ defined.add(node.args.kwarg.arg)
509
+ elif isinstance(node, ast.ClassDef):
510
+ defined.add(node.name)
511
+ elif isinstance(node, ast.Assign):
512
+ for target in node.targets:
513
+ _collect_assign_targets(target, defined)
514
+ elif isinstance(node, (ast.AnnAssign, ast.AugAssign)) and isinstance(node.target, ast.Name):
515
+ defined.add(node.target.id)
516
+ elif isinstance(node, (ast.For, ast.AsyncFor)):
517
+ _collect_assign_targets(node.target, defined)
518
+ elif isinstance(node, ast.NamedExpr): # walrus :=
519
+ if isinstance(node.target, ast.Name):
520
+ defined.add(node.target.id)
521
+ return defined
522
+
523
+
524
+ def _collect_assign_targets(target: ast.expr, defined: set[str]) -> None:
525
+ if isinstance(target, ast.Name):
526
+ defined.add(target.id)
527
+ elif isinstance(target, (ast.Tuple, ast.List)):
528
+ for elt in target.elts:
529
+ _collect_assign_targets(elt, defined)
530
+ elif isinstance(target, ast.Starred):
531
+ _collect_assign_targets(target.value, defined)
532
+
533
+
534
+ def _imported_names(res) -> set[str]:
535
+ """Flatten imported symbols into locally bound names.
536
+
537
+ Handles ``import X`` (binds X), ``import X as Y`` (binds Y),
538
+ ``from M import A`` (binds A), and ``from M import A as B`` (binds B).
539
+ """
540
+ bound: set[str] = set()
541
+ for local_name, source_name in (res.imported_symbols or {}).items():
542
+ bound.add(local_name)
543
+ # Also expose the leaf of the source so `pd.read_csv` matches
544
+ # `import pandas as pd` (local_name == "pd").
545
+ if source_name:
546
+ bound.add(source_name.split(".")[0])
547
+ return bound
548
+
549
+
550
+ def _builtin_names() -> set[str]:
551
+ """Return the full set of Python builtin names (functions + exceptions)."""
552
+ import builtins
553
+
554
+ return set(dir(builtins))
555
+
556
+
557
+ def _scan_non_python(p: Path) -> dict:
558
+ """Scan a non-Python file via the optional tree-sitter multi-language path."""
559
+ from honestcode.structure import multi_lang
560
+
561
+ lang = multi_lang.detect_language(p)
562
+ if lang is None:
563
+ return {
564
+ "success": False,
565
+ "error": f"Unsupported language for {p.suffix or 'file'}. "
566
+ "Python is fully supported; other languages need "
567
+ "'pip install honestcode[multi-language]'.",
568
+ }
569
+ try:
570
+ symbols = multi_lang.extract_symbols(p)
571
+ except multi_lang.TreeSitterUnavailable as exc:
572
+ return {
573
+ "success": False,
574
+ "error": f"{exc}. Language detected: {lang}. Run: pip install -e '.[multi-language]'",
575
+ }
576
+ except Exception as exc: # noqa: BLE001
577
+ return {"success": False, "error": f"multi-language scan failed: {exc}"}
578
+
579
+ defined = {s["name"] for s in symbols}
580
+ try:
581
+ source = p.read_text(encoding="utf-8")
582
+ except (OSError, UnicodeDecodeError) as exc:
583
+ return {"success": False, "error": f"cannot read file: {exc}"}
584
+
585
+ issues: list[dict] = []
586
+ # Negative lookbehind skips attribute calls (``obj.method(``), which
587
+ # cannot be judged statically; keyword set covers language constructs
588
+ # that look like calls (``new Foo(``, ``require(``, ...).
589
+ for m in re.finditer(r"(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(", source):
590
+ name = m.group(1)
591
+ if name in defined or name in {
592
+ "if",
593
+ "for",
594
+ "while",
595
+ "switch",
596
+ "catch",
597
+ "function",
598
+ "new",
599
+ "typeof",
600
+ "return",
601
+ }:
602
+ continue
603
+ line = source[: m.start()].count("\n") + 1
604
+ issues.append({"type": "undefined_call", "name": name, "line": line})
605
+
606
+ return {
607
+ "success": True,
608
+ "file": str(p),
609
+ "language": lang,
610
+ "issues": issues,
611
+ "defined_symbols": len(defined),
612
+ }
613
+
614
+
615
+ def scan_file(file_path: str) -> dict:
616
+ """Scan a file for potential hallucinations: undefined symbols and missing imports."""
617
+ p = Path(file_path)
618
+ if not p.exists():
619
+ return {"success": False, "error": f"File not found: {file_path}"}
620
+ if p.suffix.lower() != ".py":
621
+ return _scan_non_python(p)
622
+ try:
623
+ res = _extractor.parse_file(p)
624
+ except Exception as e: # noqa: BLE001
625
+ return {"success": False, "error": str(e)}
626
+
627
+ with _state_lock:
628
+ idx = _project_index
629
+ dep_names = _dep_kb.all_names()
630
+
631
+ try:
632
+ source = p.read_text(encoding="utf-8")
633
+ tree = ast.parse(source, filename=str(p))
634
+ except SyntaxError as e:
635
+ return {"success": False, "error": f"SyntaxError: {e}"}
636
+
637
+ # Locally defined names (functions/classes/vars + parameters + loop targets).
638
+ defined = _collect_local_defined(tree)
639
+ # Names introduced by import statements (both local alias and module root).
640
+ defined |= _imported_names(res)
641
+ # Module-qualified project symbols.
642
+ if idx is not None:
643
+ defined |= set(idx.symbols.keys())
644
+ # Dependency API names.
645
+ defined |= dep_names
646
+ # Full builtin set instead of a hand-picked subset.
647
+ defined |= _builtin_names()
648
+
649
+ issues: list[dict] = []
650
+ seen: set[tuple[str, int]] = set()
651
+ for node in ast.walk(tree):
652
+ if not isinstance(node, ast.Call):
653
+ continue
654
+ name = call_name(node.func)
655
+ if not name:
656
+ # Complex call target (e.g. ``(a + b).foo()``) — skip, can't judge.
657
+ continue
658
+ base = call_base(name)
659
+ if base in defined or name in defined:
660
+ continue
661
+ key = (name, node.lineno)
662
+ if key in seen:
663
+ continue
664
+ seen.add(key)
665
+ issues.append({"type": "undefined_call", "name": name, "line": node.lineno})
666
+
667
+ return {"success": True, "file": str(p), "issues": issues, "defined_symbols": len(defined)}
668
+
669
+
670
+ def load_package_apis(package_name: str) -> dict:
671
+ """Load (and cache) API signatures for a specific package."""
672
+ count = _dep_kb.load_package(package_name)
673
+ return {"success": True, "package": package_name, "api_count": count}
674
+
675
+
676
+ def get_project_stats() -> dict:
677
+ """Return statistics about the current index."""
678
+ with _state_lock:
679
+ idx = _project_index
680
+ if idx is None:
681
+ return {"success": False, "error": "Project not indexed."}
682
+ return {
683
+ "success": True,
684
+ "symbols": len(idx.symbols),
685
+ "dependency_apis": len(_dep_kb.all_names()),
686
+ "indexed": idx is not None,
687
+ }
688
+
689
+
690
+ def validate_types(code: str) -> dict:
691
+ """Run a lightweight AST-based type check to catch obvious mismatches."""
692
+ try:
693
+ tree = ast.parse(code)
694
+ except SyntaxError as e:
695
+ return {"success": False, "error": f"SyntaxError: {e}"}
696
+
697
+ issues: list[dict] = []
698
+
699
+ for node in ast.walk(tree):
700
+ # 1. None iteration
701
+ if isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)):
702
+ _check_none_iterable(node.iter, issues)
703
+ if isinstance(node, ast.Compare):
704
+ for op, comparator in zip(node.ops, node.comparators, strict=True):
705
+ if isinstance(op, ast.In):
706
+ _check_none_iterable(comparator, issues)
707
+
708
+ # 2. Calling a constant literal
709
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Constant):
710
+ issues.append(
711
+ {
712
+ "type": "non_callable",
713
+ "message": "Attempting to call a constant value",
714
+ "line": node.lineno,
715
+ }
716
+ )
717
+
718
+ # 3. String method on non-string constant
719
+ if (
720
+ isinstance(node, ast.Attribute)
721
+ and node.attr in {"upper", "lower", "strip", "split", "join", "startswith", "endswith"}
722
+ and isinstance(node.value, ast.Constant)
723
+ and not isinstance(node.value.value, str)
724
+ ):
725
+ issues.append(
726
+ {
727
+ "type": "type_mismatch",
728
+ "message": f"'{node.attr}' called on non-string constant",
729
+ "line": node.lineno,
730
+ }
731
+ )
732
+
733
+ # 4. Common builtins with wrong argument count
734
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
735
+ _check_builtin_argc(node, issues)
736
+
737
+ return {"success": True, "issues": issues, "note": "AST structural check complete"}
738
+
739
+
740
+ def _check_none_iterable(node: ast.expr, issues: list[dict]) -> None:
741
+ """Flag iterating over a None constant or expressions likely to return None.
742
+
743
+ Detects:
744
+ - Explicit ``None`` literal
745
+ - ``dict.get(...)`` / ``dict.pop(...)`` without a default value
746
+ - Call to functions that commonly return None (e.g. ``os.environ.get``)
747
+ """
748
+ if isinstance(node, ast.Constant) and node.value is None:
749
+ issues.append(
750
+ {
751
+ "type": "none_iteration",
752
+ "message": "Attempting to iterate over None",
753
+ "line": getattr(node, "lineno", 0),
754
+ }
755
+ )
756
+ return
757
+
758
+ # dict.get(key) without default returns None.
759
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
760
+ method = node.func.attr
761
+ if (
762
+ method in ("get", "pop")
763
+ and len(node.args) < 2
764
+ and not any(kw.arg == "default" for kw in node.keywords)
765
+ ):
766
+ issues.append(
767
+ {
768
+ "type": "none_iteration",
769
+ "message": f"'{method}' without default may return None",
770
+ "line": getattr(node, "lineno", 0),
771
+ }
772
+ )
773
+
774
+
775
+ def _check_builtin_argc(node: ast.Call, issues: list[dict]) -> None:
776
+ """Check argument counts for a small set of common builtins.
777
+
778
+ Rules are (min_positional, max_positional, allowed_keyword_names).
779
+ ``None`` means unbounded.
780
+ """
781
+ name = node.func.id
782
+ args = len(node.args)
783
+ kwargs = {kw.arg for kw in node.keywords if kw.arg is not None} # exclude **kwargs
784
+ rules = {
785
+ "len": (1, 1, set()),
786
+ "repr": (1, 1, set()),
787
+ "str": (0, 1, {"encoding", "errors"}),
788
+ "int": (0, 2, {"base"}),
789
+ "float": (0, 1, set()),
790
+ "bool": (0, 1, set()),
791
+ "abs": (1, 1, set()),
792
+ "round": (1, 2, {"ndigits"}),
793
+ "sum": (1, 2, {"start"}),
794
+ "min": (1, None, set()),
795
+ "max": (1, None, set()),
796
+ "sorted": (1, 3, {"key", "reverse"}),
797
+ "enumerate": (1, 2, {"start"}),
798
+ "zip": (0, None, {"strict"}),
799
+ "map": (2, None, set()),
800
+ "filter": (2, None, set()),
801
+ }
802
+ if name not in rules:
803
+ return
804
+ min_args, max_args, allowed_kw = rules[name]
805
+
806
+ invalid_kw = kwargs - allowed_kw
807
+ if invalid_kw:
808
+ issues.append(
809
+ {
810
+ "type": "arg_count",
811
+ "message": f"'{name}' does not accept keyword argument(s): {', '.join(sorted(invalid_kw))}",
812
+ "line": node.lineno,
813
+ }
814
+ )
815
+ return
816
+
817
+ if args < min_args:
818
+ issues.append(
819
+ {
820
+ "type": "arg_count",
821
+ "message": f"'{name}' expects at least {min_args} positional argument(s)",
822
+ "line": node.lineno,
823
+ }
824
+ )
825
+ elif max_args is not None and args > max_args:
826
+ issues.append(
827
+ {
828
+ "type": "arg_count",
829
+ "message": f"'{name}' expects at most {max_args} positional argument(s)",
830
+ "line": node.lineno,
831
+ }
832
+ )
833
+
834
+
835
+ def find_dead_code(
836
+ entrypoints: list[str] | None = None,
837
+ ignore_patterns: list[str] | None = None,
838
+ include_tests: bool = True,
839
+ ) -> dict:
840
+ """Find symbols that appear to be unused in the project."""
841
+ with _state_lock:
842
+ root = _project_root
843
+ idx = _project_index
844
+ if root is None or idx is None:
845
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
846
+
847
+ ignore_res = [re.compile(p) for p in (ignore_patterns or [])]
848
+ graph = _get_call_graph(root)
849
+
850
+ alive: set[str] = set(entrypoints or [])
851
+
852
+ # Symbols referenced anywhere in the project are considered alive.
853
+ for name in graph.references:
854
+ if name in graph.definitions:
855
+ alive.add(name)
856
+
857
+ if include_tests:
858
+ for name, locations in graph.definitions.items():
859
+ for p, _line, _end, _kind in locations:
860
+ if "test" in p.parts or p.name.startswith("test_"):
861
+ alive.add(name)
862
+ break
863
+
864
+ # Treat dunder hooks and public __init__ exports as alive.
865
+ for name in graph.definitions:
866
+ if name.startswith("__") and name.endswith("__"):
867
+ alive.add(name)
868
+
869
+ dead: list[dict] = []
870
+ for name, locations in graph.definitions.items():
871
+ if name in alive:
872
+ continue
873
+ filtered = [
874
+ (str(p), line, kind)
875
+ for p, line, _end, kind in locations
876
+ if not any(r.search(str(p)) for r in ignore_res)
877
+ ]
878
+ if filtered:
879
+ dead.append({"symbol": name, "locations": filtered})
880
+
881
+ return {"success": True, "dead_symbols": dead, "count": len(dead)}
882
+
883
+
884
+ def find_similar_code(threshold: float = 0.85) -> dict:
885
+ """Find function-level code clones across the project.
886
+
887
+ Uses a length-ratio pre-filter to skip obviously dissimilar pairs before
888
+ running the more expensive ``SequenceMatcher``.
889
+ """
890
+ with _state_lock:
891
+ root = _project_root
892
+ if root is None:
893
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
894
+
895
+ functions: list[tuple[Path, str, str, tuple[int, int]]] = []
896
+ for p, res in _parse_results_for(root).items():
897
+ code = p.read_text(encoding="utf-8")
898
+ for name, (start, end) in res.func_defs.items():
899
+ lines = code.splitlines()[start : end + 1]
900
+ body = _normalize_code("\n".join(lines))
901
+ functions.append((p, name, body, (start + 1, end + 1)))
902
+
903
+ from difflib import SequenceMatcher
904
+
905
+ # Pre-filter: group by similar length to avoid comparing very short or
906
+ # very different functions. The similarity ratio between two strings is
907
+ # bounded by min(len(a), len(b)) / max(len(a), len(b)), so we can skip
908
+ # pairs whose length ratio is already below the threshold.
909
+ pairs: list[dict] = []
910
+ seen: set[tuple[tuple[str, str], tuple[str, str]]] = set()
911
+ for i, (p1, n1, b1, r1) in enumerate(functions):
912
+ len1 = len(b1)
913
+ if len1 < 30:
914
+ continue
915
+ for j in range(i + 1, len(functions)):
916
+ p2, n2, b2, r2 = functions[j]
917
+ len2 = len(b2)
918
+ if len2 < 30:
919
+ continue
920
+ # Length-ratio bound: if the shorter is less than threshold * longer, skip.
921
+ shorter, longer = (len1, len2) if len1 <= len2 else (len2, len1)
922
+ if shorter < threshold * longer:
923
+ continue
924
+ key = tuple(sorted([(str(p1), n1), (str(p2), n2)]))
925
+ if key in seen:
926
+ continue
927
+ ratio = SequenceMatcher(None, b1, b2).ratio()
928
+ if ratio >= threshold:
929
+ seen.add(key)
930
+ pairs.append(
931
+ {
932
+ "symbol_a": {"file": str(p1), "name": n1, "lines": r1},
933
+ "symbol_b": {"file": str(p2), "name": n2, "lines": r2},
934
+ "similarity": round(ratio, 3),
935
+ }
936
+ )
937
+
938
+ pairs.sort(key=lambda x: x["similarity"], reverse=True)
939
+ return {"success": True, "pairs": pairs, "count": len(pairs)}
940
+
941
+
942
+ def explore_call_graph(symbol_name: str) -> dict:
943
+ """Explore callers and callees of a symbol across the project."""
944
+ with _state_lock:
945
+ root = _project_root
946
+ if root is None:
947
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
948
+
949
+ graph = _get_call_graph(root)
950
+
951
+ definitions = graph.definitions.get(symbol_name, [])
952
+ short_name = symbol_name.split(".")[-1]
953
+
954
+ # Names that count as "this symbol" — both the fully-qualified form and
955
+ # the short form, so a call context recorded as either will match.
956
+ self_names = {symbol_name, short_name}
957
+
958
+ # Callers: places that reference *symbol_name* (or its short name).
959
+ callers: list[dict] = []
960
+ seen_callers: set[tuple[str, int, str]] = set()
961
+ for ref_name, refs in graph.references.items():
962
+ if ref_name != symbol_name and ref_name != short_name:
963
+ continue
964
+ for p, line, kind, context in refs:
965
+ key = (str(p), line, context or "")
966
+ if key in seen_callers:
967
+ continue
968
+ seen_callers.add(key)
969
+ callers.append(
970
+ {
971
+ "caller": context or "<module>",
972
+ "file": str(p),
973
+ "line": line,
974
+ "kind": kind,
975
+ }
976
+ )
977
+
978
+ # Callees: symbols referenced from inside any of *symbol_name*'s definitions.
979
+ # A reference is "inside" when its enclosing context matches the symbol
980
+ # (by qualified or short name) AND it lives in the same file AND its line
981
+ # falls within the def's span (short names can collide across modules).
982
+ def_spans = [(p, line, end) for p, line, end, _kind in definitions]
983
+ callee_names: set[str] = set()
984
+ for ref_name, refs in graph.references.items():
985
+ for rp, rline, _rkind, rcontext in refs:
986
+ if rcontext not in self_names:
987
+ continue
988
+ if any(fp == rp and start <= rline <= end for fp, start, end in def_spans):
989
+ callee_names.add(ref_name)
990
+
991
+ callees: list[dict] = []
992
+ for callee in sorted(callee_names):
993
+ locations = graph.definitions.get(callee, [])
994
+ callees.append(
995
+ {
996
+ "callee": callee,
997
+ "defined": [
998
+ {"file": str(p), "line": line, "kind": kind}
999
+ for p, line, _end, kind in locations
1000
+ ],
1001
+ }
1002
+ )
1003
+
1004
+ result: dict = {
1005
+ "success": True,
1006
+ "symbol": symbol_name,
1007
+ "definitions": [
1008
+ {"file": str(p), "line": line, "kind": kind} for p, line, _end, kind in definitions
1009
+ ],
1010
+ "callers": callers,
1011
+ "callees": callees,
1012
+ }
1013
+ # Blast-radius summary: how far a change to this symbol would ripple.
1014
+ if definitions:
1015
+ impact = explore_impact(symbol_name, max_depth=3)
1016
+ if impact.get("success"):
1017
+ result["impact"] = impact["counts"]
1018
+ return result
1019
+
1020
+
1021
+ def explore_impact(symbol_name: str, max_depth: int = 3) -> dict:
1022
+ """Compute the blast radius of a symbol: transitively impacted symbols/files.
1023
+
1024
+ Walks the call graph in both directions (callers and callees) up to
1025
+ ``max_depth`` hops and summarizes which symbols and files would be affected
1026
+ if ``symbol_name`` changed.
1027
+ """
1028
+ with _state_lock:
1029
+ root = _project_root
1030
+ if root is None:
1031
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
1032
+
1033
+ graph = _get_call_graph(root)
1034
+ if symbol_name not in graph.definitions:
1035
+ return {"success": False, "error": f"Symbol not found in project: {symbol_name}"}
1036
+
1037
+ def _callers(name: str) -> set[str]:
1038
+ """Symbols whose bodies reference *name* (or its short form)."""
1039
+ short = name.split(".")[-1]
1040
+ found: set[str] = set()
1041
+ for ref_name, refs in graph.references.items():
1042
+ if ref_name != name and ref_name != short:
1043
+ continue
1044
+ for _p, _line, _kind, context in refs:
1045
+ if context:
1046
+ found.add(context)
1047
+ return found
1048
+
1049
+ def _callees(name: str) -> set[str]:
1050
+ """Symbols referenced from inside *name*'s definitions (same file)."""
1051
+ spans = [(p, line, end) for p, line, end, _kind in graph.definitions.get(name, [])]
1052
+ if not spans:
1053
+ return set()
1054
+ short = name.split(".")[-1]
1055
+ found: set[str] = set()
1056
+ for ref_name, refs in graph.references.items():
1057
+ for rp, rline, _rkind, rcontext in refs:
1058
+ if rcontext != name and rcontext != short:
1059
+ continue
1060
+ if any(fp == rp and start <= rline <= end for fp, start, end in spans):
1061
+ found.add(ref_name)
1062
+ return found
1063
+
1064
+ visited: set[str] = set()
1065
+ frontier: set[str] = {symbol_name}
1066
+ depth_map: dict[str, int] = {}
1067
+ depth = 0
1068
+ while frontier and depth < max_depth:
1069
+ depth += 1
1070
+ nxt: set[str] = set()
1071
+ for name in frontier:
1072
+ if name in visited:
1073
+ continue
1074
+ visited.add(name)
1075
+ depth_map[name] = depth
1076
+ nxt.update(_callers(name))
1077
+ nxt.update(_callees(name))
1078
+ frontier = nxt - visited
1079
+
1080
+ impacted_files: set[str] = set()
1081
+ for name in visited:
1082
+ for p, _line, _end, _kind in graph.definitions.get(name, []):
1083
+ impacted_files.add(str(p))
1084
+
1085
+ return {
1086
+ "success": True,
1087
+ "symbol": symbol_name,
1088
+ "max_depth": max_depth,
1089
+ "impacted_symbols": sorted(visited),
1090
+ "impacted_files": sorted(impacted_files),
1091
+ "counts": {
1092
+ "symbols": len(visited),
1093
+ "files": len(impacted_files),
1094
+ "depth": max(depth_map.values(), default=0),
1095
+ },
1096
+ }
1097
+
1098
+
1099
+ def _run_git(root: Path, *args: str) -> subprocess.CompletedProcess[str] | None:
1100
+ try:
1101
+ return subprocess.run(
1102
+ ["git", "-C", str(root), *args],
1103
+ capture_output=True,
1104
+ text=True,
1105
+ timeout=30,
1106
+ )
1107
+ except (OSError, subprocess.TimeoutExpired):
1108
+ return None
1109
+
1110
+
1111
+ def affected_files(base: str = "HEAD", head: str | None = None, max_depth: int = 4) -> dict:
1112
+ """Find files (especially tests) that may be affected by a git diff.
1113
+
1114
+ ``base`` (default ``HEAD``) is diffed against ``head`` when given, else the
1115
+ working tree. Changed files' defined symbols are traced backwards through
1116
+ the call graph so callers-of-callers up to ``max_depth`` hops are included.
1117
+ """
1118
+ with _state_lock:
1119
+ root = _project_root
1120
+ if root is None:
1121
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
1122
+
1123
+ if head:
1124
+ proc = _run_git(root, "diff", "--name-only", base, head)
1125
+ else:
1126
+ proc = _run_git(root, "diff", "--name-only", base)
1127
+ if proc is None or proc.returncode != 0:
1128
+ err = (proc.stderr if proc else "git unavailable").strip()
1129
+ return {"success": False, "error": f"git diff failed: {err}"}
1130
+ changed = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
1131
+
1132
+ graph = _get_call_graph(root)
1133
+
1134
+ # Symbols defined in changed files (paths are relative to the repo root).
1135
+ # git always emits forward slashes; normalize Windows paths to match.
1136
+ seeds: set[str] = set()
1137
+ changed_set = {c.replace("\\", "/") for c in changed}
1138
+ for name, locs in graph.definitions.items():
1139
+ for p, _line, _end, _kind in locs:
1140
+ try:
1141
+ rel = str(p.relative_to(root)).replace("\\", "/")
1142
+ except ValueError:
1143
+ rel = str(p)
1144
+ if rel in changed_set:
1145
+ seeds.add(name)
1146
+ if not seeds:
1147
+ return {
1148
+ "success": True,
1149
+ "changed": changed,
1150
+ "affected_files": [],
1151
+ "affected_tests": [],
1152
+ "propagation": 0,
1153
+ }
1154
+
1155
+ # Reverse BFS (bounded by max_depth): who references these symbols,
1156
+ # transitively. Depth counts hops from the seed symbols.
1157
+ visited: set[str] = set()
1158
+ frontier: set[str] = set(seeds)
1159
+ depth = 0
1160
+ while frontier and depth < max_depth:
1161
+ depth += 1
1162
+ nxt: set[str] = set()
1163
+ for name in frontier:
1164
+ if name in visited:
1165
+ continue
1166
+ visited.add(name)
1167
+ short = name.split(".")[-1]
1168
+ for ref_name, refs in graph.references.items():
1169
+ if ref_name != name and ref_name != short:
1170
+ continue
1171
+ for _p, _line, _kind, context in refs:
1172
+ if context and context not in visited:
1173
+ nxt.add(context)
1174
+ frontier = nxt - visited
1175
+ if len(visited) > 5000: # safety valve for pathological graphs
1176
+ break
1177
+
1178
+ affected: set[str] = set()
1179
+ for name in visited:
1180
+ for p, _line, _end, _kind in graph.definitions.get(name, []):
1181
+ affected.add(str(p))
1182
+
1183
+ affected_tests = sorted(f for f in affected if "test" in f.lower())
1184
+ return {
1185
+ "success": True,
1186
+ "changed": changed,
1187
+ "affected_files": sorted(affected),
1188
+ "affected_tests": affected_tests,
1189
+ "propagation": len(visited) - len(seeds),
1190
+ }
1191
+
1192
+
1193
+ def search_code(pattern: str, glob: str = "*.py") -> dict:
1194
+ """Search project source code with a regex pattern."""
1195
+ with _state_lock:
1196
+ root = _project_root
1197
+ if root is None:
1198
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
1199
+
1200
+ try:
1201
+ regex = re.compile(pattern)
1202
+ except re.error as e:
1203
+ return {"success": False, "error": f"Invalid regex: {e}"}
1204
+
1205
+ # FTS5 acceleration: for plain .py searches, shrink the scan to a superset
1206
+ # of candidate files via the full-text index, then run the exact regex only
1207
+ # on those. Results are identical to a full scan, but large repos touch
1208
+ # far fewer files. Falls back to a full scan when FTS5 is unavailable.
1209
+ candidates: list[Path] | None = None
1210
+ if glob == "*.py":
1211
+ try:
1212
+ cache = GraphCache.for_root(root, _cache_dir())
1213
+ if not cache.is_fresh(root):
1214
+ # First search after a change: (re)build the graph + FTS5 index
1215
+ # once, then every later search hits the index.
1216
+ _get_call_graph(root)
1217
+ if cache.is_fresh(root):
1218
+ rel_candidates = cache.search_candidate_files(pattern)
1219
+ if rel_candidates is not None:
1220
+ if not rel_candidates:
1221
+ return {
1222
+ "success": True,
1223
+ "pattern": pattern,
1224
+ "matches": [],
1225
+ "count": 0,
1226
+ "engine": "fts5",
1227
+ }
1228
+ candidates = [root / rel for rel in rel_candidates]
1229
+ except (OSError, sqlite3.Error):
1230
+ candidates = None
1231
+
1232
+ matches: list[dict] = []
1233
+ for p in candidates if candidates is not None else root.rglob(glob):
1234
+ if "__pycache__" in p.parts:
1235
+ continue
1236
+ try:
1237
+ text = p.read_text(encoding="utf-8")
1238
+ except Exception: # noqa: BLE001
1239
+ continue
1240
+ for m in regex.finditer(text):
1241
+ line = text[: m.start()].count("\n") + 1
1242
+ matches.append(
1243
+ {
1244
+ "file": str(p),
1245
+ "line": line,
1246
+ "match": m.group(0)[:200],
1247
+ }
1248
+ )
1249
+
1250
+ return {
1251
+ "success": True,
1252
+ "pattern": pattern,
1253
+ "matches": matches,
1254
+ "count": len(matches),
1255
+ "engine": "fts5" if candidates is not None else "regex",
1256
+ }
1257
+
1258
+
1259
+ def choose_tool(query: str) -> dict:
1260
+ """Choose the most appropriate tool for a natural-language query."""
1261
+ choice = HonestRouter.choose_tool(query)
1262
+ return {
1263
+ "success": True,
1264
+ "tool": choice.intent.value,
1265
+ "confidence": round(choice.confidence, 3),
1266
+ "reason": choice.reason,
1267
+ }