ihmt-memory 1.2.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.
- ihmt/__init__.py +134 -0
- ihmt/api.py +166 -0
- ihmt/chunkers/__init__.py +55 -0
- ihmt/chunkers/base.py +297 -0
- ihmt/chunkers/code.py +572 -0
- ihmt/chunkers/generic.py +19 -0
- ihmt/chunkers/narrative.py +142 -0
- ihmt/chunkers/process.py +79 -0
- ihmt/chunkers/temporal.py +187 -0
- ihmt/config.py +161 -0
- ihmt/conflict_resolver.py +468 -0
- ihmt/detectors.py +327 -0
- ihmt/models.py +566 -0
- ihmt/project_index.py +440 -0
- ihmt/recursive_summarizer.py +342 -0
- ihmt/semantic_navigator.py +847 -0
- ihmt/storage.py +589 -0
- ihmt/summarizers.py +264 -0
- ihmt/templates/memory-instructions.md +37 -0
- ihmt/textutils.py +273 -0
- ihmt/universal_ingestor.py +373 -0
- ihmt_launcher.py +54 -0
- ihmt_memory-1.2.0.dist-info/METADATA +609 -0
- ihmt_memory-1.2.0.dist-info/RECORD +28 -0
- ihmt_memory-1.2.0.dist-info/WHEEL +4 -0
- ihmt_memory-1.2.0.dist-info/entry_points.txt +2 -0
- ihmt_memory-1.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_server.py +701 -0
ihmt/chunkers/code.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""Syntax-aware splitting for source code.
|
|
2
|
+
|
|
3
|
+
The guarantee this module provides: **a class, method, function or import block
|
|
4
|
+
is never cut in half**. Python is handled with the stdlib :mod:`ast`; the
|
|
5
|
+
C-family (Java, JavaScript/TypeScript, C, C++, C#, Go, Rust, PHP, Swift, Kotlin)
|
|
6
|
+
is handled by :class:`BraceScanner`, a character-level scanner that tracks brace
|
|
7
|
+
depth while correctly skipping comments and string literals.
|
|
8
|
+
|
|
9
|
+
Chunk text stays byte-identical to the source: concatenating the chunks of a
|
|
10
|
+
file reproduces that file exactly. When a method must be lifted out of an
|
|
11
|
+
oversized class, the enclosing class header travels in ``Block.context`` (and
|
|
12
|
+
therefore in the leaf metadata) rather than being spliced into the content.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
import re
|
|
19
|
+
from typing import Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from ..textutils import truncate
|
|
22
|
+
from .base import Block, Chunk, Chunker, join_lines, split_lines
|
|
23
|
+
|
|
24
|
+
#: File extension -> language identifier.
|
|
25
|
+
LANGUAGE_BY_EXTENSION: Dict[str, str] = {
|
|
26
|
+
".py": "python",
|
|
27
|
+
".pyi": "python",
|
|
28
|
+
".java": "java",
|
|
29
|
+
".js": "javascript",
|
|
30
|
+
".mjs": "javascript",
|
|
31
|
+
".jsx": "javascript",
|
|
32
|
+
".ts": "typescript",
|
|
33
|
+
".tsx": "typescript",
|
|
34
|
+
".c": "c",
|
|
35
|
+
".h": "c",
|
|
36
|
+
".cc": "cpp",
|
|
37
|
+
".cpp": "cpp",
|
|
38
|
+
".cxx": "cpp",
|
|
39
|
+
".hpp": "cpp",
|
|
40
|
+
".hh": "cpp",
|
|
41
|
+
".cs": "csharp",
|
|
42
|
+
".go": "go",
|
|
43
|
+
".rs": "rust",
|
|
44
|
+
".kt": "kotlin",
|
|
45
|
+
".swift": "swift",
|
|
46
|
+
".php": "php",
|
|
47
|
+
".scala": "scala",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#: Languages driven by the brace scanner.
|
|
51
|
+
BRACE_LANGUAGES = frozenset(LANGUAGE_BY_EXTENSION.values()) - {"python"}
|
|
52
|
+
|
|
53
|
+
_TYPE_DECL_RE = re.compile(
|
|
54
|
+
r"\b(class|interface|enum|record|struct|namespace|trait|protocol|impl)\s+([A-Za-z_][\w$]*)"
|
|
55
|
+
)
|
|
56
|
+
_FUNC_DECL_RE = re.compile(r"\b(?:function|fn|func|sub)\s+([A-Za-z_][\w$]*)")
|
|
57
|
+
_METHOD_DECL_RE = re.compile(r"([A-Za-z_][\w$]*)\s*\([^;{()]*\)\s*(?:const\b)?[^;{]*\{")
|
|
58
|
+
_IMPORT_RE = re.compile(r"^\s*(?:import\b|#include\b|using\b|package\b|from\b|require\b|export\s+\*)")
|
|
59
|
+
_ANNOTATION_RE = re.compile(r"^\s*[@\[]")
|
|
60
|
+
|
|
61
|
+
#: Block kinds that may be split one level deeper when oversized.
|
|
62
|
+
_CONTAINER_KINDS = frozenset({"class", "interface", "enum", "record", "struct", "namespace", "impl", "trait"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BraceScanner:
|
|
66
|
+
"""Finds top-level declaration boundaries in C-family source.
|
|
67
|
+
|
|
68
|
+
The scanner is deliberately lexical rather than a full parser: it only needs
|
|
69
|
+
to know where a top-level ``{...}`` block closes, where a top-level
|
|
70
|
+
statement ends in ``;``, and where a preprocessor line ends — while never
|
|
71
|
+
being fooled by braces inside comments or string literals.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, text: str) -> None:
|
|
75
|
+
self.text = text
|
|
76
|
+
self._ends: Optional[List[int]] = None
|
|
77
|
+
self._line_depths: Dict[int, int] = {}
|
|
78
|
+
|
|
79
|
+
def unit_end_lines(self) -> List[int]:
|
|
80
|
+
"""Return the 1-based line numbers where top-level units end."""
|
|
81
|
+
if self._ends is None:
|
|
82
|
+
self._ends = self._scan()
|
|
83
|
+
return self._ends
|
|
84
|
+
|
|
85
|
+
def depth_at_line_ends(self) -> Dict[int, int]:
|
|
86
|
+
"""Brace depth at the end of each 1-based line.
|
|
87
|
+
|
|
88
|
+
A chunk boundary at depth 0 sits between top-level declarations; at
|
|
89
|
+
depth 1 it sits between the members of a class. Anything deeper would
|
|
90
|
+
mean a method was cut in half — which the chunker never does, and which
|
|
91
|
+
the test suite asserts against.
|
|
92
|
+
"""
|
|
93
|
+
self.unit_end_lines()
|
|
94
|
+
return dict(self._line_depths)
|
|
95
|
+
|
|
96
|
+
def _scan(self) -> List[int]:
|
|
97
|
+
"""Single pass: records unit boundaries and per-line brace depth."""
|
|
98
|
+
text = self.text
|
|
99
|
+
length = len(text)
|
|
100
|
+
ends: List[int] = []
|
|
101
|
+
index = 0
|
|
102
|
+
line = 1
|
|
103
|
+
depth = 0
|
|
104
|
+
has_content = False
|
|
105
|
+
at_line_start = True
|
|
106
|
+
|
|
107
|
+
def close_unit(at: int) -> None:
|
|
108
|
+
nonlocal has_content
|
|
109
|
+
if not ends or ends[-1] != at:
|
|
110
|
+
ends.append(at)
|
|
111
|
+
has_content = False
|
|
112
|
+
|
|
113
|
+
def span_lines(from_line: int, to_line: int) -> None:
|
|
114
|
+
"""Record the (unchanged) depth of lines crossed by a skip."""
|
|
115
|
+
for crossed in range(from_line, to_line):
|
|
116
|
+
self._line_depths[crossed] = depth
|
|
117
|
+
|
|
118
|
+
while index < length:
|
|
119
|
+
char = text[index]
|
|
120
|
+
nxt = text[index + 1] if index + 1 < length else ""
|
|
121
|
+
|
|
122
|
+
if char == "\n":
|
|
123
|
+
self._line_depths[line] = depth
|
|
124
|
+
line += 1
|
|
125
|
+
index += 1
|
|
126
|
+
at_line_start = True
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if char in " \t\r":
|
|
130
|
+
index += 1
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# ---- comments ------------------------------------------------
|
|
134
|
+
if char == "/" and nxt == "/":
|
|
135
|
+
index = self._skip_to_eol(index)
|
|
136
|
+
continue
|
|
137
|
+
if char == "/" and nxt == "*":
|
|
138
|
+
previous_line = line
|
|
139
|
+
index, line = self._skip_block_comment(index, line)
|
|
140
|
+
span_lines(previous_line, line)
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# ---- preprocessor (C/C++): the line itself is the unit --------
|
|
144
|
+
if char == "#" and at_line_start and depth == 0:
|
|
145
|
+
end = self._skip_to_eol(index)
|
|
146
|
+
consumed = text[index:end]
|
|
147
|
+
line_end = line + consumed.count("\n")
|
|
148
|
+
close_unit(line_end)
|
|
149
|
+
span_lines(line, line_end)
|
|
150
|
+
line = line_end
|
|
151
|
+
index = end
|
|
152
|
+
at_line_start = False
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# ---- string literals -----------------------------------------
|
|
156
|
+
if text.startswith('"""', index):
|
|
157
|
+
previous_line = line
|
|
158
|
+
index, line = self._skip_delimited(index + 3, line, '"""')
|
|
159
|
+
span_lines(previous_line, line)
|
|
160
|
+
has_content = True
|
|
161
|
+
at_line_start = False
|
|
162
|
+
continue
|
|
163
|
+
if char in "\"'`":
|
|
164
|
+
previous_line = line
|
|
165
|
+
index, line = self._skip_string(index, line, char)
|
|
166
|
+
span_lines(previous_line, line)
|
|
167
|
+
has_content = True
|
|
168
|
+
at_line_start = False
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# ---- structure -----------------------------------------------
|
|
172
|
+
at_line_start = False
|
|
173
|
+
if char == "{":
|
|
174
|
+
depth += 1
|
|
175
|
+
has_content = True
|
|
176
|
+
elif char == "}":
|
|
177
|
+
depth = max(0, depth - 1)
|
|
178
|
+
if depth == 0:
|
|
179
|
+
# Absorb a trailing ';' (C++ `};`) into the same unit.
|
|
180
|
+
close_unit(line)
|
|
181
|
+
elif char == ";" and depth == 0:
|
|
182
|
+
if has_content:
|
|
183
|
+
close_unit(line)
|
|
184
|
+
else:
|
|
185
|
+
has_content = True
|
|
186
|
+
index += 1
|
|
187
|
+
|
|
188
|
+
total_lines = self.text.count("\n") + (0 if self.text.endswith("\n") else 1)
|
|
189
|
+
self._line_depths.setdefault(max(line, 1), depth)
|
|
190
|
+
if has_content or not ends:
|
|
191
|
+
if total_lines and (not ends or ends[-1] < total_lines):
|
|
192
|
+
close_unit(max(total_lines, 1))
|
|
193
|
+
return ends
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------- scanning
|
|
196
|
+
def _skip_to_eol(self, index: int) -> int:
|
|
197
|
+
"""Skip to the end of the logical line, honouring ``\\`` continuations."""
|
|
198
|
+
text = self.text
|
|
199
|
+
while index < len(text):
|
|
200
|
+
if text[index] == "\\" and index + 1 < len(text) and text[index + 1] == "\n":
|
|
201
|
+
index += 2
|
|
202
|
+
continue
|
|
203
|
+
if text[index] == "\n":
|
|
204
|
+
return index
|
|
205
|
+
index += 1
|
|
206
|
+
return index
|
|
207
|
+
|
|
208
|
+
def _skip_block_comment(self, index: int, line: int) -> Tuple[int, int]:
|
|
209
|
+
end = self.text.find("*/", index + 2)
|
|
210
|
+
end = len(self.text) if end == -1 else end + 2
|
|
211
|
+
return end, line + self.text.count("\n", index, end)
|
|
212
|
+
|
|
213
|
+
def _skip_string(self, index: int, line: int, quote: str) -> Tuple[int, int]:
|
|
214
|
+
text = self.text
|
|
215
|
+
cursor = index + 1
|
|
216
|
+
while cursor < len(text):
|
|
217
|
+
char = text[cursor]
|
|
218
|
+
if char == "\\":
|
|
219
|
+
cursor += 2
|
|
220
|
+
continue
|
|
221
|
+
if char == "\n":
|
|
222
|
+
line += 1
|
|
223
|
+
# An unterminated single-line literal: stop at the newline
|
|
224
|
+
# rather than swallowing the rest of the file.
|
|
225
|
+
if quote != "`":
|
|
226
|
+
return cursor + 1, line
|
|
227
|
+
elif char == quote:
|
|
228
|
+
return cursor + 1, line
|
|
229
|
+
cursor += 1
|
|
230
|
+
return cursor, line
|
|
231
|
+
|
|
232
|
+
def _skip_delimited(self, index: int, line: int, closing: str) -> Tuple[int, int]:
|
|
233
|
+
end = self.text.find(closing, index)
|
|
234
|
+
end = len(self.text) if end == -1 else end + len(closing)
|
|
235
|
+
return end, line + self.text.count("\n", index, end)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class CodeAwareChunker(Chunker):
|
|
239
|
+
"""Splits source files on syntactic boundaries only."""
|
|
240
|
+
|
|
241
|
+
name = "code"
|
|
242
|
+
|
|
243
|
+
def __init__(self, config, language: Optional[str] = None) -> None:
|
|
244
|
+
super().__init__(config)
|
|
245
|
+
self.language = language
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------ api
|
|
248
|
+
@property
|
|
249
|
+
def symbol_mode(self) -> bool:
|
|
250
|
+
"""One leaf per class member instead of packed runs of blocks."""
|
|
251
|
+
return getattr(self.config, "code_chunk_mode", "pack") == "symbol"
|
|
252
|
+
|
|
253
|
+
def _split_threshold(self) -> int:
|
|
254
|
+
"""Size above which a class is split into its members.
|
|
255
|
+
|
|
256
|
+
In ``pack`` mode only oversized classes are split (block integrity
|
|
257
|
+
first). In ``symbol`` mode any class with more than a couple of small
|
|
258
|
+
members is, so that a lookup can return a single method and a project
|
|
259
|
+
map can list them; tiny types (records, interfaces) stay whole.
|
|
260
|
+
"""
|
|
261
|
+
if not self.symbol_mode:
|
|
262
|
+
return self.config.max_tokens
|
|
263
|
+
return max(60, 2 * max(24, self.config.target_tokens // 4))
|
|
264
|
+
|
|
265
|
+
def chunk(self, text: str, *, source: str = "") -> List[Chunk]: # type: ignore[override]
|
|
266
|
+
"""Split code on syntactic boundaries; see :meth:`Chunker.chunk`."""
|
|
267
|
+
if not text.strip():
|
|
268
|
+
return []
|
|
269
|
+
blocks = self.blocks(text, source=source)
|
|
270
|
+
return self.pack_each(blocks) if self.symbol_mode else self.pack(blocks)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def language_for(source: str) -> Optional[str]:
|
|
274
|
+
"""Map a filename/extension to a language identifier."""
|
|
275
|
+
if not source:
|
|
276
|
+
return None
|
|
277
|
+
suffix = source[source.rfind(".") :].lower() if "." in source else ""
|
|
278
|
+
return LANGUAGE_BY_EXTENSION.get(suffix)
|
|
279
|
+
|
|
280
|
+
def blocks(self, text: str, *, source: str = "") -> List[Block]:
|
|
281
|
+
"""Detect indivisible code blocks, choosing the strategy per language."""
|
|
282
|
+
language = self.language or self.language_for(source) or self._sniff(text)
|
|
283
|
+
if language == "python":
|
|
284
|
+
try:
|
|
285
|
+
blocks = self._python_blocks(text)
|
|
286
|
+
except (SyntaxError, ValueError, RecursionError):
|
|
287
|
+
# Broken or partial source still deserves structural chunking:
|
|
288
|
+
# degrade to the lexical scanner, never to a blind character cut.
|
|
289
|
+
blocks = self._brace_blocks(text, language="python")
|
|
290
|
+
else:
|
|
291
|
+
blocks = self._brace_blocks(text, language=language or "unknown")
|
|
292
|
+
|
|
293
|
+
if not blocks:
|
|
294
|
+
blocks = self._fallback_blocks(text)
|
|
295
|
+
for block in blocks:
|
|
296
|
+
block.tags = [t for t in block.tags if t] or block.tags
|
|
297
|
+
if language:
|
|
298
|
+
block.tags.append(f"lang:{language}")
|
|
299
|
+
return blocks
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _sniff(text: str) -> Optional[str]:
|
|
303
|
+
"""Guess the language of a snippet with no filename."""
|
|
304
|
+
head = text[:4000]
|
|
305
|
+
if re.search(r"^\s*(def|class)\s+\w+.*:\s*$", head, re.MULTILINE) and "{" not in head[:200]:
|
|
306
|
+
return "python"
|
|
307
|
+
if re.search(r"\bpublic\s+(final\s+|abstract\s+)?class\b|^\s*package\s+[\w.]+;", head, re.MULTILINE):
|
|
308
|
+
return "java"
|
|
309
|
+
if re.search(r"#include\s*[<\"]", head):
|
|
310
|
+
return "cpp"
|
|
311
|
+
if re.search(r"\b(function|const|let|=>)\b", head):
|
|
312
|
+
return "javascript"
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
# --------------------------------------------------------------- python
|
|
316
|
+
def _python_blocks(self, text: str) -> List[Block]:
|
|
317
|
+
"""Block detection driven by the Python AST.
|
|
318
|
+
|
|
319
|
+
Consecutive imports and consecutive simple statements are coalesced;
|
|
320
|
+
every class and function becomes its own block, decorators included.
|
|
321
|
+
Blocks tile the file with no gaps, so comments and blank lines between
|
|
322
|
+
definitions are always carried by the following block.
|
|
323
|
+
"""
|
|
324
|
+
tree = ast.parse(text)
|
|
325
|
+
lines = split_lines(text)
|
|
326
|
+
if not tree.body:
|
|
327
|
+
return self._fallback_blocks(text)
|
|
328
|
+
|
|
329
|
+
units: List[Dict[str, object]] = []
|
|
330
|
+
for node in tree.body:
|
|
331
|
+
start = node.lineno
|
|
332
|
+
for decorator in getattr(node, "decorator_list", []) or []:
|
|
333
|
+
start = min(start, decorator.lineno)
|
|
334
|
+
end = getattr(node, "end_lineno", None) or start
|
|
335
|
+
|
|
336
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
337
|
+
kind, name = "imports", "imports"
|
|
338
|
+
elif isinstance(node, ast.ClassDef):
|
|
339
|
+
kind, name = "class", node.name
|
|
340
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
341
|
+
kind, name = "function", node.name
|
|
342
|
+
else:
|
|
343
|
+
kind, name = "module", "module level"
|
|
344
|
+
|
|
345
|
+
mergeable = kind in ("imports", "module")
|
|
346
|
+
if units and mergeable and units[-1]["kind"] == kind:
|
|
347
|
+
units[-1]["end"] = max(int(units[-1]["end"]), end)
|
|
348
|
+
units[-1]["nodes"].append(node) # type: ignore[union-attr]
|
|
349
|
+
else:
|
|
350
|
+
units.append({"start": start, "end": end, "kind": kind, "name": name, "nodes": [node]})
|
|
351
|
+
|
|
352
|
+
blocks: List[Block] = []
|
|
353
|
+
cursor = 1
|
|
354
|
+
for unit in units:
|
|
355
|
+
end = int(unit["end"])
|
|
356
|
+
kind = str(unit["kind"])
|
|
357
|
+
name = str(unit["name"])
|
|
358
|
+
title = name if kind in ("imports", "module") else f"{kind} {name}"
|
|
359
|
+
tags = [f"{kind}:{name}"] if kind not in ("imports", "module") else [kind]
|
|
360
|
+
block = Block(
|
|
361
|
+
text=join_lines(lines, cursor, end),
|
|
362
|
+
title=title,
|
|
363
|
+
start_line=cursor,
|
|
364
|
+
end_line=end,
|
|
365
|
+
kind=kind,
|
|
366
|
+
tags=tags,
|
|
367
|
+
)
|
|
368
|
+
if kind == "class" and block.tokens > self._split_threshold():
|
|
369
|
+
node = unit["nodes"][0] # type: ignore[index]
|
|
370
|
+
blocks.extend(self._split_python_class(lines, block, node)) # type: ignore[arg-type]
|
|
371
|
+
else:
|
|
372
|
+
blocks.append(block)
|
|
373
|
+
cursor = end + 1
|
|
374
|
+
|
|
375
|
+
if cursor <= len(lines) and blocks:
|
|
376
|
+
trailing = join_lines(lines, cursor, len(lines))
|
|
377
|
+
blocks[-1].text += trailing
|
|
378
|
+
blocks[-1].end_line = len(lines)
|
|
379
|
+
return blocks
|
|
380
|
+
|
|
381
|
+
def _split_python_class(self, lines: List[str], block: Block, node: ast.ClassDef) -> List[Block]:
|
|
382
|
+
"""Split an oversized class into header + one block per member."""
|
|
383
|
+
members = [
|
|
384
|
+
m
|
|
385
|
+
for m in node.body
|
|
386
|
+
if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
387
|
+
]
|
|
388
|
+
if not members:
|
|
389
|
+
return [block]
|
|
390
|
+
|
|
391
|
+
def member_start(member: ast.AST) -> int:
|
|
392
|
+
start = member.lineno # type: ignore[attr-defined]
|
|
393
|
+
for decorator in getattr(member, "decorator_list", []) or []:
|
|
394
|
+
start = min(start, decorator.lineno)
|
|
395
|
+
return start
|
|
396
|
+
|
|
397
|
+
header_end = member_start(members[0]) - 1
|
|
398
|
+
context = truncate(join_lines(lines, node.lineno, min(header_end, node.lineno + 2)), 200)
|
|
399
|
+
out: List[Block] = []
|
|
400
|
+
if header_end >= block.start_line:
|
|
401
|
+
out.append(
|
|
402
|
+
Block(
|
|
403
|
+
text=join_lines(lines, block.start_line, header_end),
|
|
404
|
+
title=f"class {node.name} (header)",
|
|
405
|
+
start_line=block.start_line,
|
|
406
|
+
end_line=header_end,
|
|
407
|
+
kind="class-header",
|
|
408
|
+
tags=[f"class:{node.name}"],
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
cursor = max(header_end + 1, block.start_line)
|
|
412
|
+
for index, member in enumerate(members):
|
|
413
|
+
end = getattr(member, "end_lineno", None) or member_start(member)
|
|
414
|
+
if index == len(members) - 1:
|
|
415
|
+
end = block.end_line
|
|
416
|
+
out.append(
|
|
417
|
+
Block(
|
|
418
|
+
text=join_lines(lines, cursor, end),
|
|
419
|
+
title=f"{node.name}.{getattr(member, 'name', 'member')}",
|
|
420
|
+
start_line=cursor,
|
|
421
|
+
end_line=end,
|
|
422
|
+
kind="method",
|
|
423
|
+
tags=[f"class:{node.name}", f"method:{getattr(member, 'name', '')}"],
|
|
424
|
+
context=context,
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
cursor = end + 1
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
# ---------------------------------------------------------- brace family
|
|
431
|
+
def _brace_blocks(self, text: str, *, language: str) -> List[Block]:
|
|
432
|
+
"""Block detection for C-family syntax via :class:`BraceScanner`."""
|
|
433
|
+
lines = split_lines(text)
|
|
434
|
+
if not lines:
|
|
435
|
+
return []
|
|
436
|
+
ends = BraceScanner(text).unit_end_lines()
|
|
437
|
+
if not ends:
|
|
438
|
+
return self._fallback_blocks(text)
|
|
439
|
+
|
|
440
|
+
blocks: List[Block] = []
|
|
441
|
+
cursor = 1
|
|
442
|
+
for end in ends:
|
|
443
|
+
end = min(end, len(lines))
|
|
444
|
+
if end < cursor:
|
|
445
|
+
continue
|
|
446
|
+
body = join_lines(lines, cursor, end)
|
|
447
|
+
kind, name = self._identify(body)
|
|
448
|
+
block = Block(
|
|
449
|
+
text=body,
|
|
450
|
+
title=f"{kind} {name}".strip() if name else kind,
|
|
451
|
+
start_line=cursor,
|
|
452
|
+
end_line=end,
|
|
453
|
+
kind=kind,
|
|
454
|
+
tags=[f"{kind}:{name}"] if name else [kind],
|
|
455
|
+
)
|
|
456
|
+
if kind in _CONTAINER_KINDS and block.tokens > self._split_threshold():
|
|
457
|
+
blocks.extend(self._split_container(lines, block, name))
|
|
458
|
+
else:
|
|
459
|
+
blocks.append(block)
|
|
460
|
+
cursor = end + 1
|
|
461
|
+
|
|
462
|
+
if cursor <= len(lines):
|
|
463
|
+
if blocks:
|
|
464
|
+
blocks[-1].text += join_lines(lines, cursor, len(lines))
|
|
465
|
+
blocks[-1].end_line = len(lines)
|
|
466
|
+
else: # pragma: no cover - defensive
|
|
467
|
+
blocks.append(
|
|
468
|
+
Block(
|
|
469
|
+
text=join_lines(lines, cursor, len(lines)),
|
|
470
|
+
title="source",
|
|
471
|
+
start_line=cursor,
|
|
472
|
+
end_line=len(lines),
|
|
473
|
+
kind="segment",
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
return blocks
|
|
477
|
+
|
|
478
|
+
def _split_container(self, lines: List[str], block: Block, container: str) -> List[Block]:
|
|
479
|
+
"""Split an oversized class/struct/namespace into its members.
|
|
480
|
+
|
|
481
|
+
The body is re-scanned with the same brace logic, so members are cut at
|
|
482
|
+
their own closing braces and stay intact.
|
|
483
|
+
"""
|
|
484
|
+
open_line = self._find_body_open_line(lines, block)
|
|
485
|
+
if open_line is None or open_line >= block.end_line:
|
|
486
|
+
return [block]
|
|
487
|
+
|
|
488
|
+
body_start = open_line + 1
|
|
489
|
+
body_end = block.end_line - 1
|
|
490
|
+
if body_end < body_start:
|
|
491
|
+
return [block]
|
|
492
|
+
|
|
493
|
+
body_text = join_lines(lines, body_start, body_end)
|
|
494
|
+
inner_ends = BraceScanner(body_text).unit_end_lines()
|
|
495
|
+
if len(inner_ends) < 2:
|
|
496
|
+
return [block]
|
|
497
|
+
|
|
498
|
+
# The context is the declaration itself, not whatever documentation
|
|
499
|
+
# precedes it: a long Javadoc would otherwise fill the budget and hide
|
|
500
|
+
# the very line a reader needs ("class InventoryService {").
|
|
501
|
+
context = truncate(join_lines(lines, max(block.start_line, open_line - 2), open_line), 200)
|
|
502
|
+
out = [
|
|
503
|
+
Block(
|
|
504
|
+
text=join_lines(lines, block.start_line, open_line),
|
|
505
|
+
title=f"{block.kind} {container} (header)".strip(),
|
|
506
|
+
start_line=block.start_line,
|
|
507
|
+
end_line=open_line,
|
|
508
|
+
kind=f"{block.kind}-header",
|
|
509
|
+
tags=[f"{block.kind}:{container}"] if container else [block.kind],
|
|
510
|
+
)
|
|
511
|
+
]
|
|
512
|
+
cursor = body_start
|
|
513
|
+
for offset in inner_ends:
|
|
514
|
+
end = min(body_start + offset - 1, body_end)
|
|
515
|
+
if end < cursor:
|
|
516
|
+
continue
|
|
517
|
+
member_text = join_lines(lines, cursor, end)
|
|
518
|
+
_, member_name = self._identify(member_text)
|
|
519
|
+
out.append(
|
|
520
|
+
Block(
|
|
521
|
+
text=member_text,
|
|
522
|
+
title=f"{container}.{member_name}" if member_name else f"{container} member",
|
|
523
|
+
start_line=cursor,
|
|
524
|
+
end_line=end,
|
|
525
|
+
kind="method",
|
|
526
|
+
tags=[t for t in (f"{block.kind}:{container}", f"method:{member_name}") if t],
|
|
527
|
+
context=context,
|
|
528
|
+
)
|
|
529
|
+
)
|
|
530
|
+
cursor = end + 1
|
|
531
|
+
# The closing brace of the container tails the last member.
|
|
532
|
+
if cursor <= block.end_line:
|
|
533
|
+
out[-1].text += join_lines(lines, cursor, block.end_line)
|
|
534
|
+
out[-1].end_line = block.end_line
|
|
535
|
+
return out
|
|
536
|
+
|
|
537
|
+
@staticmethod
|
|
538
|
+
def _find_body_open_line(lines: List[str], block: Block) -> Optional[int]:
|
|
539
|
+
"""Line number of the ``{`` that opens a container's body."""
|
|
540
|
+
for line_no in range(block.start_line, block.end_line + 1):
|
|
541
|
+
stripped = lines[line_no - 1]
|
|
542
|
+
if "{" in stripped and not stripped.lstrip().startswith(("//", "*", "/*")):
|
|
543
|
+
return line_no
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
@staticmethod
|
|
547
|
+
def _identify(body: str) -> Tuple[str, str]:
|
|
548
|
+
"""Infer ``(kind, name)`` from the declaration text of a block."""
|
|
549
|
+
significant = [
|
|
550
|
+
line.strip()
|
|
551
|
+
for line in body.splitlines()
|
|
552
|
+
if line.strip()
|
|
553
|
+
and not line.strip().startswith(("//", "*", "/*", "*/"))
|
|
554
|
+
and not _ANNOTATION_RE.match(line)
|
|
555
|
+
]
|
|
556
|
+
head = " ".join(significant[:4])
|
|
557
|
+
if not head:
|
|
558
|
+
return "comment", ""
|
|
559
|
+
if _IMPORT_RE.match(significant[0]) or significant[0].startswith("#"):
|
|
560
|
+
return "imports", ""
|
|
561
|
+
type_match = _TYPE_DECL_RE.search(head)
|
|
562
|
+
if type_match:
|
|
563
|
+
return type_match.group(1), type_match.group(2)
|
|
564
|
+
func_match = _FUNC_DECL_RE.search(head)
|
|
565
|
+
if func_match:
|
|
566
|
+
return "function", func_match.group(1)
|
|
567
|
+
method_match = _METHOD_DECL_RE.search(head)
|
|
568
|
+
if method_match:
|
|
569
|
+
return "method", method_match.group(1)
|
|
570
|
+
if "=" in head or head.endswith(";"):
|
|
571
|
+
return "declaration", ""
|
|
572
|
+
return "block", ""
|
ihmt/chunkers/generic.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Fallback splitter for material with no recognizable structure.
|
|
2
|
+
|
|
3
|
+
Behaves like the narrative splitter with scene detection disabled: paragraphs
|
|
4
|
+
stay atomic, and the only cut inside a paragraph is the sentence-boundary split
|
|
5
|
+
of an over-long one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .narrative import NarrativeChunker
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GenericChunker(NarrativeChunker):
|
|
14
|
+
"""Paragraph-atomic splitter used when no domain-specific rule applies."""
|
|
15
|
+
|
|
16
|
+
name = "generic"
|
|
17
|
+
|
|
18
|
+
def __init__(self, config) -> None:
|
|
19
|
+
super().__init__(config, detect_scenes=False)
|