honestcode 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
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
+ errors: list[dict] = []
129
+
130
+ for name, locator, label in targets:
131
+ target = Path(locator(root))
132
+ payload: dict = {}
133
+ if target.exists():
134
+ try:
135
+ text = target.read_text(encoding="utf-8")
136
+ except OSError as exc:
137
+ errors.append(
138
+ {"client": name, "path": label, "error": f"cannot read config: {exc}"}
139
+ )
140
+ continue
141
+ if text.strip():
142
+ try:
143
+ loaded = json.loads(text)
144
+ except json.JSONDecodeError as exc:
145
+ # Never replace a user's config with a fresh object: a
146
+ # config carrying comments or a trailing comma is common,
147
+ # and silently discarding it loses every unrelated setting.
148
+ errors.append(
149
+ {
150
+ "client": name,
151
+ "path": label,
152
+ "error": f"refusing to overwrite unparsable JSON: {exc}",
153
+ }
154
+ )
155
+ continue
156
+ if not isinstance(loaded, dict):
157
+ errors.append(
158
+ {
159
+ "client": name,
160
+ "path": label,
161
+ "error": "config root is not a JSON object",
162
+ }
163
+ )
164
+ continue
165
+ payload = loaded
166
+ # Cursor and Claude Code store servers under "mcpServers";
167
+ # VS Code uses "servers".
168
+ key = "mcpServers" if name in ("claude", "cursor") else "servers"
169
+ servers = payload.setdefault(key, {})
170
+ if not isinstance(servers, dict):
171
+ errors.append({"client": name, "path": label, "error": f'"{key}" is not a JSON object'})
172
+ continue
173
+ item = dict(entry)
174
+ if name == "vscode":
175
+ # VS Code's server entries declare their transport explicitly.
176
+ item["type"] = "stdio"
177
+ servers["honestcode"] = item
178
+ if dry_run:
179
+ skipped.append({"client": name, "path": label, "would_write": True})
180
+ continue
181
+ try:
182
+ target.parent.mkdir(parents=True, exist_ok=True)
183
+ target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
184
+ except OSError as exc:
185
+ errors.append({"client": name, "path": label, "error": f"cannot write: {exc}"})
186
+ continue
187
+ written.append({"client": name, "path": label})
188
+
189
+ result: dict = {
190
+ "success": not errors,
191
+ "dry_run": dry_run,
192
+ "command": launch,
193
+ }
194
+ if written:
195
+ result["written"] = written
196
+ if skipped:
197
+ result["skipped"] = skipped
198
+ if errors:
199
+ result["errors"] = errors
200
+ return result
@@ -0,0 +1,205 @@
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
+ from honestcode.structure.utils import module_name_for
16
+
17
+ if TYPE_CHECKING:
18
+ from honestcode.mcp.knowledge_base import APIKnowledgeBase
19
+
20
+ __all__ = ["ToolIntent", "RouteChoice", "HonestRouter"]
21
+
22
+
23
+ class ToolIntent(Enum):
24
+ INDEX = "index"
25
+ CHECK_SYMBOL = "check_symbol"
26
+ CHECK_API = "check_api"
27
+ EXECUTE_CODE = "execute_code"
28
+ SCAN_FILE = "scan_file"
29
+ FIND_DEAD_CODE = "find_dead_code"
30
+ EXPLORE_CALL_GRAPH = "explore_call_graph"
31
+ SEARCH_CODE = "search_code"
32
+ VALIDATE_TYPES = "validate_types"
33
+ UNKNOWN = "unknown"
34
+
35
+
36
+ @dataclass
37
+ class RouteChoice:
38
+ intent: ToolIntent
39
+ confidence: float
40
+ reason: str
41
+
42
+
43
+ class HonestRouter:
44
+ """Route user queries and evaluate code suggestions against the index."""
45
+
46
+ def __init__(
47
+ self,
48
+ project_symbols: dict[str, Any],
49
+ dep_symbols: set[str],
50
+ dep_kb: APIKnowledgeBase | None = None,
51
+ project_root: str | Path | None = None,
52
+ ):
53
+ self.project_symbols = project_symbols
54
+ self.dep_symbols = dep_symbols
55
+ self.dep_kb = dep_kb
56
+ self.project_root = Path(project_root) if project_root else None
57
+
58
+ @staticmethod
59
+ def choose_tool(query: str) -> RouteChoice:
60
+ q = query.lower()
61
+ patterns: list[tuple[list[str], ToolIntent, str]] = [
62
+ (
63
+ ["index", "scan project", "build index"],
64
+ ToolIntent.INDEX,
65
+ "query asks to index project",
66
+ ),
67
+ (
68
+ ["symbol", "defined", "function exists"],
69
+ ToolIntent.CHECK_SYMBOL,
70
+ "query checks a symbol",
71
+ ),
72
+ (
73
+ ["api", "library", "pandas", "numpy"],
74
+ ToolIntent.CHECK_API,
75
+ "query checks an external API",
76
+ ),
77
+ (
78
+ ["run", "execute", "test code"],
79
+ ToolIntent.EXECUTE_CODE,
80
+ "query asks to execute code",
81
+ ),
82
+ (["scan file", "check file"], ToolIntent.SCAN_FILE, "query asks to scan a file"),
83
+ (["dead code", "unused"], ToolIntent.FIND_DEAD_CODE, "query asks for dead code"),
84
+ (
85
+ ["call graph", "caller", "callee"],
86
+ ToolIntent.EXPLORE_CALL_GRAPH,
87
+ "query explores call graph",
88
+ ),
89
+ (["search", "find code"], ToolIntent.SEARCH_CODE, "query searches code"),
90
+ (["type", "validate"], ToolIntent.VALIDATE_TYPES, "query validates types"),
91
+ ]
92
+ for keywords, intent, reason in patterns:
93
+ if any(k in q for k in keywords):
94
+ return RouteChoice(intent=intent, confidence=0.7, reason=reason)
95
+ return RouteChoice(intent=ToolIntent.UNKNOWN, confidence=0.0, reason="no clear intent")
96
+
97
+ def route(self, query: str, file_path: str | None = None) -> dict:
98
+ """Route a query and return a confidence judgement."""
99
+ choice = self.choose_tool(query)
100
+
101
+ if choice.intent == ToolIntent.CHECK_API:
102
+ # Find dotted API references like pandas.read_csv
103
+ matches = re.findall(r"\b([a-zA-Z_][\w]*(?:\.[a-zA-Z_][\w]*)+)\b", query)
104
+ for api in matches:
105
+ return self._check_api(api)
106
+ return {"intent": "check_api", "result": "no API reference found"}
107
+
108
+ if choice.intent == ToolIntent.CHECK_SYMBOL:
109
+ words = re.findall(r"\b([a-zA-Z_]\w{2,})\b", query)
110
+ candidates = [w for w in words if w[0].islower()]
111
+ if not candidates:
112
+ return {"intent": "check_symbol", "result": "no symbol candidates"}
113
+ symbol = candidates[0]
114
+ defined = symbol in self.project_symbols
115
+ return {
116
+ "intent": "check_symbol",
117
+ "symbol": symbol,
118
+ "defined": defined,
119
+ "confidence": 0.8 if defined else 0.3,
120
+ }
121
+
122
+ if choice.intent == ToolIntent.SCAN_FILE and file_path:
123
+ return {"intent": "scan_file", "file": file_path}
124
+
125
+ return {"intent": choice.intent.value, "confidence": choice.confidence}
126
+
127
+ def _check_api(self, api_name: str) -> dict:
128
+ if api_name in self.dep_symbols:
129
+ return {"intent": "check_api", "api": api_name, "valid": True}
130
+
131
+ parts = api_name.split(".")
132
+ base = parts[0]
133
+ suggestions: list[str] = []
134
+ if self.dep_kb is not None:
135
+ members = [k for k in self.dep_kb.all_names() if k.startswith(base + ".")]
136
+ if members:
137
+ suggestions = difflib.get_close_matches(api_name, members, n=3, cutoff=0.5)
138
+ if not suggestions:
139
+ suggestions = members[:3]
140
+ return {
141
+ "intent": "check_api",
142
+ "api": api_name,
143
+ "valid": False,
144
+ "reason": "not found in dependency API index",
145
+ "suggestions": suggestions,
146
+ }
147
+
148
+ def check_call(self, call: str, file_path: str | None = None) -> dict:
149
+ """Evaluate a single code snippet / API call for hallucination risk."""
150
+ if "." in call:
151
+ return self._check_api(call)
152
+
153
+ if self._has_symbol(call):
154
+ return {"call": call, "valid": True, "source": "project"}
155
+
156
+ if call in self.dep_symbols:
157
+ return {"call": call, "valid": True, "source": "dependency"}
158
+
159
+ # Check whether the base module looks like a project module.
160
+ # Compare against the full module path components, not a substring
161
+ # ("util" must not match "pkg.utility").
162
+ base = call.split("(")[0].split(".")[0].strip()
163
+ if file_path and base in self._module_of(file_path).split("."):
164
+ return {"call": call, "valid": True, "source": "same_module"}
165
+
166
+ suggestions = difflib.get_close_matches(
167
+ call, list(self.project_symbols) + list(self.dep_symbols), n=3
168
+ )
169
+ return {
170
+ "call": call,
171
+ "valid": False,
172
+ "risk": "high",
173
+ "suggestions": suggestions,
174
+ }
175
+
176
+ def _has_symbol(self, name: str) -> bool:
177
+ """True when *name* matches a project symbol by full or trailing name.
178
+
179
+ ``project_symbols`` is keyed by qualified name (``pkg.mod.helper``)
180
+ while callers naturally ask about the short name (``helper``), so the
181
+ trailing component is matched too. Generous on purpose: a false "valid"
182
+ costs nothing, a false "risk: high" sends the agent chasing a ghost.
183
+ """
184
+ if name in self.project_symbols:
185
+ return True
186
+ suffix = f".{name}"
187
+ return any(key.endswith(suffix) for key in self.project_symbols)
188
+
189
+ def _module_of(self, file_path: str | Path) -> str:
190
+ """Infer a Python module name from a file path relative to the project root.
191
+
192
+ Falls back to a best-effort absolute-path derivation when no root is set.
193
+ """
194
+ p = Path(file_path)
195
+
196
+ # Prefer project-relative path.
197
+ if self.project_root:
198
+ try:
199
+ p.resolve().relative_to(self.project_root.resolve())
200
+ return module_name_for(p, self.project_root)
201
+ except ValueError:
202
+ pass
203
+
204
+ parts = list(p.with_suffix("").parts)
205
+ return ".".join(parts)