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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Zero-dependency file watcher for automatic re-indexing.
|
|
2
|
+
|
|
3
|
+
Polls the source tree's ``.py`` file mtimes on a background thread and fires
|
|
4
|
+
an ``on_change`` callback once files have stayed unchanged for ``debounce``
|
|
5
|
+
seconds. A poll-based design keeps the project dependency-free (pure stdlib),
|
|
6
|
+
cross-platform, and good enough for local dev machines — a single project of a
|
|
7
|
+
few thousand files is fully rescanned in well under a second.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
watcher = ProjectWatcher(root, on_change=lambda changed: ...)
|
|
12
|
+
watcher.start()
|
|
13
|
+
...
|
|
14
|
+
watcher.stop()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _take_snapshot(root: Path) -> dict[Path, float]:
|
|
28
|
+
"""Map each .py file to its mtime (ns), for change detection."""
|
|
29
|
+
snap: dict[Path, float] = {}
|
|
30
|
+
for p in root.rglob("*.py"):
|
|
31
|
+
if "__pycache__" in p.parts or p.name.startswith("."):
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
snap[p] = p.stat().st_mtime_ns
|
|
35
|
+
except OSError:
|
|
36
|
+
continue
|
|
37
|
+
return snap
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProjectWatcher:
|
|
41
|
+
"""Watch a project for source changes and fire a debounced callback.
|
|
42
|
+
|
|
43
|
+
``on_change(changed: list[Path])`` is invoked on the watcher thread once
|
|
44
|
+
the tree is quiescent for ``debounce`` seconds. The callback must be quick
|
|
45
|
+
or move heavy work to its own thread.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
root: Path,
|
|
51
|
+
on_change,
|
|
52
|
+
debounce: float = 2.0,
|
|
53
|
+
poll_interval: float = 0.5,
|
|
54
|
+
name: str = "honestcode-watcher",
|
|
55
|
+
) -> None:
|
|
56
|
+
self.root = Path(root)
|
|
57
|
+
self.on_change = on_change
|
|
58
|
+
self.debounce = debounce
|
|
59
|
+
self.poll_interval = poll_interval
|
|
60
|
+
self._snapshot = _take_snapshot(self.root)
|
|
61
|
+
self._pending: list[Path] = []
|
|
62
|
+
self._last_change: float | None = None
|
|
63
|
+
self._stop = threading.Event()
|
|
64
|
+
self._thread = threading.Thread(target=self._run, name=name, daemon=True)
|
|
65
|
+
|
|
66
|
+
# -- lifecycle ---------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def start(self) -> ProjectWatcher:
|
|
69
|
+
self._thread.start()
|
|
70
|
+
logger.info("Watcher started for %s (debounce %.1fs)", self.root, self.debounce)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def stop(self) -> None:
|
|
74
|
+
self._stop.set()
|
|
75
|
+
self._thread.join(timeout=5)
|
|
76
|
+
if self._thread.is_alive():
|
|
77
|
+
logger.warning("Watcher thread for %s did not stop within 5s", self.root)
|
|
78
|
+
else:
|
|
79
|
+
logger.info("Watcher stopped for %s", self.root)
|
|
80
|
+
|
|
81
|
+
# -- internals ---------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _run(self) -> None:
|
|
84
|
+
while not self._stop.is_set():
|
|
85
|
+
self._stop.wait(self.poll_interval)
|
|
86
|
+
self._scan_once()
|
|
87
|
+
|
|
88
|
+
def _scan_once(self) -> None:
|
|
89
|
+
now = _take_snapshot(self.root)
|
|
90
|
+
changed: list[Path] = []
|
|
91
|
+
old = self._snapshot
|
|
92
|
+
for p in old:
|
|
93
|
+
if p not in now:
|
|
94
|
+
changed.append(p)
|
|
95
|
+
for p, mtime in now.items():
|
|
96
|
+
if old.get(p) != mtime:
|
|
97
|
+
changed.append(p)
|
|
98
|
+
self._snapshot = now
|
|
99
|
+
|
|
100
|
+
if changed:
|
|
101
|
+
self._pending.extend(changed)
|
|
102
|
+
self._last_change = time.monotonic()
|
|
103
|
+
logger.debug("Detected %d changed files", len(changed))
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
self._pending
|
|
108
|
+
and self._last_change is not None
|
|
109
|
+
and (time.monotonic() - self._last_change) >= self.debounce
|
|
110
|
+
):
|
|
111
|
+
pending, self._pending = self._pending, []
|
|
112
|
+
self._last_change = None
|
|
113
|
+
try:
|
|
114
|
+
self.on_change(pending)
|
|
115
|
+
except Exception: # noqa: BLE001
|
|
116
|
+
logger.exception("on_change callback failed")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Honest core: symbol indexing, routing and hallucination checks."""
|
|
2
|
+
|
|
3
|
+
from honestcode.honest.router import HonestRouter, RouteChoice, ToolIntent
|
|
4
|
+
from honestcode.honest.symbol_index import (
|
|
5
|
+
ProjectIndex,
|
|
6
|
+
SymbolIndex,
|
|
7
|
+
SymbolInfo,
|
|
8
|
+
get_project_index,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"HonestRouter",
|
|
13
|
+
"ProjectIndex",
|
|
14
|
+
"RouteChoice",
|
|
15
|
+
"SymbolIndex",
|
|
16
|
+
"SymbolInfo",
|
|
17
|
+
"ToolIntent",
|
|
18
|
+
"get_project_index",
|
|
19
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Project-level binding (``.honestcode/``) and MCP client auto-configuration.
|
|
2
|
+
|
|
3
|
+
``init`` creates a ``.honestcode/config.json`` marker next to the project root so
|
|
4
|
+
CLI commands can discover the project without being told the path every time —
|
|
5
|
+
the same idea as CodeGraph's ``.codegraph/`` binding directory. ``install``
|
|
6
|
+
probes well-known MCP client config files and registers the server for you.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
BINDING_DIR = ".honestcode"
|
|
20
|
+
CONFIG_NAME = "config.json"
|
|
21
|
+
|
|
22
|
+
# MCP client config locations, probed in this order.
|
|
23
|
+
_CLIENT_TARGETS = [
|
|
24
|
+
("cursor", lambda root: root / ".cursor" / "mcp.json", ".cursor/mcp.json"),
|
|
25
|
+
("vscode", lambda root: root / ".vscode" / "mcp.json", ".vscode/mcp.json"),
|
|
26
|
+
(
|
|
27
|
+
"claude",
|
|
28
|
+
lambda root: Path.home() / ".claude.json",
|
|
29
|
+
"~/.claude.json",
|
|
30
|
+
),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Binding:
|
|
36
|
+
root: Path
|
|
37
|
+
config_path: Path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _server_command() -> list[str]:
|
|
41
|
+
"""Command to launch the MCP server from this installation."""
|
|
42
|
+
if shutil.which("honestcode-mcp"):
|
|
43
|
+
return ["honestcode-mcp"]
|
|
44
|
+
return [sys.executable, "-m", "honestcode.mcp.server"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def init_project(path: str | None = None, force: bool = False) -> dict:
|
|
48
|
+
"""Create a ``.honestcode/`` binding for *path* (default: cwd)."""
|
|
49
|
+
root = Path(path or os.getcwd()).resolve()
|
|
50
|
+
if not root.is_dir():
|
|
51
|
+
return {"success": False, "error": f"Not a directory: {root}"}
|
|
52
|
+
binding_dir = root / BINDING_DIR
|
|
53
|
+
config_path = binding_dir / CONFIG_NAME
|
|
54
|
+
if config_path.exists() and not force:
|
|
55
|
+
return {
|
|
56
|
+
"success": True,
|
|
57
|
+
"bound": True,
|
|
58
|
+
"root": str(root),
|
|
59
|
+
"config": str(config_path),
|
|
60
|
+
"message": "Already bound (use --force to re-bind)",
|
|
61
|
+
}
|
|
62
|
+
binding_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
config = {
|
|
64
|
+
"root": str(root),
|
|
65
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
66
|
+
"version": 1,
|
|
67
|
+
}
|
|
68
|
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
69
|
+
return {
|
|
70
|
+
"success": True,
|
|
71
|
+
"bound": True,
|
|
72
|
+
"root": str(root),
|
|
73
|
+
"config": str(config_path),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def uninit_project(path: str | None = None) -> dict:
|
|
78
|
+
"""Remove the ``.honestcode/`` binding for *path* (default: cwd)."""
|
|
79
|
+
root = Path(path or os.getcwd()).resolve()
|
|
80
|
+
binding_dir = root / BINDING_DIR
|
|
81
|
+
config_path = binding_dir / CONFIG_NAME
|
|
82
|
+
if not config_path.exists():
|
|
83
|
+
return {"success": False, "error": f"No binding found at {root / BINDING_DIR}"}
|
|
84
|
+
shutil.rmtree(binding_dir)
|
|
85
|
+
return {"success": True, "root": str(root), "unbound": True}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_project_root(start: str | None = None) -> Path | None:
|
|
89
|
+
"""Walk upward from *start* (default: cwd) looking for a project marker.
|
|
90
|
+
|
|
91
|
+
Prefers a ``.honestcode/`` binding, then falls back to a ``.git`` directory.
|
|
92
|
+
"""
|
|
93
|
+
cur = Path(start or os.getcwd()).resolve()
|
|
94
|
+
if not cur.is_dir():
|
|
95
|
+
cur = cur.parent
|
|
96
|
+
for candidate in [cur, *cur.parents]:
|
|
97
|
+
if (candidate / BINDING_DIR / CONFIG_NAME).exists():
|
|
98
|
+
return candidate
|
|
99
|
+
if (candidate / ".git").exists():
|
|
100
|
+
return candidate
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def install_mcp_config(
|
|
105
|
+
path: str | None = None,
|
|
106
|
+
client: str | None = None,
|
|
107
|
+
dry_run: bool = False,
|
|
108
|
+
command: list[str] | None = None,
|
|
109
|
+
) -> dict:
|
|
110
|
+
"""Register the MCP server with a client's config file.
|
|
111
|
+
|
|
112
|
+
Probes Cursor, VS Code and Claude Code config locations and writes the
|
|
113
|
+
stdio server entry. Use ``dry_run=True`` to preview without writing.
|
|
114
|
+
"""
|
|
115
|
+
root = Path(path or os.getcwd()).resolve()
|
|
116
|
+
launch = command or _server_command()
|
|
117
|
+
|
|
118
|
+
targets = _CLIENT_TARGETS if not client else [t for t in _CLIENT_TARGETS if t[0] == client]
|
|
119
|
+
if not targets:
|
|
120
|
+
return {"success": False, "error": f"Unknown client: {client}"}
|
|
121
|
+
|
|
122
|
+
entry = {
|
|
123
|
+
"command": launch[0],
|
|
124
|
+
"args": launch[1:],
|
|
125
|
+
}
|
|
126
|
+
written: list[dict] = []
|
|
127
|
+
skipped: list[dict] = []
|
|
128
|
+
|
|
129
|
+
for name, locator, label in targets:
|
|
130
|
+
target = Path(locator(root))
|
|
131
|
+
payload: dict = {}
|
|
132
|
+
if target.exists():
|
|
133
|
+
try:
|
|
134
|
+
payload = json.loads(target.read_text(encoding="utf-8"))
|
|
135
|
+
except (OSError, json.JSONDecodeError):
|
|
136
|
+
payload = {}
|
|
137
|
+
# Cursor and Claude Code store servers under "mcpServers";
|
|
138
|
+
# VS Code uses "servers".
|
|
139
|
+
key = "mcpServers" if name in ("claude", "cursor") else "servers"
|
|
140
|
+
servers = payload.setdefault(key, {})
|
|
141
|
+
servers["honestcode"] = entry
|
|
142
|
+
if dry_run:
|
|
143
|
+
skipped.append({"client": name, "path": label, "would_write": True})
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
148
|
+
except OSError as exc:
|
|
149
|
+
return {"success": False, "error": f"Cannot write {label}: {exc}"}
|
|
150
|
+
written.append({"client": name, "path": label})
|
|
151
|
+
|
|
152
|
+
result: dict = {"success": True, "dry_run": dry_run, "command": launch}
|
|
153
|
+
if written:
|
|
154
|
+
result["written"] = written
|
|
155
|
+
if skipped:
|
|
156
|
+
result["skipped"] = skipped
|
|
157
|
+
return result
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HonestRouter: route natural-language queries to the right tool and detect
|
|
3
|
+
likely hallucinations by checking project symbols and installed dependency APIs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import difflib
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from honestcode.mcp.knowledge_base import APIKnowledgeBase
|
|
17
|
+
|
|
18
|
+
__all__ = ["ToolIntent", "RouteChoice", "HonestRouter"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolIntent(Enum):
|
|
22
|
+
INDEX = "index"
|
|
23
|
+
CHECK_SYMBOL = "check_symbol"
|
|
24
|
+
CHECK_API = "check_api"
|
|
25
|
+
EXECUTE_CODE = "execute_code"
|
|
26
|
+
SCAN_FILE = "scan_file"
|
|
27
|
+
FIND_DEAD_CODE = "find_dead_code"
|
|
28
|
+
EXPLORE_CALL_GRAPH = "explore_call_graph"
|
|
29
|
+
SEARCH_CODE = "search_code"
|
|
30
|
+
VALIDATE_TYPES = "validate_types"
|
|
31
|
+
UNKNOWN = "unknown"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class RouteChoice:
|
|
36
|
+
intent: ToolIntent
|
|
37
|
+
confidence: float
|
|
38
|
+
reason: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HonestRouter:
|
|
42
|
+
"""Route user queries and evaluate code suggestions against the index."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
project_symbols: dict[str, Any],
|
|
47
|
+
dep_symbols: set[str],
|
|
48
|
+
dep_kb: APIKnowledgeBase | None = None,
|
|
49
|
+
project_root: str | Path | None = None,
|
|
50
|
+
):
|
|
51
|
+
self.project_symbols = project_symbols
|
|
52
|
+
self.dep_symbols = dep_symbols
|
|
53
|
+
self.dep_kb = dep_kb
|
|
54
|
+
self.project_root = Path(project_root) if project_root else None
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def choose_tool(query: str) -> RouteChoice:
|
|
58
|
+
q = query.lower()
|
|
59
|
+
patterns: list[tuple[list[str], ToolIntent, str]] = [
|
|
60
|
+
(
|
|
61
|
+
["index", "scan project", "build index"],
|
|
62
|
+
ToolIntent.INDEX,
|
|
63
|
+
"query asks to index project",
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
["symbol", "defined", "function exists"],
|
|
67
|
+
ToolIntent.CHECK_SYMBOL,
|
|
68
|
+
"query checks a symbol",
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
["api", "library", "pandas", "numpy"],
|
|
72
|
+
ToolIntent.CHECK_API,
|
|
73
|
+
"query checks an external API",
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
["run", "execute", "test code"],
|
|
77
|
+
ToolIntent.EXECUTE_CODE,
|
|
78
|
+
"query asks to execute code",
|
|
79
|
+
),
|
|
80
|
+
(["scan file", "check file"], ToolIntent.SCAN_FILE, "query asks to scan a file"),
|
|
81
|
+
(["dead code", "unused"], ToolIntent.FIND_DEAD_CODE, "query asks for dead code"),
|
|
82
|
+
(
|
|
83
|
+
["call graph", "caller", "callee"],
|
|
84
|
+
ToolIntent.EXPLORE_CALL_GRAPH,
|
|
85
|
+
"query explores call graph",
|
|
86
|
+
),
|
|
87
|
+
(["search", "find code"], ToolIntent.SEARCH_CODE, "query searches code"),
|
|
88
|
+
(["type", "validate"], ToolIntent.VALIDATE_TYPES, "query validates types"),
|
|
89
|
+
]
|
|
90
|
+
for keywords, intent, reason in patterns:
|
|
91
|
+
if any(k in q for k in keywords):
|
|
92
|
+
return RouteChoice(intent=intent, confidence=0.7, reason=reason)
|
|
93
|
+
return RouteChoice(intent=ToolIntent.UNKNOWN, confidence=0.0, reason="no clear intent")
|
|
94
|
+
|
|
95
|
+
def route(self, query: str, file_path: str | None = None) -> dict:
|
|
96
|
+
"""Route a query and return a confidence judgement."""
|
|
97
|
+
choice = self.choose_tool(query)
|
|
98
|
+
|
|
99
|
+
if choice.intent == ToolIntent.CHECK_API:
|
|
100
|
+
# Find dotted API references like pandas.read_csv
|
|
101
|
+
matches = re.findall(r"\b([a-zA-Z_][\w]*(?:\.[a-zA-Z_][\w]*)+)\b", query)
|
|
102
|
+
for api in matches:
|
|
103
|
+
return self._check_api(api)
|
|
104
|
+
return {"intent": "check_api", "result": "no API reference found"}
|
|
105
|
+
|
|
106
|
+
if choice.intent == ToolIntent.CHECK_SYMBOL:
|
|
107
|
+
words = re.findall(r"\b([a-zA-Z_]\w{2,})\b", query)
|
|
108
|
+
candidates = [w for w in words if w[0].islower()]
|
|
109
|
+
if not candidates:
|
|
110
|
+
return {"intent": "check_symbol", "result": "no symbol candidates"}
|
|
111
|
+
symbol = candidates[0]
|
|
112
|
+
defined = symbol in self.project_symbols
|
|
113
|
+
return {
|
|
114
|
+
"intent": "check_symbol",
|
|
115
|
+
"symbol": symbol,
|
|
116
|
+
"defined": defined,
|
|
117
|
+
"confidence": 0.8 if defined else 0.3,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if choice.intent == ToolIntent.SCAN_FILE and file_path:
|
|
121
|
+
return {"intent": "scan_file", "file": file_path}
|
|
122
|
+
|
|
123
|
+
return {"intent": choice.intent.value, "confidence": choice.confidence}
|
|
124
|
+
|
|
125
|
+
def _check_api(self, api_name: str) -> dict:
|
|
126
|
+
if api_name in self.dep_symbols:
|
|
127
|
+
return {"intent": "check_api", "api": api_name, "valid": True}
|
|
128
|
+
|
|
129
|
+
parts = api_name.split(".")
|
|
130
|
+
base = parts[0]
|
|
131
|
+
suggestions: list[str] = []
|
|
132
|
+
if self.dep_kb is not None:
|
|
133
|
+
members = [k for k in self.dep_kb.all_names() if k.startswith(base + ".")]
|
|
134
|
+
if members:
|
|
135
|
+
suggestions = difflib.get_close_matches(api_name, members, n=3, cutoff=0.5)
|
|
136
|
+
if not suggestions:
|
|
137
|
+
suggestions = members[:3]
|
|
138
|
+
return {
|
|
139
|
+
"intent": "check_api",
|
|
140
|
+
"api": api_name,
|
|
141
|
+
"valid": False,
|
|
142
|
+
"reason": "not found in dependency API index",
|
|
143
|
+
"suggestions": suggestions,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def check_call(self, call: str, file_path: str | None = None) -> dict:
|
|
147
|
+
"""Evaluate a single code snippet / API call for hallucination risk."""
|
|
148
|
+
if "." in call:
|
|
149
|
+
return self._check_api(call)
|
|
150
|
+
|
|
151
|
+
if call in self.project_symbols:
|
|
152
|
+
return {"call": call, "valid": True, "source": "project"}
|
|
153
|
+
|
|
154
|
+
if call in self.dep_symbols:
|
|
155
|
+
return {"call": call, "valid": True, "source": "dependency"}
|
|
156
|
+
|
|
157
|
+
# Check whether the base module looks like a project module.
|
|
158
|
+
# Compare against the full module path components, not a substring
|
|
159
|
+
# ("util" must not match "pkg.utility").
|
|
160
|
+
base = call.split("(")[0].split(".")[0].strip()
|
|
161
|
+
if file_path and base in self._module_of(file_path).split("."):
|
|
162
|
+
return {"call": call, "valid": True, "source": "same_module"}
|
|
163
|
+
|
|
164
|
+
suggestions = difflib.get_close_matches(
|
|
165
|
+
call, list(self.project_symbols) + list(self.dep_symbols), n=3
|
|
166
|
+
)
|
|
167
|
+
return {
|
|
168
|
+
"call": call,
|
|
169
|
+
"valid": False,
|
|
170
|
+
"risk": "high",
|
|
171
|
+
"suggestions": suggestions,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def _module_of(self, file_path: str | Path) -> str:
|
|
175
|
+
"""Infer a Python module name from a file path relative to the project root.
|
|
176
|
+
|
|
177
|
+
Falls back to a best-effort absolute-path derivation when no root is set.
|
|
178
|
+
"""
|
|
179
|
+
p = Path(file_path)
|
|
180
|
+
|
|
181
|
+
# Prefer project-relative path.
|
|
182
|
+
if self.project_root:
|
|
183
|
+
try:
|
|
184
|
+
rel = p.resolve().relative_to(self.project_root.resolve())
|
|
185
|
+
except ValueError:
|
|
186
|
+
rel = p
|
|
187
|
+
else:
|
|
188
|
+
rel = p
|
|
189
|
+
|
|
190
|
+
parts = list(rel.with_suffix("").parts)
|
|
191
|
+
|
|
192
|
+
# Only strip "src" if it is a real layout prefix (has sub-packages after it).
|
|
193
|
+
if len(parts) > 1 and parts[0] == "src":
|
|
194
|
+
parts = parts[1:]
|
|
195
|
+
|
|
196
|
+
# __init__.py represents the package itself.
|
|
197
|
+
if parts and parts[-1] == "__init__":
|
|
198
|
+
parts.pop()
|
|
199
|
+
|
|
200
|
+
return ".".join(parts)
|