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.
- honestcode/__init__.py +19 -0
- honestcode/cli.py +428 -0
- honestcode/graph/__init__.py +13 -0
- honestcode/graph/graph_store.py +276 -0
- honestcode/graph/watcher.py +116 -0
- honestcode/guards/__init__.py +104 -0
- honestcode/guards/guard.py +777 -0
- honestcode/guards/store.py +457 -0
- honestcode/guards/taxonomy.py +228 -0
- honestcode/honest/__init__.py +19 -0
- honestcode/honest/project_binding.py +200 -0
- honestcode/honest/router.py +205 -0
- honestcode/honest/symbol_index.py +446 -0
- honestcode/integrations/__init__.py +14 -0
- honestcode/integrations/pydantic_ai.py +304 -0
- honestcode/mcp/__init__.py +46 -0
- honestcode/mcp/knowledge_base.py +290 -0
- honestcode/mcp/server.py +357 -0
- honestcode/mcp/tools.py +2002 -0
- honestcode/py.typed +0 -0
- honestcode/sandbox/__init__.py +334 -0
- honestcode/structure/__init__.py +6 -0
- honestcode/structure/extractor.py +353 -0
- honestcode/structure/multi_lang.py +267 -0
- honestcode/structure/relations.py +47 -0
- honestcode/structure/utils.py +262 -0
- honestcode/verify/__init__.py +19 -0
- honestcode/verify/evidence.py +195 -0
- honestcode/verify/surface.py +497 -0
- honestcode/verify/verify.py +1074 -0
- honestcode-0.5.0.dist-info/METADATA +669 -0
- honestcode-0.5.0.dist-info/RECORD +36 -0
- honestcode-0.5.0.dist-info/WHEEL +5 -0
- honestcode-0.5.0.dist-info/entry_points.txt +3 -0
- honestcode-0.5.0.dist-info/licenses/LICENSE +21 -0
- honestcode-0.5.0.dist-info/top_level.txt +1 -0
honestcode/mcp/tools.py
ADDED
|
@@ -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
|
+
}
|