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.

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