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.
- honestcode/__init__.py +19 -0
- honestcode/cli.py +338 -0
- honestcode/graph/__init__.py +13 -0
- honestcode/graph/graph_store.py +229 -0
- honestcode/graph/watcher.py +116 -0
- honestcode/honest/__init__.py +19 -0
- honestcode/honest/project_binding.py +157 -0
- honestcode/honest/router.py +200 -0
- honestcode/honest/symbol_index.py +291 -0
- honestcode/mcp/__init__.py +46 -0
- honestcode/mcp/knowledge_base.py +141 -0
- honestcode/mcp/server.py +305 -0
- honestcode/mcp/tools.py +1267 -0
- honestcode/py.typed +0 -0
- honestcode/sandbox/__init__.py +195 -0
- honestcode/structure/__init__.py +6 -0
- honestcode/structure/extractor.py +296 -0
- honestcode/structure/multi_lang.py +180 -0
- honestcode/structure/relations.py +43 -0
- honestcode/structure/utils.py +31 -0
- honestcode-0.2.0.dist-info/METADATA +811 -0
- honestcode-0.2.0.dist-info/RECORD +26 -0
- honestcode-0.2.0.dist-info/WHEEL +5 -0
- honestcode-0.2.0.dist-info/entry_points.txt +3 -0
- honestcode-0.2.0.dist-info/licenses/LICENSE +21 -0
- honestcode-0.2.0.dist-info/top_level.txt +1 -0
honestcode/mcp/tools.py
ADDED
|
@@ -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
|
+
}
|