contextual-engine 0.1.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.
Files changed (60) hide show
  1. contextual/__init__.py +18 -0
  2. contextual/__main__.py +11 -0
  3. contextual/cli.py +339 -0
  4. contextual/cli_docs.py +685 -0
  5. contextual/config.py +7 -0
  6. contextual/core/__init__.py +11 -0
  7. contextual/core/errors.py +470 -0
  8. contextual/core/models.py +590 -0
  9. contextual/docs/__init__.py +66 -0
  10. contextual/docs/chunker.py +550 -0
  11. contextual/docs/pipeline.py +513 -0
  12. contextual/docs/retrieval.py +654 -0
  13. contextual/docs/watcher.py +265 -0
  14. contextual/embedding/__init__.py +87 -0
  15. contextual/embedding/cache.py +455 -0
  16. contextual/embedding/embedder.py +414 -0
  17. contextual/embedding/helpers.py +252 -0
  18. contextual/git/__init__.py +22 -0
  19. contextual/git/blame.py +334 -0
  20. contextual/indexing/__init__.py +20 -0
  21. contextual/indexing/bug_sweep.py +119 -0
  22. contextual/indexing/chunker.py +691 -0
  23. contextual/indexing/embedder.py +271 -0
  24. contextual/indexing/file_watcher.py +154 -0
  25. contextual/indexing/incremental.py +260 -0
  26. contextual/indexing/index_writer.py +442 -0
  27. contextual/indexing/pipeline.py +438 -0
  28. contextual/indexing/processor.py +436 -0
  29. contextual/indexing/queries/readme.md +22 -0
  30. contextual/indexing/symbol_extractor.py +426 -0
  31. contextual/indexing/tokenizer.py +203 -0
  32. contextual/integrations/__init__.py +10 -0
  33. contextual/mcp/__init__.py +15 -0
  34. contextual/mcp/__main__.py +24 -0
  35. contextual/mcp/docs_tools.py +286 -0
  36. contextual/mcp/server.py +118 -0
  37. contextual/mcp/tools.py +443 -0
  38. contextual/observability/__init__.py +21 -0
  39. contextual/observability/logging.py +115 -0
  40. contextual/py.typed +0 -0
  41. contextual/retrieval/__init__.py +24 -0
  42. contextual/retrieval/context_assembler.py +372 -0
  43. contextual/retrieval/ranker.py +193 -0
  44. contextual/retrieval/search.py +548 -0
  45. contextual/security/__init__.py +52 -0
  46. contextual/security/paths.py +347 -0
  47. contextual/security/sanitize.py +349 -0
  48. contextual/security/workspace.py +348 -0
  49. contextual/storage/__init__.py +36 -0
  50. contextual/storage/fts_manager.py +273 -0
  51. contextual/storage/migration_v2.py +289 -0
  52. contextual/storage/migrations.py +316 -0
  53. contextual/storage/schema.py +210 -0
  54. contextual/storage/sqlite_pool.py +468 -0
  55. contextual/storage/vec0_manager.py +421 -0
  56. contextual_engine-0.1.0.dist-info/METADATA +297 -0
  57. contextual_engine-0.1.0.dist-info/RECORD +60 -0
  58. contextual_engine-0.1.0.dist-info/WHEEL +4 -0
  59. contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
  60. contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
