honestcode 0.5.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.
@@ -0,0 +1,2002 @@
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 importlib.metadata
25
+ import importlib.util
26
+ import logging
27
+ import os
28
+ import re
29
+ import sqlite3
30
+ import subprocess
31
+ import sys
32
+ import sysconfig
33
+ import threading
34
+ import time
35
+ from contextlib import suppress
36
+ from pathlib import Path
37
+
38
+ from honestcode.graph.graph_store import CallGraph, GraphCache
39
+ from honestcode.graph.watcher import ProjectWatcher
40
+ from honestcode.honest.router import HonestRouter
41
+ from honestcode.honest.symbol_index import ProjectIndex, get_project_index
42
+ from honestcode.mcp.knowledge_base import APIKnowledgeBase
43
+ from honestcode.sandbox import SandboxExecutor
44
+ from honestcode.structure.extractor import StructureExtractor
45
+ from honestcode.structure.relations import ParseResult
46
+ from honestcode.structure.utils import (
47
+ call_name,
48
+ iter_python_files,
49
+ module_name_for,
50
+ read_python_source,
51
+ stdlib_roots,
52
+ )
53
+ from honestcode.verify.evidence import (
54
+ ACTION_INSPECT,
55
+ CONFIDENCE_LOW,
56
+ KIND_SYNTAX_ERROR,
57
+ KIND_UNDEFINED_SYMBOL,
58
+ Finding,
59
+ build_report,
60
+ render_text,
61
+ )
62
+ from honestcode.verify.verify import (
63
+ resolve_package_relative_imports,
64
+ star_imports_unresolved,
65
+ valid_imported_names,
66
+ verify_tree,
67
+ )
68
+
69
+ __all__ = [
70
+ "index_project",
71
+ "load_project_deps",
72
+ "check_symbol",
73
+ "check_api",
74
+ "execute_code",
75
+ "scan_file",
76
+ "load_package_apis",
77
+ "get_project_stats",
78
+ "validate_types",
79
+ "find_dead_code",
80
+ "find_similar_code",
81
+ "explore_call_graph",
82
+ "search_code",
83
+ "choose_tool",
84
+ "verify_file",
85
+ "reset_dependency_state",
86
+ ]
87
+
88
+ logger = logging.getLogger(__name__)
89
+
90
+ _AUTO_INDEX_ENABLED = os.environ.get("HONESTCODE_AUTO_INDEX", "1").lower() not in {
91
+ "0",
92
+ "false",
93
+ "no",
94
+ "off",
95
+ }
96
+ # Auto-declaring the project's dependencies (without importing them) is what
97
+ # keeps a normal project from reporting every `from dep import X` as undefined
98
+ # when the MCP server runs in a different interpreter than the project's venv.
99
+ _AUTO_DEPS_ENABLED = os.environ.get("HONESTCODE_AUTO_DEPS", "1").lower() not in {
100
+ "0",
101
+ "false",
102
+ "no",
103
+ "off",
104
+ }
105
+ _PROJECT_MARKERS = (".honestcode", ".git", "pyproject.toml", "setup.py", "requirements.txt")
106
+
107
+ # How many package levels above the verified file may be adopted as the import
108
+ # root when no project marker exists.
109
+ _MAX_PACKAGE_WALK = 4
110
+
111
+
112
+ def _find_project_root(start: Path) -> Path | None:
113
+ """Walk upward from *start* looking for a project marker."""
114
+ cur = start.resolve()
115
+ for candidate in [cur, *cur.parents]:
116
+ if (candidate / ".honestcode" / "config.json").exists():
117
+ return candidate
118
+ if (candidate / ".git").exists():
119
+ return candidate
120
+ for candidate in [cur, *cur.parents]:
121
+ if any((candidate / m).exists() for m in _PROJECT_MARKERS):
122
+ return candidate
123
+ return None
124
+
125
+
126
+ def _package_root(start: Path) -> Path | None:
127
+ """Return the import root for a directory, walking out of packages.
128
+
129
+ Python treats the first ancestor without an ``__init__.py`` as the import
130
+ root. Walking out to it keeps sibling packages inside the indexed scope —
131
+ indexing only the verified file's own directory hid them and reported
132
+ correct cross-package imports as undefined symbols.
133
+
134
+ The walk is bounded: a project root that is itself a package would otherwise
135
+ let the search climb into a shared directory (a temp or home folder) and
136
+ index unrelated trees.
137
+ """
138
+ current = start.resolve()
139
+ for _ in range(_MAX_PACKAGE_WALK):
140
+ if not (current / "__init__.py").exists():
141
+ break
142
+ current = current.parent
143
+ if current == current.parent: # filesystem root — never index an entire drive
144
+ return None
145
+ return current
146
+
147
+
148
+ # Per-root index memo so scanning files from two projects in one session never
149
+ # grounds one of them with the other's index. Entries hold whatever
150
+ # get_project_index returned last; freshness is re-validated inside it.
151
+ _index_by_root: dict[str, ProjectIndex] = {}
152
+
153
+ # Project root -> manifest fingerprint for the last automatic dependency
154
+ # declaration. Keeps the (cheap, but not free) manifest parse out of every scan
155
+ # while still noticing an edited requirements.txt / pyproject.toml.
156
+ _deps_stamp_by_root: dict[str, tuple] = {}
157
+
158
+
159
+ def _index_for_file(path: Path) -> ProjectIndex | None:
160
+ """Resolve the (fresh) project index for the project containing *path*.
161
+
162
+ Resolution order:
163
+ 1. the **nearest explicitly indexed root** (a root passed to
164
+ ``index_project``) that contains *path* — an explicit binding is a
165
+ deliberate statement of scope and must win over generic upward marker
166
+ discovery. Nested projects rely on this: a demo directory indexed
167
+ explicitly inside a git repo must not be verified against the repo
168
+ root's index, or its local module names stop resolving and findings
169
+ are silently lost;
170
+ 2. the nearest project above *path* discovered via markers;
171
+ 3. ``None`` (scan_file then falls back to trusting-import behaviour).
172
+
173
+ ``get_project_index`` validates its cache against the tree's file metadata
174
+ on every call, so the returned index always reflects files written moments
175
+ ago. Per-root memoization additionally keeps a session that touches two
176
+ projects from grounding one with the other's index.
177
+ """
178
+ global _project_index, _project_root
179
+ if not _AUTO_INDEX_ENABLED:
180
+ with _state_lock:
181
+ return _project_index
182
+
183
+ resolved = path.resolve()
184
+ root = _nearest_explicit_root(resolved)
185
+ if root is None:
186
+ root = _find_project_root(path)
187
+ if root is None:
188
+ # No marker anywhere: index from the file's package root. Agents work in
189
+ # scratch/workspace directories all the time; giving up here made
190
+ # verify_file silently return "pass" for broken code.
191
+ root = _package_root(resolved.parent)
192
+ if root is None:
193
+ return None
194
+
195
+ fresh = get_project_index(root, force_rebuild=False)
196
+ with _state_lock:
197
+ _index_by_root[str(root)] = fresh
198
+ _project_index = fresh
199
+ _project_root = root
200
+ return fresh
201
+
202
+
203
+ def _nearest_explicit_root(resolved: Path) -> Path | None:
204
+ """Return the deepest explicitly indexed root containing *resolved*."""
205
+ with _state_lock:
206
+ candidates = [Path(r) for r in _index_by_root]
207
+ proot = _project_root
208
+ if proot is not None and Path(proot) not in candidates:
209
+ candidates.append(Path(proot))
210
+ best: Path | None = None
211
+ for cand in candidates:
212
+ try:
213
+ resolved.relative_to(cand)
214
+ except ValueError:
215
+ continue
216
+ if best is None or len(cand.parts) > len(best.parts):
217
+ best = cand
218
+ return best
219
+
220
+
221
+ def _ensure_index_for(path: Path) -> bool:
222
+ """Auto-index the project containing *path* when no index is loaded.
223
+
224
+ Kept for backwards compatibility; ``scan_file`` now uses
225
+ :func:`_index_for_file`, which is per-project-root and always fresh.
226
+ """
227
+ global _project_index, _project_root
228
+ with _state_lock:
229
+ if _project_index is not None or not _AUTO_INDEX_ENABLED:
230
+ return False
231
+ root = _find_project_root(path)
232
+ if root is None:
233
+ return False
234
+ idx = get_project_index(root, force_rebuild=False)
235
+ with _state_lock:
236
+ _project_index = idx
237
+ _project_root = root
238
+ _invalidate_graph_cache()
239
+ return True
240
+
241
+
242
+ # ── Global server state ────────────────────────────────────────────────
243
+ _state_lock = threading.RLock()
244
+ _project_index: ProjectIndex | None = None
245
+ _project_root: Path | None = None
246
+ _dep_kb: APIKnowledgeBase = APIKnowledgeBase()
247
+ _sandbox: SandboxExecutor = SandboxExecutor()
248
+ _extractor: StructureExtractor = StructureExtractor()
249
+ # In-memory call-graph cache keyed by resolved project root. The SQLite
250
+ # GraphCache on disk is the source of truth; this is just a hot layer.
251
+ _graph_mem_cache: dict[str, CallGraph] = {}
252
+
253
+
254
+ def _cache_dir() -> Path:
255
+ """Directory for symbol/graph caches (override with HONESTCODE_CACHE_DIR)."""
256
+ return Path(os.environ.get("HONESTCODE_CACHE_DIR", os.path.expanduser("~/.cache/honestcode")))
257
+
258
+
259
+ def _manifest_stamp(root: Path) -> tuple | None:
260
+ """Fingerprint the dependency manifests, or ``None`` when neither exists."""
261
+ stamp: list[tuple] = []
262
+ found = False
263
+ for name in ("requirements.txt", "pyproject.toml"):
264
+ path = root / name
265
+ try:
266
+ st = path.stat()
267
+ except OSError:
268
+ stamp.append((name, None, None))
269
+ continue
270
+ found = True
271
+ stamp.append((name, st.st_mtime_ns, st.st_size))
272
+ return tuple(stamp) if found else None
273
+
274
+
275
+ def _ensure_deps_for(root: Path) -> None:
276
+ """Declare the project's dependencies so their imports are not hallucinations.
277
+
278
+ Declared-only on purpose: importing every requirement would execute
279
+ arbitrary installed code and make the first scan of a data-science project
280
+ take minutes. Import trust is what prevents ``from flask import Flask``
281
+ from becoming an ``undefined_symbol`` finding when the server and the
282
+ project do not share an interpreter; member-level invented-API checks stay
283
+ silent for packages whose surfaces were never enumerated.
284
+ """
285
+ if not _AUTO_DEPS_ENABLED:
286
+ return
287
+ stamp = _manifest_stamp(root)
288
+ if stamp is None:
289
+ return
290
+ key = str(root.resolve())
291
+ with _state_lock:
292
+ if _deps_stamp_by_root.get(key) == stamp:
293
+ return
294
+ try:
295
+ load_project_deps(str(root), import_packages=False)
296
+ except Exception: # noqa: BLE001 - dependency metadata must never break a scan
297
+ logger.debug("Could not declare dependencies for %s", root, exc_info=True)
298
+ return
299
+ with _state_lock:
300
+ _deps_stamp_by_root[key] = stamp
301
+
302
+
303
+ # ── Internal helpers ───────────────────────────────────────────────────
304
+ def _module_name(p: Path, root: Path) -> str:
305
+ """Infer a dotted module name from *p* relative to *root*.
306
+
307
+ Thin alias for :func:`honestcode.structure.utils.module_name_for` — the
308
+ single implementation of this logic (it used to be copied in four places).
309
+ """
310
+ return module_name_for(p, root)
311
+
312
+
313
+ def _resolve_relative_module(current_module: str, level: int, target: str) -> str:
314
+ """Resolve a relative import target to an absolute module name."""
315
+ if level == 0:
316
+ return target
317
+ parts = current_module.split(".")
318
+ if level > len(parts):
319
+ return ""
320
+ prefix = parts[:-level]
321
+ if target:
322
+ prefix.append(target)
323
+ return ".".join(prefix)
324
+
325
+
326
+ def _normalize_imported_symbols(
327
+ imported_symbols: dict[str, str],
328
+ tree: ast.AST,
329
+ module: str,
330
+ ) -> dict[str, str]:
331
+ """Convert relative import sources to absolute module names.
332
+
333
+ ``StructureExtractor`` stores relative imports without their leading dots.
334
+ Normalizing them here lets ``valid_imported_names`` validate them against
335
+ the project index without producing false positives on intra-package
336
+ imports.
337
+ """
338
+ normalized = dict(imported_symbols)
339
+ for node in ast.walk(tree):
340
+ if not isinstance(node, ast.ImportFrom):
341
+ continue
342
+ if node.level == 0:
343
+ continue
344
+ target = node.module or ""
345
+ abs_module = _resolve_relative_module(module, node.level, target)
346
+ if not abs_module:
347
+ continue
348
+ for alias in node.names:
349
+ if alias.name == "*":
350
+ # Restate a relative star import as an absolute one: drop the
351
+ # raw key the extractor wrote, add the resolved source.
352
+ normalized.pop(f"*{target}", None)
353
+ normalized[f"*{abs_module}"] = abs_module
354
+ continue
355
+ local = alias.asname or alias.name
356
+ normalized[local] = f"{abs_module}.{alias.name}"
357
+ return normalized
358
+
359
+
360
+ def _with_module_prefix(p: Path, root: Path, res: ParseResult) -> ParseResult:
361
+ """Return a copy of *res* whose symbol names are prefixed by their module."""
362
+ module = _module_name(p, root)
363
+ if not module:
364
+ return res
365
+ prefix = module + "."
366
+
367
+ def prefixed(d: dict[str, tuple[int, int]]) -> dict[str, tuple[int, int]]:
368
+ return {prefix + k if not k.startswith(prefix) else k: v for k, v in d.items()}
369
+
370
+ return ParseResult(
371
+ edges=res.edges,
372
+ func_defs=prefixed(res.func_defs),
373
+ class_defs=prefixed(res.class_defs),
374
+ var_defs=prefixed(res.var_defs),
375
+ imports=res.imports,
376
+ imported_symbols=res.imported_symbols,
377
+ exported_symbols={
378
+ prefix + s if not s.startswith(prefix) else s for s in (res.exported_symbols or set())
379
+ },
380
+ class_bases={
381
+ prefix + k if not k.startswith(prefix) else k: v
382
+ for k, v in (res.class_bases or {}).items()
383
+ },
384
+ )
385
+
386
+
387
+ def _parse_results_for(root: Path) -> dict[Path, ParseResult]:
388
+ """Parse every indexable *.py file under root and return a path -> result map."""
389
+ results: dict[Path, ParseResult] = {}
390
+ for p in iter_python_files(root):
391
+ try:
392
+ res = _extractor.parse_file(p)
393
+ results[p] = _with_module_prefix(p, root, res)
394
+ except Exception: # noqa: BLE001
395
+ continue
396
+ return results
397
+
398
+
399
+ def _normalize_code(text: str) -> str:
400
+ """Normalize code for similarity comparison."""
401
+ lines = text.splitlines()
402
+ out: list[str] = []
403
+ for line in lines:
404
+ stripped = line.strip()
405
+ if not stripped or stripped.startswith("#"):
406
+ continue
407
+ out.append(re.sub(r"\s+", " ", stripped))
408
+ return "\n".join(out)
409
+
410
+
411
+ def _build_call_graph(root: Path) -> CallGraph:
412
+ """Build a project-wide definition/reference graph from ASTs.
413
+
414
+ Reference ``context`` is recorded as the **module-qualified** enclosing
415
+ symbol name (e.g. ``pkg.core.main``) when derivable, so that callers of
416
+ ``explore_call_graph`` can match by the same key they pass in. The short
417
+ name (``main``) is still recorded as a secondary key for backwards
418
+ compatibility with short-name lookups.
419
+ """
420
+ graph = CallGraph()
421
+
422
+ for p, res in _parse_results_for(root).items():
423
+ # Build a lineno -> module-qualified enclosing context map for this file.
424
+ # Map from short name -> qualified name using the (already-prefixed)
425
+ # function/class defs returned by _parse_results_for.
426
+ module = _module_name(p, root)
427
+ qualified_by_short: dict[str, str] = {}
428
+ for qname in list(res.func_defs.keys()) + list(res.class_defs.keys()):
429
+ short = qname.split(".")[-1]
430
+ qualified_by_short.setdefault(short, qname)
431
+
432
+ # Definitions
433
+ for name, (start, end) in res.func_defs.items():
434
+ graph.definitions.setdefault(name, []).append((p, start + 1, end + 1, "function"))
435
+ for name, (start, end) in res.class_defs.items():
436
+ graph.definitions.setdefault(name, []).append((p, start + 1, end + 1, "class"))
437
+ for name, (start, end) in res.var_defs.items():
438
+ graph.definitions.setdefault(name, []).append((p, start + 1, end + 1, "variable"))
439
+
440
+ # AST-level references with enclosing context (module-qualified).
441
+ # The extractor's same-scope edges are intentionally not re-recorded
442
+ # here: the AST walk below already captures every Name/Attribute/
443
+ # Call reference (with context), and re-adding edges would duplicate
444
+ # caller entries with wrong (definition) line numbers.
445
+ imported = res.imported_symbols or {}
446
+ try:
447
+ tree = ast.parse(p.read_text(encoding="utf-8"), filename=str(p))
448
+ except SyntaxError:
449
+ continue
450
+
451
+ for node in ast.iter_child_nodes(tree):
452
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
453
+ ctx = qualified_by_short.get(node.name, node.name)
454
+ _record_reference(node, p, graph, ctx, imported, module)
455
+ _collect_refs_in(node, p, graph, ctx, qualified_by_short, imported, module)
456
+ else:
457
+ _record_reference(node, p, graph, module or None, imported, module)
458
+ _collect_refs_in(
459
+ node, p, graph, module or None, qualified_by_short, imported, module
460
+ )
461
+
462
+ return graph
463
+
464
+
465
+ def _collect_refs_in(
466
+ node: ast.AST,
467
+ p: Path,
468
+ graph: CallGraph,
469
+ context: str | None,
470
+ qualified_by_short: dict[str, str] | None = None,
471
+ imported: dict[str, str] | None = None,
472
+ module: str | None = None,
473
+ ) -> None:
474
+ """Collect call/name references inside *node*, attaching *context*.
475
+
476
+ When *qualified_by_short* is provided and a nested ``FunctionDef`` /
477
+ ``ClassDef`` is encountered, the context is updated to that node's
478
+ module-qualified name (looked up by its short name) so that references
479
+ inside a class method are attributed to ``pkg.Mod.Class.method`` rather
480
+ than to the enclosing class.
481
+ """
482
+ for child in ast.iter_child_nodes(node):
483
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
484
+ # Recurse with the child's qualified name as the new context.
485
+ if qualified_by_short is not None:
486
+ child_ctx = qualified_by_short.get(child.name)
487
+ if child_ctx is None:
488
+ # Build a dotted path from the current context.
489
+ child_ctx = f"{context}.{child.name}" if context else child.name
490
+ else:
491
+ child_ctx = child.name
492
+ _record_reference(child, p, graph, child_ctx, imported, module)
493
+ _collect_refs_in(child, p, graph, child_ctx, qualified_by_short, imported, module)
494
+ else:
495
+ _record_reference(child, p, graph, context, imported, module)
496
+ _collect_refs_in(child, p, graph, context, qualified_by_short, imported, module)
497
+
498
+
499
+ def _record_reference(
500
+ child: ast.AST,
501
+ p: Path,
502
+ graph: CallGraph,
503
+ context: str | None,
504
+ imported: dict[str, str] | None = None,
505
+ module: str | None = None,
506
+ ) -> None:
507
+ """Record a single AST node as a reference if it is a call/name/attribute.
508
+
509
+ When *imported* is given, the reference is additionally recorded under its
510
+ import-resolved qualified name — ``verify_tree`` written after ``from
511
+ honestcode.verify.verify import verify_tree`` is stored both as
512
+ ``verify_tree`` and as ``honestcode.verify.verify.verify_tree``. Without
513
+ the qualified form, definition-side keys (which are module-qualified) and
514
+ reference-side keys (which are written as in source) never met, and
515
+ ``find_dead_code`` reported live entry points as dead.
516
+ """
517
+ key: str | None = None
518
+ kind = "reference"
519
+ if isinstance(child, ast.Call):
520
+ key = call_name(child.func)
521
+ kind = "call"
522
+ elif isinstance(child, ast.Name):
523
+ if isinstance(child.ctx, ast.Store):
524
+ return
525
+ key = child.id
526
+ elif isinstance(child, ast.Attribute):
527
+ if isinstance(child.ctx, ast.Store):
528
+ return
529
+ key = call_name(child)
530
+ kind = "attribute"
531
+ if not key:
532
+ return
533
+ graph.references.setdefault(key, []).append((p, child.lineno, kind, context))
534
+ if imported is not None:
535
+ head, _, rest = key.partition(".")
536
+ src = imported.get(head)
537
+ # Only dotted absolute sources qualify; relative-import sources cannot
538
+ # be resolved reliably here (the leading dots are lost by the parser).
539
+ if src and "." in src:
540
+ qual = f"{src}.{rest}" if rest else src
541
+ if qual != key:
542
+ graph.references.setdefault(qual, []).append((p, child.lineno, kind, context))
543
+
544
+
545
+ # ── Tools ──────────────────────────────────────────────────────────────
546
+ def _invalidate_graph_cache() -> None:
547
+ """Drop the in-memory call-graph cache (after re-indexing)."""
548
+ with _state_lock:
549
+ _graph_mem_cache.clear()
550
+
551
+
552
+ def _get_call_graph(root: Path) -> CallGraph:
553
+ """Return a fresh call graph, reading from the SQLite cache when possible.
554
+
555
+ Falls back to a full AST rebuild and persists it to disk, so repeated graph
556
+ queries (call-graph, dead-code, impact, affected) stay fast on large repos.
557
+ """
558
+ with _state_lock:
559
+ cached = _graph_mem_cache.get(str(root))
560
+ if cached is not None:
561
+ return cached
562
+
563
+ cache = GraphCache.for_root(root, _cache_dir())
564
+ if cache.is_fresh(root):
565
+ loaded = cache.load()
566
+ if loaded is not None:
567
+ with _state_lock:
568
+ _graph_mem_cache[str(root)] = loaded
569
+ return loaded
570
+
571
+ graph = _build_call_graph(root)
572
+ try:
573
+ cache.save(graph, root)
574
+ except OSError:
575
+ logger.debug("Could not persist graph cache for %s", root)
576
+ with _state_lock:
577
+ _graph_mem_cache[str(root)] = graph
578
+ return graph
579
+
580
+
581
+ # One background watcher per watched root. A single global used to mean the
582
+ # second ``index_project(watch=True)`` silently went unwatched while still
583
+ # reporting ``watching: true``.
584
+ _watchers: dict[str, ProjectWatcher] = {}
585
+
586
+
587
+ def _ensure_watcher(root: Path) -> None:
588
+ """Start a background file watcher that auto-reindexes on changes."""
589
+ key = str(root)
590
+ with _state_lock:
591
+ if key in _watchers:
592
+ return
593
+
594
+ def _on_change(changed: list) -> None:
595
+ global _project_index, _project_root
596
+ idx = get_project_index(root, force_rebuild=False)
597
+ with _state_lock:
598
+ _index_by_root[key] = idx
599
+ # The watched root only becomes "the" project when the session is
600
+ # not already anchored elsewhere; multi-root sessions keep the
601
+ # last explicitly indexed project for the graph tools.
602
+ if _project_root is None or Path(_project_root) == root:
603
+ _project_index = idx
604
+ _project_root = root
605
+ _invalidate_graph_cache()
606
+ logger.info("Watcher: re-indexed after %d file changes", len(changed))
607
+
608
+ watcher = ProjectWatcher(root, _on_change).start()
609
+ with _state_lock:
610
+ _watchers[key] = watcher
611
+
612
+
613
+ def stop_watching() -> dict:
614
+ """Stop every background file watcher, if any."""
615
+ with _state_lock:
616
+ if not _watchers:
617
+ return {"success": True, "watching": False}
618
+ watchers = list(_watchers.values())
619
+ _watchers.clear()
620
+ for watcher in watchers:
621
+ watcher.stop()
622
+ return {"success": True, "watching": False}
623
+
624
+
625
+ def index_project(root_path: str, force_rebuild: bool = False, watch: bool = False) -> dict:
626
+ """Build (or reuse) the symbol index for a project directory."""
627
+ global _project_index, _project_root
628
+ root = Path(root_path)
629
+ if not root.exists():
630
+ return {"success": False, "error": f"Path does not exist: {root_path}"}
631
+ resolved = root.resolve()
632
+ idx = get_project_index(resolved, force_rebuild=force_rebuild)
633
+ with _state_lock:
634
+ _project_root = resolved
635
+ _project_index = idx
636
+ _index_by_root[str(resolved)] = idx
637
+ _invalidate_graph_cache()
638
+ if watch:
639
+ _ensure_watcher(resolved)
640
+ return {
641
+ "success": True,
642
+ "symbols_indexed": len(idx.symbols),
643
+ "root": str(resolved),
644
+ "cached": idx.cached,
645
+ "watching": str(resolved) in _watchers,
646
+ }
647
+
648
+
649
+ def load_project_deps(root_path: str, import_packages: bool = True) -> dict:
650
+ """Load dependency APIs declared in requirements.txt / pyproject.toml.
651
+
652
+ With ``import_packages=False`` the declared packages are only *registered*
653
+ (their imports become trusted) without importing them — useful for
654
+ offline/hermetic analysis and benchmarks, where installing or importing
655
+ every dependency is undesirable. Member-level invented-API checks stay
656
+ silent for packages that were declared but not imported.
657
+ """
658
+ root = Path(root_path)
659
+ loaded: list[str] = []
660
+ for candidate in ("requirements.txt", "pyproject.toml"):
661
+ f = root / candidate
662
+ if not f.exists():
663
+ continue
664
+ pkgs = _parse_requires(f)
665
+ for pkg in pkgs:
666
+ try:
667
+ if not import_packages:
668
+ _dep_kb.declare_package(pkg)
669
+ loaded.append(pkg)
670
+ continue
671
+ n = _dep_kb.load_package(pkg)
672
+ if n:
673
+ loaded.append(pkg)
674
+ except Exception: # noqa: BLE001
675
+ logger.debug("Failed to load package %s", pkg)
676
+ return {"success": True, "packages_loaded": loaded, "total_apis": len(_dep_kb.all_names())}
677
+
678
+
679
+ def _parse_requires(f: Path) -> list[str]:
680
+ text = f.read_text(encoding="utf-8")
681
+ pkgs: list[str] = []
682
+ if f.name == "requirements.txt":
683
+ for line in text.splitlines():
684
+ line = line.strip()
685
+ if not line or line.startswith("#"):
686
+ continue
687
+ # Skip pip options / VCS installs.
688
+ if line.startswith("-") or line.startswith("http://") or line.startswith("https://"):
689
+ continue
690
+ m = re.match(r"^([A-Za-z0-9_.\-]+)", line)
691
+ if m:
692
+ pkgs.append(m.group(1))
693
+ else: # pyproject.toml
694
+ pkgs.extend(_parse_pyproject_deps(text))
695
+ return pkgs
696
+
697
+
698
+ def _parse_pyproject_deps(text: str) -> list[str]:
699
+ """Extract package names from pyproject.toml, preferring TOML parsing."""
700
+ try:
701
+ try:
702
+ import tomllib # Python 3.11+
703
+ except ImportError:
704
+ import tomli as tomllib # type: ignore[no-redef]
705
+ data = tomllib.loads(text)
706
+ deps: list[str] = []
707
+ for key in ("project.dependencies", "tool.poetry.dependencies"):
708
+ parts = key.split(".")
709
+ cur = data
710
+ for part in parts:
711
+ cur = cur.get(part, {})
712
+ if isinstance(cur, list):
713
+ deps.extend(_strip_version_spec(d) for d in cur if isinstance(d, str))
714
+ elif isinstance(cur, dict):
715
+ # Poetry declares dependencies as {name: version} pairs.
716
+ deps.extend(k for k in cur if isinstance(k, str))
717
+ # Optional groups are still declared project dependencies: the project
718
+ # may legitimately import them (HonestCode's own optional integrations
719
+ # are the motivating case). Trusting the declaration silences the
720
+ # import; member surfaces remain silent until the package is loaded.
721
+ optional = data.get("project", {}).get("optional-dependencies", {})
722
+ if isinstance(optional, dict):
723
+ for group in optional.values():
724
+ if isinstance(group, list):
725
+ deps.extend(_strip_version_spec(d) for d in group if isinstance(d, str))
726
+ return deps
727
+ except Exception: # noqa: BLE001
728
+ pass
729
+
730
+ # Fallback regex for quoted / unquoted declarations.
731
+ pkgs: list[str] = []
732
+ for line in text.splitlines():
733
+ line = line.strip()
734
+ if not line or line.startswith("#"):
735
+ continue
736
+ # "name>=1.0" or name = ">=1.0"
737
+ m = re.match(r'^["\']?([A-Za-z0-9_.\-]+)["\']?\s*(?:[<>=!~^]=?|$|\s)', line)
738
+ if m:
739
+ pkgs.append(m.group(1))
740
+ return pkgs
741
+
742
+
743
+ def _strip_version_spec(dep: str) -> str:
744
+ """Return the package name part of a dependency specifier."""
745
+ dep = dep.strip()
746
+ m = re.match(r"^([A-Za-z0-9_.\-]+)", dep)
747
+ return m.group(1) if m else dep
748
+
749
+
750
+ def check_symbol(symbol_name: str, file_path: str | None = None) -> dict:
751
+ """Check whether a symbol is defined in the project.
752
+
753
+ When *file_path* is supplied, the symbol must be defined in that file: the
754
+ parameter used to be accepted and then ignored, so a caller could not ask
755
+ the narrower question it promises.
756
+ """
757
+ with _state_lock:
758
+ idx = _project_index
759
+ if idx is None:
760
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
761
+
762
+ info = idx.symbols.get(symbol_name)
763
+ if file_path:
764
+ root = Path(idx.root)
765
+ candidate = Path(file_path)
766
+ try:
767
+ rel = candidate.resolve().relative_to(root.resolve()).as_posix()
768
+ except (OSError, ValueError):
769
+ rel = str(candidate).replace("\\", "/")
770
+ info_in_file = [
771
+ symbol_info
772
+ for name, symbol_info in idx.symbols.items()
773
+ if symbol_info.file == rel and (name == symbol_name or name.endswith(f".{symbol_name}"))
774
+ ]
775
+ # A short name is matched by its qualified suffix, so the file filter
776
+ # has to run even when the bare name itself was not an index key.
777
+ info = info_in_file[0] if info_in_file else None
778
+
779
+ return {
780
+ "success": True,
781
+ "symbol": symbol_name,
782
+ "defined": info is not None,
783
+ "location": {"file": info.file, "line": info.line} if info else None,
784
+ **({"file": file_path} if file_path else {}),
785
+ }
786
+
787
+
788
+ def _is_stdlib_root(name: str) -> bool:
789
+ """Whether *name* is a standard-library root that really is the stdlib.
790
+
791
+ ``sys.stdlib_module_names`` alone is not enough: a workspace file called
792
+ ``math.py`` shadows the real module for imports from the server's cwd, so
793
+ the spec's origin must sit inside the interpreter's stdlib directory (or
794
+ the module must be baked in) before importing it is allowed.
795
+ """
796
+ if name not in stdlib_roots():
797
+ return False
798
+ try:
799
+ spec = importlib.util.find_spec(name)
800
+ except Exception: # noqa: BLE001 - a broken spec is not a stdlib module
801
+ return False
802
+ if spec is None:
803
+ return False
804
+ origin = getattr(spec, "origin", None)
805
+ if origin in (None, "built-in", "frozen"):
806
+ # A workspace directory can shadow a stdlib name as a namespace
807
+ # package (origin None); only genuinely built-in/frozen modules are
808
+ # safe to trust in that case. "built-in"/"frozen" cannot be shadowed.
809
+ return origin is not None or name in sys.builtin_module_names
810
+ stdlib_dir = Path(sysconfig.get_paths()["stdlib"]).resolve()
811
+ try:
812
+ return Path(origin).resolve().is_relative_to(stdlib_dir)
813
+ except (OSError, ValueError):
814
+ return False
815
+
816
+
817
+ def _installed_import_root(name: str) -> bool:
818
+ """Whether *name* is an installed distribution or a real import root.
819
+
820
+ ``check_api`` imports the base package on demand to answer a CLI caller
821
+ who cannot run ``load-package`` first. Importing executes code, so the
822
+ gate must not open for a bare file in the workspace: an agent-written
823
+ ``evil_payload.py`` next to the server's cwd is importable via ``sys.path``
824
+ but is not an installed distribution, and importing it would run
825
+ agent-authored code inside the server process. Distribution metadata is
826
+ read without executing anything.
827
+ """
828
+ if name in _dep_kb.roots() or _is_stdlib_root(name):
829
+ return True
830
+ try:
831
+ importlib.metadata.distribution(name)
832
+ return True
833
+ except Exception: # noqa: BLE001 - not installed under this spelling
834
+ pass
835
+ try:
836
+ return name in importlib.metadata.packages_distributions()
837
+ except Exception: # noqa: BLE001 - metadata unavailable
838
+ return False
839
+
840
+
841
+ def check_api(api_name: str) -> dict:
842
+ """Verify a library API call is correct (e.g. pd.read_exel -> read_excel)."""
843
+ if _dep_kb.has(api_name):
844
+ return {"success": True, "api": api_name, "valid": True}
845
+
846
+ base = api_name.split(".")[0]
847
+ dep_names = _dep_kb.all_names()
848
+ base_known = _dep_kb.has(base) or any(k.startswith(base + ".") for k in dep_names)
849
+ if not base_known and _installed_import_root(base):
850
+ # A CLI invocation is a fresh process: the user cannot first run
851
+ # ``load-package`` and then ``check-api``. Load the base package on
852
+ # demand — but only when it is genuinely installed (or already
853
+ # declared); an uninstalled name must not be imported, or a file the
854
+ # agent dropped into the workspace gets executed server-side.
855
+ with suppress(Exception): # an absent package is a valid answer
856
+ _dep_kb.load_package(base)
857
+ dep_names = _dep_kb.all_names()
858
+ base_known = _dep_kb.has(base) or any(k.startswith(base + ".") for k in dep_names)
859
+ if not base_known:
860
+ return {
861
+ "success": True,
862
+ "api": api_name,
863
+ "valid": False,
864
+ "reason": "unknown module",
865
+ "suggestion": [],
866
+ }
867
+
868
+ members = [k for k in dep_names if k.startswith(base + ".")]
869
+ attr_part = api_name.split(".")[-1]
870
+ exact = [m for m in members if m == api_name]
871
+ if exact:
872
+ return {"success": True, "api": api_name, "valid": True}
873
+
874
+ close = [m for m in members if m.split(".")[-1] == attr_part]
875
+ if close:
876
+ return {
877
+ "success": True,
878
+ "api": api_name,
879
+ "valid": False,
880
+ "reason": "Attribute not found on module",
881
+ "suggestion": close[:3],
882
+ }
883
+
884
+ from difflib import get_close_matches
885
+
886
+ fuzzy = get_close_matches(api_name, members, n=3, cutoff=0.6)
887
+ return {
888
+ "success": True,
889
+ "api": api_name,
890
+ "valid": False,
891
+ "reason": "Attribute not found on module",
892
+ "suggestion": fuzzy if fuzzy else members[:3],
893
+ }
894
+
895
+
896
+ def execute_code(code: str, prelude: str = "", known_names: list[str] | None = None) -> dict:
897
+ """Execute code in a sandboxed subprocess and return structured results."""
898
+ result = _sandbox.execute(code=code, prelude=prelude, known_names=set(known_names or []))
899
+ return result.to_dict()
900
+
901
+
902
+ def _collect_local_defined(tree: ast.AST) -> set[str]:
903
+ """Collect locally defined names (functions, classes, vars, parameters).
904
+
905
+ Includes parameters and assignment targets so that ``self.helper()`` or
906
+ ``client.fetch()`` are not flagged when ``self`` / ``client`` are bound
907
+ in the enclosing scope. Names bound by ``with ... as``, ``except ... as``,
908
+ lambda parameters, comprehension targets and ``global``/``nonlocal``
909
+ declarations count too — missing them reported ``fn()`` under
910
+ ``with Ctx() as fn:`` as an undefined symbol.
911
+ """
912
+ defined: set[str] = set()
913
+ for node in ast.walk(tree):
914
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
915
+ defined.add(node.name)
916
+ _collect_arg_names(node.args, defined)
917
+ elif isinstance(node, ast.Lambda):
918
+ _collect_arg_names(node.args, defined)
919
+ elif isinstance(node, ast.ClassDef):
920
+ defined.add(node.name)
921
+ elif isinstance(node, ast.Assign):
922
+ for target in node.targets:
923
+ _collect_assign_targets(target, defined)
924
+ elif isinstance(node, (ast.AnnAssign, ast.AugAssign)) and isinstance(node.target, ast.Name):
925
+ defined.add(node.target.id)
926
+ elif isinstance(node, (ast.For, ast.AsyncFor)):
927
+ _collect_assign_targets(node.target, defined)
928
+ elif isinstance(node, (ast.With, ast.AsyncWith)):
929
+ for item in node.items:
930
+ if item.optional_vars is not None:
931
+ _collect_assign_targets(item.optional_vars, defined)
932
+ elif isinstance(node, ast.ExceptHandler):
933
+ if node.name:
934
+ defined.add(node.name)
935
+ elif isinstance(node, ast.comprehension):
936
+ _collect_assign_targets(node.target, defined)
937
+ elif isinstance(node, (ast.Global, ast.Nonlocal)):
938
+ defined.update(node.names)
939
+ elif isinstance(node, ast.NamedExpr): # walrus :=
940
+ if isinstance(node.target, ast.Name):
941
+ defined.add(node.target.id)
942
+ return defined
943
+
944
+
945
+ def _collect_arg_names(args: ast.arguments, defined: set[str]) -> None:
946
+ for a in args.args + args.posonlyargs + args.kwonlyargs:
947
+ if a.arg:
948
+ defined.add(a.arg)
949
+ if args.vararg:
950
+ defined.add(args.vararg.arg)
951
+ if args.kwarg:
952
+ defined.add(args.kwarg.arg)
953
+
954
+
955
+ def _collect_assign_targets(target: ast.expr, defined: set[str]) -> None:
956
+ if isinstance(target, ast.Name):
957
+ defined.add(target.id)
958
+ elif isinstance(target, (ast.Tuple, ast.List)):
959
+ for elt in target.elts:
960
+ _collect_assign_targets(elt, defined)
961
+ elif isinstance(target, ast.Starred):
962
+ _collect_assign_targets(target.value, defined)
963
+
964
+
965
+ def _builtin_names() -> set[str]:
966
+ """Return the full set of Python builtin names (functions + exceptions)."""
967
+ import builtins
968
+
969
+ return set(dir(builtins))
970
+
971
+
972
+ # Per-language aids for the heuristic non-Python scan: builtins that are legal
973
+ # to call without a definition in the scanned file, and regexes that surface
974
+ # names the file imports or declares. Without these, every cross-module call
975
+ # was reported as "undefined" — pure noise that violated the project's
976
+ # silence-beats-noise rule.
977
+ _LANG_BUILTINS: dict[str, set[str]] = {
978
+ "javascript": {
979
+ "require",
980
+ "setTimeout",
981
+ "setInterval",
982
+ "clearTimeout",
983
+ "clearInterval",
984
+ "queueMicrotask",
985
+ "structuredClone",
986
+ "fetch",
987
+ "parseInt",
988
+ "parseFloat",
989
+ "isNaN",
990
+ "isFinite",
991
+ "encodeURIComponent",
992
+ "decodeURIComponent",
993
+ "Promise",
994
+ "URL",
995
+ "URLSearchParams",
996
+ "AbortController",
997
+ "TextEncoder",
998
+ "TextDecoder",
999
+ "Proxy",
1000
+ "Reflect",
1001
+ "Symbol",
1002
+ "BigInt",
1003
+ "Date",
1004
+ "RegExp",
1005
+ },
1006
+ "go": {
1007
+ "len",
1008
+ "cap",
1009
+ "make",
1010
+ "new",
1011
+ "append",
1012
+ "copy",
1013
+ "delete",
1014
+ "panic",
1015
+ "recover",
1016
+ "print",
1017
+ "println",
1018
+ "close",
1019
+ "min",
1020
+ "max",
1021
+ "clear",
1022
+ },
1023
+ "rust": {
1024
+ "println",
1025
+ "print",
1026
+ "eprintln",
1027
+ "eprint",
1028
+ "format",
1029
+ "vec",
1030
+ "panic",
1031
+ "assert",
1032
+ "assert_eq",
1033
+ "assert_ne",
1034
+ "write",
1035
+ "writeln",
1036
+ "todo",
1037
+ "unimplemented",
1038
+ "matches",
1039
+ "dbg",
1040
+ "env",
1041
+ "concat",
1042
+ "stringify",
1043
+ "include_str",
1044
+ "include_bytes",
1045
+ "cfg",
1046
+ },
1047
+ }
1048
+ _LANG_BUILTINS["typescript"] = _LANG_BUILTINS["javascript"]
1049
+ _LANG_BUILTINS["tsx"] = _LANG_BUILTINS["javascript"]
1050
+
1051
+ _LANG_IMPORT_PATTERNS: dict[str, list[str]] = {
1052
+ # Each pattern's group(s) yield imported local names.
1053
+ "javascript": [
1054
+ r"import\s*\{([^}]+)\}\s*from",
1055
+ r"import\s*\*\s*as\s+(\w+)",
1056
+ r"import\s+(\w+)\s*(?:,|from)",
1057
+ r"(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(",
1058
+ r"(?:const|let|var)\s+(\w+)\s*=\s*require\(",
1059
+ ],
1060
+ "go": [
1061
+ r'import\s+(?:\w+\s+)?"([^"]+)"',
1062
+ r'^\s*(?:\w+\s+)?"([^"]+)"',
1063
+ ],
1064
+ "rust": [
1065
+ r"use\s+[\w:]*::\{([^}]+)\}",
1066
+ r"use\s+([\w:]+)",
1067
+ ],
1068
+ "java": [
1069
+ r"import\s+(?:static\s+)?([\w.]+)\s*;",
1070
+ ],
1071
+ }
1072
+ _LANG_IMPORT_PATTERNS["typescript"] = _LANG_IMPORT_PATTERNS["javascript"]
1073
+ _LANG_IMPORT_PATTERNS["tsx"] = _LANG_IMPORT_PATTERNS["javascript"]
1074
+
1075
+ # Language keywords / soft keywords that syntactically look like calls.
1076
+ _LANG_KEYWORDS: set[str] = {
1077
+ "if",
1078
+ "for",
1079
+ "while",
1080
+ "switch",
1081
+ "catch",
1082
+ "function",
1083
+ "new",
1084
+ "typeof",
1085
+ "return",
1086
+ "else",
1087
+ "do",
1088
+ "try",
1089
+ "throw",
1090
+ "case",
1091
+ "await",
1092
+ "async",
1093
+ "yield",
1094
+ "delete",
1095
+ "void",
1096
+ "instanceof",
1097
+ "super",
1098
+ "func",
1099
+ "go",
1100
+ "defer",
1101
+ "select",
1102
+ "fn",
1103
+ "match",
1104
+ "loop",
1105
+ "unsafe",
1106
+ "impl",
1107
+ "dyn",
1108
+ "move",
1109
+ "synchronized",
1110
+ "assert",
1111
+ }
1112
+
1113
+ # Fallback declaration patterns for names tree-sitter may not surface
1114
+ # (variables, Go receivers, Java members) — best-effort, silence-biased.
1115
+ _LANG_DECL_PATTERNS: dict[str, list[str]] = {
1116
+ "javascript": [
1117
+ r"\b(?:const|let|var)\s+(\w+)",
1118
+ r"\bfunction\s*\*?\s*(\w+)",
1119
+ r"\bclass\s+(\w+)",
1120
+ ],
1121
+ "go": [
1122
+ r"func\s+(?:\([^)]*\)\s*)?(\w+)\s*",
1123
+ r"type\s+(\w+)\s+",
1124
+ r"(?:^|\n)\s*(?:var|const)\s+(\w+)",
1125
+ ],
1126
+ "rust": [r"\blet\s+(?:mut\s+)?(\w+)", r"\b(?:static|const)\s+(\w+)"],
1127
+ "java": [
1128
+ r"\b(?:class|interface|enum)\s+(\w+)",
1129
+ r"\b(?:public|protected|private)\s+[\w<>\[\],.\s]+?\s+(\w+)\s*\([^;)]*\)\s*[;{]",
1130
+ ],
1131
+ }
1132
+ _LANG_DECL_PATTERNS["typescript"] = _LANG_DECL_PATTERNS["javascript"]
1133
+ _LANG_DECL_PATTERNS["tsx"] = _LANG_DECL_PATTERNS["javascript"]
1134
+
1135
+
1136
+ def _scan_non_python(p: Path) -> dict:
1137
+ """Scan a non-Python file via the optional tree-sitter multi-language path.
1138
+
1139
+ This path is heuristic by nature: a call is reported only when its name is
1140
+ neither defined in nor imported by the file (and is not a language builtin
1141
+ or keyword), and every finding is marked ``confidence: low`` so agents and
1142
+ humans treat it as a pointer, not a verdict.
1143
+ """
1144
+ from honestcode.structure import multi_lang
1145
+
1146
+ lang = multi_lang.detect_language(p)
1147
+ if lang is None:
1148
+ return {
1149
+ "success": False,
1150
+ "error": f"Unsupported language for {p.suffix or 'file'}. "
1151
+ "Python is fully supported; other languages need the "
1152
+ "multi-language extra: pip install -e '.[multi-language]'.",
1153
+ }
1154
+ try:
1155
+ symbols = multi_lang.extract_symbols(p)
1156
+ except multi_lang.TreeSitterUnavailable as exc:
1157
+ return {
1158
+ "success": False,
1159
+ "error": f"{exc}. Language detected: {lang}. Run: pip install -e '.[multi-language]'",
1160
+ }
1161
+ except Exception as exc: # noqa: BLE001
1162
+ return {"success": False, "error": f"multi-language scan failed: {exc}"}
1163
+ try:
1164
+ # Heuristic regex scan: replacement characters only cost a missed match,
1165
+ # whereas a hard UnicodeDecodeError would take the whole tool down.
1166
+ source = p.read_text(encoding="utf-8", errors="replace")
1167
+ except OSError as exc:
1168
+ return {"success": False, "error": f"cannot read file: {exc}"}
1169
+
1170
+ defined = {s["name"] for s in symbols}
1171
+
1172
+ # Imported names: the last segment of the import target (plus, for
1173
+ # multi-name forms, every declared local).
1174
+ import_name_group = _LANG_IMPORT_PATTERNS.get(lang, [])
1175
+ for pat in import_name_group:
1176
+ for m in re.finditer(pat, source, re.MULTILINE):
1177
+ for group in m.groups():
1178
+ if not group:
1179
+ continue
1180
+ for part in group.split(","):
1181
+ part = part.strip().split(" as ")[-1].strip()
1182
+ if part:
1183
+ defined.add(part.rsplit(":", 1)[-1].rsplit(".", 1)[-1])
1184
+ # `use std::io;` / `import a.b.C;` — the tail is the used name.
1185
+ tail = m.group(0).rstrip(";").split()[-1].strip("\"';,()")
1186
+ if tail and re.fullmatch(r"[\w:./-]+", tail):
1187
+ defined.add(tail.rsplit(":", 1)[-1].rsplit(".", 1)[-1])
1188
+
1189
+ for pat in _LANG_DECL_PATTERNS.get(lang, []):
1190
+ for m in re.finditer(pat, source, re.MULTILINE):
1191
+ for group in m.groups():
1192
+ if group and group.isidentifier():
1193
+ defined.add(group)
1194
+
1195
+ builtins = _LANG_BUILTINS.get(lang, set())
1196
+ keywords = _LANG_KEYWORDS
1197
+
1198
+ findings: list[Finding] = []
1199
+ # Negative lookbehind skips attribute calls (``obj.method(``), which
1200
+ # cannot be judged statically; keyword set covers language constructs
1201
+ # that look like calls (``new Foo(``, ``require(``, ...).
1202
+ for m in re.finditer(r"(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(", source):
1203
+ name = m.group(1)
1204
+ if name in defined or name in builtins or name in keywords:
1205
+ continue
1206
+ findings.append(
1207
+ Finding(
1208
+ file=str(p),
1209
+ line=source[: m.start()].count("\n") + 1,
1210
+ kind=KIND_UNDEFINED_SYMBOL,
1211
+ symbol=name,
1212
+ message=(
1213
+ f"`{name}` is not defined or imported in this file "
1214
+ f"(heuristic {lang} scan — inspect before changing)."
1215
+ ),
1216
+ evidence={"language": lang, "heuristic": True},
1217
+ confidence=CONFIDENCE_LOW,
1218
+ action=ACTION_INSPECT,
1219
+ )
1220
+ )
1221
+
1222
+ report = build_report(file=str(p), findings=findings, checked=0)
1223
+ report["language"] = lang
1224
+ report["defined_symbols"] = len(defined)
1225
+ return report
1226
+
1227
+
1228
+ def scan_file(file_path: str) -> dict:
1229
+ """Scan a file for potential hallucinations: undefined symbols and invented APIs."""
1230
+ p = Path(file_path)
1231
+ if not p.exists():
1232
+ return {"success": False, "error": f"File not found: {file_path}"}
1233
+ if p.suffix.lower() != ".py":
1234
+ return _scan_non_python(p)
1235
+ try:
1236
+ res = _extractor.parse_file(p)
1237
+ except Exception as e: # noqa: BLE001
1238
+ return {"success": False, "error": str(e)}
1239
+
1240
+ # Ground the file against a FRESH index of its own project. Most agents
1241
+ # call scan_file on a freshly-written path without remembering to call
1242
+ # index_project first; resolving per-file also keeps a session that touches
1243
+ # several projects from grounding one with another's index.
1244
+ idx = _index_for_file(p)
1245
+ if idx is not None:
1246
+ _ensure_deps_for(Path(idx.root))
1247
+ else:
1248
+ fallback_root = _find_project_root(p)
1249
+ if fallback_root is not None:
1250
+ _ensure_deps_for(fallback_root)
1251
+
1252
+ with _state_lock:
1253
+ dep_names = _dep_kb.all_names()
1254
+ dep_roots = _dep_kb.roots() | {n.split(".", 1)[0] for n in dep_names} | stdlib_roots()
1255
+ dep_enumerated = _dep_kb.enumerated_modules()
1256
+
1257
+ source = read_python_source(p)
1258
+ if source is None:
1259
+ return {"success": False, "error": f"cannot read file: {p}"}
1260
+ try:
1261
+ tree = ast.parse(source, filename=str(p))
1262
+ except SyntaxError as e:
1263
+ # A syntax error is itself a verification finding, not a tool failure:
1264
+ # the agent needs the structured protocol (status/findings/text) so it
1265
+ # can act on it like any other kind.
1266
+ finding = Finding(
1267
+ file=str(p),
1268
+ line=e.lineno or 1,
1269
+ kind=KIND_SYNTAX_ERROR,
1270
+ symbol="<syntax>",
1271
+ message=f"SyntaxError: {e.msg}",
1272
+ evidence={"offset": e.offset or 0},
1273
+ action=ACTION_INSPECT,
1274
+ )
1275
+ report = build_report(file=str(p), findings=[finding], checked=0)
1276
+ report["defined_symbols"] = 0
1277
+ return report
1278
+
1279
+ # Locally defined names (functions/classes/vars + parameters + loop targets).
1280
+ defined = _collect_local_defined(tree)
1281
+ # Module-qualified project symbols.
1282
+ if idx is not None:
1283
+ defined |= set(idx.symbols.keys())
1284
+ # Names introduced by import statements, validated against the index and
1285
+ # the loaded dependency API knowledge base.
1286
+ module = _module_name(p, Path(idx.root)) if idx is not None else p.stem
1287
+ imported_symbols = _normalize_imported_symbols(res.imported_symbols or {}, tree, module)
1288
+ if idx is not None:
1289
+ # A monorepo sub-project writes absolute imports meaning its own
1290
+ # package (``from pkg.foo import Thing`` under ``services/web/``);
1291
+ # against the repo-rooted index those names only exist one package
1292
+ # level up. Re-point them so valid imports are not hallucinations.
1293
+ imported_symbols = resolve_package_relative_imports(imported_symbols, idx, module)
1294
+ defined |= valid_imported_names(imported_symbols, idx, dep_names, dep_roots)
1295
+ # A star import whose source cannot be enumerated may have bound any name;
1296
+ # unproven bare calls in this file must then stay silent.
1297
+ star_open = star_imports_unresolved(imported_symbols, idx)
1298
+ # Dependency API names.
1299
+ defined |= dep_names
1300
+ # Full builtin set instead of a hand-picked subset.
1301
+ defined |= _builtin_names()
1302
+
1303
+ findings, checked = verify_tree(
1304
+ tree,
1305
+ path=p,
1306
+ root=Path(idx.root) if idx is not None else None,
1307
+ index=idx,
1308
+ defined=defined,
1309
+ dep_names=dep_names,
1310
+ imported_symbols=imported_symbols,
1311
+ dep_roots=dep_roots,
1312
+ dep_enumerated=dep_enumerated,
1313
+ star_open=star_open,
1314
+ )
1315
+ report = build_report(file=str(p), findings=findings, checked=checked)
1316
+ report["defined_symbols"] = len(defined)
1317
+ return report
1318
+
1319
+
1320
+ def verify_file(file_path: str) -> dict:
1321
+ """Verify a file and return structured, agent-readable evidence.
1322
+
1323
+ Unlike ``scan_file`` (which also returns legacy ``issues``), this tool
1324
+ exposes only the agent-facing protocol: ``status``, ``findings`` with
1325
+ ``evidence``, and a human-readable ``text`` summary.
1326
+ """
1327
+ report = scan_file(file_path)
1328
+ if not report.get("success", True):
1329
+ return report
1330
+ result = {
1331
+ "success": True,
1332
+ "status": report["status"],
1333
+ "file": report["file"],
1334
+ "findings": report["findings"],
1335
+ "summary": report["summary"],
1336
+ "text": render_text(report),
1337
+ }
1338
+ for key in ("language", "defined_symbols"):
1339
+ if key in report:
1340
+ result[key] = report[key]
1341
+ return result
1342
+
1343
+
1344
+ def load_package_apis(package_name: str) -> dict:
1345
+ """Load (and cache) API signatures for a specific package."""
1346
+ count = _dep_kb.load_package(package_name)
1347
+ return {"success": True, "package": package_name, "api_count": count}
1348
+
1349
+
1350
+ def reset_dependency_state() -> dict:
1351
+ """Forget every loaded dependency API and declared root.
1352
+
1353
+ Used by tests and the accuracy benchmark to isolate scenarios; a live MCP
1354
+ session has no reason to call this.
1355
+ """
1356
+ _dep_kb.reset()
1357
+ with _state_lock:
1358
+ _deps_stamp_by_root.clear()
1359
+ return {"success": True}
1360
+
1361
+
1362
+ def get_project_stats() -> dict:
1363
+ """Return statistics about the current index."""
1364
+ with _state_lock:
1365
+ idx = _project_index
1366
+ if idx is None:
1367
+ return {"success": False, "error": "Project not indexed."}
1368
+ return {
1369
+ "success": True,
1370
+ "symbols": len(idx.symbols),
1371
+ "dependency_apis": len(_dep_kb.all_names()),
1372
+ "indexed": idx is not None,
1373
+ }
1374
+
1375
+
1376
+ def validate_types(code: str) -> dict:
1377
+ """Run a lightweight AST-based type check to catch obvious mismatches."""
1378
+ try:
1379
+ tree = ast.parse(code)
1380
+ except SyntaxError as e:
1381
+ return {"success": False, "error": f"SyntaxError: {e}"}
1382
+
1383
+ issues: list[dict] = []
1384
+
1385
+ for node in ast.walk(tree):
1386
+ # 1. None iteration
1387
+ if isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)):
1388
+ _check_none_iterable(node.iter, issues)
1389
+ if isinstance(node, ast.Compare):
1390
+ for op, comparator in zip(node.ops, node.comparators, strict=True):
1391
+ if isinstance(op, ast.In):
1392
+ _check_none_iterable(comparator, issues)
1393
+
1394
+ # 2. Calling a constant literal
1395
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Constant):
1396
+ issues.append(
1397
+ {
1398
+ "type": "non_callable",
1399
+ "message": "Attempting to call a constant value",
1400
+ "line": node.lineno,
1401
+ }
1402
+ )
1403
+
1404
+ # 3. String method on non-string constant
1405
+ if (
1406
+ isinstance(node, ast.Attribute)
1407
+ and node.attr in {"upper", "lower", "strip", "split", "join", "startswith", "endswith"}
1408
+ and isinstance(node.value, ast.Constant)
1409
+ and not isinstance(node.value.value, str)
1410
+ ):
1411
+ issues.append(
1412
+ {
1413
+ "type": "type_mismatch",
1414
+ "message": f"'{node.attr}' called on non-string constant",
1415
+ "line": node.lineno,
1416
+ }
1417
+ )
1418
+
1419
+ # 4. Common builtins with wrong argument count
1420
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
1421
+ _check_builtin_argc(node, issues)
1422
+
1423
+ return {"success": True, "issues": issues, "note": "AST structural check complete"}
1424
+
1425
+
1426
+ def _check_none_iterable(node: ast.expr, issues: list[dict]) -> None:
1427
+ """Flag iterating over a None constant or expressions likely to return None.
1428
+
1429
+ Detects:
1430
+ - Explicit ``None`` literal
1431
+ - ``dict.get(...)`` / ``dict.pop(...)`` without a default value
1432
+ - Call to functions that commonly return None (e.g. ``os.environ.get``)
1433
+ """
1434
+ if isinstance(node, ast.Constant) and node.value is None:
1435
+ issues.append(
1436
+ {
1437
+ "type": "none_iteration",
1438
+ "message": "Attempting to iterate over None",
1439
+ "line": getattr(node, "lineno", 0),
1440
+ }
1441
+ )
1442
+ return
1443
+
1444
+ # dict.get(key) without default returns None.
1445
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
1446
+ method = node.func.attr
1447
+ if (
1448
+ method in ("get", "pop")
1449
+ and len(node.args) < 2
1450
+ and not any(kw.arg == "default" for kw in node.keywords)
1451
+ ):
1452
+ issues.append(
1453
+ {
1454
+ "type": "none_iteration",
1455
+ "message": f"'{method}' without default may return None",
1456
+ "line": getattr(node, "lineno", 0),
1457
+ }
1458
+ )
1459
+
1460
+
1461
+ def _check_builtin_argc(node: ast.Call, issues: list[dict]) -> None:
1462
+ """Check argument counts for a small set of common builtins.
1463
+
1464
+ Rules are (min_positional, max_positional, allowed_keyword_names).
1465
+ ``None`` means unbounded.
1466
+ """
1467
+ name = node.func.id
1468
+ args = len(node.args)
1469
+ kwargs = {kw.arg for kw in node.keywords if kw.arg is not None} # exclude **kwargs
1470
+ # ``str(object='', encoding=..., errors=...)`` accepts three positionals
1471
+ # (``str(b'x', 'utf-8', 'strict')``) and the ``object`` keyword. ``max`` /
1472
+ # ``min`` take ``key`` and ``default`` as keywords, and ``sorted``'s
1473
+ # ``key``/``reverse`` are keyword-only — the previous table inverted all
1474
+ # three and flagged valid calls.
1475
+ rules = {
1476
+ "len": (1, 1, set()),
1477
+ "repr": (1, 1, set()),
1478
+ "str": (0, 3, {"object", "encoding", "errors"}),
1479
+ "int": (0, 2, {"base"}),
1480
+ "float": (0, 1, set()),
1481
+ "bool": (0, 1, set()),
1482
+ "abs": (1, 1, set()),
1483
+ "round": (1, 2, {"ndigits"}),
1484
+ "sum": (1, 2, {"start"}),
1485
+ "min": (1, None, {"key", "default"}),
1486
+ "max": (1, None, {"key", "default"}),
1487
+ "sorted": (1, 1, {"key", "reverse"}),
1488
+ "enumerate": (1, 2, {"start"}),
1489
+ "zip": (0, None, {"strict"}),
1490
+ "map": (2, None, set()),
1491
+ "filter": (2, None, set()),
1492
+ }
1493
+ if name not in rules:
1494
+ return
1495
+ min_args, max_args, allowed_kw = rules[name]
1496
+
1497
+ invalid_kw = kwargs - allowed_kw
1498
+ if invalid_kw:
1499
+ issues.append(
1500
+ {
1501
+ "type": "arg_count",
1502
+ "message": f"'{name}' does not accept keyword argument(s): {', '.join(sorted(invalid_kw))}",
1503
+ "line": node.lineno,
1504
+ }
1505
+ )
1506
+ return
1507
+
1508
+ if args < min_args:
1509
+ issues.append(
1510
+ {
1511
+ "type": "arg_count",
1512
+ "message": f"'{name}' expects at least {min_args} positional argument(s)",
1513
+ "line": node.lineno,
1514
+ }
1515
+ )
1516
+ elif max_args is not None and args > max_args:
1517
+ issues.append(
1518
+ {
1519
+ "type": "arg_count",
1520
+ "message": f"'{name}' expects at most {max_args} positional argument(s)",
1521
+ "line": node.lineno,
1522
+ }
1523
+ )
1524
+
1525
+
1526
+ def find_dead_code(
1527
+ entrypoints: list[str] | None = None,
1528
+ ignore_patterns: list[str] | None = None,
1529
+ include_tests: bool = True,
1530
+ ) -> dict:
1531
+ """Find symbols that appear to be unused in the project."""
1532
+ with _state_lock:
1533
+ root = _project_root
1534
+ idx = _project_index
1535
+ if root is None or idx is None:
1536
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
1537
+
1538
+ ignore_res = [re.compile(p) for p in (ignore_patterns or [])]
1539
+ graph = _get_call_graph(root)
1540
+
1541
+ alive: set[str] = set(entrypoints or [])
1542
+
1543
+ # Symbols referenced anywhere in the project are considered alive.
1544
+ # Definitions are keyed by qualified name while references are keyed by
1545
+ # the name as written in source (plus import-resolved qualified aliases —
1546
+ # see _record_reference), so also match on the trailing component.
1547
+ # Deliberately generous: dead-code must err toward silence, and a short
1548
+ # name that matches an unrelated definition only costs a missed report,
1549
+ # never a false one.
1550
+ by_short: dict[str, list[str]] = {}
1551
+ for def_name in graph.definitions:
1552
+ by_short.setdefault(def_name.rsplit(".", 1)[-1], []).append(def_name)
1553
+
1554
+ for name in graph.references:
1555
+ for def_name in by_short.get(name.rsplit(".", 1)[-1], ()):
1556
+ alive.add(def_name)
1557
+
1558
+ if include_tests:
1559
+ for name, locations in graph.definitions.items():
1560
+ for p, _line, _end, _kind in locations:
1561
+ if "test" in p.parts or p.name.startswith("test_"):
1562
+ alive.add(name)
1563
+ break
1564
+
1565
+ # Treat dunder hooks and public __init__ exports as alive.
1566
+ for name in graph.definitions:
1567
+ if name.startswith("__") and name.endswith("__"):
1568
+ alive.add(name)
1569
+
1570
+ dead: list[dict] = []
1571
+ for name, locations in graph.definitions.items():
1572
+ if name in alive:
1573
+ continue
1574
+ filtered = [
1575
+ (str(p), line, kind)
1576
+ for p, line, _end, kind in locations
1577
+ if not any(r.search(str(p)) for r in ignore_res)
1578
+ ]
1579
+ if filtered:
1580
+ dead.append({"symbol": name, "locations": filtered})
1581
+
1582
+ return {"success": True, "dead_symbols": dead, "count": len(dead)}
1583
+
1584
+
1585
+ def find_similar_code(threshold: float = 0.85, time_budget: float = 30.0) -> dict:
1586
+ """Find function-level code clones across the project.
1587
+
1588
+ Uses a length-ratio pre-filter to skip obviously dissimilar pairs before
1589
+ running the more expensive ``SequenceMatcher``. The comparison loop is
1590
+ quadratic in the number of functions, so it runs under a wall-clock budget
1591
+ (*time_budget* seconds): on a corpus large enough to exhaust the budget
1592
+ the pairs found so far are returned with ``truncated: true`` rather than
1593
+ stalling the tool for minutes.
1594
+ """
1595
+ with _state_lock:
1596
+ root = _project_root
1597
+ if root is None:
1598
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
1599
+
1600
+ functions: list[tuple[Path, str, str, tuple[int, int]]] = []
1601
+ for p, res in _parse_results_for(root).items():
1602
+ code = read_python_source(p)
1603
+ if code is None:
1604
+ continue
1605
+ for name, (start, end) in res.func_defs.items():
1606
+ lines = code.splitlines()[start : end + 1]
1607
+ body = _normalize_code("\n".join(lines))
1608
+ functions.append((p, name, body, (start + 1, end + 1)))
1609
+
1610
+ from difflib import SequenceMatcher
1611
+
1612
+ deadline = time.monotonic() + max(1.0, time_budget)
1613
+ truncated = False
1614
+
1615
+ # Pre-filter: group by similar length to avoid comparing very short or
1616
+ # very different functions. The similarity ratio between two strings is
1617
+ # bounded by min(len(a), len(b)) / max(len(a), len(b)), so we can skip
1618
+ # pairs whose length ratio is already below the threshold.
1619
+ pairs: list[dict] = []
1620
+ seen: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1621
+ for i, (p1, n1, b1, r1) in enumerate(functions):
1622
+ len1 = len(b1)
1623
+ if len1 < 30:
1624
+ continue
1625
+ if time.monotonic() > deadline:
1626
+ truncated = True
1627
+ break
1628
+ for j in range(i + 1, len(functions)):
1629
+ p2, n2, b2, r2 = functions[j]
1630
+ len2 = len(b2)
1631
+ if len2 < 30:
1632
+ continue
1633
+ # Length-ratio bound: if the shorter is less than threshold * longer, skip.
1634
+ shorter, longer = (len1, len2) if len1 <= len2 else (len2, len1)
1635
+ if shorter < threshold * longer:
1636
+ continue
1637
+ key = tuple(sorted([(str(p1), n1), (str(p2), n2)]))
1638
+ if key in seen:
1639
+ continue
1640
+ ratio = SequenceMatcher(None, b1, b2).ratio()
1641
+ if ratio >= threshold:
1642
+ seen.add(key)
1643
+ pairs.append(
1644
+ {
1645
+ "symbol_a": {"file": str(p1), "name": n1, "lines": r1},
1646
+ "symbol_b": {"file": str(p2), "name": n2, "lines": r2},
1647
+ "similarity": round(ratio, 3),
1648
+ }
1649
+ )
1650
+ if time.monotonic() > deadline:
1651
+ truncated = True
1652
+ break
1653
+
1654
+ pairs.sort(key=lambda x: x["similarity"], reverse=True)
1655
+ result = {"success": True, "pairs": pairs, "count": len(pairs)}
1656
+ if truncated:
1657
+ result["truncated"] = True
1658
+ return result
1659
+
1660
+
1661
+ def explore_call_graph(symbol_name: str) -> dict:
1662
+ """Explore callers and callees of a symbol across the project."""
1663
+ with _state_lock:
1664
+ root = _project_root
1665
+ if root is None:
1666
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
1667
+
1668
+ graph = _get_call_graph(root)
1669
+
1670
+ definitions = graph.definitions.get(symbol_name, [])
1671
+ short_name = symbol_name.split(".")[-1]
1672
+
1673
+ # Names that count as "this symbol" — both the fully-qualified form and
1674
+ # the short form, so a call context recorded as either will match.
1675
+ self_names = {symbol_name, short_name}
1676
+
1677
+ # Callers: places that reference *symbol_name* (or its short name).
1678
+ callers: list[dict] = []
1679
+ seen_callers: set[tuple[str, int, str]] = set()
1680
+ for ref_name, refs in graph.references.items():
1681
+ if ref_name != symbol_name and ref_name != short_name:
1682
+ continue
1683
+ for p, line, kind, context in refs:
1684
+ key = (str(p), line, context or "")
1685
+ if key in seen_callers:
1686
+ continue
1687
+ seen_callers.add(key)
1688
+ callers.append(
1689
+ {
1690
+ "caller": context or "<module>",
1691
+ "file": str(p),
1692
+ "line": line,
1693
+ "kind": kind,
1694
+ }
1695
+ )
1696
+
1697
+ # Callees: symbols referenced from inside any of *symbol_name*'s definitions.
1698
+ # A reference is "inside" when its enclosing context matches the symbol
1699
+ # (by qualified or short name) AND it lives in the same file AND its line
1700
+ # falls within the def's span (short names can collide across modules).
1701
+ def_spans = [(p, line, end) for p, line, end, _kind in definitions]
1702
+ callee_names: set[str] = set()
1703
+ for ref_name, refs in graph.references.items():
1704
+ for rp, rline, _rkind, rcontext in refs:
1705
+ if rcontext not in self_names:
1706
+ continue
1707
+ if any(fp == rp and start <= rline <= end for fp, start, end in def_spans):
1708
+ callee_names.add(ref_name)
1709
+
1710
+ callees: list[dict] = []
1711
+ for callee in sorted(callee_names):
1712
+ locations = graph.definitions.get(callee, [])
1713
+ callees.append(
1714
+ {
1715
+ "callee": callee,
1716
+ "defined": [
1717
+ {"file": str(p), "line": line, "kind": kind}
1718
+ for p, line, _end, kind in locations
1719
+ ],
1720
+ }
1721
+ )
1722
+
1723
+ result: dict = {
1724
+ "success": True,
1725
+ "symbol": symbol_name,
1726
+ "definitions": [
1727
+ {"file": str(p), "line": line, "kind": kind} for p, line, _end, kind in definitions
1728
+ ],
1729
+ "callers": callers,
1730
+ "callees": callees,
1731
+ }
1732
+ # Blast-radius summary: how far a change to this symbol would ripple.
1733
+ if definitions:
1734
+ impact = explore_impact(symbol_name, max_depth=3)
1735
+ if impact.get("success"):
1736
+ result["impact"] = impact["counts"]
1737
+ return result
1738
+
1739
+
1740
+ def explore_impact(symbol_name: str, max_depth: int = 3) -> dict:
1741
+ """Compute the blast radius of a symbol: transitively impacted symbols/files.
1742
+
1743
+ Walks the call graph in both directions (callers and callees) up to
1744
+ ``max_depth`` hops and summarizes which symbols and files would be affected
1745
+ if ``symbol_name`` changed.
1746
+ """
1747
+ with _state_lock:
1748
+ root = _project_root
1749
+ if root is None:
1750
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
1751
+
1752
+ graph = _get_call_graph(root)
1753
+ if symbol_name not in graph.definitions:
1754
+ return {"success": False, "error": f"Symbol not found in project: {symbol_name}"}
1755
+
1756
+ def _callers(name: str) -> set[str]:
1757
+ """Symbols whose bodies reference *name* (or its short form)."""
1758
+ short = name.split(".")[-1]
1759
+ found: set[str] = set()
1760
+ for ref_name, refs in graph.references.items():
1761
+ if ref_name != name and ref_name != short:
1762
+ continue
1763
+ for _p, _line, _kind, context in refs:
1764
+ if context:
1765
+ found.add(context)
1766
+ return found
1767
+
1768
+ def _callees(name: str) -> set[str]:
1769
+ """Symbols referenced from inside *name*'s definitions (same file)."""
1770
+ spans = [(p, line, end) for p, line, end, _kind in graph.definitions.get(name, [])]
1771
+ if not spans:
1772
+ return set()
1773
+ short = name.split(".")[-1]
1774
+ found: set[str] = set()
1775
+ for ref_name, refs in graph.references.items():
1776
+ for rp, rline, _rkind, rcontext in refs:
1777
+ if rcontext != name and rcontext != short:
1778
+ continue
1779
+ if any(fp == rp and start <= rline <= end for fp, start, end in spans):
1780
+ found.add(ref_name)
1781
+ return found
1782
+
1783
+ visited: set[str] = set()
1784
+ frontier: set[str] = {symbol_name}
1785
+ depth_map: dict[str, int] = {}
1786
+ depth = 0
1787
+ while frontier and depth < max_depth:
1788
+ depth += 1
1789
+ nxt: set[str] = set()
1790
+ for name in frontier:
1791
+ if name in visited:
1792
+ continue
1793
+ visited.add(name)
1794
+ depth_map[name] = depth
1795
+ nxt.update(_callers(name))
1796
+ nxt.update(_callees(name))
1797
+ frontier = nxt - visited
1798
+
1799
+ impacted_files: set[str] = set()
1800
+ for name in visited:
1801
+ for p, _line, _end, _kind in graph.definitions.get(name, []):
1802
+ impacted_files.add(str(p))
1803
+
1804
+ return {
1805
+ "success": True,
1806
+ "symbol": symbol_name,
1807
+ "max_depth": max_depth,
1808
+ "impacted_symbols": sorted(visited),
1809
+ "impacted_files": sorted(impacted_files),
1810
+ "counts": {
1811
+ "symbols": len(visited),
1812
+ "files": len(impacted_files),
1813
+ "depth": max(depth_map.values(), default=0),
1814
+ },
1815
+ }
1816
+
1817
+
1818
+ def _run_git(root: Path, *args: str) -> subprocess.CompletedProcess[str] | None:
1819
+ try:
1820
+ return subprocess.run(
1821
+ ["git", "-C", str(root), *args],
1822
+ capture_output=True,
1823
+ text=True,
1824
+ timeout=30,
1825
+ )
1826
+ except (OSError, subprocess.TimeoutExpired):
1827
+ return None
1828
+
1829
+
1830
+ def affected_files(base: str = "HEAD", head: str | None = None, max_depth: int = 4) -> dict:
1831
+ """Find files (especially tests) that may be affected by a git diff.
1832
+
1833
+ ``base`` (default ``HEAD``) is diffed against ``head`` when given, else the
1834
+ working tree. Changed files' defined symbols are traced backwards through
1835
+ the call graph so callers-of-callers up to ``max_depth`` hops are included.
1836
+ """
1837
+ with _state_lock:
1838
+ root = _project_root
1839
+ if root is None:
1840
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
1841
+
1842
+ def _valid_ref(ref: str) -> bool:
1843
+ # A leading dash would be parsed by git as an option (``--output=...``
1844
+ # writes a file); newlines cannot be legitimate refs either.
1845
+ return bool(ref) and not ref.startswith("-") and "\n" not in ref and "\r" not in ref
1846
+
1847
+ if not _valid_ref(base) or (head is not None and not _valid_ref(head)):
1848
+ return {
1849
+ "success": False,
1850
+ "error": f"invalid git ref: {base if not _valid_ref(base) else head}",
1851
+ }
1852
+
1853
+ # ``--relative`` makes paths relative to the project root even when it is a
1854
+ # subdirectory of the git repository; without it the changed paths and the
1855
+ # graph's root-relative paths never matched and the result was silently empty.
1856
+ if head:
1857
+ proc = _run_git(root, "diff", "--relative", "--name-only", base, head)
1858
+ else:
1859
+ proc = _run_git(root, "diff", "--relative", "--name-only", base)
1860
+ if proc is None or proc.returncode != 0:
1861
+ err = (proc.stderr if proc else "git unavailable").strip()
1862
+ return {"success": False, "error": f"git diff failed: {err}"}
1863
+ changed = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
1864
+
1865
+ graph = _get_call_graph(root)
1866
+
1867
+ # Symbols defined in changed files (paths are relative to the repo root).
1868
+ # git always emits forward slashes; normalize Windows paths to match.
1869
+ seeds: set[str] = set()
1870
+ changed_set = {c.replace("\\", "/") for c in changed}
1871
+ for name, locs in graph.definitions.items():
1872
+ for p, _line, _end, _kind in locs:
1873
+ try:
1874
+ rel = str(p.relative_to(root)).replace("\\", "/")
1875
+ except ValueError:
1876
+ rel = str(p)
1877
+ if rel in changed_set:
1878
+ seeds.add(name)
1879
+ if not seeds:
1880
+ return {
1881
+ "success": True,
1882
+ "changed": changed,
1883
+ "affected_files": [],
1884
+ "affected_tests": [],
1885
+ "propagation": 0,
1886
+ }
1887
+
1888
+ # Reverse BFS (bounded by max_depth): who references these symbols,
1889
+ # transitively. Depth counts hops from the seed symbols.
1890
+ visited: set[str] = set()
1891
+ frontier: set[str] = set(seeds)
1892
+ depth = 0
1893
+ while frontier and depth < max_depth:
1894
+ depth += 1
1895
+ nxt: set[str] = set()
1896
+ for name in frontier:
1897
+ if name in visited:
1898
+ continue
1899
+ visited.add(name)
1900
+ short = name.split(".")[-1]
1901
+ for ref_name, refs in graph.references.items():
1902
+ if ref_name != name and ref_name != short:
1903
+ continue
1904
+ for _p, _line, _kind, context in refs:
1905
+ if context and context not in visited:
1906
+ nxt.add(context)
1907
+ frontier = nxt - visited
1908
+ if len(visited) > 5000: # safety valve for pathological graphs
1909
+ break
1910
+
1911
+ affected: set[str] = set()
1912
+ for name in visited:
1913
+ for p, _line, _end, _kind in graph.definitions.get(name, []):
1914
+ affected.add(str(p))
1915
+
1916
+ affected_tests = sorted(f for f in affected if "test" in f.lower())
1917
+ return {
1918
+ "success": True,
1919
+ "changed": changed,
1920
+ "affected_files": sorted(affected),
1921
+ "affected_tests": affected_tests,
1922
+ "propagation": len(visited) - len(seeds),
1923
+ }
1924
+
1925
+
1926
+ def search_code(pattern: str, glob: str = "*.py") -> dict:
1927
+ """Search project source code with a regex pattern."""
1928
+ with _state_lock:
1929
+ root = _project_root
1930
+ if root is None:
1931
+ return {"success": False, "error": "Project not indexed. Call index_project first."}
1932
+
1933
+ try:
1934
+ regex = re.compile(pattern)
1935
+ except re.error as e:
1936
+ return {"success": False, "error": f"Invalid regex: {e}"}
1937
+
1938
+ # FTS5 acceleration: for plain .py searches, shrink the scan to a superset
1939
+ # of candidate files via the full-text index, then run the exact regex only
1940
+ # on those. Results are identical to a full scan, but large repos touch
1941
+ # far fewer files. Falls back to a full scan when FTS5 is unavailable.
1942
+ candidates: list[Path] | None = None
1943
+ if glob == "*.py":
1944
+ try:
1945
+ cache = GraphCache.for_root(root, _cache_dir())
1946
+ if not cache.is_fresh(root):
1947
+ # First search after a change: (re)build the graph + FTS5 index
1948
+ # once, then every later search hits the index.
1949
+ _get_call_graph(root)
1950
+ if cache.is_fresh(root):
1951
+ rel_candidates = cache.search_candidate_files(pattern)
1952
+ if rel_candidates is not None:
1953
+ if not rel_candidates:
1954
+ return {
1955
+ "success": True,
1956
+ "pattern": pattern,
1957
+ "matches": [],
1958
+ "count": 0,
1959
+ "engine": "fts5",
1960
+ }
1961
+ candidates = [root / rel for rel in rel_candidates]
1962
+ except (OSError, sqlite3.Error):
1963
+ candidates = None
1964
+
1965
+ matches: list[dict] = []
1966
+ for p in candidates if candidates is not None else root.rglob(glob):
1967
+ if "__pycache__" in p.parts:
1968
+ continue
1969
+ # read_python_source honours PEP 263 cookies and skips undecodable
1970
+ # files instead of raising; a strict utf-8 read silently dropped every
1971
+ # legally non-UTF-8 file from the search.
1972
+ text = read_python_source(p)
1973
+ if text is None:
1974
+ continue
1975
+ for m in regex.finditer(text):
1976
+ line = text[: m.start()].count("\n") + 1
1977
+ matches.append(
1978
+ {
1979
+ "file": str(p),
1980
+ "line": line,
1981
+ "match": m.group(0)[:200],
1982
+ }
1983
+ )
1984
+
1985
+ return {
1986
+ "success": True,
1987
+ "pattern": pattern,
1988
+ "matches": matches,
1989
+ "count": len(matches),
1990
+ "engine": "fts5" if candidates is not None else "regex",
1991
+ }
1992
+
1993
+
1994
+ def choose_tool(query: str) -> dict:
1995
+ """Choose the most appropriate tool for a natural-language query."""
1996
+ choice = HonestRouter.choose_tool(query)
1997
+ return {
1998
+ "success": True,
1999
+ "tool": choice.intent.value,
2000
+ "confidence": round(choice.confidence, 3),
2001
+ "reason": choice.reason,
2002
+ }