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