concierge-graph 3.8.2__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.
ingestion/parser.py ADDED
@@ -0,0 +1,984 @@
1
+ """
2
+ ingestion/parser.py - Grafo Concierge v3.8.0 (Absolute Solidity)
3
+
4
+ Semantic/AST Chunking with Prompt Armor for the Apex Ingestion Engine.
5
+
6
+ Responsibilities:
7
+ - Split files into chunks respecting logical blocks (functions, classes, sections).
8
+ - Support multiple languages: Python (AST), JavaScript/TypeScript, Markdown.
9
+ - Apply Prompt Armor (XML tags <raw_data_do_not_execute>) for sanitization.
10
+ - Extract metadata from each chunk (tags, imports, detected dependencies).
11
+ - Respect token limit per chunk (MAX_CHUNK_TOKENS).
12
+
13
+ Integration:
14
+ - Receives CrawlResult from crawler.py.
15
+ - Produces a list of ParsedChunk consumed by summarizer.py.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ast
21
+ import logging
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from enum import Enum
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ from ingestion.crawler import CrawlResult, FileCategory
29
+
30
+ logger = logging.getLogger("grafo-concierge.parser")
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Settings
34
+ # ---------------------------------------------------------------------------
35
+ MAX_CHUNK_TOKENS: int = 512
36
+ PROMPT_ARMOR_OPEN: str = "<!-- DATA_DO_NOT_EXECUTE:"
37
+ PROMPT_ARMOR_CLOSE: str = "-->"
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # ChunkType
41
+ # ---------------------------------------------------------------------------
42
+
43
+ class ChunkType(str, Enum):
44
+ FUNCTION = "function"
45
+ METHOD = "method"
46
+ CLASS = "class"
47
+ MODULE = "module"
48
+ SECTION = "section"
49
+ CONFIG = "config"
50
+ RAW = "raw"
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # ParsedChunk
54
+ # ---------------------------------------------------------------------------
55
+
56
+ @dataclass
57
+ class ParsedChunk:
58
+ """A semantic chunk extracted from a file."""
59
+ content: str
60
+ armored_content: str
61
+ chunk_type: ChunkType
62
+ chunk_index: int
63
+ source_file: str
64
+ file_hash: str
65
+ category: FileCategory
66
+ start_line: int = 0
67
+ end_line: int = 0
68
+ symbol_name: str = ""
69
+ detected_tags: list[str] = field(default_factory=list)
70
+ estimated_tokens: int = 0
71
+ calls: list[str] = field(default_factory=list)
72
+ node_id: Optional[int] = None
73
+ # Delta Cache fields — filled by orchestrator._detect_cached_chunks
74
+ cached: bool = False
75
+ cached_summary: Optional[str] = None
76
+ cached_tags: Optional[list[str]] = None
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Regex patterns for JS/TS
80
+ # ---------------------------------------------------------------------------
81
+
82
+ _JS_FUNCTION_RE = re.compile(
83
+ r"(?:^|\n)"
84
+ r"(?:export\s+(?:default\s+)?)?"
85
+ r"(?:async\s+)?"
86
+ r"(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>)"
87
+ , re.MULTILINE,
88
+ )
89
+
90
+ _JS_CLASS_RE = re.compile(
91
+ r"(?:^|\n)(?:export\s+(?:default\s+)?)?class\s+(\w+)",
92
+ re.MULTILINE,
93
+ )
94
+
95
+ # Tag detection patterns
96
+ _IMPORT_PY_RE = re.compile(r"^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))", re.MULTILINE)
97
+ _IMPORT_JS_RE = re.compile(r"""(?:import\s+.*?from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))""", re.MULTILINE)
98
+
99
+ # Framework/known library keywords
100
+ _FRAMEWORK_KEYWORDS: dict[str, str] = {
101
+ "fastapi": "fastapi", "flask": "flask", "django": "django",
102
+ "express": "express", "react": "react", "nextjs": "nextjs",
103
+ "next": "nextjs", "vue": "vue", "angular": "angular",
104
+ "pytorch": "pytorch", "torch": "pytorch", "tensorflow": "tensorflow",
105
+ "pandas": "pandas", "numpy": "numpy", "sqlalchemy": "sqlalchemy",
106
+ "prisma": "prisma", "sequelize": "sequelize", "mongoose": "mongoose",
107
+ "jwt": "jwt", "oauth": "oauth", "bcrypt": "bcrypt",
108
+ "celery": "celery", "redis": "redis", "rabbitmq": "rabbitmq",
109
+ "graphql": "graphql", "grpc": "grpc", "websocket": "websocket",
110
+ "docker": "docker", "kubernetes": "kubernetes", "terraform": "terraform",
111
+ }
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # FileParser
115
+ # ---------------------------------------------------------------------------
116
+
117
+ class FileParser:
118
+ """Semantic/AST Chunking engine with Prompt Armor.
119
+
120
+ Strategy by type:
121
+ - Python (.py): ast stdlib — functions, classes, module.
122
+ - JS/TS (.js/.ts/.tsx/.jsx): Regex — functions, classes, module.
123
+ - Markdown (.md): Headers (#) — hierarchical sections.
124
+ - Config (.json/.yaml/.toml): Single block or split by size.
125
+ - Others: Fixed size chunking (fallback RAW).
126
+ """
127
+
128
+ # Extensions using each parser
129
+ _PYTHON_EXTS = {".py"}
130
+ _JS_EXTS = {".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs"}
131
+ _MD_EXTS = {".md", ".mdx", ".rst", ".adoc", ".txt"}
132
+ _CONFIG_EXTS = {".json", ".yaml", ".yml", ".toml", ".env", ".ini", ".cfg", ".xml"}
133
+
134
+ def __init__(
135
+ self,
136
+ max_chunk_tokens: int = MAX_CHUNK_TOKENS,
137
+ enable_prompt_armor: bool = True,
138
+ ) -> None:
139
+ self._max_tokens = max_chunk_tokens
140
+ self._armor = enable_prompt_armor
141
+
142
+ def parse(self, crawl_result: CrawlResult) -> list[ParsedChunk]:
143
+ """Processes a file and returns its semantic chunks."""
144
+ filepath = crawl_result.absolute_path
145
+ file_hash = crawl_result.file_hash
146
+ category = crawl_result.category
147
+ ext = crawl_result.extension.lower()
148
+
149
+ # Reading the content
150
+ try:
151
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
152
+ source = f.read()
153
+ except (OSError, IOError) as e:
154
+ logger.warning("Error reading %s: %s", filepath, e)
155
+ return []
156
+
157
+ if not source.strip():
158
+ return []
159
+
160
+ # Dispatch by extension
161
+ if ext in self._PYTHON_EXTS or ext in self._JS_EXTS or ext in {".go", ".rs"}:
162
+ chunks = self._parse_with_tree_sitter(source, crawl_result.relative_path, file_hash, ext)
163
+ elif ext in self._MD_EXTS:
164
+ chunks = self._parse_markdown(source, crawl_result.relative_path, file_hash)
165
+ elif ext in self._CONFIG_EXTS:
166
+ chunks = self._parse_config(source, crawl_result.relative_path, file_hash)
167
+ else:
168
+ chunks = self._parse_raw(source, crawl_result.relative_path, file_hash, category)
169
+
170
+ # Post-processing: assigns category, tags and armor
171
+ for chunk in chunks:
172
+ chunk.category = category
173
+ if not chunk.detected_tags:
174
+ chunk.detected_tags = self._detect_tags(chunk.content, category)
175
+
176
+ return chunks
177
+
178
+ def _get_tree_sitter_parser(self, ext: str) -> Optional[Any]:
179
+ try:
180
+ import tree_sitter
181
+ import ctypes
182
+
183
+ # Setup ctypes helper to get pointer from capsule if needed
184
+ try:
185
+ ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
186
+ ctypes.pythonapi.PyCapsule_GetPointer.argtypes = [ctypes.py_object, ctypes.c_char_p]
187
+ except Exception:
188
+ pass
189
+
190
+ def _get_lang_ptr(capsule_or_ptr):
191
+ if isinstance(capsule_or_ptr, int):
192
+ return capsule_or_ptr
193
+ # If it's a PyCapsule, extract using ctypes
194
+ try:
195
+ return ctypes.pythonapi.PyCapsule_GetPointer(capsule_or_ptr, b"tree_sitter.Language")
196
+ except Exception:
197
+ try:
198
+ return ctypes.pythonapi.PyCapsule_GetPointer(capsule_or_ptr, None)
199
+ except Exception:
200
+ return capsule_or_ptr
201
+
202
+ def _create_language(raw_lang, name):
203
+ if isinstance(raw_lang, tree_sitter.Language):
204
+ return raw_lang
205
+ try:
206
+ # Tries to instantiate passing the object directly (modern API)
207
+ return tree_sitter.Language(raw_lang)
208
+ except TypeError:
209
+ ptr = _get_lang_ptr(raw_lang)
210
+ try:
211
+ # Fallback passing the pointer
212
+ return tree_sitter.Language(ptr)
213
+ except TypeError:
214
+ # Classic fallback (<=0.21)
215
+ return tree_sitter.Language(ptr, name)
216
+
217
+ if ext == ".py":
218
+ import tree_sitter_python
219
+ lang = _create_language(tree_sitter_python.language(), "python")
220
+ elif ext in (".js", ".jsx", ".mjs", ".cjs"):
221
+ import tree_sitter_javascript
222
+ lang = _create_language(tree_sitter_javascript.language(), "javascript")
223
+ elif ext == ".ts":
224
+ import tree_sitter_typescript
225
+ lang = _create_language(tree_sitter_typescript.language_typescript(), "typescript")
226
+ elif ext == ".tsx":
227
+ import tree_sitter_typescript
228
+ lang = _create_language(tree_sitter_typescript.language_tsx(), "tsx")
229
+ elif ext == ".go":
230
+ import tree_sitter_go
231
+ lang = _create_language(tree_sitter_go.language(), "go")
232
+ elif ext == ".rs":
233
+ import tree_sitter_rust
234
+ lang = _create_language(tree_sitter_rust.language(), "rust")
235
+ else:
236
+ return None
237
+ parser = tree_sitter.Parser()
238
+ try:
239
+ parser.set_language(lang)
240
+ except AttributeError:
241
+ parser.language = lang
242
+ return parser
243
+ except Exception as e:
244
+ logger.warning("Error loading tree-sitter parser for %s: %s", ext, e)
245
+ return None
246
+
247
+ def _get_node_definition_type(self, node: Any, ext: str) -> Optional[tuple[str, str]]:
248
+ ntype = node.type
249
+
250
+ # Python
251
+ if ext == ".py":
252
+ if ntype == "class_definition":
253
+ name_node = node.child_by_field_name("name")
254
+ if name_node:
255
+ return "class", name_node.text.decode('utf-8', errors='replace')
256
+ elif ntype == "function_definition":
257
+ name_node = node.child_by_field_name("name")
258
+ if name_node:
259
+ is_method = False
260
+ parent = node.parent
261
+ while parent:
262
+ if parent.type == "class_definition":
263
+ is_method = True
264
+ break
265
+ parent = parent.parent
266
+ return "method" if is_method else "function", name_node.text.decode('utf-8', errors='replace')
267
+
268
+ # JS/TS
269
+ elif ext in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"):
270
+ if ntype in ("class_declaration", "class"):
271
+ name_node = node.child_by_field_name("name")
272
+ if name_node:
273
+ return "class", name_node.text.decode('utf-8', errors='replace')
274
+ elif ntype in ("function_declaration", "generator_function_declaration"):
275
+ name_node = node.child_by_field_name("name")
276
+ if name_node:
277
+ return "function", name_node.text.decode('utf-8', errors='replace')
278
+ elif ntype == "method_definition":
279
+ name_node = node.child_by_field_name("name")
280
+ if name_node:
281
+ return "method", name_node.text.decode('utf-8', errors='replace')
282
+ elif ntype == "variable_declarator":
283
+ init_node = node.child_by_field_name("value") or node.child_by_field_name("init")
284
+ if init_node and init_node.type == "arrow_function":
285
+ name_node = node.child_by_field_name("name") or node.child(0)
286
+ if name_node:
287
+ return "function", name_node.text.decode('utf-8', errors='replace')
288
+
289
+ # Go
290
+ elif ext == ".go":
291
+ if ntype == "function_declaration":
292
+ name_node = node.child_by_field_name("name")
293
+ if name_node:
294
+ return "function", name_node.text.decode('utf-8', errors='replace')
295
+ elif ntype == "method_declaration":
296
+ name_node = node.child_by_field_name("name")
297
+ if name_node:
298
+ return "method", name_node.text.decode('utf-8', errors='replace')
299
+ elif ntype == "type_spec":
300
+ type_node = node.child_by_field_name("type")
301
+ if type_node and type_node.type == "struct_type":
302
+ name_node = node.child_by_field_name("name")
303
+ if name_node:
304
+ return "class", name_node.text.decode('utf-8', errors='replace')
305
+
306
+ # Rust
307
+ elif ext == ".rs":
308
+ if ntype == "function_item":
309
+ name_node = node.child_by_field_name("name")
310
+ if name_node:
311
+ is_method = False
312
+ parent = node.parent
313
+ while parent:
314
+ if parent.type == "impl_item":
315
+ is_method = True
316
+ break
317
+ parent = parent.parent
318
+ return "method" if is_method else "function", name_node.text.decode('utf-8', errors='replace')
319
+ elif ntype in ("struct_item", "enum_item", "union_item", "trait_item"):
320
+ name_node = node.child_by_field_name("name")
321
+ if name_node:
322
+ return "class", name_node.text.decode('utf-8', errors='replace')
323
+
324
+ return None
325
+
326
+ def _is_call_node(self, node: Any, ext: str) -> bool:
327
+ ntype = node.type
328
+ if ext == ".py" and ntype == "call":
329
+ return True
330
+ elif ext in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs") and ntype == "call_expression":
331
+ return True
332
+ elif ext == ".go" and ntype == "call_expression":
333
+ return True
334
+ elif ext == ".rs" and ntype in ("call_expression", "method_call_expression"):
335
+ return True
336
+ return False
337
+
338
+ def _get_callable_name(self, node: Any) -> str:
339
+ if node.type == "identifier" or node.type == "variable_declarator":
340
+ return node.text.decode('utf-8', errors='replace')
341
+
342
+ if node.type == "attribute":
343
+ attr_node = node.child_by_field_name("attribute")
344
+ if attr_node:
345
+ return attr_node.text.decode('utf-8', errors='replace')
346
+
347
+ if node.type == "member_expression":
348
+ prop_node = node.child_by_field_name("property")
349
+ if prop_node:
350
+ return prop_node.text.decode('utf-8', errors='replace')
351
+
352
+ if node.type == "selector_expression":
353
+ field_node = node.child_by_field_name("field")
354
+ if field_node:
355
+ return field_node.text.decode('utf-8', errors='replace')
356
+
357
+ if node.children:
358
+ for child in reversed(node.children):
359
+ if "identifier" in child.type:
360
+ return child.text.decode('utf-8', errors='replace')
361
+
362
+ return node.text.decode('utf-8', errors='replace')
363
+
364
+ def _get_call_name(self, node: Any, ext: str) -> Optional[str]:
365
+ if ext == ".rs" and node.type == "method_call_expression":
366
+ method_node = node.child_by_field_name("method")
367
+ if method_node:
368
+ return method_node.text.decode('utf-8', errors='replace')
369
+
370
+ func_node = node.child_by_field_name("function")
371
+ if func_node:
372
+ return self._get_callable_name(func_node)
373
+
374
+ return None
375
+
376
+ def _parse_with_tree_sitter(self, source: str, rel_path: str, file_hash: str, ext: str) -> list[ParsedChunk]:
377
+ parser = self._get_tree_sitter_parser(ext)
378
+ if not parser:
379
+ if ext == ".py":
380
+ return self._parse_python(source, rel_path, file_hash)
381
+ elif ext in self._JS_EXTS:
382
+ return self._parse_javascript(source, rel_path, file_hash)
383
+ return self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
384
+
385
+ try:
386
+ source_bytes = source.encode('utf-8')
387
+ tree = parser.parse(source_bytes)
388
+ root = tree.root_node
389
+
390
+ if root.has_error:
391
+ logger.warning("Syntax error (AST has_error) in %s (RAW fallback)", rel_path)
392
+ return self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
393
+
394
+ definitions = []
395
+ stack = [(root, False)]
396
+ defn_stack = []
397
+
398
+ module_defn = {
399
+ "type": "module",
400
+ "name": "<module>",
401
+ "start_line": 1,
402
+ "end_line": source.count("\n") + 1,
403
+ "code": source,
404
+ "calls": set()
405
+ }
406
+
407
+ while stack:
408
+ node, visited = stack.pop()
409
+ if visited:
410
+ def_info = self._get_node_definition_type(node, ext)
411
+ if def_info:
412
+ if defn_stack:
413
+ popped = defn_stack.pop()
414
+ parent_class = None
415
+ for parent_defn in reversed(defn_stack):
416
+ if parent_defn["type"] == "class":
417
+ parent_class = parent_defn["name"]
418
+ break
419
+ if parent_class and popped["type"] == "method":
420
+ popped["name"] = f"{parent_class}.{popped['name']}"
421
+ definitions.append(popped)
422
+ else:
423
+ stack.append((node, True))
424
+
425
+ def_info = self._get_node_definition_type(node, ext)
426
+ if def_info:
427
+ dtype, dname = def_info
428
+ defn = {
429
+ "type": dtype,
430
+ "name": dname,
431
+ "start_line": node.start_point[0] + 1,
432
+ "end_line": node.end_point[0] + 1,
433
+ "code": node.text.decode('utf-8', errors='replace'),
434
+ "calls": set()
435
+ }
436
+ defn_stack.append(defn)
437
+ elif self._is_call_node(node, ext):
438
+ call_name = self._get_call_name(node, ext)
439
+ if call_name:
440
+ if defn_stack:
441
+ defn_stack[-1]["calls"].add(call_name)
442
+ else:
443
+ module_defn["calls"].add(call_name)
444
+
445
+ for child in reversed(node.children):
446
+ stack.append((child, False))
447
+
448
+ definitions.append(module_defn)
449
+
450
+ chunks = []
451
+ for i, defn in enumerate(definitions):
452
+ content = defn["code"]
453
+ armored = self._apply_prompt_armor(content)
454
+ tokens = self._estimate_tokens(content)
455
+
456
+ if defn["type"] == "class":
457
+ ctype = ChunkType.CLASS
458
+ elif defn["type"] == "method":
459
+ ctype = ChunkType.METHOD
460
+ elif defn["type"] == "function":
461
+ ctype = ChunkType.FUNCTION
462
+ elif defn["type"] == "module":
463
+ ctype = ChunkType.MODULE
464
+ else:
465
+ ctype = ChunkType.RAW
466
+
467
+ chunk = ParsedChunk(
468
+ content=content,
469
+ armored_content=armored,
470
+ chunk_type=ctype,
471
+ chunk_index=i,
472
+ source_file=rel_path,
473
+ file_hash=file_hash,
474
+ category=FileCategory.CODE,
475
+ start_line=defn["start_line"],
476
+ end_line=defn["end_line"],
477
+ symbol_name=defn["name"],
478
+ detected_tags=self._detect_tags(content, FileCategory.CODE),
479
+ estimated_tokens=tokens
480
+ )
481
+ chunk.calls = list(defn["calls"])
482
+ chunks.append(chunk)
483
+
484
+ except Exception as e:
485
+ logger.warning("Error processing AST with tree-sitter for %s (native/RAW fallback): %s", rel_path, e)
486
+ if ext == ".py":
487
+ return self._parse_python(source, rel_path, file_hash)
488
+ elif ext in self._JS_EXTS:
489
+ return self._parse_javascript(source, rel_path, file_hash)
490
+ return self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
491
+
492
+ return chunks
493
+
494
+ def parse_batch(self, crawl_results: list[CrawlResult]) -> list[ParsedChunk]:
495
+ """Processes multiple files with Semantic Fallback."""
496
+ all_chunks: list[ParsedChunk] = []
497
+ for cr in crawl_results:
498
+ try:
499
+ chunks = self.parse(cr)
500
+ all_chunks.extend(chunks)
501
+ except Exception as e:
502
+ logger.error("Fallback: failure parsing %s: %s", cr.relative_path, e)
503
+ return all_chunks
504
+
505
+ # ===================================================================
506
+ # PYTHON — AST Chunking
507
+ # ===================================================================
508
+
509
+ def _parse_python(self, source: str, rel_path: str, file_hash: str) -> list[ParsedChunk]:
510
+ lines = source.splitlines(keepends=True)
511
+ chunks: list[ParsedChunk] = []
512
+ chunk_idx = 0
513
+
514
+ try:
515
+ tree = ast.parse(source, filename=rel_path)
516
+ except SyntaxError as e:
517
+ logger.warning("SyntaxError in %s (RAW fallback): %s", rel_path, e)
518
+ return self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
519
+
520
+ # Collect nodes (functions, classes, methods)
521
+ nodes_to_process: list[tuple[ChunkType, str, int, int, ast.AST]] = []
522
+
523
+ # Traverse AST child nodes
524
+ for node in ast.iter_child_nodes(tree):
525
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
526
+ start = node.lineno
527
+ end = node.end_lineno if hasattr(node, "end_lineno") and node.end_lineno else start
528
+ nodes_to_process.append((ChunkType.FUNCTION, node.name, start, end, node))
529
+ elif isinstance(node, ast.ClassDef):
530
+ start = node.lineno
531
+ end = node.end_lineno if hasattr(node, "end_lineno") and node.end_lineno else start
532
+ nodes_to_process.append((ChunkType.CLASS, node.name, start, end, node))
533
+ # Maps internal methods of the class
534
+ for subnode in ast.iter_child_nodes(node):
535
+ if isinstance(subnode, (ast.FunctionDef, ast.AsyncFunctionDef)):
536
+ sub_start = subnode.lineno
537
+ sub_end = subnode.end_lineno if hasattr(subnode, "end_lineno") and subnode.end_lineno else sub_start
538
+ nodes_to_process.append((ChunkType.METHOD, f"{node.name}.{subnode.name}", sub_start, sub_end, subnode))
539
+
540
+ # Adds the module at the end to match the behavior of tree-sitter
541
+ nodes_to_process.append((ChunkType.MODULE, "<module>", 1, len(lines) if lines else 1, tree))
542
+
543
+ # Maps calls using definition scope
544
+ calls_map = {tree: set()}
545
+
546
+ def traverse_ast(n, current_defn):
547
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
548
+ calls_map[n] = set()
549
+ for child in ast.iter_child_nodes(n):
550
+ traverse_ast(child, n)
551
+ else:
552
+ if isinstance(n, ast.Call):
553
+ name = None
554
+ if isinstance(n.func, ast.Name):
555
+ name = n.func.id
556
+ elif isinstance(n.func, ast.Attribute):
557
+ name = n.func.attr
558
+ if name:
559
+ calls_map[current_defn].add(name)
560
+ for child in ast.iter_child_nodes(n):
561
+ traverse_ast(child, current_defn)
562
+
563
+ traverse_ast(tree, tree)
564
+
565
+ for ctype, name, start, end, ast_node in nodes_to_process:
566
+ if ctype == ChunkType.MODULE:
567
+ block_text = source.rstrip()
568
+ else:
569
+ block_text = "".join(lines[start - 1:end]).rstrip()
570
+
571
+ armored = self._apply_prompt_armor(block_text)
572
+ tokens = self._estimate_tokens(block_text)
573
+
574
+ # Obtém chamadas específicas deste escopo
575
+ calls = list(calls_map.get(ast_node, set()))
576
+
577
+ # If it exceeds max_tokens, subdivides (except module to simplify)
578
+ if tokens > self._max_tokens and ctype != ChunkType.MODULE:
579
+ sub_chunks = self._split_oversized(block_text, rel_path, file_hash, ctype, name, start, chunk_idx)
580
+ # Copia chamadas para sub-chunks
581
+ for sc in sub_chunks:
582
+ sc.calls = calls
583
+ chunks.extend(sub_chunks)
584
+ chunk_idx += len(sub_chunks)
585
+ else:
586
+ chunk = ParsedChunk(
587
+ content=block_text,
588
+ armored_content=armored,
589
+ chunk_type=ctype,
590
+ chunk_index=chunk_idx,
591
+ source_file=rel_path,
592
+ file_hash=file_hash,
593
+ category=FileCategory.CODE,
594
+ start_line=start,
595
+ end_line=end,
596
+ symbol_name=name,
597
+ detected_tags=self._detect_tags(block_text, FileCategory.CODE),
598
+ estimated_tokens=tokens,
599
+ )
600
+ chunk.calls = calls
601
+ chunks.append(chunk)
602
+ chunk_idx += 1
603
+
604
+ return chunks
605
+
606
+ # ===================================================================
607
+ # JS/TS — Regex Chunking
608
+ # ===================================================================
609
+
610
+ def _parse_javascript(self, source: str, rel_path: str, file_hash: str) -> list[ParsedChunk]:
611
+ lines = source.splitlines(keepends=True)
612
+ total_lines = len(lines)
613
+ chunks: list[ParsedChunk] = []
614
+ chunk_idx = 0
615
+
616
+ # Collects positions of functions and classes
617
+ boundaries: list[tuple[int, str, ChunkType]] = []
618
+
619
+ for m in _JS_CLASS_RE.finditer(source):
620
+ line_no = source[:m.start()].count("\n") + 1
621
+ name = m.group(1) or "AnonymousClass"
622
+ boundaries.append((line_no, name, ChunkType.CLASS))
623
+
624
+ for m in _JS_FUNCTION_RE.finditer(source):
625
+ line_no = source[:m.start()].count("\n") + 1
626
+ name = m.group(1) or m.group(2) or "anonymous"
627
+ boundaries.append((line_no, name, ChunkType.FUNCTION))
628
+
629
+ boundaries.sort(key=lambda b: b[0])
630
+
631
+ if not boundaries:
632
+ return self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
633
+
634
+ # MODULE block: imports at the top (before the first boundary)
635
+ first_boundary_line = boundaries[0][0]
636
+ if first_boundary_line > 1:
637
+ module_text = "".join(lines[:first_boundary_line - 1]).strip()
638
+ if module_text:
639
+ armored = self._apply_prompt_armor(module_text)
640
+ chunks.append(ParsedChunk(
641
+ content=module_text,
642
+ armored_content=armored,
643
+ chunk_type=ChunkType.MODULE,
644
+ chunk_index=chunk_idx,
645
+ source_file=rel_path,
646
+ file_hash=file_hash,
647
+ category=FileCategory.CODE,
648
+ start_line=1,
649
+ end_line=first_boundary_line - 1,
650
+ symbol_name="<module>",
651
+ detected_tags=self._detect_tags(module_text, FileCategory.CODE),
652
+ estimated_tokens=self._estimate_tokens(module_text),
653
+ ))
654
+ chunk_idx += 1
655
+
656
+ # Chunks for each detected block
657
+ for i, (line_no, name, ctype) in enumerate(boundaries):
658
+ # Determines end of block: next boundary or EOF
659
+ if i + 1 < len(boundaries):
660
+ end_line = boundaries[i + 1][0] - 1
661
+ else:
662
+ end_line = total_lines
663
+
664
+ # Finds the actual end of the block via brace count
665
+ block_lines = lines[line_no - 1:end_line]
666
+ actual_end = self._find_block_end_braces(block_lines)
667
+ if actual_end > 0:
668
+ end_line = line_no - 1 + actual_end
669
+
670
+ block_text = "".join(lines[line_no - 1:end_line]).rstrip()
671
+ if not block_text.strip():
672
+ continue
673
+
674
+ armored = self._apply_prompt_armor(block_text)
675
+ tokens = self._estimate_tokens(block_text)
676
+
677
+ if tokens > self._max_tokens:
678
+ sub = self._split_oversized(block_text, rel_path, file_hash, ctype, name, line_no, chunk_idx)
679
+ chunks.extend(sub)
680
+ chunk_idx += len(sub)
681
+ else:
682
+ chunks.append(ParsedChunk(
683
+ content=block_text,
684
+ armored_content=armored,
685
+ chunk_type=ctype,
686
+ chunk_index=chunk_idx,
687
+ source_file=rel_path,
688
+ file_hash=file_hash,
689
+ category=FileCategory.CODE,
690
+ start_line=line_no,
691
+ end_line=end_line,
692
+ symbol_name=name,
693
+ detected_tags=self._detect_tags(block_text, FileCategory.CODE),
694
+ estimated_tokens=tokens,
695
+ ))
696
+ chunk_idx += 1
697
+
698
+ return chunks if chunks else self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
699
+
700
+ @staticmethod
701
+ def _find_block_end_braces(block_lines: list[str]) -> int:
702
+ """Finds the line where braces { } balance (1-indexed relative)."""
703
+ depth = 0
704
+ started = False
705
+ for i, line in enumerate(block_lines, 1):
706
+ for ch in line:
707
+ if ch == "{":
708
+ depth += 1
709
+ started = True
710
+ elif ch == "}":
711
+ depth -= 1
712
+ if started and depth <= 0:
713
+ return i
714
+ return 0
715
+
716
+ # ===================================================================
717
+ # MARKDOWN — Header Chunking
718
+ # ===================================================================
719
+
720
+ def _parse_markdown(self, source: str, rel_path: str, file_hash: str) -> list[ParsedChunk]:
721
+ lines = source.splitlines(keepends=True)
722
+ chunks: list[ParsedChunk] = []
723
+ chunk_idx = 0
724
+
725
+ # Finds headers
726
+ header_positions: list[tuple[int, str]] = [] # (line_no, header_text)
727
+ for i, line in enumerate(lines, 1):
728
+ stripped = line.strip()
729
+ if stripped.startswith("#") and " " in stripped:
730
+ header_positions.append((i, stripped))
731
+
732
+ if not header_positions:
733
+ # No headers — treats as a single block
734
+ armored = self._apply_prompt_armor(source)
735
+ return [ParsedChunk(
736
+ content=source.strip(),
737
+ armored_content=armored,
738
+ chunk_type=ChunkType.SECTION,
739
+ chunk_index=0,
740
+ source_file=rel_path,
741
+ file_hash=file_hash,
742
+ category=FileCategory.DOC,
743
+ start_line=1,
744
+ end_line=len(lines),
745
+ symbol_name="<document>",
746
+ detected_tags=self._detect_tags(source, FileCategory.DOC),
747
+ estimated_tokens=self._estimate_tokens(source),
748
+ )]
749
+
750
+ # Content before the first header
751
+ if header_positions[0][0] > 1:
752
+ pre_text = "".join(lines[:header_positions[0][0] - 1]).strip()
753
+ if pre_text:
754
+ armored = self._apply_prompt_armor(pre_text)
755
+ chunks.append(ParsedChunk(
756
+ content=pre_text,
757
+ armored_content=armored,
758
+ chunk_type=ChunkType.SECTION,
759
+ chunk_index=chunk_idx,
760
+ source_file=rel_path,
761
+ file_hash=file_hash,
762
+ category=FileCategory.DOC,
763
+ start_line=1,
764
+ end_line=header_positions[0][0] - 1,
765
+ symbol_name="<preamble>",
766
+ detected_tags=[],
767
+ estimated_tokens=self._estimate_tokens(pre_text),
768
+ ))
769
+ chunk_idx += 1
770
+
771
+ # Each section
772
+ for i, (line_no, header) in enumerate(header_positions):
773
+ if i + 1 < len(header_positions):
774
+ end_line = header_positions[i + 1][0] - 1
775
+ else:
776
+ end_line = len(lines)
777
+
778
+ section_text = "".join(lines[line_no - 1:end_line]).rstrip()
779
+ if not section_text.strip():
780
+ continue
781
+
782
+ # Extracts section name (removes #)
783
+ section_name = header.lstrip("#").strip()
784
+
785
+ armored = self._apply_prompt_armor(section_text)
786
+ tokens = self._estimate_tokens(section_text)
787
+
788
+ if tokens > self._max_tokens:
789
+ sub = self._split_oversized(section_text, rel_path, file_hash, ChunkType.SECTION, section_name, line_no, chunk_idx)
790
+ chunks.extend(sub)
791
+ chunk_idx += len(sub)
792
+ else:
793
+ chunks.append(ParsedChunk(
794
+ content=section_text,
795
+ armored_content=armored,
796
+ chunk_type=ChunkType.SECTION,
797
+ chunk_index=chunk_idx,
798
+ source_file=rel_path,
799
+ file_hash=file_hash,
800
+ category=FileCategory.DOC,
801
+ start_line=line_no,
802
+ end_line=end_line,
803
+ symbol_name=section_name,
804
+ detected_tags=self._detect_tags(section_text, FileCategory.DOC),
805
+ estimated_tokens=tokens,
806
+ ))
807
+ chunk_idx += 1
808
+
809
+ return chunks
810
+
811
+ # ===================================================================
812
+ # CONFIG — Single block or split
813
+ # ===================================================================
814
+
815
+ def _parse_config(self, source: str, rel_path: str, file_hash: str) -> list[ParsedChunk]:
816
+ tokens = self._estimate_tokens(source)
817
+ armored = self._apply_prompt_armor(source)
818
+
819
+ if tokens <= self._max_tokens:
820
+ return [ParsedChunk(
821
+ content=source.strip(),
822
+ armored_content=armored,
823
+ chunk_type=ChunkType.CONFIG,
824
+ chunk_index=0,
825
+ source_file=rel_path,
826
+ file_hash=file_hash,
827
+ category=FileCategory.CONFIG,
828
+ start_line=1,
829
+ end_line=source.count("\n") + 1,
830
+ symbol_name=Path(rel_path).name,
831
+ detected_tags=self._detect_tags(source, FileCategory.CONFIG),
832
+ estimated_tokens=tokens,
833
+ )]
834
+
835
+ # Large config — split by size
836
+ return self._split_oversized(source, rel_path, file_hash, ChunkType.CONFIG, Path(rel_path).name, 1, 0)
837
+
838
+ # ===================================================================
839
+ # RAW — Fallback
840
+ # ===================================================================
841
+
842
+ def _parse_raw(self, source: str, rel_path: str, file_hash: str, category: FileCategory) -> list[ParsedChunk]:
843
+ tokens = self._estimate_tokens(source)
844
+ armored = self._apply_prompt_armor(source)
845
+
846
+ if tokens <= self._max_tokens:
847
+ return [ParsedChunk(
848
+ content=source.strip(),
849
+ armored_content=armored,
850
+ chunk_type=ChunkType.RAW,
851
+ chunk_index=0,
852
+ source_file=rel_path,
853
+ file_hash=file_hash,
854
+ category=category,
855
+ start_line=1,
856
+ end_line=source.count("\n") + 1,
857
+ symbol_name=Path(rel_path).name,
858
+ detected_tags=self._detect_tags(source, category),
859
+ estimated_tokens=tokens,
860
+ )]
861
+
862
+ return self._split_oversized(source, rel_path, file_hash, ChunkType.RAW, Path(rel_path).name, 1, 0)
863
+
864
+ # ===================================================================
865
+ # SPLIT OVERSIZED — subdivides large chunks
866
+ # ===================================================================
867
+
868
+ def _split_oversized(
869
+ self, text: str, rel_path: str, file_hash: str,
870
+ chunk_type: ChunkType, base_name: str, base_line: int, start_idx: int,
871
+ ) -> list[ParsedChunk]:
872
+ """Subdivides a block that exceeds max_chunk_tokens into sub-chunks."""
873
+ lines = text.splitlines(keepends=True)
874
+ chunks: list[ParsedChunk] = []
875
+ chunk_idx = start_idx
876
+ max_chars = self._max_tokens * 4 # inverse heuristic
877
+
878
+ current_lines: list[str] = []
879
+ current_start = base_line
880
+ current_chars = 0
881
+
882
+ for i, line in enumerate(lines):
883
+ current_lines.append(line)
884
+ current_chars += len(line)
885
+
886
+ if current_chars >= max_chars:
887
+ block = "".join(current_lines).rstrip()
888
+ armored = self._apply_prompt_armor(block)
889
+ chunks.append(ParsedChunk(
890
+ content=block,
891
+ armored_content=armored,
892
+ chunk_type=chunk_type,
893
+ chunk_index=chunk_idx,
894
+ source_file=rel_path,
895
+ file_hash=file_hash,
896
+ category=FileCategory.CODE,
897
+ start_line=current_start,
898
+ end_line=current_start + len(current_lines) - 1,
899
+ symbol_name=f"{base_name}[{chunk_idx - start_idx}]",
900
+ detected_tags=self._detect_tags(block, FileCategory.CODE),
901
+ estimated_tokens=self._estimate_tokens(block),
902
+ ))
903
+ chunk_idx += 1
904
+ current_start = base_line + i + 1
905
+ current_lines = []
906
+ current_chars = 0
907
+
908
+ # Rest
909
+ if current_lines:
910
+ block = "".join(current_lines).rstrip()
911
+ if block.strip():
912
+ armored = self._apply_prompt_armor(block)
913
+ chunks.append(ParsedChunk(
914
+ content=block,
915
+ armored_content=armored,
916
+ chunk_type=chunk_type,
917
+ chunk_index=chunk_idx,
918
+ source_file=rel_path,
919
+ file_hash=file_hash,
920
+ category=FileCategory.CODE,
921
+ start_line=current_start,
922
+ end_line=current_start + len(current_lines) - 1,
923
+ symbol_name=f"{base_name}[{chunk_idx - start_idx}]" if chunks else base_name,
924
+ detected_tags=self._detect_tags(block, FileCategory.CODE),
925
+ estimated_tokens=self._estimate_tokens(block),
926
+ ))
927
+
928
+ return chunks
929
+
930
+ # ===================================================================
931
+ # PROMPT ARMOR
932
+ # ===================================================================
933
+
934
+ def _apply_prompt_armor(self, content: str) -> str:
935
+ """Wraps content in Prompt Armor XML tags (structured XML comments with defensive escaping)."""
936
+ if not self._armor:
937
+ return content
938
+ escaped_content = content.replace("-->", "-- >")
939
+ return f"{PROMPT_ARMOR_OPEN}\n{escaped_content}\n{PROMPT_ARMOR_CLOSE}"
940
+
941
+ # ===================================================================
942
+ # TOKEN ESTIMATION
943
+ # ===================================================================
944
+
945
+ def _estimate_tokens(self, text: str) -> int:
946
+ """Estimates tokens: ~4 characters per token."""
947
+ return max(1, len(text) // 4)
948
+
949
+ # ===================================================================
950
+ # TAG DETECTION
951
+ # ===================================================================
952
+
953
+ def _detect_tags(self, content: str, category: FileCategory) -> list[str]:
954
+ """Detects automatic tags based on the content."""
955
+ tags: set[str] = set()
956
+
957
+ content_lower = content.lower()
958
+
959
+ if category == FileCategory.CODE:
960
+ # Extracts Python imports
961
+ for m in _IMPORT_PY_RE.finditer(content):
962
+ mod = (m.group(1) or m.group(2) or "").split(".")[0].lower()
963
+ if mod:
964
+ tags.add(mod)
965
+ # Extracts JS/TS imports
966
+ for m in _IMPORT_JS_RE.finditer(content):
967
+ mod = (m.group(1) or m.group(2) or "")
968
+ mod = mod.strip("./").split("/")[0].lower()
969
+ if mod and not mod.startswith("."):
970
+ tags.add(mod)
971
+
972
+ # Detects known frameworks
973
+ for keyword, tag in _FRAMEWORK_KEYWORDS.items():
974
+ if keyword in content_lower:
975
+ tags.add(tag)
976
+
977
+ # For docs: detects technical keywords
978
+ if category == FileCategory.DOC:
979
+ doc_keywords = ["api", "auth", "database", "deploy", "migration", "security", "testing"]
980
+ for kw in doc_keywords:
981
+ if kw in content_lower:
982
+ tags.add(kw)
983
+
984
+ return sorted(tags)