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.
- contextual/__init__.py +18 -0
- contextual/__main__.py +11 -0
- contextual/cli.py +339 -0
- contextual/cli_docs.py +685 -0
- contextual/config.py +7 -0
- contextual/core/__init__.py +11 -0
- contextual/core/errors.py +470 -0
- contextual/core/models.py +590 -0
- contextual/docs/__init__.py +66 -0
- contextual/docs/chunker.py +550 -0
- contextual/docs/pipeline.py +513 -0
- contextual/docs/retrieval.py +654 -0
- contextual/docs/watcher.py +265 -0
- contextual/embedding/__init__.py +87 -0
- contextual/embedding/cache.py +455 -0
- contextual/embedding/embedder.py +414 -0
- contextual/embedding/helpers.py +252 -0
- contextual/git/__init__.py +22 -0
- contextual/git/blame.py +334 -0
- contextual/indexing/__init__.py +20 -0
- contextual/indexing/bug_sweep.py +119 -0
- contextual/indexing/chunker.py +691 -0
- contextual/indexing/embedder.py +271 -0
- contextual/indexing/file_watcher.py +154 -0
- contextual/indexing/incremental.py +260 -0
- contextual/indexing/index_writer.py +442 -0
- contextual/indexing/pipeline.py +438 -0
- contextual/indexing/processor.py +436 -0
- contextual/indexing/queries/readme.md +22 -0
- contextual/indexing/symbol_extractor.py +426 -0
- contextual/indexing/tokenizer.py +203 -0
- contextual/integrations/__init__.py +10 -0
- contextual/mcp/__init__.py +15 -0
- contextual/mcp/__main__.py +24 -0
- contextual/mcp/docs_tools.py +286 -0
- contextual/mcp/server.py +118 -0
- contextual/mcp/tools.py +443 -0
- contextual/observability/__init__.py +21 -0
- contextual/observability/logging.py +115 -0
- contextual/py.typed +0 -0
- contextual/retrieval/__init__.py +24 -0
- contextual/retrieval/context_assembler.py +372 -0
- contextual/retrieval/ranker.py +193 -0
- contextual/retrieval/search.py +548 -0
- contextual/security/__init__.py +52 -0
- contextual/security/paths.py +347 -0
- contextual/security/sanitize.py +349 -0
- contextual/security/workspace.py +348 -0
- contextual/storage/__init__.py +36 -0
- contextual/storage/fts_manager.py +273 -0
- contextual/storage/migration_v2.py +289 -0
- contextual/storage/migrations.py +316 -0
- contextual/storage/schema.py +210 -0
- contextual/storage/sqlite_pool.py +468 -0
- contextual/storage/vec0_manager.py +421 -0
- contextual_engine-0.1.0.dist-info/METADATA +297 -0
- contextual_engine-0.1.0.dist-info/RECORD +60 -0
- contextual_engine-0.1.0.dist-info/WHEEL +4 -0
- contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
- contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""File discovery and chunking orchestrator.
|
|
2
|
+
|
|
3
|
+
Walks project directories, respects .gitignore/.contextualignore patterns,
|
|
4
|
+
excludes generated files and vendor directories, and coordinates parallel
|
|
5
|
+
chunking across discovered source files.
|
|
6
|
+
|
|
7
|
+
Key responsibilities:
|
|
8
|
+
- Honor .gitignore + .contextualignore via pathspec
|
|
9
|
+
- Skip generated files (*.min.js, *_pb2.py, *.d.ts, etc.)
|
|
10
|
+
- Skip vendor directories (node_modules/, venv/, __pycache__/, etc.)
|
|
11
|
+
- Detect language from file extensions
|
|
12
|
+
- Coordinate chunking while respecting file size limits
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
import pathspec
|
|
22
|
+
|
|
23
|
+
from contextual.core.errors import ErrorCode, IndexingError
|
|
24
|
+
from contextual.indexing.chunker import chunk_file, detect_language
|
|
25
|
+
from contextual.observability import get_logger
|
|
26
|
+
from contextual.security.paths import safe_path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from contextual.core.models import Chunk
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
# Generated file patterns (skip these)
|
|
35
|
+
GENERATED_PATTERNS = {
|
|
36
|
+
# Minified/compiled
|
|
37
|
+
r"\.min\.js$",
|
|
38
|
+
r"\.min\.css$",
|
|
39
|
+
r"-min\.js$",
|
|
40
|
+
r"\.bundle\.js$",
|
|
41
|
+
# Protocol buffers
|
|
42
|
+
r"_pb2\.py$",
|
|
43
|
+
r"_pb2_grpc\.py$",
|
|
44
|
+
r"\.pb\.go$",
|
|
45
|
+
r"\.pb\.h$",
|
|
46
|
+
r"\.pb\.cc$",
|
|
47
|
+
# TypeScript declarations (generated)
|
|
48
|
+
r"\.d\.ts$",
|
|
49
|
+
# Auto-generated comments
|
|
50
|
+
r"^.*?//\s*Code generated .* DO NOT EDIT", # Go standard
|
|
51
|
+
r"^.*?//\s*AUTO-GENERATED",
|
|
52
|
+
r"^.*?#\s*AUTO-GENERATED",
|
|
53
|
+
# Build artifacts
|
|
54
|
+
r"\.pyc$",
|
|
55
|
+
r"\.pyo$",
|
|
56
|
+
r"\.so$",
|
|
57
|
+
r"\.dylib$",
|
|
58
|
+
r"\.dll$",
|
|
59
|
+
r"\.exe$",
|
|
60
|
+
# Lock files (too large, not code)
|
|
61
|
+
r"package-lock\.json$",
|
|
62
|
+
r"yarn\.lock$",
|
|
63
|
+
r"poetry\.lock$",
|
|
64
|
+
r"Pipfile\.lock$",
|
|
65
|
+
r"Cargo\.lock$",
|
|
66
|
+
r"Gemfile\.lock$",
|
|
67
|
+
r"pnpm-lock\.yaml$",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Vendor/build directory patterns (skip entire directories)
|
|
71
|
+
VENDOR_DIR_PATTERNS = {
|
|
72
|
+
"node_modules",
|
|
73
|
+
"__pycache__",
|
|
74
|
+
".git",
|
|
75
|
+
".svn",
|
|
76
|
+
".hg",
|
|
77
|
+
"venv",
|
|
78
|
+
"env",
|
|
79
|
+
".venv",
|
|
80
|
+
".env",
|
|
81
|
+
"virtualenv",
|
|
82
|
+
"build",
|
|
83
|
+
"dist",
|
|
84
|
+
"target", # Rust/Java
|
|
85
|
+
".tox",
|
|
86
|
+
".pytest_cache",
|
|
87
|
+
".mypy_cache",
|
|
88
|
+
".ruff_cache",
|
|
89
|
+
"htmlcov",
|
|
90
|
+
"coverage",
|
|
91
|
+
".coverage",
|
|
92
|
+
".eggs",
|
|
93
|
+
"*.egg-info",
|
|
94
|
+
".gradle",
|
|
95
|
+
"gradle",
|
|
96
|
+
"bin", # Common binary dir
|
|
97
|
+
"obj", # C#/C++ object files
|
|
98
|
+
".next", # Next.js
|
|
99
|
+
".nuxt", # Nuxt.js
|
|
100
|
+
"bower_components",
|
|
101
|
+
"jspm_packages",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Compiled regex for generated file detection
|
|
105
|
+
_GENERATED_REGEX = [re.compile(pattern) for pattern in GENERATED_PATTERNS]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class FileProcessor:
|
|
109
|
+
"""File discovery and chunking orchestrator.
|
|
110
|
+
|
|
111
|
+
Walks a project directory tree, applying ignore patterns and
|
|
112
|
+
filtering rules, then coordinates chunking of discovered files.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, project_root: Path) -> None:
|
|
116
|
+
"""Initialize processor.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
project_root: Absolute path to project root
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
IndexingError: If project root invalid
|
|
123
|
+
"""
|
|
124
|
+
# Validate project root
|
|
125
|
+
if not project_root.is_dir():
|
|
126
|
+
raise IndexingError(
|
|
127
|
+
ErrorCode.SECURITY_PATH_INVALID,
|
|
128
|
+
f"Project root is not a directory: {project_root}",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
self.project_root = project_root.resolve()
|
|
132
|
+
|
|
133
|
+
# Load ignore patterns
|
|
134
|
+
self.gitignore_spec = self._load_gitignore()
|
|
135
|
+
self.contextualignore_spec = self._load_contextualignore()
|
|
136
|
+
|
|
137
|
+
def _load_gitignore(self) -> pathspec.PathSpec | None:
|
|
138
|
+
"""Load .gitignore patterns from project root."""
|
|
139
|
+
gitignore_path = self.project_root / ".gitignore"
|
|
140
|
+
if not gitignore_path.exists():
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
with open(gitignore_path, encoding="utf-8") as f:
|
|
145
|
+
patterns = f.read().splitlines()
|
|
146
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.warning(
|
|
149
|
+
"gitignore_load_failed",
|
|
150
|
+
path=str(gitignore_path),
|
|
151
|
+
error=str(e),
|
|
152
|
+
)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def _load_contextualignore(self) -> pathspec.PathSpec | None:
|
|
156
|
+
"""Load .contextualignore patterns from project root."""
|
|
157
|
+
ignore_path = self.project_root / ".contextualignore"
|
|
158
|
+
if not ignore_path.exists():
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
with open(ignore_path, encoding="utf-8") as f:
|
|
163
|
+
patterns = f.read().splitlines()
|
|
164
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.warning(
|
|
167
|
+
"contextualignore_load_failed",
|
|
168
|
+
path=str(ignore_path),
|
|
169
|
+
error=str(e),
|
|
170
|
+
)
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def discover_files(self) -> list[Path]:
|
|
174
|
+
"""Discover all indexable files in project.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
List of absolute paths to files that should be chunked
|
|
178
|
+
"""
|
|
179
|
+
discovered = []
|
|
180
|
+
|
|
181
|
+
for path in self._walk_directory(self.project_root):
|
|
182
|
+
# Get relative path for pattern matching
|
|
183
|
+
try:
|
|
184
|
+
rel_path = path.relative_to(self.project_root)
|
|
185
|
+
except ValueError:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# Apply filters
|
|
189
|
+
if self._should_skip(path, rel_path):
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# Check if language supported
|
|
193
|
+
language = detect_language(path)
|
|
194
|
+
if not language:
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
discovered.append(path)
|
|
198
|
+
|
|
199
|
+
logger.info(
|
|
200
|
+
"file_discovery_complete",
|
|
201
|
+
total_files=len(discovered),
|
|
202
|
+
project_root=str(self.project_root),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return discovered
|
|
206
|
+
|
|
207
|
+
def _walk_directory(self, root: Path) -> list[Path]:
|
|
208
|
+
"""Recursively walk directory tree.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
root: Directory to walk
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of file paths (not directories)
|
|
215
|
+
"""
|
|
216
|
+
files = []
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
for item in root.iterdir():
|
|
220
|
+
# Skip vendor directories
|
|
221
|
+
if item.is_dir():
|
|
222
|
+
if self._is_vendor_dir(item):
|
|
223
|
+
continue
|
|
224
|
+
# Recurse
|
|
225
|
+
files.extend(self._walk_directory(item))
|
|
226
|
+
else:
|
|
227
|
+
files.append(item)
|
|
228
|
+
except PermissionError:
|
|
229
|
+
logger.warning("permission_denied", path=str(root))
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.error("walk_error", path=str(root), error=str(e))
|
|
232
|
+
|
|
233
|
+
return files
|
|
234
|
+
|
|
235
|
+
def _is_vendor_dir(self, path: Path) -> bool:
|
|
236
|
+
"""Check if directory is a vendor/build directory to skip."""
|
|
237
|
+
dir_name = path.name
|
|
238
|
+
|
|
239
|
+
# Exact match
|
|
240
|
+
if dir_name in VENDOR_DIR_PATTERNS:
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
# Glob patterns (e.g., *.egg-info)
|
|
244
|
+
for pattern in VENDOR_DIR_PATTERNS:
|
|
245
|
+
if "*" in pattern:
|
|
246
|
+
if path.match(pattern):
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
def _should_skip(self, path: Path, rel_path: Path) -> bool:
|
|
252
|
+
"""Check if file should be skipped.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
path: Absolute path
|
|
256
|
+
rel_path: Path relative to project root
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
True if file should be skipped
|
|
260
|
+
"""
|
|
261
|
+
rel_path_str = str(rel_path)
|
|
262
|
+
|
|
263
|
+
# Check .gitignore
|
|
264
|
+
if self.gitignore_spec and self.gitignore_spec.match_file(rel_path_str):
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
# Check .contextualignore
|
|
268
|
+
if self.contextualignore_spec and self.contextualignore_spec.match_file(
|
|
269
|
+
rel_path_str
|
|
270
|
+
):
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
# Check generated file patterns
|
|
274
|
+
if self._is_generated_file(path):
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
def _is_generated_file(self, path: Path) -> bool:
|
|
280
|
+
"""Check if file matches generated file patterns."""
|
|
281
|
+
path_str = str(path)
|
|
282
|
+
|
|
283
|
+
# Check regex patterns
|
|
284
|
+
for regex in _GENERATED_REGEX:
|
|
285
|
+
if regex.search(path_str):
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
# Check file content for generation markers
|
|
289
|
+
if self._has_generation_marker(path):
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
def _has_generation_marker(self, path: Path) -> bool:
|
|
295
|
+
"""Check if file has auto-generation markers in first few lines.
|
|
296
|
+
|
|
297
|
+
Reads first 10 lines to detect comments like:
|
|
298
|
+
- // Code generated ... DO NOT EDIT
|
|
299
|
+
- # AUTO-GENERATED
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
with open(path, "rb") as f:
|
|
303
|
+
# Read first 1KB (usually enough for headers)
|
|
304
|
+
header = f.read(1024)
|
|
305
|
+
|
|
306
|
+
# Decode and check first few lines
|
|
307
|
+
text = header.decode("utf-8", errors="ignore")
|
|
308
|
+
first_lines = text.split("\n")[:10]
|
|
309
|
+
|
|
310
|
+
generation_markers = [
|
|
311
|
+
"CODE GENERATED",
|
|
312
|
+
"AUTO-GENERATED",
|
|
313
|
+
"AUTOGENERATED",
|
|
314
|
+
"DO NOT EDIT",
|
|
315
|
+
"Generated by",
|
|
316
|
+
"This file is automatically generated",
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
for line in first_lines:
|
|
320
|
+
line_upper = line.upper()
|
|
321
|
+
for marker in generation_markers:
|
|
322
|
+
if marker in line_upper:
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
except Exception:
|
|
328
|
+
# If we can't read the file, don't skip it based on this check
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
def process_files(
|
|
332
|
+
self,
|
|
333
|
+
file_paths: list[Path] | None = None,
|
|
334
|
+
max_file_size: int = 2 * 1024 * 1024,
|
|
335
|
+
) -> list[Chunk]:
|
|
336
|
+
"""Process files and generate chunks.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
file_paths: Specific files to process (None = discover all)
|
|
340
|
+
max_file_size: Maximum file size in bytes (default 2MB)
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
List of all generated chunks
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
IndexingError: If processing fails
|
|
347
|
+
"""
|
|
348
|
+
# Discover files if not provided
|
|
349
|
+
if file_paths is None:
|
|
350
|
+
file_paths = self.discover_files()
|
|
351
|
+
|
|
352
|
+
all_chunks = []
|
|
353
|
+
processed_count = 0
|
|
354
|
+
skipped_count = 0
|
|
355
|
+
|
|
356
|
+
for file_path in file_paths:
|
|
357
|
+
try:
|
|
358
|
+
# Validate path security
|
|
359
|
+
file_path = safe_path(file_path, self.project_root)
|
|
360
|
+
|
|
361
|
+
# Check file size
|
|
362
|
+
file_size = file_path.stat().st_size
|
|
363
|
+
if file_size > max_file_size:
|
|
364
|
+
logger.warning(
|
|
365
|
+
"file_too_large",
|
|
366
|
+
path=str(file_path),
|
|
367
|
+
size=file_size,
|
|
368
|
+
max_size=max_file_size,
|
|
369
|
+
)
|
|
370
|
+
skipped_count += 1
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
# Read file content
|
|
374
|
+
with open(file_path, "rb") as f:
|
|
375
|
+
content = f.read()
|
|
376
|
+
|
|
377
|
+
# Detect language
|
|
378
|
+
language = detect_language(file_path)
|
|
379
|
+
if not language:
|
|
380
|
+
skipped_count += 1
|
|
381
|
+
continue
|
|
382
|
+
|
|
383
|
+
# Get relative path
|
|
384
|
+
rel_path = file_path.relative_to(self.project_root)
|
|
385
|
+
|
|
386
|
+
# Chunk the file
|
|
387
|
+
chunks = chunk_file(rel_path, content, language)
|
|
388
|
+
all_chunks.extend(chunks)
|
|
389
|
+
processed_count += 1
|
|
390
|
+
|
|
391
|
+
logger.debug(
|
|
392
|
+
"file_processed",
|
|
393
|
+
path=str(rel_path),
|
|
394
|
+
language=language,
|
|
395
|
+
chunks=len(chunks),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
except Exception as e:
|
|
399
|
+
logger.error(
|
|
400
|
+
"file_processing_failed",
|
|
401
|
+
path=str(file_path),
|
|
402
|
+
error=str(e),
|
|
403
|
+
)
|
|
404
|
+
skipped_count += 1
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
logger.info(
|
|
408
|
+
"processing_complete",
|
|
409
|
+
processed=processed_count,
|
|
410
|
+
skipped=skipped_count,
|
|
411
|
+
total_chunks=len(all_chunks),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return all_chunks
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def index_project(
|
|
418
|
+
project_root: Path,
|
|
419
|
+
file_paths: list[Path] | None = None,
|
|
420
|
+
max_file_size: int = 2 * 1024 * 1024,
|
|
421
|
+
) -> list[Chunk]:
|
|
422
|
+
"""Index a project directory.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
project_root: Absolute path to project root
|
|
426
|
+
file_paths: Specific files to index (None = discover all)
|
|
427
|
+
max_file_size: Maximum file size in bytes
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
List of all generated chunks
|
|
431
|
+
|
|
432
|
+
Raises:
|
|
433
|
+
IndexingError: If indexing fails
|
|
434
|
+
"""
|
|
435
|
+
processor = FileProcessor(project_root)
|
|
436
|
+
return processor.process_files(file_paths, max_file_size)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Tree-sitter Query Files
|
|
2
|
+
|
|
3
|
+
This directory is reserved for Tree-sitter .scm query files for symbol extraction.
|
|
4
|
+
|
|
5
|
+
## Current Status
|
|
6
|
+
Symbol extraction is currently implemented via direct AST traversal in `symbol_extractor.py`.
|
|
7
|
+
Tree-sitter query files (.scm) are an optional enhancement for more precise extraction.
|
|
8
|
+
|
|
9
|
+
## Planned Files
|
|
10
|
+
- python.scm - Python symbol queries
|
|
11
|
+
- typescript.scm - TypeScript symbol queries
|
|
12
|
+
- javascript.scm - JavaScript symbol queries
|
|
13
|
+
- rust.scm - Rust symbol queries
|
|
14
|
+
- go.scm - Go symbol queries
|
|
15
|
+
|
|
16
|
+
## When to Use
|
|
17
|
+
.scm query files will be used when:
|
|
18
|
+
1. More precise extraction is needed
|
|
19
|
+
2. Language-specific edge cases require custom queries
|
|
20
|
+
3. Performance optimization is needed
|
|
21
|
+
|
|
22
|
+
For Phase 1, direct AST traversal is sufficient.
|