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.
- honestcode/__init__.py +19 -0
- honestcode/cli.py +338 -0
- honestcode/graph/__init__.py +13 -0
- honestcode/graph/graph_store.py +229 -0
- honestcode/graph/watcher.py +116 -0
- honestcode/honest/__init__.py +19 -0
- honestcode/honest/project_binding.py +157 -0
- honestcode/honest/router.py +200 -0
- honestcode/honest/symbol_index.py +291 -0
- honestcode/mcp/__init__.py +46 -0
- honestcode/mcp/knowledge_base.py +141 -0
- honestcode/mcp/server.py +305 -0
- honestcode/mcp/tools.py +1267 -0
- honestcode/py.typed +0 -0
- honestcode/sandbox/__init__.py +195 -0
- honestcode/structure/__init__.py +6 -0
- honestcode/structure/extractor.py +296 -0
- honestcode/structure/multi_lang.py +180 -0
- honestcode/structure/relations.py +43 -0
- honestcode/structure/utils.py +31 -0
- honestcode-0.2.0.dist-info/METADATA +811 -0
- honestcode-0.2.0.dist-info/RECORD +26 -0
- honestcode-0.2.0.dist-info/WHEEL +5 -0
- honestcode-0.2.0.dist-info/entry_points.txt +3 -0
- honestcode-0.2.0.dist-info/licenses/LICENSE +21 -0
- honestcode-0.2.0.dist-info/top_level.txt +1 -0
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.1.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,338 @@
|
|
|
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 typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from honestcode.honest import project_binding as _binding
|
|
32
|
+
from honestcode.mcp import tools as _tools
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
|
|
37
|
+
__all__ = ["main"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _emit(result: dict[str, Any]) -> int:
|
|
41
|
+
"""Print *result* as JSON to stdout and return an exit code."""
|
|
42
|
+
sys.stdout.write(json.dumps(result, indent=2, default=str) + "\n")
|
|
43
|
+
# Non-zero exit when the operation reported a failure.
|
|
44
|
+
return 0 if result.get("success", True) else 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _emit_issues(result: dict[str, Any], issue_key: str = "issues") -> int:
|
|
48
|
+
"""Like _emit but exit non-zero when issues were found."""
|
|
49
|
+
sys.stdout.write(json.dumps(result, indent=2, default=str) + "\n")
|
|
50
|
+
if not result.get("success", True):
|
|
51
|
+
return 1
|
|
52
|
+
n = len(result.get(issue_key, []))
|
|
53
|
+
return 1 if n else 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cmd_index(args: argparse.Namespace) -> int:
|
|
57
|
+
return _emit(_tools.index_project(args.path, force_rebuild=args.force, watch=args.watch))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cmd_affected(args: argparse.Namespace) -> int:
|
|
61
|
+
return _emit(_tools.affected_files(base=args.base, head=args.head, max_depth=args.depth))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cmd_impact(args: argparse.Namespace) -> int:
|
|
65
|
+
return _emit(_tools.explore_impact(args.symbol, max_depth=args.depth))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cmd_watch(args: argparse.Namespace) -> int:
|
|
69
|
+
"""Index and keep the project fresh until interrupted (Ctrl+C)."""
|
|
70
|
+
res = _tools.index_project(args.path, force_rebuild=args.force, watch=True)
|
|
71
|
+
_emit(res)
|
|
72
|
+
if not res.get("success"):
|
|
73
|
+
return 1
|
|
74
|
+
print(f"Watching {args.path} — press Ctrl+C to stop.", file=sys.stderr)
|
|
75
|
+
try:
|
|
76
|
+
while True:
|
|
77
|
+
time.sleep(60)
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
_emit(_tools.stop_watching())
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
84
|
+
return _emit(_binding.init_project(args.path, force=args.force))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cmd_uninit(args: argparse.Namespace) -> int:
|
|
88
|
+
return _emit(_binding.uninit_project(args.path))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cmd_install(args: argparse.Namespace) -> int:
|
|
92
|
+
return _emit(
|
|
93
|
+
_binding.install_mcp_config(
|
|
94
|
+
path=args.path,
|
|
95
|
+
client=args.client,
|
|
96
|
+
dry_run=args.dry_run,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _cmd_root(args: argparse.Namespace) -> int:
|
|
102
|
+
root = _binding.find_project_root(args.path)
|
|
103
|
+
if root is None:
|
|
104
|
+
return _emit(
|
|
105
|
+
{"success": False, "error": "No project root found (no .honestcode/ or .git/)"}
|
|
106
|
+
)
|
|
107
|
+
return _emit({"success": True, "root": str(root)})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _cmd_deps(args: argparse.Namespace) -> int:
|
|
111
|
+
return _emit(_tools.load_project_deps(args.path))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
115
|
+
return _emit_issues(_tools.scan_file(args.file), issue_key="issues")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _cmd_check_symbol(args: argparse.Namespace) -> int:
|
|
119
|
+
res = _tools.check_symbol(args.symbol, file_path=args.file or None)
|
|
120
|
+
_emit(res)
|
|
121
|
+
return 0 if res.get("defined") else 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cmd_check_api(args: argparse.Namespace) -> int:
|
|
125
|
+
res = _tools.check_api(args.api)
|
|
126
|
+
_emit(res)
|
|
127
|
+
return 0 if res.get("valid") else 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
131
|
+
return _emit_issues(_tools.validate_types(args.code), issue_key="issues")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cmd_execute(args: argparse.Namespace) -> int:
|
|
135
|
+
return _emit(_tools.execute_code(args.code, prelude=args.prelude, known_names=args.known_names))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _cmd_dead_code(args: argparse.Namespace) -> int:
|
|
139
|
+
return _emit_issues(
|
|
140
|
+
_tools.find_dead_code(
|
|
141
|
+
entrypoints=args.entrypoints,
|
|
142
|
+
ignore_patterns=args.ignore,
|
|
143
|
+
include_tests=args.include_tests,
|
|
144
|
+
),
|
|
145
|
+
issue_key="dead_symbols",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _cmd_similar(args: argparse.Namespace) -> int:
|
|
150
|
+
return _emit_issues(_tools.find_similar_code(threshold=args.threshold), issue_key="pairs")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _cmd_call_graph(args: argparse.Namespace) -> int:
|
|
154
|
+
return _emit(_tools.explore_call_graph(args.symbol))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _cmd_search(args: argparse.Namespace) -> int:
|
|
158
|
+
return _emit_issues(_tools.search_code(args.pattern, glob=args.glob), issue_key="matches")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cmd_load_package(args: argparse.Namespace) -> int:
|
|
162
|
+
return _emit(_tools.load_package_apis(args.package))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _cmd_stats(args: argparse.Namespace) -> int:
|
|
166
|
+
return _emit(_tools.get_project_stats())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _cmd_choose_tool(args: argparse.Namespace) -> int:
|
|
170
|
+
return _emit(_tools.choose_tool(args.query))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
174
|
+
parser = argparse.ArgumentParser(
|
|
175
|
+
prog="honestcode",
|
|
176
|
+
description="HonestCode CLI — verify AI-generated code against your project.",
|
|
177
|
+
)
|
|
178
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
179
|
+
|
|
180
|
+
p = sub.add_parser("index", help="Build (or reuse) the project symbol index.")
|
|
181
|
+
p.add_argument("path", help="Project root directory.")
|
|
182
|
+
p.add_argument("--force", action="store_true", help="Force rebuild the index.")
|
|
183
|
+
p.add_argument(
|
|
184
|
+
"--watch",
|
|
185
|
+
action="store_true",
|
|
186
|
+
help="Start a background watcher that re-indexes on file changes.",
|
|
187
|
+
)
|
|
188
|
+
p.set_defaults(func=_cmd_index)
|
|
189
|
+
|
|
190
|
+
p = sub.add_parser(
|
|
191
|
+
"watch",
|
|
192
|
+
help="Index a project and keep it fresh (background watcher, Ctrl+C to stop).",
|
|
193
|
+
)
|
|
194
|
+
p.add_argument("path", help="Project root directory.")
|
|
195
|
+
p.add_argument("--force", action="store_true", help="Force rebuild the index.")
|
|
196
|
+
p.set_defaults(func=_cmd_watch)
|
|
197
|
+
|
|
198
|
+
p = sub.add_parser("deps", help="Load dependency APIs from requirements.txt / pyproject.toml.")
|
|
199
|
+
p.add_argument("path", help="Project root directory.")
|
|
200
|
+
p.set_defaults(func=_cmd_deps)
|
|
201
|
+
|
|
202
|
+
p = sub.add_parser("scan", help="Scan a file for undefined calls and API issues.")
|
|
203
|
+
p.add_argument("file", help="Path to the Python file to scan.")
|
|
204
|
+
p.set_defaults(func=_cmd_scan)
|
|
205
|
+
|
|
206
|
+
p = sub.add_parser("check-symbol", help="Check whether a symbol is defined in the project.")
|
|
207
|
+
p.add_argument("symbol", help="Module-qualified symbol name, e.g. pkg.core.helper.")
|
|
208
|
+
p.add_argument("--file", default="", help="Optional file the symbol appears in.")
|
|
209
|
+
p.set_defaults(func=_cmd_check_symbol)
|
|
210
|
+
|
|
211
|
+
p = sub.add_parser("check-api", help="Verify a library API call exists.")
|
|
212
|
+
p.add_argument("api", help="Fully qualified API name, e.g. pandas.read_csv.")
|
|
213
|
+
p.set_defaults(func=_cmd_check_api)
|
|
214
|
+
|
|
215
|
+
p = sub.add_parser("validate", help="Run structural type checks on a code snippet.")
|
|
216
|
+
p.add_argument("code", help="Python source to validate.")
|
|
217
|
+
p.set_defaults(func=_cmd_validate)
|
|
218
|
+
|
|
219
|
+
p = sub.add_parser("execute", help="Execute code in a sandboxed subprocess.")
|
|
220
|
+
p.add_argument("code", help="Python source to execute.")
|
|
221
|
+
p.add_argument("--prelude", default="", help="Setup code run before the main code.")
|
|
222
|
+
p.add_argument(
|
|
223
|
+
"--known-names",
|
|
224
|
+
nargs="*",
|
|
225
|
+
default=None,
|
|
226
|
+
help="Known symbol names for typo suggestions.",
|
|
227
|
+
)
|
|
228
|
+
p.set_defaults(func=_cmd_execute)
|
|
229
|
+
|
|
230
|
+
p = sub.add_parser(
|
|
231
|
+
"dead-code",
|
|
232
|
+
help="Find unused symbols. Exit code 1 if any dead symbols are found.",
|
|
233
|
+
)
|
|
234
|
+
p.add_argument(
|
|
235
|
+
"--entrypoints",
|
|
236
|
+
nargs="*",
|
|
237
|
+
default=None,
|
|
238
|
+
help="Symbol names to always treat as alive.",
|
|
239
|
+
)
|
|
240
|
+
p.add_argument(
|
|
241
|
+
"--ignore",
|
|
242
|
+
nargs="*",
|
|
243
|
+
default=None,
|
|
244
|
+
help="Regex patterns for files to exclude from dead-code results.",
|
|
245
|
+
)
|
|
246
|
+
p.add_argument(
|
|
247
|
+
"--no-tests",
|
|
248
|
+
dest="include_tests",
|
|
249
|
+
action="store_false",
|
|
250
|
+
help="Do not treat symbols in test files as alive.",
|
|
251
|
+
)
|
|
252
|
+
p.set_defaults(func=_cmd_dead_code, include_tests=True)
|
|
253
|
+
|
|
254
|
+
p = sub.add_parser("similar", help="Find function-level code clones across the project.")
|
|
255
|
+
p.add_argument(
|
|
256
|
+
"--threshold",
|
|
257
|
+
type=float,
|
|
258
|
+
default=0.85,
|
|
259
|
+
help="Similarity ratio above which two functions are reported (0-1).",
|
|
260
|
+
)
|
|
261
|
+
p.set_defaults(func=_cmd_similar)
|
|
262
|
+
|
|
263
|
+
p = sub.add_parser("call-graph", help="Explore callers and callees of a symbol.")
|
|
264
|
+
p.add_argument("symbol", help="Symbol name to explore.")
|
|
265
|
+
p.set_defaults(func=_cmd_call_graph)
|
|
266
|
+
|
|
267
|
+
p = sub.add_parser("impact", help="Compute the blast radius of a symbol.")
|
|
268
|
+
p.add_argument("symbol", help="Symbol name to analyze.")
|
|
269
|
+
p.add_argument("--depth", type=int, default=3, help="Max call-graph hops (default: 3).")
|
|
270
|
+
p.set_defaults(func=_cmd_impact)
|
|
271
|
+
|
|
272
|
+
p = sub.add_parser(
|
|
273
|
+
"affected",
|
|
274
|
+
help="Files/tests that may be affected by a git diff (from the call graph).",
|
|
275
|
+
)
|
|
276
|
+
p.add_argument("--base", default="HEAD", help="Git ref to diff against (default: HEAD).")
|
|
277
|
+
p.add_argument("--head", default=None, help="Optional second ref for base..head diffs.")
|
|
278
|
+
p.add_argument("--depth", type=int, default=4, help="Max call-graph hops (default: 4).")
|
|
279
|
+
p.set_defaults(func=_cmd_affected)
|
|
280
|
+
|
|
281
|
+
p = sub.add_parser("search", help="Search project source code with a regex pattern.")
|
|
282
|
+
p.add_argument("pattern", help="Regular expression to search for.")
|
|
283
|
+
p.add_argument("--glob", default="*.py", help="Glob pattern for files to search.")
|
|
284
|
+
p.set_defaults(func=_cmd_search)
|
|
285
|
+
|
|
286
|
+
p = sub.add_parser("load-package", help="Load API signatures for a specific package.")
|
|
287
|
+
p.add_argument("package", help="Importable package name, e.g. numpy.")
|
|
288
|
+
p.set_defaults(func=_cmd_load_package)
|
|
289
|
+
|
|
290
|
+
p = sub.add_parser("stats", help="Show statistics about the indexed project.")
|
|
291
|
+
p.set_defaults(func=_cmd_stats)
|
|
292
|
+
|
|
293
|
+
p = sub.add_parser("choose-tool", help="Show which tool a natural-language query maps to.")
|
|
294
|
+
p.add_argument("query", help="Natural-language query.")
|
|
295
|
+
p.set_defaults(func=_cmd_choose_tool)
|
|
296
|
+
|
|
297
|
+
p = sub.add_parser(
|
|
298
|
+
"init",
|
|
299
|
+
help="Bind a directory as a HonestCode project (.honestcode/).",
|
|
300
|
+
)
|
|
301
|
+
p.add_argument("path", nargs="?", default=None, help="Project directory (default: cwd).")
|
|
302
|
+
p.add_argument("--force", action="store_true", help="Re-create an existing binding.")
|
|
303
|
+
p.set_defaults(func=_cmd_init)
|
|
304
|
+
|
|
305
|
+
p = sub.add_parser("uninit", help="Remove the .honestcode/ project binding.")
|
|
306
|
+
p.add_argument("path", nargs="?", default=None, help="Project directory (default: cwd).")
|
|
307
|
+
p.set_defaults(func=_cmd_uninit)
|
|
308
|
+
|
|
309
|
+
p = sub.add_parser(
|
|
310
|
+
"install",
|
|
311
|
+
help="Register the MCP server with a client (Cursor, VS Code, Claude Code).",
|
|
312
|
+
)
|
|
313
|
+
p.add_argument("path", nargs="?", default=None, help="Project directory (default: cwd).")
|
|
314
|
+
p.add_argument(
|
|
315
|
+
"--client",
|
|
316
|
+
choices=["cursor", "vscode", "claude"],
|
|
317
|
+
default=None,
|
|
318
|
+
help="Only configure this client (default: all detected).",
|
|
319
|
+
)
|
|
320
|
+
p.add_argument("--dry-run", action="store_true", help="Preview changes without writing.")
|
|
321
|
+
p.set_defaults(func=_cmd_install)
|
|
322
|
+
|
|
323
|
+
p = sub.add_parser("root", help="Print the detected project root (binding or git).")
|
|
324
|
+
p.add_argument("path", nargs="?", default=None, help="Start directory (default: cwd).")
|
|
325
|
+
p.set_defaults(func=_cmd_root)
|
|
326
|
+
|
|
327
|
+
return parser
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def main(argv: list[str] | None = None) -> int:
|
|
331
|
+
parser = _build_parser()
|
|
332
|
+
args = parser.parse_args(argv)
|
|
333
|
+
func: Callable[[argparse.Namespace], int] = args.func
|
|
334
|
+
return func(args)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__":
|
|
338
|
+
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"]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Persistent call-graph and full-text search index backed by SQLite.
|
|
2
|
+
|
|
3
|
+
``GraphCache`` persists the project-wide call graph (definitions + references)
|
|
4
|
+
to a SQLite database keyed by a stable hash of the project root. Freshness is
|
|
5
|
+
decided by comparing stored SHA-256 hashes of every ``.py`` file against the
|
|
6
|
+
current tree, so graph queries (``explore_call_graph``, ``find_dead_code``,
|
|
7
|
+
``impact``, ``affected``) read from disk instead of re-parsing the project on
|
|
8
|
+
every call.
|
|
9
|
+
|
|
10
|
+
The same database hosts a FTS5 virtual table over file contents. ``search_code``
|
|
11
|
+
uses it to shrink a regex scan to a *superset* of candidate files, keeping
|
|
12
|
+
results identical to a full scan while touching far fewer files on large repos.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
import sqlite3
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
__all__ = ["CallGraph", "GraphCache"]
|
|
28
|
+
|
|
29
|
+
_DB_VERSION = 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CallGraph:
|
|
34
|
+
"""Project-wide definition/reference graph.
|
|
35
|
+
|
|
36
|
+
``definitions`` maps a symbol name to ``(file, line, end_line, kind)``.
|
|
37
|
+
``references`` maps a symbol name to ``(file, line, kind, context)`` where
|
|
38
|
+
``context`` is the callee when the reference is a call site (else ``None``).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
definitions: dict[str, list[tuple[Path, int, int, str]]] = field(default_factory=dict)
|
|
42
|
+
references: dict[str, list[tuple[Path, int, str, str | None]]] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _python_files(root: Path) -> list[Path]:
|
|
46
|
+
return sorted(
|
|
47
|
+
p for p in root.rglob("*.py") if not p.name.startswith(".") and "__pycache__" not in p.parts
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _file_hashes(root: Path) -> dict[str, str]:
|
|
52
|
+
"""Map each relative .py path to a SHA-256 of its bytes."""
|
|
53
|
+
hashes: dict[str, str] = {}
|
|
54
|
+
for p in _python_files(root):
|
|
55
|
+
try:
|
|
56
|
+
hashes[str(p.relative_to(root))] = hashlib.sha256(p.read_bytes()).hexdigest()
|
|
57
|
+
except OSError:
|
|
58
|
+
continue
|
|
59
|
+
return hashes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _stable_hash(text: str) -> str:
|
|
63
|
+
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GraphCache:
|
|
67
|
+
"""SQLite-backed cache of a project's call graph + FTS5 file index."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, path: Path) -> None:
|
|
70
|
+
self.path = path
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def for_root(cls, root: Path, cache_dir: Path) -> GraphCache:
|
|
74
|
+
resolved = root.resolve()
|
|
75
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
return cls(cache_dir / f"{resolved.name}_{_stable_hash(str(resolved))}.graph.db")
|
|
77
|
+
|
|
78
|
+
# -- freshness ---------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def is_fresh(self, root: Path) -> bool:
|
|
81
|
+
"""True when the cached graph covers exactly the current file tree."""
|
|
82
|
+
if not self.path.exists():
|
|
83
|
+
return False
|
|
84
|
+
conn = None
|
|
85
|
+
try:
|
|
86
|
+
conn = sqlite3.connect(str(self.path))
|
|
87
|
+
cur = conn.cursor()
|
|
88
|
+
row = cur.execute("SELECT value FROM meta WHERE key='hashes'").fetchone()
|
|
89
|
+
root_row = cur.execute("SELECT value FROM meta WHERE key='root'").fetchone()
|
|
90
|
+
except sqlite3.Error:
|
|
91
|
+
return False
|
|
92
|
+
finally:
|
|
93
|
+
if conn is not None:
|
|
94
|
+
conn.close()
|
|
95
|
+
if row is None or root_row is None:
|
|
96
|
+
return False
|
|
97
|
+
if root_row[0] != str(root.resolve()):
|
|
98
|
+
return False
|
|
99
|
+
try:
|
|
100
|
+
stored = json.loads(row[0])
|
|
101
|
+
except (json.JSONDecodeError, TypeError):
|
|
102
|
+
return False
|
|
103
|
+
return stored == _file_hashes(root)
|
|
104
|
+
|
|
105
|
+
# -- persistence -------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def save(self, graph: CallGraph, root: Path) -> None:
|
|
108
|
+
"""Persist the graph plus a FTS5 index of the current file tree."""
|
|
109
|
+
root = root.resolve()
|
|
110
|
+
hashes = _file_hashes(root)
|
|
111
|
+
files = sorted(hashes.keys())
|
|
112
|
+
conn = sqlite3.connect(str(self.path))
|
|
113
|
+
try:
|
|
114
|
+
conn.execute("DROP TABLE IF EXISTS definitions")
|
|
115
|
+
conn.execute("DROP TABLE IF EXISTS refs")
|
|
116
|
+
conn.execute("DROP TABLE IF EXISTS meta")
|
|
117
|
+
conn.execute(
|
|
118
|
+
"CREATE TABLE definitions ("
|
|
119
|
+
"symbol TEXT NOT NULL, file TEXT NOT NULL, "
|
|
120
|
+
"line INTEGER NOT NULL, end_line INTEGER NOT NULL, kind TEXT NOT NULL)"
|
|
121
|
+
)
|
|
122
|
+
conn.execute(
|
|
123
|
+
"CREATE TABLE refs ("
|
|
124
|
+
"caller TEXT NOT NULL, file TEXT NOT NULL, "
|
|
125
|
+
"line INTEGER NOT NULL, kind TEXT NOT NULL, context TEXT)"
|
|
126
|
+
)
|
|
127
|
+
conn.executemany(
|
|
128
|
+
"INSERT INTO definitions VALUES (?, ?, ?, ?, ?)",
|
|
129
|
+
[
|
|
130
|
+
(name, str(p), line, end_line, kind)
|
|
131
|
+
for name, locs in graph.definitions.items()
|
|
132
|
+
for p, line, end_line, kind in locs
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
conn.executemany(
|
|
136
|
+
"INSERT INTO refs VALUES (?, ?, ?, ?, ?)",
|
|
137
|
+
[
|
|
138
|
+
(name, str(p), line, kind, context)
|
|
139
|
+
for name, locs in graph.references.items()
|
|
140
|
+
for p, line, kind, context in locs
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
|
|
144
|
+
conn.executemany(
|
|
145
|
+
"INSERT INTO meta VALUES (?, ?)",
|
|
146
|
+
[
|
|
147
|
+
("version", str(_DB_VERSION)),
|
|
148
|
+
("root", str(root)),
|
|
149
|
+
("files", json.dumps(files)),
|
|
150
|
+
("hashes", json.dumps(hashes)),
|
|
151
|
+
],
|
|
152
|
+
)
|
|
153
|
+
conn.commit()
|
|
154
|
+
self._rebuild_fts(conn, root, files)
|
|
155
|
+
finally:
|
|
156
|
+
conn.close()
|
|
157
|
+
|
|
158
|
+
def load(self) -> CallGraph | None:
|
|
159
|
+
"""Load the graph from disk, or ``None`` if missing/corrupt."""
|
|
160
|
+
if not self.path.exists():
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
conn = sqlite3.connect(str(self.path))
|
|
164
|
+
cur = conn.cursor()
|
|
165
|
+
definitions: dict[str, list[tuple[Path, int, int, str]]] = {}
|
|
166
|
+
for name, f, line, end_line, kind in cur.execute(
|
|
167
|
+
"SELECT symbol, file, line, end_line, kind FROM definitions"
|
|
168
|
+
):
|
|
169
|
+
definitions.setdefault(name, []).append((Path(f), line, end_line, kind))
|
|
170
|
+
references: dict[str, list[tuple[Path, int, str, str | None]]] = {}
|
|
171
|
+
for name, f, line, kind, context in cur.execute(
|
|
172
|
+
"SELECT caller, file, line, kind, context FROM refs"
|
|
173
|
+
):
|
|
174
|
+
references.setdefault(name, []).append((Path(f), line, kind, context))
|
|
175
|
+
conn.close()
|
|
176
|
+
return CallGraph(definitions=definitions, references=references)
|
|
177
|
+
except sqlite3.Error as exc:
|
|
178
|
+
logger.warning("Failed to load graph cache %s: %s", self.path, exc)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
# -- FTS5 full-text search --------------------------------------------
|
|
182
|
+
|
|
183
|
+
def _rebuild_fts(self, conn: sqlite3.Connection, root: Path, files: list[str]) -> None:
|
|
184
|
+
try:
|
|
185
|
+
conn.execute("DROP TABLE IF EXISTS fts_files")
|
|
186
|
+
conn.execute(
|
|
187
|
+
"CREATE VIRTUAL TABLE fts_files USING fts5("
|
|
188
|
+
"path UNINDEXED, content, tokenize='unicode61')"
|
|
189
|
+
)
|
|
190
|
+
except sqlite3.Error:
|
|
191
|
+
logger.debug("FTS5 unavailable; search_code will fall back to regex scans")
|
|
192
|
+
return
|
|
193
|
+
for rel in files:
|
|
194
|
+
path = root / rel
|
|
195
|
+
try:
|
|
196
|
+
content = path.read_text(encoding="utf-8")
|
|
197
|
+
except (OSError, UnicodeDecodeError):
|
|
198
|
+
continue
|
|
199
|
+
conn.execute("INSERT INTO fts_files (path, content) VALUES (?, ?)", (rel, content))
|
|
200
|
+
conn.commit()
|
|
201
|
+
|
|
202
|
+
def search_candidate_files(self, pattern: str) -> list[str] | None:
|
|
203
|
+
"""Return files that *may* match ``pattern`` using FTS5 prefix queries.
|
|
204
|
+
|
|
205
|
+
The candidate set is a superset of what a plain regex scan can match
|
|
206
|
+
(token prefix queries such as ``foo*`` also find ``foobar``), so callers
|
|
207
|
+
get identical results while scanning far fewer files.
|
|
208
|
+
|
|
209
|
+
Returns ``None`` when FTS5 is unavailable (fall back to a full scan),
|
|
210
|
+
or an empty list when nothing can match (short-circuit to no results).
|
|
211
|
+
"""
|
|
212
|
+
tokens = [t for t in re.findall(r"\w+", pattern) if t]
|
|
213
|
+
if not tokens:
|
|
214
|
+
return None
|
|
215
|
+
try:
|
|
216
|
+
conn = sqlite3.connect(str(self.path))
|
|
217
|
+
try:
|
|
218
|
+
cur = conn.cursor()
|
|
219
|
+
seen: set[str] = set()
|
|
220
|
+
for tok in tokens:
|
|
221
|
+
for (path,) in cur.execute(
|
|
222
|
+
"SELECT path FROM fts_files WHERE fts_files MATCH ?", (tok + "*",)
|
|
223
|
+
):
|
|
224
|
+
seen.add(path)
|
|
225
|
+
finally:
|
|
226
|
+
conn.close()
|
|
227
|
+
except sqlite3.Error:
|
|
228
|
+
return None
|
|
229
|
+
return sorted(seen)
|