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
|
@@ -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)
|