@@ -0,0 +1,348 @@
1
+ """Workspace isolation and management.
2
+
3
+ This module provides per-project workspace isolation. Each project gets its own
4
+ .contextual/ directory containing:
5
+ - SQLite database (contextual.db)
6
+ - LanceDB vector store (lance/)
7
+ - Tantivy BM25 index (tantivy/)
8
+ - Configuration (config.toml)
9
+ - Logs (logs/)
10
+
11
+ Workspace isolation prevents:
12
+ - Cross-project data leakage
13
+ - Permission escalation
14
+ - Global state contamination
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from pathlib import Path
21
+
22
+ from contextual.core.errors import ErrorCode, SecurityError, security_context
23
+ from contextual.security.paths import safe_path
24
+
25
+
26
+ class WorkspaceManager:
27
+ """Manager for per-project workspaces.
28
+
29
+ Each project gets an isolated .contextual/ directory with proper permissions.
30
+ This class handles workspace initialization, validation, and path resolution.
31
+
32
+ Attributes:
33
+ project_root: Absolute path to project being indexed.
34
+ workspace_dir: Path to .contextual/ directory.
35
+ """
36
+
37
+ def __init__(self, project_root: Path) -> None:
38
+ """Initialize workspace manager.
39
+
40
+ Args:
41
+ project_root: Absolute path to project root.
42
+
43
+ Raises:
44
+ SecurityError: If project_root is invalid.
45
+ """
46
+ if not project_root.is_absolute():
47
+ msg = "Project root must be absolute"
48
+ raise SecurityError(
49
+ code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
50
+ message=msg,
51
+ context=security_context(path=str(project_root)),
52
+ )
53
+
54
+ if not project_root.exists():
55
+ msg = "Project root does not exist"
56
+ raise SecurityError(
57
+ code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
58
+ message=msg,
59
+ context=security_context(path=str(project_root)),
60
+ )
61
+
62
+ if not project_root.is_dir():
63
+ msg = "Project root is not a directory"
64
+ raise SecurityError(
65
+ code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
66
+ message=msg,
67
+ context=security_context(path=str(project_root)),
68
+ )
69
+
70
+ self.project_root = project_root
71
+ self.workspace_dir = project_root / ".contextual"
72
+
73
+ def init_workspace(
74
+ self,
75
+ *,
76
+ workspace_permissions: int = 0o700,
77
+ db_permissions: int = 0o600,
78
+ ) -> None:
79
+ """Initialize workspace directory with proper permissions.
80
+
81
+ Creates .contextual/ directory and subdirectories if they don't exist.
82
+ Sets strict permissions to prevent unauthorized access.
83
+
84
+ Directory structure:
85
+ .contextual/
86
+ ├── contextual.db (SQLite database)
87
+ ├── lance/ (LanceDB vector store)
88
+ ├── tantivy/ (Tantivy BM25 index)
89
+ ├── config.toml (Project-local config)
90
+ └── logs/ (Log files)
91
+
92
+ Args:
93
+ workspace_permissions: Permissions for .contextual/ directory (default 0o700).
94
+ db_permissions: Permissions for database files (default 0o600).
95
+
96
+ Raises:
97
+ SecurityError: If workspace initialization fails.
98
+ """
99
+ # Create workspace directory
100
+ try:
101
+ self.workspace_dir.mkdir(mode=workspace_permissions, exist_ok=True)
102
+ except OSError as e:
103
+ msg = f"Failed to create workspace directory: {e}"
104
+ raise SecurityError(
105
+ code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
106
+ message=msg,
107
+ context=security_context(path=str(self.workspace_dir)),
108
+ ) from e
109
+
110
+ # Set permissions explicitly (mkdir mode can be affected by umask)
111
+ try:
112
+ os.chmod(self.workspace_dir, workspace_permissions)
113
+ except OSError as e:
114
+ msg = f"Failed to set workspace permissions: {e}"
115
+ raise SecurityError(
116
+ code=ErrorCode.SECURITY_WORKSPACE_PERMISSION_DENIED,
117
+ message=msg,
118
+ context=security_context(path=str(self.workspace_dir)),
119
+ ) from e
120
+
121
+ # Create subdirectories
122
+ subdirs = ["lance", "tantivy", "logs"]
123
+ for subdir in subdirs:
124
+ subdir_path = self.workspace_dir / subdir
125
+ try:
126
+ subdir_path.mkdir(mode=workspace_permissions, exist_ok=True)
127
+ os.chmod(subdir_path, workspace_permissions)
128
+ except OSError as e:
129
+ msg = f"Failed to create subdirectory {subdir}: {e}"
130
+ raise SecurityError(
131
+ code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
132
+ message=msg,
133
+ context=security_context(path=str(subdir_path)),
134
+ ) from e
135
+
136
+ # Create .gitignore to exclude workspace from version control
137
+ gitignore_path = self.workspace_dir / ".gitignore"
138
+ if not gitignore_path.exists():
139
+ gitignore_content = (
140
+ "# Contextual workspace - do not commit\n"
141
+ "*\n"
142
+ "!.gitignore\n"
143
+ "!config.toml\n"
144
+ )
145
+ try:
146
+ gitignore_path.write_text(gitignore_content)
147
+ os.chmod(gitignore_path, 0o644)
148
+ except OSError:
149
+ # Non-fatal - workspace can function without .gitignore
150
+ pass
151
+
152
+ def get_db_path(self) -> Path:
153
+ """Get path to SQLite database file.
154
+
155
+ Returns:
156
+ Path to contextual.db within workspace.
157
+
158
+ Raises:
159
+ SecurityError: If path validation fails.
160
+ """
161
+ return self._get_workspace_path("contextual.db")
162
+
163
+ def get_lance_dir(self) -> Path:
164
+ """Get path to LanceDB directory.
165
+
166
+ Returns:
167
+ Path to lance/ subdirectory within workspace.
168
+
169
+ Raises:
170
+ SecurityError: If path validation fails.
171
+ """
172
+ return self._get_workspace_path("lance")
173
+
174
+ def get_tantivy_dir(self) -> Path:
175
+ """Get path to Tantivy index directory.
176
+
177
+ Returns:
178
+ Path to tantivy/ subdirectory within workspace.
179
+
180
+ Raises:
181
+ SecurityError: If path validation fails.
182
+ """
183
+ return self._get_workspace_path("tantivy")
184
+
185
+ def get_config_path(self) -> Path:
186
+ """Get path to project-local config file.
187
+
188
+ Returns:
189
+ Path to config.toml within workspace.
190
+
191
+ Raises:
192
+ SecurityError: If path validation fails.
193
+ """
194
+ return self._get_workspace_path("config.toml")
195
+
196
+ def get_log_dir(self) -> Path:
197
+ """Get path to log directory.
198
+
199
+ Returns:
200
+ Path to logs/ subdirectory within workspace.
201
+
202
+ Raises:
203
+ SecurityError: If path validation fails.
204
+ """
205
+ return self._get_workspace_path("logs")
206
+
207
+ def _get_workspace_path(self, relative_path: str) -> Path:
208
+ """Get a path within the workspace with safety validation.
209
+
210
+ Args:
211
+ relative_path: Path relative to workspace directory.
212
+
213
+ Returns:
214
+ Validated absolute path within workspace.
215
+
216
+ Raises:
217
+ SecurityError: If path is invalid or outside workspace.
218
+ """
219
+ # Use safe_path to validate (allow_create for files that might not exist yet)
220
+ return safe_path(self.workspace_dir, relative_path, allow_create=True)
221
+
222
+ def ensure_workspace_exists(self) -> None:
223
+ """Ensure workspace directory exists.
224
+
225
+ Creates workspace if it doesn't exist.
226
+
227
+ Raises:
228
+ SecurityError: If workspace initialization fails.
229
+ """
230
+ if not self.workspace_dir.exists():
231
+ self.init_workspace()
232
+
233
+ def validate_workspace_isolation(self) -> None:
234
+ """Validate that workspace is properly isolated.
235
+
236
+ Checks:
237
+ 1. Workspace directory exists within project root
238
+ 2. Workspace directory has correct permissions
239
+ 3. No symlinks pointing outside project root
240
+
241
+ Raises:
242
+ SecurityError: If isolation is violated.
243
+ """
244
+ # Check workspace exists
245
+ if not self.workspace_dir.exists():
246
+ msg = "Workspace directory does not exist"
247
+ raise SecurityError(
248
+ code=ErrorCode.SECURITY_WORKSPACE_ISOLATION_VIOLATION,
249
+ message=msg,
250
+ context=security_context(path=str(self.workspace_dir)),
251
+ )
252
+
253
+ # Check workspace is within project root
254
+ try:
255
+ self.workspace_dir.relative_to(self.project_root)
256
+ except ValueError as e:
257
+ msg = "Workspace directory is outside project root"
258
+ raise SecurityError(
259
+ code=ErrorCode.SECURITY_WORKSPACE_ISOLATION_VIOLATION,
260
+ message=msg,
261
+ context=security_context(
262
+ path=str(self.workspace_dir),
263
+ root=str(self.project_root),
264
+ ),
265
+ ) from e
266
+
267
+ # Check workspace is not a symlink pointing outside project root
268
+ if self.workspace_dir.is_symlink():
269
+ try:
270
+ resolved = self.workspace_dir.resolve(strict=True)
271
+ resolved.relative_to(self.project_root)
272
+ except (ValueError, OSError, RuntimeError) as e:
273
+ msg = "Workspace symlink points outside project root"
274
+ raise SecurityError(
275
+ code=ErrorCode.SECURITY_WORKSPACE_ISOLATION_VIOLATION,
276
+ message=msg,
277
+ context=security_context(path=str(self.workspace_dir)),
278
+ ) from e
279
+
280
+ # Check permissions (on Unix-like systems)
281
+ if hasattr(os, "stat"):
282
+ try:
283
+ stat_info = self.workspace_dir.stat()
284
+ mode = stat_info.st_mode & 0o777
285
+
286
+ # Warn if permissions are too permissive (group/other have access)
287
+ if mode & 0o077: # Group or other have any permissions
288
+ # This is a warning, not an error - user might have reasons
289
+ # for broader permissions in a trusted environment
290
+ pass
291
+ except OSError:
292
+ # Permission check failed - non-fatal
293
+ pass
294
+
295
+ def get_workspace_info(self) -> dict[str, str]:
296
+ """Get information about workspace.
297
+
298
+ Returns:
299
+ Dictionary with workspace paths and status.
300
+ """
301
+ return {
302
+ "project_root": str(self.project_root),
303
+ "workspace_dir": str(self.workspace_dir),
304
+ "db_path": str(self.get_db_path()),
305
+ "lance_dir": str(self.get_lance_dir()),
306
+ "tantivy_dir": str(self.get_tantivy_dir()),
307
+ "config_path": str(self.get_config_path()),
308
+ "log_dir": str(self.get_log_dir()),
309
+ "exists": str(self.workspace_dir.exists()),
310
+ }
311
+
312
+
313
+ def init_workspace(project_root: Path) -> WorkspaceManager:
314
+ """Initialize a workspace for a project.
315
+
316
+ Convenience function that creates a WorkspaceManager and initializes
317
+ the workspace directory.
318
+
319
+ Args:
320
+ project_root: Absolute path to project root.
321
+
322
+ Returns:
323
+ Initialized WorkspaceManager.
324
+
325
+ Raises:
326
+ SecurityError: If initialization fails.
327
+ """
328
+ manager = WorkspaceManager(project_root)
329
+ manager.init_workspace()
330
+ return manager
331
+
332
+
333
+ def get_workspace_manager(project_root: Path) -> WorkspaceManager:
334
+ """Get a workspace manager for a project.
335
+
336
+ Creates WorkspaceManager but does not initialize workspace directory.
337
+ Use this when you want to check workspace status before initialization.
338
+
339
+ Args:
340
+ project_root: Absolute path to project root.
341
+
342
+ Returns:
343
+ WorkspaceManager instance.
344
+
345
+ Raises:
346
+ SecurityError: If project_root is invalid.
347
+ """
348
+ return WorkspaceManager(project_root)
@@ -0,0 +1,36 @@
1
+ """Storage layer: SQLite, LanceDB, and bi-temporal query infrastructure.
2
+
3
+ Provides connection pooling, schema management, migrations, and temporal query helpers.
4
+ All persistence operations go through this layer.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from contextual.storage.migrations import (
9
+ apply_initial_schema,
10
+ get_current_version,
11
+ record_migration,
12
+ run_migrations,
13
+ )
14
+ from contextual.storage.schema import apply_schema
15
+ from contextual.storage.sqlite_pool import SQLitePool
16
+ from contextual.storage.vec0_manager import (
17
+ count_vectors,
18
+ delete_vectors,
19
+ insert_vectors,
20
+ search_vectors,
21
+ )
22
+
23
+
24
+ __all__ = [
25
+ "SQLitePool",
26
+ "apply_initial_schema",
27
+ "apply_schema",
28
+ "count_vectors",
29
+ "delete_vectors",
30
+ "get_current_version",
31
+ "insert_vectors",
32
+ "record_migration",
33
+ "run_migrations",
34
+ "search_vectors",
35
+ ]
36
+
@@ -0,0 +1,273 @@
1
+ """FTS5 full-text search manager for code chunks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from typing import TYPE_CHECKING
7
+
8
+ from contextual.core.errors import ErrorCode, StorageError, storage_context
9
+ from contextual.observability.logging import get_logger
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from sqlite3 import Connection
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ _FTS_TABLE = "fts_chunks"
18
+ _FTS_ERROR_CODE = getattr(ErrorCode, "STORAGE_FTS_FAILED", ErrorCode.STORAGE_SQLITE_QUERY_FAILED)
19
+
20
+
21
+ def escape_fts_query(query: str) -> str:
22
+ """Escape a user query for safe FTS5 phrase matching.
23
+
24
+ Args:
25
+ query: Raw user search text.
26
+
27
+ Returns:
28
+ Phrase-quoted FTS5-safe query string.
29
+ """
30
+ escaped = query.replace("\\", "\\\\").replace('"', '\\"')
31
+ return f'"{escaped}"'
32
+
33
+
34
+ def search_fts(
35
+ conn: Connection,
36
+ query: str,
37
+ k: int = 10,
38
+ language_filter: str | None = None,
39
+ ) -> list[tuple[int, float]]:
40
+ """Search chunks using SQLite FTS5 and return ranked BM25 scores.
41
+
42
+ Args:
43
+ conn: SQLite connection.
44
+ query: FTS5 query text.
45
+ k: Maximum result count in range [1, 1000].
46
+ language_filter: Optional language suffix (currently ignored - can be implemented via joins).
47
+
48
+ Returns:
49
+ List of `(chunk_id, score)` tuples where score is positive and higher is better.
50
+
51
+ Raises:
52
+ StorageError: If SQLite execution fails unexpectedly.
53
+ """
54
+ if query is None:
55
+ return []
56
+ if not isinstance(query, str) or not query.strip():
57
+ return []
58
+ if k <= 0 or k > 1000:
59
+ raise ValueError("k must be between 1 and 1000")
60
+
61
+ normalized_query = query.strip()
62
+ escaped_query = escape_fts_query(normalized_query)
63
+
64
+ sql = (
65
+ "SELECT chunk_id, -bm25(fts_chunks) AS score "
66
+ "FROM fts_chunks "
67
+ "WHERE fts_chunks MATCH ?"
68
+ )
69
+ params: list[object] = [escaped_query]
70
+
71
+ # Language filtering can be added later via JOIN with files table if needed
72
+ # TODO: Implement language filtering via JOIN with files table
73
+
74
+ sql += " ORDER BY score DESC LIMIT ?"
75
+ params.append(k)
76
+
77
+ try:
78
+ cursor = conn.execute(sql, tuple(params))
79
+ rows = cursor.fetchall()
80
+ return [(int(chunk_id), float(score)) for chunk_id, score in rows]
81
+ except sqlite3.OperationalError as exc:
82
+ # Invalid FTS syntax should not break callers; return no results.
83
+ logger.warning(
84
+ "Invalid FTS5 query; returning empty results",
85
+ query=normalized_query,
86
+ error=str(exc),
87
+ )
88
+ return []
89
+ except sqlite3.Error as exc:
90
+ msg = f"FTS5 search failed: {exc}"
91
+ logger.error("FTS5 search failed", error=str(exc))
92
+ raise StorageError(
93
+ code=_FTS_ERROR_CODE,
94
+ message=msg,
95
+ context=storage_context(table=_FTS_TABLE, query=normalized_query),
96
+ ) from exc
97
+
98
+
99
+ def count_matches(conn: Connection, query: str) -> int:
100
+ """Count total chunks that match an FTS5 query.
101
+
102
+ Args:
103
+ conn: SQLite connection.
104
+ query: FTS5 query text.
105
+
106
+ Returns:
107
+ Number of matching chunks.
108
+
109
+ Raises:
110
+ StorageError: If SQLite execution fails unexpectedly.
111
+ """
112
+ if query is None:
113
+ return 0
114
+ if not isinstance(query, str) or not query.strip():
115
+ return 0
116
+
117
+ normalized_query = query.strip()
118
+ escaped_query = escape_fts_query(normalized_query)
119
+
120
+ sql = (
121
+ "SELECT COUNT(*) "
122
+ "FROM fts_chunks "
123
+ "WHERE fts_chunks MATCH ?"
124
+ )
125
+
126
+ try:
127
+ cursor = conn.execute(sql, (escaped_query,))
128
+ row = cursor.fetchone()
129
+ return int(row[0]) if row else 0
130
+ except sqlite3.OperationalError as exc:
131
+ logger.warning(
132
+ "Invalid FTS5 count query; returning zero",
133
+ query=normalized_query,
134
+ error=str(exc),
135
+ )
136
+ return 0
137
+ except sqlite3.Error as exc:
138
+ msg = f"FTS5 count failed: {exc}"
139
+ logger.error("FTS5 count failed", error=str(exc))
140
+ raise StorageError(
141
+ code=_FTS_ERROR_CODE,
142
+ message=msg,
143
+ context=storage_context(table=_FTS_TABLE, query=normalized_query),
144
+ ) from exc
145
+
146
+
147
+ def get_chunk_snippet(
148
+ conn: Connection,
149
+ chunk_id: int,
150
+ query: str,
151
+ max_len: int = 200,
152
+ ) -> str:
153
+ """Get a highlighted snippet for a matching chunk.
154
+
155
+ Args:
156
+ conn: SQLite connection.
157
+ chunk_id: Chunk identifier from `chunks.id`.
158
+ query: Query text used for highlighting.
159
+ max_len: Maximum snippet character length.
160
+
161
+ Returns:
162
+ Highlighted snippet string, truncated to `max_len`, or empty string.
163
+
164
+ Raises:
165
+ StorageError: If SQLite execution fails unexpectedly.
166
+ """
167
+ if query is None:
168
+ return ""
169
+ if not isinstance(query, str) or not query.strip():
170
+ return ""
171
+ if max_len <= 0:
172
+ return ""
173
+
174
+ normalized_query = query.strip()
175
+ escaped_query = escape_fts_query(normalized_query)
176
+
177
+ sql = (
178
+ "SELECT snippet(fts_chunks, 1, '<b>', '</b>', '...', 20) AS snippet_text "
179
+ "FROM fts_chunks "
180
+ "WHERE chunk_id = ? AND fts_chunks MATCH ?"
181
+ )
182
+
183
+ try:
184
+ cursor = conn.execute(sql, (chunk_id, escaped_query))
185
+ row = cursor.fetchone()
186
+ if not row or row[0] is None:
187
+ return ""
188
+ snippet = str(row[0])
189
+ return snippet if len(snippet) <= max_len else snippet[:max_len]
190
+ except sqlite3.OperationalError as exc:
191
+ logger.warning(
192
+ "Invalid FTS5 snippet query; returning empty snippet",
193
+ query=normalized_query,
194
+ chunk_id=chunk_id,
195
+ error=str(exc),
196
+ )
197
+ return ""
198
+ except sqlite3.Error as exc:
199
+ msg = f"FTS5 snippet retrieval failed: {exc}"
200
+ logger.error("FTS5 snippet retrieval failed", error=str(exc), chunk_id=chunk_id)
201
+ raise StorageError(
202
+ code=_FTS_ERROR_CODE,
203
+ message=msg,
204
+ context=storage_context(table=_FTS_TABLE, query=normalized_query),
205
+ ) from exc
206
+
207
+
208
+ def test_fts_search() -> None:
209
+ """Validate core FTS5 search and escaping behavior."""
210
+ conn = sqlite3.connect(":memory:")
211
+
212
+ conn.execute("""
213
+ CREATE TABLE chunks (
214
+ id INTEGER PRIMARY KEY,
215
+ file_id INTEGER,
216
+ start_line INTEGER,
217
+ end_line INTEGER,
218
+ symbol_name TEXT,
219
+ symbol_type TEXT,
220
+ content TEXT,
221
+ content_hash TEXT,
222
+ embedding_id INTEGER,
223
+ indexed_at INTEGER
224
+ )
225
+ """)
226
+
227
+ conn.execute("""
228
+ CREATE VIRTUAL TABLE fts_chunks USING fts5(
229
+ chunk_id UNINDEXED,
230
+ content,
231
+ symbol_name,
232
+ tokenize='unicode61 remove_diacritics 2'
233
+ )
234
+ """)
235
+
236
+ conn.execute(
237
+ "INSERT INTO chunks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
238
+ (1, 1, 10, 20, "getUserData", "function", "def getUserData(): return data", "hash1", None, 1000),
239
+ )
240
+ conn.execute(
241
+ "INSERT INTO fts_chunks VALUES (?, ?, ?)",
242
+ (1, "def getUserData(): return data", "getUserData"),
243
+ )
244
+
245
+ results = search_fts(conn, "getUserData", k=10)
246
+ assert len(results) == 1
247
+ assert results[0][0] == 1
248
+ assert results[0][1] > 0
249
+
250
+ escaped = escape_fts_query('test "quote"')
251
+ assert '"' in escaped
252
+
253
+
254
+ def test_fts_helpers() -> None:
255
+ """Validate count and snippet helper functions."""
256
+ conn = sqlite3.connect(":memory:")
257
+ conn.execute("""
258
+ CREATE VIRTUAL TABLE fts_chunks USING fts5(
259
+ chunk_id UNINDEXED,
260
+ content,
261
+ symbol_name,
262
+ tokenize='unicode61 remove_diacritics 2'
263
+ )
264
+ """)
265
+ conn.execute(
266
+ "INSERT INTO fts_chunks VALUES (?, ?, ?)",
267
+ (11, "class Service: async def run(self): pass", "Service"),
268
+ )
269
+
270
+ assert count_matches(conn, "Service") == 1
271
+ snippet = get_chunk_snippet(conn, chunk_id=11, query="async def")
272
+ assert snippet
273
+ assert isinstance(snippet, str)