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,291 @@
1
+ """
2
+ Project-wide symbol index.
3
+
4
+ Builds a unified view of every function, class, and variable defined in a
5
+ project, and caches the result to disk.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import os
14
+ from dataclasses import asdict, dataclass, field
15
+ from pathlib import Path
16
+
17
+ from honestcode.structure.extractor import StructureExtractor
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ __all__ = ["SymbolInfo", "SymbolIndex", "ProjectIndex", "get_project_index"]
22
+
23
+
24
+ @dataclass
25
+ class SymbolInfo:
26
+ file: str
27
+ line: int
28
+ kind: str
29
+ exported: bool = True
30
+
31
+
32
+ @dataclass
33
+ class SymbolIndex:
34
+ symbols: dict[str, SymbolInfo] = field(default_factory=dict)
35
+
36
+ @property
37
+ def exported_symbols(self) -> dict[str, SymbolInfo]:
38
+ return {n: i for n, i in self.symbols.items() if i.exported}
39
+
40
+ @property
41
+ def local_symbols(self) -> dict[str, SymbolInfo]:
42
+ return {n: i for n, i in self.symbols.items() if not i.exported}
43
+
44
+
45
+ @dataclass
46
+ class ProjectIndex:
47
+ root: str
48
+ symbols: dict[str, SymbolInfo]
49
+ files: list[str]
50
+ cache_key: str = ""
51
+ cached: bool = False
52
+
53
+ def to_sqlite(self, db_path: str | Path) -> None:
54
+ import sqlite3
55
+
56
+ conn = sqlite3.connect(str(db_path))
57
+ conn.execute("DROP TABLE IF EXISTS symbols")
58
+ conn.execute(
59
+ "CREATE TABLE symbols (name TEXT PRIMARY KEY, file TEXT, line INTEGER, kind TEXT, exported INTEGER)" # noqa: E501
60
+ )
61
+ for name, info in self.symbols.items():
62
+ conn.execute(
63
+ "INSERT OR REPLACE INTO symbols VALUES (?, ?, ?, ?, ?)",
64
+ (name, info.file, info.line, info.kind, int(info.exported)),
65
+ )
66
+ conn.execute("DROP TABLE IF EXISTS meta")
67
+ conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
68
+ conn.execute("INSERT INTO meta VALUES (?, ?)", ("root", self.root))
69
+ conn.execute("INSERT INTO meta VALUES (?, ?)", ("files", json.dumps(self.files)))
70
+ conn.commit()
71
+ conn.close()
72
+
73
+ @classmethod
74
+ def from_sqlite(cls, db_path: str | Path) -> ProjectIndex | None:
75
+ import sqlite3
76
+
77
+ p = Path(db_path)
78
+ if not p.exists():
79
+ return None
80
+ try:
81
+ conn = sqlite3.connect(str(p))
82
+ cur = conn.cursor()
83
+ cur.execute("SELECT name, file, line, kind, exported FROM symbols")
84
+ symbols = {
85
+ name: SymbolInfo(file=f, line=line, kind=kind, exported=bool(exported))
86
+ for name, f, line, kind, exported in cur.fetchall()
87
+ }
88
+ cur.execute("SELECT key, value FROM meta")
89
+ meta = dict(cur.fetchall())
90
+ conn.close()
91
+ return cls(
92
+ root=meta.get("root", ""),
93
+ symbols=symbols,
94
+ files=json.loads(meta.get("files", "[]")),
95
+ )
96
+ except Exception as e: # noqa: BLE001
97
+ logger.warning("Failed to load SQLite index from %s: %s", db_path, e)
98
+ return None
99
+
100
+
101
+ def _should_rebuild(root: Path, cache_path: Path) -> bool:
102
+ """Determine whether the on-disk cache is stale.
103
+
104
+ Considers file list, content hashes, and mtime for robust invalidation.
105
+ """
106
+ if not cache_path.exists():
107
+ return True
108
+
109
+ try:
110
+ cache = json.loads(cache_path.read_text(encoding="utf-8"))
111
+ except Exception: # noqa: BLE001
112
+ return True
113
+
114
+ current_files = sorted(
115
+ str(p.relative_to(root))
116
+ for p in root.rglob("*.py")
117
+ if not p.name.startswith(".") and "__pycache__" not in p.parts
118
+ )
119
+ if cache.get("files") != current_files:
120
+ return True
121
+
122
+ old_hashes = cache.get("hashes", {})
123
+ for rel in current_files:
124
+ p = root / rel
125
+ try:
126
+ digest = hashlib.sha256(p.read_bytes()).hexdigest()
127
+ except Exception: # noqa: BLE001
128
+ return True
129
+ if old_hashes.get(rel) != digest:
130
+ return True
131
+
132
+ return False
133
+
134
+
135
+ def _build_cache_key(root: Path) -> str:
136
+ """Build a deterministic cache key from file contents."""
137
+ h = hashlib.sha256()
138
+ for p in sorted(root.rglob("*.py")):
139
+ if p.name.startswith(".") or "__pycache__" in p.parts:
140
+ continue
141
+ try:
142
+ h.update(p.read_bytes())
143
+ except Exception: # noqa: BLE001
144
+ continue
145
+ return h.hexdigest()
146
+
147
+
148
+ def _index_directory(root: Path, extractor: StructureExtractor) -> ProjectIndex:
149
+ symbols: dict[str, SymbolInfo] = {}
150
+ files: list[str] = []
151
+ for p in sorted(root.rglob("*.py")):
152
+ if p.name.startswith(".") or "__pycache__" in p.parts:
153
+ continue
154
+ rel = str(p.relative_to(root))
155
+ files.append(rel)
156
+ try:
157
+ res = extractor.parse_file(p)
158
+ except Exception as e: # noqa: BLE001
159
+ logger.warning("Failed to parse %s: %s", p, e)
160
+ continue
161
+ _merge(p, root, res, symbols)
162
+
163
+ return ProjectIndex(
164
+ root=str(root),
165
+ symbols=symbols,
166
+ files=files,
167
+ cache_key=_build_cache_key(root),
168
+ )
169
+
170
+
171
+ def _module_name(file: Path, root: Path) -> str:
172
+ """Infer a dotted module name from *file* relative to *root*.
173
+
174
+ Only strips the ``src`` prefix when it is a standalone directory component
175
+ (i.e. the standard ``src`` layout) and there are further path segments.
176
+ """
177
+ try:
178
+ rel = file.resolve().relative_to(root.resolve())
179
+ except ValueError:
180
+ rel = file
181
+ parts = list(rel.with_suffix("").parts)
182
+ # Only strip "src" if it is a real layout prefix (has sub-packages after it).
183
+ if len(parts) > 1 and parts[0] == "src":
184
+ parts = parts[1:]
185
+ if parts and parts[-1] == "__init__":
186
+ parts.pop()
187
+ return ".".join(parts)
188
+
189
+
190
+ def _merge(file: Path, root: Path, res, symbols: dict[str, SymbolInfo]) -> None:
191
+ """Merge a ParseResult's definitions into the global symbol table."""
192
+ rel = "/".join(file.relative_to(root).parts)
193
+ module = _module_name(file, root)
194
+
195
+ def _add(name: str, line: int, kind: str) -> None:
196
+ full_name = f"{module}.{name}" if module and name else (name or module)
197
+ exported = not name.split(".")[-1].startswith("_") if name else True
198
+ symbols[full_name] = SymbolInfo(file=rel, line=line, kind=kind, exported=exported)
199
+
200
+ for fn, (s, _e) in res.func_defs.items():
201
+ _add(fn, s + 1, "function")
202
+ for cn, (s, _e) in res.class_defs.items():
203
+ _add(cn, s + 1, "class")
204
+ for vn, (s, _e) in res.var_defs.items():
205
+ _add(vn, s + 1, "variable")
206
+
207
+
208
+ # Module-level cache to avoid re-indexing within a single process.
209
+ _index_cache: dict[str, ProjectIndex] = {}
210
+
211
+
212
+ def get_project_index(
213
+ root: str | Path,
214
+ force_rebuild: bool = False,
215
+ cache_dir: str | Path | None = None,
216
+ ) -> ProjectIndex:
217
+ """Return (and cache) the project index for *root*.
218
+
219
+ ``ProjectIndex.cached`` reports whether the result came from the in-memory
220
+ or on-disk cache (``False`` when it was rebuilt from source).
221
+ """
222
+ root = Path(root)
223
+ resolved = root.resolve()
224
+ cache_dir = Path(
225
+ cache_dir
226
+ or os.environ.get("HONESTCODE_INDEX_DIR")
227
+ or os.path.expanduser("~/.cache/honestcode")
228
+ )
229
+ cache_dir.mkdir(parents=True, exist_ok=True)
230
+ cache_path = cache_dir / f"{resolved.name}_{_stable_hash(str(resolved))}.json"
231
+
232
+ if not force_rebuild:
233
+ cached = _index_cache.get(str(resolved))
234
+ if cached is not None:
235
+ return cached
236
+
237
+ if not _should_rebuild(resolved, cache_path):
238
+ try:
239
+ data = json.loads(cache_path.read_text(encoding="utf-8"))
240
+ symbols = {n: SymbolInfo(**i) for n, i in data.get("symbols", {}).items()}
241
+ idx = ProjectIndex(
242
+ root=data.get("root", str(resolved)),
243
+ symbols=symbols,
244
+ files=data.get("files", []),
245
+ cache_key=data.get("cache_key", ""),
246
+ cached=True,
247
+ )
248
+ _index_cache[str(resolved)] = idx
249
+ return idx
250
+ except Exception as e: # noqa: BLE001
251
+ logger.warning("Failed to load cache %s: %s", cache_path, e)
252
+
253
+ extractor = StructureExtractor()
254
+ idx = _index_directory(resolved, extractor)
255
+ idx.cached = False
256
+
257
+ try:
258
+ cache_path.write_text(
259
+ json.dumps(
260
+ {
261
+ "root": idx.root,
262
+ "symbols": {n: asdict(i) for n, i in idx.symbols.items()},
263
+ "files": idx.files,
264
+ "cache_key": idx.cache_key,
265
+ "hashes": _file_hashes(resolved),
266
+ },
267
+ indent=2,
268
+ ),
269
+ encoding="utf-8",
270
+ )
271
+ except Exception as e: # noqa: BLE001
272
+ logger.warning("Failed to write cache %s: %s", cache_path, e)
273
+
274
+ _index_cache[str(resolved)] = idx
275
+ return idx
276
+
277
+
278
+ def _stable_hash(text: str) -> str:
279
+ return hashlib.sha256(text.encode()).hexdigest()[:16]
280
+
281
+
282
+ def _file_hashes(root: Path) -> dict[str, str]:
283
+ hashes: dict[str, str] = {}
284
+ for p in sorted(root.rglob("*.py")):
285
+ if p.name.startswith(".") or "__pycache__" in p.parts:
286
+ continue
287
+ try:
288
+ hashes[str(p.relative_to(root))] = hashlib.sha256(p.read_bytes()).hexdigest()
289
+ except Exception: # noqa: BLE001
290
+ continue
291
+ return hashes
@@ -0,0 +1,46 @@
1
+ """MCP server integration for HonestCode.
2
+
3
+ Import directly from submodules to avoid loading the MCP server eagerly:
4
+
5
+ from honestcode.mcp.tools import scan_file
6
+ from honestcode.mcp.server import main
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__ = [
12
+ "APIKnowledgeBase",
13
+ "APISignature",
14
+ "check_api",
15
+ "check_symbol",
16
+ "choose_tool",
17
+ "execute_code",
18
+ "explore_call_graph",
19
+ "find_dead_code",
20
+ "find_similar_code",
21
+ "get_project_stats",
22
+ "index_project",
23
+ "load_package_apis",
24
+ "load_project_deps",
25
+ "main",
26
+ "mcp",
27
+ "scan_file",
28
+ "search_code",
29
+ "validate_types",
30
+ ]
31
+
32
+
33
+ def __getattr__(name: str):
34
+ if name in ("main", "mcp"):
35
+ from honestcode.mcp.server import main, mcp
36
+
37
+ return {"main": main, "mcp": mcp}[name]
38
+ if name in ("APIKnowledgeBase", "APISignature"):
39
+ from honestcode.mcp.knowledge_base import APIKnowledgeBase, APISignature
40
+
41
+ return {"APIKnowledgeBase": APIKnowledgeBase, "APISignature": APISignature}[name]
42
+ if name in __all__:
43
+ from honestcode.mcp import tools
44
+
45
+ return getattr(tools, name)
46
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,141 @@
1
+ """
2
+ Knowledge base of dependency APIs for HonestCode.
3
+
4
+ Loads API signatures from installed packages via ``pydoc`` / AST inspection
5
+ and stores them for fast lookup during hallucination checks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import logging
12
+ import pkgutil
13
+ import threading
14
+ from dataclasses import dataclass
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ __all__ = ["APISignature", "APIKnowledgeBase"]
19
+
20
+
21
+ @dataclass
22
+ class APISignature:
23
+ name: str
24
+ signature: str = ""
25
+ doc: str = ""
26
+ module: str = ""
27
+
28
+
29
+ class APIKnowledgeBase:
30
+ """Lightweight cache of public API signatures for known packages."""
31
+
32
+ def __init__(self):
33
+ self._lock = threading.RLock()
34
+ self.apis: dict[str, APISignature] = {}
35
+ self._loaded_packages: set[str] = set()
36
+
37
+ def load_package(self, package_name: str) -> int:
38
+ """Extract public API signatures from an installed package.
39
+
40
+ Imports happen outside the lock to avoid blocking concurrent readers,
41
+ but the shared cache is updated under the lock.
42
+ """
43
+ with self._lock:
44
+ if package_name in self._loaded_packages:
45
+ return sum(1 for v in self.apis.values() if v.module.startswith(package_name))
46
+ self._loaded_packages.add(package_name)
47
+
48
+ try:
49
+ mod = importlib.import_module(package_name)
50
+ except Exception as e: # noqa: BLE001
51
+ logger.warning("Cannot import %s: %s", package_name, e)
52
+ with self._lock:
53
+ self._loaded_packages.discard(package_name)
54
+ return 0
55
+
56
+ new_apis: dict[str, APISignature] = {}
57
+ seen: set[str] = set()
58
+ count = self._collect_from_module(mod, package_name, package_name, new_apis, seen)
59
+
60
+ # Recurse into submodules (one level) to enrich the base.
61
+ try:
62
+ paths = getattr(mod, "__path__", None)
63
+ if paths is not None:
64
+ for info in pkgutil.iter_modules(paths):
65
+ if info.name.startswith("_"):
66
+ continue
67
+ sub = f"{package_name}.{info.name}"
68
+ try:
69
+ submod = importlib.import_module(sub)
70
+ except Exception as e: # noqa: BLE001
71
+ logger.debug("Cannot import submodule %s: %s", sub, e)
72
+ continue
73
+ count += self._collect_from_module(submod, sub, sub, new_apis, seen)
74
+ except Exception as e: # noqa: BLE001
75
+ logger.warning("Failed to enumerate submodules of %s: %s", package_name, e)
76
+
77
+ with self._lock:
78
+ self.apis.update(new_apis)
79
+
80
+ logger.info("Loaded %d APIs from %s", count, package_name)
81
+ return count
82
+
83
+ def _collect_from_module(
84
+ self,
85
+ mod,
86
+ module_name: str,
87
+ api_module: str,
88
+ out: dict[str, APISignature],
89
+ seen: set[str],
90
+ ) -> int:
91
+ """Collect public attributes from a module into *out*.
92
+
93
+ Returns the number of newly added signatures.
94
+ """
95
+ count = 0
96
+ try:
97
+ names = dir(mod)
98
+ except Exception as e: # noqa: BLE001
99
+ logger.warning("dir(%s) failed: %s", module_name, e)
100
+ return 0
101
+
102
+ for name in names:
103
+ if name.startswith("_"):
104
+ continue
105
+ full = f"{api_module}.{name}"
106
+ if full in seen:
107
+ continue
108
+ seen.add(full)
109
+ try:
110
+ obj = getattr(mod, name)
111
+ sig = self._signature(obj)
112
+ except Exception: # noqa: BLE001
113
+ sig = ""
114
+ out[full] = APISignature(name=full, signature=sig, module=api_module)
115
+ count += 1
116
+ return count
117
+
118
+ @staticmethod
119
+ def _signature(obj) -> str:
120
+ try:
121
+ import inspect
122
+
123
+ return str(inspect.signature(obj))
124
+ except (TypeError, ValueError):
125
+ return ""
126
+
127
+ def get(self, name: str) -> APISignature | None:
128
+ with self._lock:
129
+ return self.apis.get(name)
130
+
131
+ def has(self, name: str) -> bool:
132
+ with self._lock:
133
+ return name in self.apis
134
+
135
+ def search(self, prefix: str, limit: int = 10) -> list[str]:
136
+ with self._lock:
137
+ return [k for k in self.apis if k.startswith(prefix)][:limit]
138
+
139
+ def all_names(self) -> set[str]:
140
+ with self._lock:
141
+ return set(self.apis.keys())