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