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,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.