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 ADDED
@@ -0,0 +1,19 @@
1
+ """HonestCode: verify AI-generated code against project structure and installed APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from honestcode import honest, mcp, sandbox, structure
9
+
10
+ __version__ = "0.5.0"
11
+ __all__ = ["honest", "mcp", "sandbox", "structure"]
12
+
13
+
14
+ def __getattr__(name: str):
15
+ if name in __all__:
16
+ import importlib
17
+
18
+ return importlib.import_module(f"honestcode.{name}")
19
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
honestcode/cli.py ADDED
@@ -0,0 +1,428 @@
1
+ """
2
+ Command-line interface for HonestCode.
3
+
4
+ Every MCP tool has a CLI equivalent so the same capabilities can be used
5
+ from scripts, CI pipelines, and non-MCP harnesses without launching a
6
+ server process.
7
+
8
+ Usage:
9
+ honestcode index /path/to/project
10
+ honestcode deps /path/to/project
11
+ honestcode scan src/main.py
12
+ honestcode check-symbol pkg.core.helper
13
+ honestcode check-api pandas.read_csv
14
+ honestcode validate "for x in None: pass"
15
+ honestcode dead-code --entrypoints pkg.cli.main
16
+ honestcode search "def \\w+_helper"
17
+ honestcode call-graph pkg.core.helper
18
+ honestcode similar --threshold 0.85
19
+ honestcode stats
20
+ honestcode choose-tool "is my_symbol defined"
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING, Any
31
+
32
+ from honestcode.honest import project_binding as _binding
33
+ from honestcode.mcp import tools as _tools
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Callable
37
+
38
+ __all__ = ["main"]
39
+
40
+
41
+ def _emit(result: dict[str, Any]) -> int:
42
+ """Print *result* as JSON to stdout and return an exit code."""
43
+ sys.stdout.write(json.dumps(result, indent=2, default=str) + "\n")
44
+ # Non-zero exit when the operation reported a failure.
45
+ return 0 if result.get("success", True) else 1
46
+
47
+
48
+ def _emit_issues(result: dict[str, Any], issue_key: str = "issues") -> int:
49
+ """Like _emit but exit non-zero when issues were found."""
50
+ sys.stdout.write(json.dumps(result, indent=2, default=str) + "\n")
51
+ if not result.get("success", True):
52
+ return 1
53
+ n = len(result.get(issue_key, []))
54
+ return 1 if n else 0
55
+
56
+
57
+ def _ensure_index(args: argparse.Namespace) -> None:
58
+ """Load the project index for a fresh CLI process.
59
+
60
+ Every command runs in its own process, so an index built by an earlier
61
+ ``honestcode index`` call is not in memory. Resolve the project from
62
+ ``--root`` (nearest ``.git`` / ``.honestcode``) and build/reuse the cached
63
+ index before any query that needs it.
64
+ """
65
+ explicit = getattr(args, "root", None)
66
+ if explicit is None and _tools._project_root is not None:
67
+ # Same-process usage (tests, embedded callers): the caller already
68
+ # chose a project; revalidate it instead of silently switching to cwd.
69
+ _tools.index_project(str(_tools._project_root))
70
+ return
71
+ if explicit is not None:
72
+ # An explicit --root is authoritative: do not walk up to an enclosing
73
+ # git repository and index projects the caller did not name.
74
+ start = Path(explicit)
75
+ if start.is_file():
76
+ start = start.parent
77
+ root = start
78
+ else:
79
+ start = Path.cwd()
80
+ root = _binding.find_project_root(str(start)) or start
81
+ _tools.index_project(str(root))
82
+
83
+
84
+ def _cmd_index(args: argparse.Namespace) -> int:
85
+ return _emit(_tools.index_project(args.path, force_rebuild=args.force, watch=args.watch))
86
+
87
+
88
+ def _cmd_affected(args: argparse.Namespace) -> int:
89
+ _ensure_index(args)
90
+ return _emit(_tools.affected_files(base=args.base, head=args.head, max_depth=args.depth))
91
+
92
+
93
+ def _cmd_impact(args: argparse.Namespace) -> int:
94
+ _ensure_index(args)
95
+ return _emit(_tools.explore_impact(args.symbol, max_depth=args.depth))
96
+
97
+
98
+ def _cmd_watch(args: argparse.Namespace) -> int:
99
+ """Index and keep the project fresh until interrupted (Ctrl+C)."""
100
+ res = _tools.index_project(args.path, force_rebuild=args.force, watch=True)
101
+ _emit(res)
102
+ if not res.get("success"):
103
+ return 1
104
+ print(f"Watching {args.path} — press Ctrl+C to stop.", file=sys.stderr)
105
+ try:
106
+ while True:
107
+ time.sleep(60)
108
+ except KeyboardInterrupt:
109
+ _emit(_tools.stop_watching())
110
+ return 0
111
+
112
+
113
+ def _cmd_init(args: argparse.Namespace) -> int:
114
+ return _emit(_binding.init_project(args.path, force=args.force))
115
+
116
+
117
+ def _cmd_uninit(args: argparse.Namespace) -> int:
118
+ return _emit(_binding.uninit_project(args.path))
119
+
120
+
121
+ def _cmd_install(args: argparse.Namespace) -> int:
122
+ return _emit(
123
+ _binding.install_mcp_config(
124
+ path=args.path,
125
+ client=args.client,
126
+ dry_run=args.dry_run,
127
+ )
128
+ )
129
+
130
+
131
+ def _cmd_root(args: argparse.Namespace) -> int:
132
+ root = _binding.find_project_root(args.path)
133
+ if root is None:
134
+ return _emit(
135
+ {"success": False, "error": "No project root found (no .honestcode/ or .git/)"}
136
+ )
137
+ return _emit({"success": True, "root": str(root)})
138
+
139
+
140
+ def _cmd_deps(args: argparse.Namespace) -> int:
141
+ return _emit(_tools.load_project_deps(args.path))
142
+
143
+
144
+ def _cmd_scan(args: argparse.Namespace) -> int:
145
+ report = _tools.scan_file(args.file)
146
+ if not report.get("success", True):
147
+ return _emit(report)
148
+ if getattr(args, "format", "json") == "text":
149
+ from honestcode.verify.evidence import render_text
150
+
151
+ sys.stdout.write(render_text(report) + "\n")
152
+ return 1 if report["status"] == "fail" else 0
153
+ return _emit_issues(report, issue_key="issues")
154
+
155
+
156
+ def _cmd_verify(args: argparse.Namespace) -> int:
157
+ report = _tools.verify_file(args.file)
158
+ if not report.get("success", True):
159
+ return _emit(report)
160
+ sys.stdout.write(report["text"] + "\n")
161
+ return 1 if report["status"] == "fail" else 0
162
+
163
+
164
+ def _cmd_check_symbol(args: argparse.Namespace) -> int:
165
+ _ensure_index(args)
166
+ res = _tools.check_symbol(args.symbol, file_path=args.file or None)
167
+ _emit(res)
168
+ return 0 if res.get("defined") else 1
169
+
170
+
171
+ def _cmd_check_api(args: argparse.Namespace) -> int:
172
+ res = _tools.check_api(args.api)
173
+ _emit(res)
174
+ return 0 if res.get("valid") else 1
175
+
176
+
177
+ def _cmd_validate(args: argparse.Namespace) -> int:
178
+ return _emit_issues(_tools.validate_types(args.code), issue_key="issues")
179
+
180
+
181
+ def _cmd_execute(args: argparse.Namespace) -> int:
182
+ return _emit(_tools.execute_code(args.code, prelude=args.prelude, known_names=args.known_names))
183
+
184
+
185
+ def _cmd_dead_code(args: argparse.Namespace) -> int:
186
+ _ensure_index(args)
187
+ return _emit_issues(
188
+ _tools.find_dead_code(
189
+ entrypoints=args.entrypoints,
190
+ ignore_patterns=args.ignore,
191
+ include_tests=args.include_tests,
192
+ ),
193
+ issue_key="dead_symbols",
194
+ )
195
+
196
+
197
+ def _cmd_similar(args: argparse.Namespace) -> int:
198
+ _ensure_index(args)
199
+ return _emit_issues(_tools.find_similar_code(threshold=args.threshold), issue_key="pairs")
200
+
201
+
202
+ def _cmd_call_graph(args: argparse.Namespace) -> int:
203
+ _ensure_index(args)
204
+ return _emit(_tools.explore_call_graph(args.symbol))
205
+
206
+
207
+ def _cmd_search(args: argparse.Namespace) -> int:
208
+ _ensure_index(args)
209
+ return _emit_issues(_tools.search_code(args.pattern, glob=args.glob), issue_key="matches")
210
+
211
+
212
+ def _cmd_load_package(args: argparse.Namespace) -> int:
213
+ return _emit(_tools.load_package_apis(args.package))
214
+
215
+
216
+ def _cmd_stats(args: argparse.Namespace) -> int:
217
+ _ensure_index(args)
218
+ return _emit(_tools.get_project_stats())
219
+
220
+
221
+ def _cmd_choose_tool(args: argparse.Namespace) -> int:
222
+ return _emit(_tools.choose_tool(args.query))
223
+
224
+
225
+ def _version_string() -> str:
226
+ try:
227
+ from importlib.metadata import version
228
+
229
+ return f"honestcode {version('honestcode')}"
230
+ except Exception: # noqa: BLE001 - package metadata unavailable (e.g. source tree)
231
+ from honestcode import __version__
232
+
233
+ return f"honestcode {__version__}"
234
+
235
+
236
+ def _add_root_arg(parser: argparse.ArgumentParser) -> None:
237
+ parser.add_argument(
238
+ "--root",
239
+ default=None,
240
+ help="Project root (default: cwd, or the nearest .git/.honestcode).",
241
+ )
242
+
243
+
244
+ def _build_parser() -> argparse.ArgumentParser:
245
+ parser = argparse.ArgumentParser(
246
+ prog="honestcode",
247
+ description="HonestCode CLI — verify AI-generated code against your project.",
248
+ )
249
+ parser.add_argument("--version", action="version", version=_version_string())
250
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
251
+
252
+ p = sub.add_parser("index", help="Build (or reuse) the project symbol index.")
253
+ p.add_argument("path", help="Project root directory.")
254
+ p.add_argument("--force", action="store_true", help="Force rebuild the index.")
255
+ p.add_argument(
256
+ "--watch",
257
+ action="store_true",
258
+ help="Start a background watcher that re-indexes on file changes.",
259
+ )
260
+ p.set_defaults(func=_cmd_index)
261
+
262
+ p = sub.add_parser(
263
+ "watch",
264
+ help="Index a project and keep it fresh (background watcher, Ctrl+C to stop).",
265
+ )
266
+ p.add_argument("path", help="Project root directory.")
267
+ p.add_argument("--force", action="store_true", help="Force rebuild the index.")
268
+ p.set_defaults(func=_cmd_watch)
269
+
270
+ p = sub.add_parser("deps", help="Load dependency APIs from requirements.txt / pyproject.toml.")
271
+ p.add_argument("path", help="Project root directory.")
272
+ p.set_defaults(func=_cmd_deps)
273
+
274
+ p = sub.add_parser("scan", help="Scan a file for undefined calls and API issues.")
275
+ p.add_argument("file", help="Path to the Python file to scan.")
276
+ p.add_argument(
277
+ "--format",
278
+ choices=["json", "text"],
279
+ default="json",
280
+ help="Output format (default: json).",
281
+ )
282
+ p.set_defaults(func=_cmd_scan)
283
+
284
+ p = sub.add_parser("verify", help="Verify a file and print agent-readable evidence.")
285
+ p.add_argument("file", help="Path to the Python file to verify.")
286
+ p.set_defaults(func=_cmd_verify)
287
+
288
+ p = sub.add_parser("check-symbol", help="Check whether a symbol is defined in the project.")
289
+ p.add_argument("symbol", help="Module-qualified symbol name, e.g. pkg.core.helper.")
290
+ p.add_argument("--file", default="", help="Require the symbol to be defined in this file.")
291
+ _add_root_arg(p)
292
+ p.set_defaults(func=_cmd_check_symbol)
293
+
294
+ p = sub.add_parser("check-api", help="Verify a library API call exists.")
295
+ p.add_argument("api", help="Fully qualified API name, e.g. pandas.read_csv.")
296
+ p.set_defaults(func=_cmd_check_api)
297
+
298
+ p = sub.add_parser("validate", help="Run structural type checks on a code snippet.")
299
+ p.add_argument("code", help="Python source to validate.")
300
+ p.set_defaults(func=_cmd_validate)
301
+
302
+ p = sub.add_parser("execute", help="Execute code in a sandboxed subprocess.")
303
+ p.add_argument("code", help="Python source to execute.")
304
+ p.add_argument("--prelude", default="", help="Setup code run before the main code.")
305
+ p.add_argument(
306
+ "--known-names",
307
+ nargs="*",
308
+ default=None,
309
+ help="Known symbol names for typo suggestions.",
310
+ )
311
+ p.set_defaults(func=_cmd_execute)
312
+
313
+ p = sub.add_parser(
314
+ "dead-code",
315
+ help="Find unused symbols. Exit code 1 if any dead symbols are found.",
316
+ )
317
+ p.add_argument(
318
+ "--entrypoints",
319
+ nargs="*",
320
+ default=None,
321
+ help="Symbol names to always treat as alive.",
322
+ )
323
+ p.add_argument(
324
+ "--ignore",
325
+ nargs="*",
326
+ default=None,
327
+ help="Regex patterns for files to exclude from dead-code results.",
328
+ )
329
+ p.add_argument(
330
+ "--no-tests",
331
+ dest="include_tests",
332
+ action="store_false",
333
+ help="Do not treat symbols in test files as alive.",
334
+ )
335
+ _add_root_arg(p)
336
+ p.set_defaults(func=_cmd_dead_code, include_tests=True)
337
+
338
+ p = sub.add_parser("similar", help="Find function-level code clones across the project.")
339
+ p.add_argument(
340
+ "--threshold",
341
+ type=float,
342
+ default=0.85,
343
+ help="Similarity ratio above which two functions are reported (0-1).",
344
+ )
345
+ _add_root_arg(p)
346
+ p.set_defaults(func=_cmd_similar)
347
+
348
+ p = sub.add_parser("call-graph", help="Explore callers and callees of a symbol.")
349
+ p.add_argument("symbol", help="Symbol name to explore.")
350
+ _add_root_arg(p)
351
+ p.set_defaults(func=_cmd_call_graph)
352
+
353
+ p = sub.add_parser("impact", help="Compute the blast radius of a symbol.")
354
+ p.add_argument("symbol", help="Symbol name to analyze.")
355
+ p.add_argument("--depth", type=int, default=3, help="Max call-graph hops (default: 3).")
356
+ _add_root_arg(p)
357
+ p.set_defaults(func=_cmd_impact)
358
+
359
+ p = sub.add_parser(
360
+ "affected",
361
+ help="Files/tests that may be affected by a git diff (from the call graph).",
362
+ )
363
+ p.add_argument("--base", default="HEAD", help="Git ref to diff against (default: HEAD).")
364
+ p.add_argument("--head", default=None, help="Optional second ref for base..head diffs.")
365
+ p.add_argument("--depth", type=int, default=4, help="Max call-graph hops (default: 4).")
366
+ _add_root_arg(p)
367
+ p.set_defaults(func=_cmd_affected)
368
+
369
+ p = sub.add_parser("search", help="Search project source code with a regex pattern.")
370
+ p.add_argument("pattern", help="Regular expression to search for.")
371
+ p.add_argument("--glob", default="*.py", help="Glob pattern for files to search.")
372
+ _add_root_arg(p)
373
+ p.set_defaults(func=_cmd_search)
374
+
375
+ p = sub.add_parser("load-package", help="Load API signatures for a specific package.")
376
+ p.add_argument("package", help="Importable package name, e.g. numpy.")
377
+ p.set_defaults(func=_cmd_load_package)
378
+
379
+ p = sub.add_parser("stats", help="Show statistics about the indexed project.")
380
+ _add_root_arg(p)
381
+ p.set_defaults(func=_cmd_stats)
382
+
383
+ p = sub.add_parser("choose-tool", help="Show which tool a natural-language query maps to.")
384
+ p.add_argument("query", help="Natural-language query.")
385
+ p.set_defaults(func=_cmd_choose_tool)
386
+
387
+ p = sub.add_parser(
388
+ "init",
389
+ help="Bind a directory as a HonestCode project (.honestcode/).",
390
+ )
391
+ p.add_argument("path", nargs="?", default=None, help="Project directory (default: cwd).")
392
+ p.add_argument("--force", action="store_true", help="Re-create an existing binding.")
393
+ p.set_defaults(func=_cmd_init)
394
+
395
+ p = sub.add_parser("uninit", help="Remove the .honestcode/ project binding.")
396
+ p.add_argument("path", nargs="?", default=None, help="Project directory (default: cwd).")
397
+ p.set_defaults(func=_cmd_uninit)
398
+
399
+ p = sub.add_parser(
400
+ "install",
401
+ help="Register the MCP server with a client (Cursor, VS Code, Claude Code).",
402
+ )
403
+ p.add_argument("path", nargs="?", default=None, help="Project directory (default: cwd).")
404
+ p.add_argument(
405
+ "--client",
406
+ choices=["cursor", "vscode", "claude"],
407
+ default=None,
408
+ help="Only configure this client (default: all detected).",
409
+ )
410
+ p.add_argument("--dry-run", action="store_true", help="Preview changes without writing.")
411
+ p.set_defaults(func=_cmd_install)
412
+
413
+ p = sub.add_parser("root", help="Print the detected project root (binding or git).")
414
+ p.add_argument("path", nargs="?", default=None, help="Start directory (default: cwd).")
415
+ p.set_defaults(func=_cmd_root)
416
+
417
+ return parser
418
+
419
+
420
+ def main(argv: list[str] | None = None) -> int:
421
+ parser = _build_parser()
422
+ args = parser.parse_args(argv)
423
+ func: Callable[[argparse.Namespace], int] = args.func
424
+ return func(args)
425
+
426
+
427
+ if __name__ == "__main__":
428
+ sys.exit(main())
@@ -0,0 +1,13 @@
1
+ """Persistent graph storage for HonestCode.
2
+
3
+ The call graph (definitions + references) is expensive to rebuild — it needs
4
+ an AST pass over every source file. ``graph_store.GraphCache`` persists it to
5
+ SQLite next to the symbol index so graph queries read from disk instead of
6
+ re-parsing the project on every call, and a FTS5 virtual table backs fast
7
+ full-text search.
8
+ """
9
+
10
+ from honestcode.graph.graph_store import CallGraph, GraphCache
11
+ from honestcode.graph.watcher import ProjectWatcher
12
+
13
+ __all__ = ["CallGraph", "GraphCache", "ProjectWatcher"]