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,691 @@
1
+ """Tree-sitter AST-based code chunking engine.
2
+
3
+ Implements the Sweep split-merge algorithm (validated as cAST in arXiv:2506.15655)
4
+ with deterministic structural headers for embedding. Every chunk gets context
5
+ about its file location, parent scope, imports, and decorators - reducing retrieval
6
+ errors by ~35% per Anthropic's contextual retrieval research.
7
+
8
+ Key invariants:
9
+ - Bytes not chars (tree-sitter indexes in bytes)
10
+ - 1,500 byte target / 2,000 max / 50 min
11
+ - Content-hash keyed (SHA-256) for incremental re-embedding
12
+ - Thread-safe parser pool (Parser objects are NOT thread-safe)
13
+ - Never split mid-function (preserve AST structural boundaries)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import threading
20
+ from collections import defaultdict
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, cast
24
+
25
+ from tree_sitter_language_pack import SupportedLanguage, get_parser
26
+
27
+ from contextual.core.errors import ErrorCode, IndexingError
28
+ from contextual.core.models import Chunk, ChunkType, Language, ModelType
29
+ from contextual.observability import get_logger
30
+
31
+
32
+ if TYPE_CHECKING:
33
+ from tree_sitter import Node, Parser, Tree
34
+
35
+ logger = get_logger(__name__)
36
+
37
+ # Chunking parameters from TECHNICAL_SPEC §6
38
+ TARGET_CHUNK_BYTES = 1500
39
+ MAX_CHUNK_BYTES = 2000
40
+ MIN_CHUNK_BYTES = 50
41
+ MAX_FILE_BYTES = 2 * 1024 * 1024 # 2 MB
42
+ FALLBACK_THRESHOLD_BYTES = 500 * 1024 # 500 KB
43
+
44
+ # Language mappings (extension → tree-sitter language name)
45
+ LANGUAGE_MAP = {
46
+ # Code languages
47
+ ".py": "python",
48
+ ".ts": "typescript",
49
+ ".tsx": "tsx",
50
+ ".js": "javascript",
51
+ ".jsx": "javascript", # JSX uses javascript parser
52
+ ".go": "go",
53
+ ".java": "java",
54
+ ".rs": "rust",
55
+ ".cs": "c_sharp",
56
+ # Config formats
57
+ ".json": "json",
58
+ ".yaml": "yaml",
59
+ ".yml": "yaml",
60
+ ".toml": "toml",
61
+ "Dockerfile": "dockerfile",
62
+ ".md": "markdown",
63
+ }
64
+
65
+ # Top-level node types per language (from TECHNICAL_SPEC §6.1)
66
+ TOP_LEVEL_NODES = {
67
+ "python": {"function_definition", "class_definition", "decorated_definition"},
68
+ "typescript": {
69
+ "function_declaration",
70
+ "class_declaration",
71
+ "interface_declaration",
72
+ "export_statement",
73
+ "lexical_declaration",
74
+ },
75
+ "tsx": {
76
+ "function_declaration",
77
+ "class_declaration",
78
+ "interface_declaration",
79
+ "export_statement",
80
+ "lexical_declaration",
81
+ },
82
+ "javascript": {
83
+ "function_declaration",
84
+ "class_declaration",
85
+ "export_statement",
86
+ "lexical_declaration",
87
+ },
88
+ "go": {"function_declaration", "type_declaration", "method_declaration"},
89
+ "java": {"class_declaration", "method_declaration", "interface_declaration"},
90
+ "rust": {
91
+ "function_item",
92
+ "impl_item",
93
+ "struct_item",
94
+ "enum_item",
95
+ "trait_item",
96
+ },
97
+ "c_sharp": {"class_declaration", "method_declaration", "interface_declaration"},
98
+ }
99
+
100
+
101
+ @dataclass
102
+ class ParsedNode:
103
+ """Wrapper for tree-sitter nodes with byte ranges."""
104
+
105
+ node: Node
106
+ start_byte: int
107
+ end_byte: int
108
+ node_type: str
109
+ text: bytes
110
+
111
+ @property
112
+ def size_bytes(self) -> int:
113
+ """Size in bytes."""
114
+ return self.end_byte - self.start_byte
115
+
116
+
117
+ class ParserPool:
118
+ """Thread-safe parser pool.
119
+
120
+ tree_sitter.Parser is NOT thread-safe. We maintain a pool of parsers
121
+ per language and hand them out with thread-local storage for safety.
122
+ """
123
+
124
+ def __init__(self) -> None:
125
+ self._parsers: dict[str, list[Parser]] = defaultdict(list)
126
+ self._lock = threading.Lock()
127
+ self._local = threading.local()
128
+
129
+ def get_parser(self, language: str) -> Parser:
130
+ """Get or create a parser for this language in this thread."""
131
+ # Check thread-local cache first
132
+ cache_key = f"parser_{language}"
133
+ if hasattr(self._local, cache_key):
134
+ return getattr(self._local, cache_key)
135
+
136
+ # Try to get from pool
137
+ with self._lock:
138
+ if self._parsers[language]:
139
+ parser = self._parsers[language].pop()
140
+ else:
141
+ # Create new parser
142
+ parser = get_parser(cast("SupportedLanguage", language))
143
+
144
+ # Cache in thread-local
145
+ setattr(self._local, cache_key, parser)
146
+ return parser
147
+
148
+ def return_parser(self, language: str, parser: Parser) -> None:
149
+ """Return parser to pool (for cross-thread scenarios)."""
150
+ with self._lock:
151
+ self._parsers[language].append(parser)
152
+
153
+
154
+ # Global parser pool
155
+ _parser_pool = ParserPool()
156
+
157
+
158
+ class ASTChunker:
159
+ """AST-based code chunker using tree-sitter.
160
+
161
+ Implements split-merge algorithm:
162
+ 1. Parse AST and extract top-level nodes
163
+ 2. Merge small adjacent nodes until hitting target size
164
+ 3. Split large nodes recursively at structural boundaries
165
+ 4. Attach structural headers (file, parent, imports, decorators)
166
+ """
167
+
168
+ def __init__(self, file_path: Path, content: bytes, language: str) -> None:
169
+ """Initialize chunker.
170
+
171
+ Args:
172
+ file_path: Relative path from project root
173
+ content: File content as bytes
174
+ language: Language identifier (python, typescript, etc.)
175
+
176
+ Raises:
177
+ IndexingError: If file too large or parsing fails
178
+ """
179
+ self.file_path = file_path
180
+ self.content = content
181
+ self.language = language
182
+ self.relative_path = str(file_path)
183
+
184
+ # Size check
185
+ if len(content) > MAX_FILE_BYTES:
186
+ raise IndexingError(
187
+ ErrorCode.INDEXING_CHUNKER_SPLIT_FAILED,
188
+ f"File too large: {len(content)} bytes (max {MAX_FILE_BYTES})",
189
+ )
190
+
191
+ # Parse AST
192
+ self.parser = _parser_pool.get_parser(language)
193
+ self.tree: Tree = self.parser.parse(content)
194
+ self.root = self.tree.root_node
195
+
196
+ # Extract structural context
197
+ self.imports = self._extract_imports()
198
+ self.decorators: dict[int, list[str]] = {} # node_id → decorator names
199
+
200
+ def chunk(self) -> list[Chunk]:
201
+ """Chunk the file into AST-aware segments.
202
+
203
+ Returns:
204
+ List of Chunk objects with headers, bodies, and metadata
205
+ """
206
+ # Extract top-level nodes
207
+ top_level = self._extract_top_level_nodes()
208
+
209
+ if not top_level:
210
+ # Fallback: treat entire file as one chunk if no structure
211
+ logger.warning(
212
+ "no_top_level_nodes",
213
+ file=self.relative_path,
214
+ language=self.language,
215
+ )
216
+ return self._create_fallback_chunk()
217
+
218
+ # Split-merge algorithm
219
+ chunks = self._split_merge(top_level)
220
+
221
+ # Build Chunk objects with headers
222
+ return [self._build_chunk(parsed_node) for parsed_node in chunks]
223
+
224
+ def _extract_top_level_nodes(self) -> list[ParsedNode]:
225
+ """Extract top-level structural nodes (functions, classes, etc.)."""
226
+ if self.language not in TOP_LEVEL_NODES:
227
+ # Config files: split on reasonable boundaries or whole file
228
+ return self._extract_config_chunks()
229
+
230
+ target_types = TOP_LEVEL_NODES[self.language]
231
+ nodes = []
232
+
233
+ def visit(node: Node) -> None:
234
+ """Recursively visit nodes, collecting top-level targets."""
235
+ if node.type in target_types:
236
+ nodes.append(
237
+ ParsedNode(
238
+ node=node,
239
+ start_byte=node.start_byte,
240
+ end_byte=node.end_byte,
241
+ node_type=node.type,
242
+ text=self.content[node.start_byte : node.end_byte],
243
+ )
244
+ )
245
+ # Don't recurse into matched nodes
246
+ return
247
+
248
+ # Recurse into children
249
+ for child in node.children:
250
+ visit(child)
251
+
252
+ visit(self.root)
253
+ return nodes
254
+
255
+ def _extract_config_chunks(self) -> list[ParsedNode]:
256
+ """Extract chunks from config files (JSON, YAML, TOML, etc.).
257
+
258
+ For config files, we split on reasonable boundaries like top-level
259
+ keys or sections, with sensible fallback to whole-file.
260
+ """
261
+ # Simple strategy: whole file if small, line-based split if large
262
+ if len(self.content) <= MAX_CHUNK_BYTES:
263
+ return [
264
+ ParsedNode(
265
+ node=self.root,
266
+ start_byte=0,
267
+ end_byte=len(self.content),
268
+ node_type="document",
269
+ text=self.content,
270
+ )
271
+ ]
272
+
273
+ # Line-based fallback
274
+ return self._line_based_chunks()
275
+
276
+ def _line_based_chunks(self) -> list[ParsedNode]:
277
+ """Fallback: split on line boundaries when AST strategy fails."""
278
+ lines = self.content.split(b"\n")
279
+ chunks = []
280
+ current_chunk = []
281
+ current_bytes = 0
282
+
283
+ for line in lines:
284
+ line_bytes = len(line) + 1 # +1 for newline
285
+ if current_bytes + line_bytes > TARGET_CHUNK_BYTES and current_chunk:
286
+ # Flush current chunk
287
+ chunk_text = b"\n".join(current_chunk) + b"\n"
288
+ chunks.append(
289
+ ParsedNode(
290
+ node=self.root, # Placeholder
291
+ start_byte=0, # Will fix in post-processing
292
+ end_byte=len(chunk_text),
293
+ node_type="line_chunk",
294
+ text=chunk_text,
295
+ )
296
+ )
297
+ current_chunk = [line]
298
+ current_bytes = line_bytes
299
+ else:
300
+ current_chunk.append(line)
301
+ current_bytes += line_bytes
302
+
303
+ # Final chunk
304
+ if current_chunk:
305
+ chunk_text = b"\n".join(current_chunk) + b"\n"
306
+ chunks.append(
307
+ ParsedNode(
308
+ node=self.root,
309
+ start_byte=0,
310
+ end_byte=len(chunk_text),
311
+ node_type="line_chunk",
312
+ text=chunk_text,
313
+ )
314
+ )
315
+
316
+ return chunks
317
+
318
+ def _split_merge(self, nodes: list[ParsedNode]) -> list[ParsedNode]:
319
+ """Split-merge algorithm.
320
+
321
+ 1. Merge adjacent small nodes until target size
322
+ 2. Split large nodes at structural boundaries
323
+ 3. Keep complete functions together (never split mid-function)
324
+ """
325
+ result = []
326
+ i = 0
327
+
328
+ while i < len(nodes):
329
+ current = nodes[i]
330
+
331
+ # If node is under max, try to merge with next nodes
332
+ if current.size_bytes < MAX_CHUNK_BYTES:
333
+ merged = self._merge_forward(nodes, i)
334
+ result.append(merged)
335
+ i += merged.end_byte # Skip merged nodes (placeholder logic)
336
+ else:
337
+ # Node too large, try to split
338
+ split_nodes = self._split_node(current)
339
+ result.extend(split_nodes)
340
+
341
+ i += 1
342
+
343
+ return result
344
+
345
+ def _merge_forward(self, nodes: list[ParsedNode], start_idx: int) -> ParsedNode:
346
+ """Merge nodes forward from start_idx until hitting target or max size.
347
+
348
+ Returns:
349
+ Merged ParsedNode
350
+ """
351
+ merged_start = nodes[start_idx].start_byte
352
+ merged_end = nodes[start_idx].end_byte
353
+ merged_type = nodes[start_idx].node_type
354
+ last_idx = start_idx
355
+
356
+ for i in range(start_idx + 1, len(nodes)):
357
+ candidate_end = nodes[i].end_byte
358
+ candidate_size = candidate_end - merged_start
359
+
360
+ if candidate_size > MAX_CHUNK_BYTES:
361
+ break
362
+
363
+ merged_end = candidate_end
364
+ last_idx = i
365
+
366
+ if candidate_size >= TARGET_CHUNK_BYTES:
367
+ break
368
+
369
+ merged_text = self.content[merged_start:merged_end]
370
+ result = ParsedNode(
371
+ node=nodes[start_idx].node, # Use first node as representative
372
+ start_byte=merged_start,
373
+ end_byte=merged_end,
374
+ node_type=merged_type,
375
+ text=merged_text,
376
+ )
377
+ result.end_byte = last_idx # Hack: store how many nodes we merged
378
+ return result
379
+
380
+ def _split_node(self, node: ParsedNode) -> list[ParsedNode]:
381
+ """Split a large node at structural boundaries.
382
+
383
+ Strategy: Find child nodes and split recursively, or fall back to
384
+ byte-based splitting at safe boundaries (newlines).
385
+ """
386
+ # Try to split at child boundaries
387
+ if node.node.child_count > 1:
388
+ children = []
389
+ for child in node.node.children:
390
+ if child.end_byte - child.start_byte >= MIN_CHUNK_BYTES:
391
+ children.append(
392
+ ParsedNode(
393
+ node=child,
394
+ start_byte=child.start_byte,
395
+ end_byte=child.end_byte,
396
+ node_type=child.type,
397
+ text=self.content[child.start_byte : child.end_byte],
398
+ )
399
+ )
400
+
401
+ if children:
402
+ # Recursively split-merge children
403
+ return self._split_merge(children)
404
+
405
+ # Fallback: split at newline boundaries
406
+ return self._split_at_newlines(node)
407
+
408
+ def _split_at_newlines(self, node: ParsedNode) -> list[ParsedNode]:
409
+ """Split large node at newline boundaries as last resort."""
410
+ text = node.text
411
+ chunks = []
412
+ current_start = 0
413
+
414
+ lines = text.split(b"\n")
415
+ current_chunk = []
416
+ current_bytes = 0
417
+
418
+ for line in lines:
419
+ line_bytes = len(line) + 1
420
+ if current_bytes + line_bytes > TARGET_CHUNK_BYTES and current_chunk:
421
+ chunk_text = b"\n".join(current_chunk) + b"\n"
422
+ chunks.append(chunk_text)
423
+ current_chunk = [line]
424
+ current_bytes = line_bytes
425
+ else:
426
+ current_chunk.append(line)
427
+ current_bytes += line_bytes
428
+
429
+ if current_chunk:
430
+ chunk_text = b"\n".join(current_chunk)
431
+ chunks.append(chunk_text)
432
+
433
+ # Convert to ParsedNode objects
434
+ return [
435
+ ParsedNode(
436
+ node=node.node,
437
+ start_byte=0,
438
+ end_byte=len(chunk),
439
+ node_type=node.node_type,
440
+ text=chunk,
441
+ )
442
+ for chunk in chunks
443
+ if len(chunk) >= MIN_CHUNK_BYTES
444
+ ]
445
+
446
+ def _extract_imports(self) -> list[str]:
447
+ """Extract import statements from the file."""
448
+ imports = []
449
+
450
+ # Language-specific import extraction
451
+ if self.language == "python":
452
+ imports = self._extract_python_imports()
453
+ elif self.language in {"typescript", "tsx", "javascript"}:
454
+ imports = self._extract_js_imports()
455
+ # Add more languages as needed
456
+
457
+ return imports
458
+
459
+ def _extract_python_imports(self) -> list[str]:
460
+ """Extract Python import statements."""
461
+ imports = []
462
+
463
+ def visit(node: Node) -> None:
464
+ if node.type in {"import_statement", "import_from_statement"}:
465
+ import_text = self.content[node.start_byte : node.end_byte].decode(
466
+ "utf-8", errors="replace"
467
+ )
468
+ imports.append(import_text.strip())
469
+ for child in node.children:
470
+ visit(child)
471
+
472
+ visit(self.root)
473
+ return imports
474
+
475
+ def _extract_js_imports(self) -> list[str]:
476
+ """Extract JavaScript/TypeScript import statements."""
477
+ imports = []
478
+
479
+ def visit(node: Node) -> None:
480
+ if node.type in {"import_statement", "import_declaration"}:
481
+ import_text = self.content[node.start_byte : node.end_byte].decode(
482
+ "utf-8", errors="replace"
483
+ )
484
+ imports.append(import_text.strip())
485
+ for child in node.children:
486
+ visit(child)
487
+
488
+ visit(self.root)
489
+ return imports
490
+
491
+ def _get_parent_scope(self, node: Node) -> str | None:
492
+ """Get parent class or module for a node."""
493
+ current = node.parent
494
+ while current:
495
+ if current.type in {"class_definition", "class_declaration"}:
496
+ # Find class name
497
+ for child in current.children:
498
+ if child.type in {"identifier", "type_identifier"}:
499
+ return self.content[child.start_byte : child.end_byte].decode(
500
+ "utf-8", errors="replace"
501
+ )
502
+ current = current.parent
503
+ return None
504
+
505
+ def _extract_symbol_name(self, node: Node) -> str | None:
506
+ """Extract function/class/method name from node."""
507
+ # Look for identifier child nodes
508
+ for child in node.children:
509
+ if child.type in {"identifier", "type_identifier", "property_identifier"}:
510
+ return self.content[child.start_byte : child.end_byte].decode(
511
+ "utf-8", errors="replace"
512
+ )
513
+ return None
514
+
515
+ def _build_chunk(self, parsed_node: ParsedNode) -> Chunk:
516
+ """Build a Chunk object with structural header.
517
+
518
+ Header format (TECHNICAL_SPEC §6.2):
519
+ FILE: <relative_path>
520
+ LANG: <language>
521
+ PARENT: <parent_class_or_module>
522
+ IMPORTS:
523
+ <imports_or_none>
524
+ DECORATORS:
525
+ <decorators_or_none>
526
+ -----
527
+ <chunk_body>
528
+ """
529
+ # Extract parent scope
530
+ parent_scope = self._get_parent_scope(parsed_node.node)
531
+
532
+ # Build header
533
+ header_lines = [
534
+ f"FILE: {self.relative_path}",
535
+ f"LANG: {self.language}",
536
+ f"PARENT: {parent_scope or 'none'}",
537
+ "IMPORTS:",
538
+ ]
539
+
540
+ if self.imports:
541
+ header_lines.extend(self.imports)
542
+ else:
543
+ header_lines.append("none")
544
+
545
+ header_lines.append("DECORATORS:")
546
+ # TODO: Extract decorators per node
547
+ header_lines.append("none")
548
+ header_lines.append("-----")
549
+
550
+ header = "\n".join(header_lines)
551
+
552
+ # Body is the actual code
553
+ body = parsed_node.text.decode("utf-8", errors="replace")
554
+
555
+ # Full text for hashing
556
+ full_text = f"{header}\n{body}"
557
+
558
+ # Content hash (SHA-256)
559
+ content_hash = hashlib.sha256(full_text.encode("utf-8")).hexdigest()
560
+
561
+ # Extract symbol name from node
562
+ symbol_name = self._extract_symbol_name(parsed_node.node)
563
+
564
+ # Map language to enum
565
+ try:
566
+ language_enum = Language(self.language)
567
+ except ValueError:
568
+ language_enum = Language.PYTHON # Fallback
569
+
570
+ # Map chunk type to enum
571
+ chunk_type_map = {
572
+ "function_definition": ChunkType.FUNCTION,
573
+ "function_declaration": ChunkType.FUNCTION,
574
+ "method_declaration": ChunkType.METHOD,
575
+ "class_definition": ChunkType.CLASS,
576
+ "class_declaration": ChunkType.CLASS,
577
+ "interface_declaration": ChunkType.INTERFACE,
578
+ "module": ChunkType.MODULE,
579
+ "file": ChunkType.MODULE,
580
+ "line_chunk": ChunkType.MODULE,
581
+ }
582
+ chunk_type_enum = chunk_type_map.get(
583
+ parsed_node.node_type, ChunkType.MODULE
584
+ )
585
+
586
+ # Extract start and end lines
587
+ start_line = parsed_node.node.start_point[0]
588
+ end_line = parsed_node.node.end_point[0]
589
+
590
+ return Chunk(
591
+ vector=[0.0] * 768, # Empty, filled during embedding phase
592
+ path=self.relative_path,
593
+ language=language_enum,
594
+ symbol_name=symbol_name,
595
+ chunk_type=chunk_type_enum,
596
+ content_hash=content_hash,
597
+ header=header,
598
+ body=body,
599
+ start_line=start_line,
600
+ end_line=end_line,
601
+ model_type=ModelType.JINA_CODE_V2, # Default model
602
+ )
603
+
604
+ def _create_fallback_chunk(self) -> list[Chunk]:
605
+ """Create single chunk when no structure found."""
606
+ header_lines = [
607
+ f"FILE: {self.relative_path}",
608
+ f"LANG: {self.language}",
609
+ "PARENT: none",
610
+ "IMPORTS:",
611
+ "none",
612
+ "DECORATORS:",
613
+ "none",
614
+ "-----",
615
+ ]
616
+ header = "\n".join(header_lines)
617
+ body = self.content.decode("utf-8", errors="replace")
618
+ full_text = f"{header}\n{body}"
619
+ content_hash = hashlib.sha256(full_text.encode("utf-8")).hexdigest()
620
+
621
+ # Map language to enum
622
+ try:
623
+ language_enum = Language(self.language)
624
+ except ValueError:
625
+ language_enum = Language.PYTHON # Fallback
626
+
627
+ return [
628
+ Chunk(
629
+ vector=[0.0] * 768,
630
+ path=self.relative_path,
631
+ language=language_enum,
632
+ symbol_name=None,
633
+ chunk_type=ChunkType.MODULE,
634
+ content_hash=content_hash,
635
+ header=header,
636
+ body=body,
637
+ start_line=0,
638
+ end_line=len(body.splitlines()),
639
+ model_type=ModelType.JINA_CODE_V2,
640
+ )
641
+ ]
642
+
643
+
644
+ def detect_language(file_path: Path) -> str | None:
645
+ """Detect language from file extension.
646
+
647
+ Args:
648
+ file_path: Path to file
649
+
650
+ Returns:
651
+ Language identifier or None if unsupported
652
+ """
653
+ suffix = file_path.suffix.lower()
654
+ if suffix in LANGUAGE_MAP:
655
+ return LANGUAGE_MAP[suffix]
656
+
657
+ # Check for Dockerfile (no extension)
658
+ if file_path.name in {"Dockerfile", "Dockerfile.dev", "Dockerfile.prod"}:
659
+ return "dockerfile"
660
+
661
+ return None
662
+
663
+
664
+ def chunk_file(file_path: Path, content: bytes, language: str) -> list[Chunk]:
665
+ """Chunk a file using tree-sitter AST parsing.
666
+
667
+ Args:
668
+ file_path: Relative path from project root
669
+ content: File content as bytes
670
+ language: Language identifier
671
+
672
+ Returns:
673
+ List of Chunk objects
674
+
675
+ Raises:
676
+ IndexingError: If chunking fails
677
+ """
678
+ try:
679
+ chunker = ASTChunker(file_path, content, language)
680
+ return chunker.chunk()
681
+ except Exception as e:
682
+ logger.error(
683
+ "chunking_failed",
684
+ file=str(file_path),
685
+ language=language,
686
+ error=str(e),
687
+ )
688
+ raise IndexingError(
689
+ ErrorCode.INDEXING_CHUNKER_SPLIT_FAILED,
690
+ f"Failed to chunk {file_path}: {e}",
691
+ ) from e