agent-codinglanguage-mapper 1.0.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 (42) hide show
  1. agent_codinglanguage_mapper/__init__.py +20 -0
  2. agent_codinglanguage_mapper/__main__.py +4 -0
  3. agent_codinglanguage_mapper/_version.py +2 -0
  4. agent_codinglanguage_mapper/adapters/__init__.py +4 -0
  5. agent_codinglanguage_mapper/adapters/delphi.py +822 -0
  6. agent_codinglanguage_mapper/adapters/delphi_frontend/__init__.py +6 -0
  7. agent_codinglanguage_mapper/adapters/delphi_frontend/comment_builder.py +29 -0
  8. agent_codinglanguage_mapper/adapters/delphi_frontend/consts.py +345 -0
  9. agent_codinglanguage_mapper/adapters/delphi_frontend/grammar.py +460 -0
  10. agent_codinglanguage_mapper/adapters/delphi_frontend/lark_builder.py +2674 -0
  11. agent_codinglanguage_mapper/adapters/delphi_frontend/lark_tokens.py +237 -0
  12. agent_codinglanguage_mapper/adapters/delphi_frontend/lsp_server.py +1832 -0
  13. agent_codinglanguage_mapper/adapters/delphi_frontend/nodes.py +371 -0
  14. agent_codinglanguage_mapper/adapters/delphi_frontend/parser.py +193 -0
  15. agent_codinglanguage_mapper/adapters/delphi_frontend/preprocessor.py +997 -0
  16. agent_codinglanguage_mapper/adapters/delphi_frontend/project_discovery.py +518 -0
  17. agent_codinglanguage_mapper/adapters/delphi_frontend/project_indexer.py +319 -0
  18. agent_codinglanguage_mapper/adapters/delphi_frontend/semantic.py +375 -0
  19. agent_codinglanguage_mapper/adapters/delphi_frontend/semantic_builder.py +1384 -0
  20. agent_codinglanguage_mapper/adapters/delphi_frontend/source_reader.py +17 -0
  21. agent_codinglanguage_mapper/adapters/delphi_frontend/workspace.py +67 -0
  22. agent_codinglanguage_mapper/adapters/tree_sitter.py +1722 -0
  23. agent_codinglanguage_mapper/cli.py +138 -0
  24. agent_codinglanguage_mapper/discovery.py +1328 -0
  25. agent_codinglanguage_mapper/indexer.py +1102 -0
  26. agent_codinglanguage_mapper/integration.py +186 -0
  27. agent_codinglanguage_mapper/lsp_server.py +413 -0
  28. agent_codinglanguage_mapper/lsp_service.py +447 -0
  29. agent_codinglanguage_mapper/mapper.py +596 -0
  30. agent_codinglanguage_mapper/models.py +101 -0
  31. agent_codinglanguage_mapper/protocol.py +152 -0
  32. agent_codinglanguage_mapper/rendering.py +153 -0
  33. agent_codinglanguage_mapper/templates/opencode/plugins/codebase_map.ts +191 -0
  34. agent_codinglanguage_mapper/templates/skill/SKILL.md +52 -0
  35. agent_codinglanguage_mapper/worker.py +29 -0
  36. agent_codinglanguage_mapper/workspace.py +114 -0
  37. agent_codinglanguage_mapper-1.0.0.dist-info/METADATA +383 -0
  38. agent_codinglanguage_mapper-1.0.0.dist-info/RECORD +42 -0
  39. agent_codinglanguage_mapper-1.0.0.dist-info/WHEEL +5 -0
  40. agent_codinglanguage_mapper-1.0.0.dist-info/entry_points.txt +2 -0
  41. agent_codinglanguage_mapper-1.0.0.dist-info/licenses/LICENSE +373 -0
  42. agent_codinglanguage_mapper-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,596 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import json
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ from .indexer import WorkspaceIndexCache, build_workspace_snapshot
11
+ from .models import Language, Problem, Project, Reference, Relation, Resolution, SourceRange, Symbol
12
+ from .protocol import AgentRequest, AgentResponse, Context, Focus, Page, ProtocolError
13
+ from .workspace import WorkspaceSnapshot, stable_id
14
+
15
+
16
+ _SOURCE_CHUNK_CHARS = 6000
17
+
18
+
19
+ def _symbol_mapping(symbol: Symbol) -> dict[str, object]:
20
+ return {
21
+ "target_id": symbol.target_id,
22
+ "name": symbol.name,
23
+ "qualified_name": symbol.qualified_name,
24
+ "kind": symbol.kind,
25
+ "language": symbol.language.value,
26
+ "project_id": symbol.project_id,
27
+ "file_id": symbol.file_id,
28
+ "citation": _citation(symbol.name_range or symbol.range),
29
+ "signature": symbol.signature,
30
+ "native_kind": symbol.native_kind,
31
+ "role": symbol.role,
32
+ "parent_id": symbol.parent_id,
33
+ "name_range": (
34
+ {
35
+ "path": symbol.name_range.path,
36
+ "start_line": symbol.name_range.start_line,
37
+ "start_character": symbol.name_range.start_character,
38
+ "end_line": symbol.name_range.end_line,
39
+ "end_character": symbol.name_range.end_character,
40
+ }
41
+ if symbol.name_range is not None
42
+ else None
43
+ ),
44
+ "range": {
45
+ "path": symbol.range.path,
46
+ "start_line": symbol.range.start_line,
47
+ "start_character": symbol.range.start_character,
48
+ "end_line": symbol.range.end_line,
49
+ "end_character": symbol.range.end_character,
50
+ },
51
+ }
52
+
53
+
54
+ def _range_mapping(source_range: SourceRange) -> dict[str, object]:
55
+ return {
56
+ "path": source_range.path,
57
+ "start_line": source_range.start_line,
58
+ "start_character": source_range.start_character,
59
+ "end_line": source_range.end_line,
60
+ "end_character": source_range.end_character,
61
+ }
62
+
63
+
64
+ def _citation(source_range: SourceRange) -> str:
65
+ return f"{source_range.path}:{source_range.start_line + 1}"
66
+
67
+
68
+ def _reference_mapping(reference: Reference) -> dict[str, object]:
69
+ return {
70
+ "source_id": reference.source_id,
71
+ "target_id": reference.target_id,
72
+ "name": reference.name,
73
+ "language": reference.language.value,
74
+ "resolution": reference.resolution.value,
75
+ "reference_kind": reference.reference_kind,
76
+ "qualifier": reference.qualifier,
77
+ "import_path": reference.import_path,
78
+ "imported_symbols": [list(item) for item in reference.imported_symbols],
79
+ "module_alias": reference.module_alias,
80
+ "wildcard_import": reference.wildcard_import,
81
+ "project_wide": reference.project_wide,
82
+ "range": _range_mapping(reference.range),
83
+ "citation": _citation(reference.range),
84
+ }
85
+
86
+
87
+ def _relation_mapping(
88
+ relation: Relation,
89
+ source_symbol: Symbol | None,
90
+ target_symbol: Symbol | None,
91
+ ) -> dict[str, object]:
92
+ return {
93
+ "source_id": relation.source_id,
94
+ "target_id": relation.target_id,
95
+ "target_name": relation.target_name,
96
+ "target_library": relation.target_library,
97
+ "kind": relation.kind,
98
+ "language": source_symbol.language.value if source_symbol is not None else "",
99
+ "resolution": relation.resolution.value,
100
+ "evidence": [_range_mapping(item) for item in relation.evidence],
101
+ "citations": [_citation(item) for item in relation.evidence],
102
+ "source_symbol": _symbol_mapping(source_symbol) if source_symbol is not None else None,
103
+ "target_symbol": _symbol_mapping(target_symbol) if target_symbol is not None else None,
104
+ }
105
+
106
+
107
+ def _problem_mapping(problem: Problem) -> dict[str, object]:
108
+ return {
109
+ "code": problem.code,
110
+ "message": problem.message,
111
+ "severity": problem.severity,
112
+ "language": problem.language.value if problem.language is not None else "",
113
+ "range": _range_mapping(problem.range) if problem.range is not None else None,
114
+ "citation": _citation(problem.range) if problem.range is not None else "",
115
+ "metadata": problem.metadata,
116
+ }
117
+
118
+
119
+ class CodebaseMapper:
120
+ def __init__(
121
+ self,
122
+ root: str | Path,
123
+ snapshot: WorkspaceSnapshot | None = None,
124
+ *,
125
+ languages: tuple[Language, ...] | None = None,
126
+ lazy_details: bool = False,
127
+ index_cache: WorkspaceIndexCache | None = None,
128
+ ) -> None:
129
+ self.root = Path(root).expanduser().resolve()
130
+ self.snapshot = snapshot or self._synthetic_snapshot()
131
+ self._languages = languages
132
+ self._lazy_details = lazy_details
133
+ self._index_cache = index_cache
134
+ self._detailed_files: set[str] = set()
135
+ self._focus = Focus()
136
+ self._closed = False
137
+
138
+ def _synthetic_snapshot(self) -> WorkspaceSnapshot:
139
+ project_id = stable_id("project", str(self.root))
140
+ return WorkspaceSnapshot(projects=(Project(project_id, self.root.name or str(self.root), str(self.root)),))
141
+
142
+ @classmethod
143
+ def open(cls, root: str | Path, languages: str | tuple[Language, ...] = "auto") -> "CodebaseMapper":
144
+ selected = None if languages == "auto" else tuple(Language(item) if isinstance(item, str) else item for item in languages)
145
+ index_cache = WorkspaceIndexCache()
146
+ return cls(
147
+ root,
148
+ build_workspace_snapshot(root, selected, detail_files=(), index_cache=index_cache),
149
+ languages=selected,
150
+ lazy_details=True,
151
+ index_cache=index_cache,
152
+ )
153
+
154
+ def __enter__(self) -> "CodebaseMapper":
155
+ return self
156
+
157
+ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
158
+ self.close()
159
+
160
+ def close(self) -> None:
161
+ self._closed = True
162
+
163
+ def dispatch(self, request: AgentRequest) -> AgentResponse:
164
+ if self._closed:
165
+ raise RuntimeError("mapper is closed")
166
+ self._refresh_changed_sources()
167
+ request = self._request_with_focus(request)
168
+ if request.action == "open":
169
+ items: list[dict[str, object]] = [
170
+ {
171
+ "project_id": project.project_id,
172
+ "name": project.name,
173
+ "root": project.root,
174
+ "languages": [language.value for language in project.languages],
175
+ "kind": project.kind,
176
+ }
177
+ for project in self.snapshot.projects
178
+ ]
179
+ return self._page_response(request, items)
180
+ if request.action == "find":
181
+ query = request.query.casefold()
182
+ requested_source = self.snapshot.files_by_id.get(request.file_id)
183
+ symbols = [symbol for symbol in self.snapshot.symbols
184
+ if (query in symbol.name.casefold() or query in symbol.qualified_name.casefold())
185
+ and (not request.project_id or symbol.project_id == request.project_id)
186
+ and (
187
+ not request.file_id
188
+ or symbol.file_id == request.file_id
189
+ or requested_source is not None
190
+ and Path(symbol.range.path).as_posix() == requested_source.path
191
+ )
192
+ and (not request.languages or symbol.language in request.languages)]
193
+ return self._page_response(request, [_symbol_mapping(symbol) for symbol in symbols])
194
+ if request.action in {"inspect", "focus"}:
195
+ self._ensure_details(request)
196
+ symbol = self.snapshot.symbols_by_id.get(request.target_id)
197
+ if symbol is None:
198
+ return AgentResponse(self.snapshot.revision, {"items": []}, focus=self._focus)
199
+ if request.action == "focus":
200
+ self._focus = Focus(
201
+ project_id=request.project_id or symbol.project_id,
202
+ file_id=request.file_id or symbol.file_id,
203
+ target_id=symbol.target_id,
204
+ languages=request.languages or (symbol.language,),
205
+ )
206
+ response_focus = self._focus
207
+ if not response_focus.target_id:
208
+ response_focus = Focus(
209
+ project_id=symbol.project_id,
210
+ file_id=symbol.file_id,
211
+ target_id=symbol.target_id,
212
+ languages=(symbol.language,),
213
+ )
214
+ return self._inspect_response(request, symbol, response_focus)
215
+ if request.action == "problems":
216
+ items = [_problem_mapping(problem) for problem in self.snapshot.problems]
217
+ return self._page_response(request, items)
218
+ self._ensure_details(request)
219
+ return self._trace_response(request)
220
+
221
+ request = dispatch
222
+ handle = dispatch
223
+
224
+ def _request_with_focus(self, request: AgentRequest) -> AgentRequest:
225
+ if not self._focus.target_id:
226
+ return request
227
+ target_id = request.target_id
228
+ project_id = request.project_id
229
+ file_id = request.file_id
230
+ languages = request.languages
231
+ if request.action in {"inspect", "trace", "focus"} and not target_id:
232
+ target_id = self._focus.target_id
233
+ if request.action == "find":
234
+ if request.project_id or request.file_id or request.languages:
235
+ project_id = request.project_id
236
+ file_id = request.file_id
237
+ languages = request.languages
238
+ else:
239
+ project_id = self._focus.project_id
240
+ file_id = file_id or self._focus.file_id
241
+ languages = self._focus.languages
242
+ if (
243
+ target_id == request.target_id
244
+ and project_id == request.project_id
245
+ and file_id == request.file_id
246
+ and languages == request.languages
247
+ ):
248
+ return request
249
+ return replace(
250
+ request,
251
+ target_id=target_id,
252
+ project_id=project_id,
253
+ file_id=file_id,
254
+ languages=languages,
255
+ )
256
+
257
+ def _refresh_changed_sources(self) -> None:
258
+ if (
259
+ not self._lazy_details
260
+ or self._index_cache is None
261
+ or not self._index_cache.sources_changed(self.root)
262
+ ):
263
+ return
264
+ self.snapshot = build_workspace_snapshot(
265
+ self.root,
266
+ self._languages,
267
+ detail_files=(),
268
+ index_cache=self._index_cache,
269
+ )
270
+ self._detailed_files.clear()
271
+ self._focus = Focus()
272
+
273
+ def _ensure_details(self, request: AgentRequest) -> None:
274
+ if not self._lazy_details or not request.target_id:
275
+ return
276
+ target = self.snapshot.symbols_by_id.get(request.target_id)
277
+ if target is None:
278
+ return
279
+ if request.action in {"inspect", "focus"} or request.relation in {"callees", "uses"}:
280
+ source_file = self.snapshot.files_by_id.get(target.file_id)
281
+ required = {source_file.path} if source_file is not None else set()
282
+ else:
283
+ required = {item.path for item in self.snapshot.files}
284
+ missing = required - self._detailed_files
285
+ if not missing:
286
+ return
287
+ self.snapshot = build_workspace_snapshot(
288
+ self.root,
289
+ self._languages,
290
+ detail_files=missing,
291
+ base_snapshot=self.snapshot,
292
+ index_cache=self._index_cache,
293
+ )
294
+ self._detailed_files.update(missing)
295
+
296
+ def _inspect_response(
297
+ self,
298
+ request: AgentRequest,
299
+ symbol: Symbol,
300
+ focus: Focus,
301
+ ) -> AgentResponse:
302
+ if request.detail == "members":
303
+ members = [item for item in self.snapshot.symbols if item.parent_id == symbol.target_id]
304
+ return self._page_response(request, [_symbol_mapping(item) for item in members], focus)
305
+ if request.detail == "implementations":
306
+ implementations = self._implementation_symbols(symbol)
307
+ return self._page_response(request, [_symbol_mapping(item) for item in implementations], focus)
308
+
309
+ item = _symbol_mapping(symbol)
310
+ text_key = ""
311
+ full_text = ""
312
+ text_offset = 0
313
+ if request.detail == "declaration":
314
+ text_key = "declaration"
315
+ full_text = symbol.signature
316
+ item["citation"] = _citation(symbol.name_range or symbol.range)
317
+ elif request.detail in {"body", "context"}:
318
+ source_range = symbol.range
319
+ context_lines = 8 if request.detail == "context" else 0
320
+ full_text, source_path, first_line = self._source_excerpt(source_range, context_lines=context_lines)
321
+ text_key = "source"
322
+ item["citation"] = f"{source_path}:{first_line}"
323
+ if text_key:
324
+ text_offset = self._decode_cursor(request)
325
+ if text_offset > len(full_text) or (request.cursor and text_offset == len(full_text)):
326
+ raise ProtocolError("cursor offset is invalid")
327
+ item[text_key] = full_text[text_offset:text_offset + _SOURCE_CHUNK_CHARS]
328
+ if text_key == "source":
329
+ line_offset = full_text[:text_offset].count("\n")
330
+ item["citation"] = f"{symbol.range.path}:{int(str(item['citation']).rsplit(':', 1)[1]) + line_offset}"
331
+ item["truncated"] = text_offset + len(cast(str, item[text_key])) < len(full_text)
332
+ item = self._bounded_detail_item(item, request.detail, request.max_chars)
333
+ item_chars = len(json.dumps(item, sort_keys=True, separators=(",", ":")))
334
+ page = Page()
335
+ if text_key:
336
+ returned_text = cast(str, item.get(text_key, ""))
337
+ if not returned_text and text_offset < len(full_text):
338
+ raise ProtocolError("max_chars is too small to return source evidence")
339
+ end = text_offset + len(returned_text)
340
+ source_truncated = end < len(full_text)
341
+ item["truncated"] = bool(item.get("truncated")) or source_truncated
342
+ page = Page(
343
+ returned=1,
344
+ total=1,
345
+ truncated=source_truncated,
346
+ next_cursor=self._encode_cursor(request, end) if source_truncated else "",
347
+ )
348
+ return AgentResponse(
349
+ self.snapshot.revision,
350
+ {"item": item},
351
+ focus,
352
+ page=page,
353
+ context=Context(chars=item_chars),
354
+ )
355
+
356
+ def _implementation_symbols(self, symbol: Symbol) -> list[Symbol]:
357
+ target_ids = {symbol.target_id}
358
+ if symbol.language is Language.DELPHI:
359
+ key = symbol.qualified_name.casefold()
360
+ signature_key = self._normalized_delphi_signature(symbol)
361
+ for candidate in self.snapshot.symbols:
362
+ roles = {candidate.role, symbol.role}
363
+ if (
364
+ candidate.project_id == symbol.project_id
365
+ and candidate.language is symbol.language
366
+ and candidate.kind == symbol.kind
367
+ and candidate.qualified_name.casefold() == key
368
+ and self._normalized_delphi_signature(candidate) == signature_key
369
+ and "implementation" in roles
370
+ and bool(roles.intersection({"declaration", "definition"}))
371
+ ):
372
+ target_ids.add(candidate.target_id)
373
+ for relation in self.snapshot.relations_by_target.get(symbol.target_id, ()):
374
+ if relation.kind == "implements" and relation.resolution is Resolution.COMPLETE and relation.source_id:
375
+ target_ids.add(relation.source_id)
376
+ return [item for item in self.snapshot.symbols if item.target_id in target_ids]
377
+
378
+ @staticmethod
379
+ def _normalized_delphi_signature(symbol: Symbol) -> str:
380
+ signature = " ".join(symbol.signature.casefold().split())
381
+ indexed_name = symbol.name.casefold()
382
+ simple_name = symbol.qualified_name.rsplit(".", 1)[-1].casefold()
383
+ signature = signature.replace(indexed_name, simple_name, 1)
384
+
385
+ depth = 0
386
+ for index, character in enumerate(signature):
387
+ if character == "(":
388
+ depth += 1
389
+ elif character == ")" and depth:
390
+ depth -= 1
391
+ elif character == ";" and depth == 0:
392
+ signature = signature[:index]
393
+ break
394
+ return "".join(signature.split())
395
+
396
+ @staticmethod
397
+ def _bounded_detail_item(
398
+ item: dict[str, object],
399
+ detail: str,
400
+ max_chars: int,
401
+ ) -> dict[str, object]:
402
+ def encoded_size(value: dict[str, object]) -> int:
403
+ return len(json.dumps(value, sort_keys=True, separators=(",", ":")))
404
+
405
+ if encoded_size(item) <= max_chars:
406
+ return item
407
+
408
+ bounded: dict[str, object] = {"target_id": item["target_id"], "truncated": True}
409
+ for key in ("citation", "name", "language"):
410
+ candidate = {**bounded, key: item[key]}
411
+ if encoded_size(candidate) <= max_chars:
412
+ bounded = candidate
413
+
414
+ detail_key = "source" if detail in {"body", "context"} else "declaration" if detail == "declaration" else ""
415
+ if detail_key:
416
+ text = cast(str, item[detail_key])
417
+ low = 0
418
+ high = len(text)
419
+ while low <= high:
420
+ middle = (low + high) // 2
421
+ candidate = {**bounded, detail_key: text[:middle]}
422
+ if encoded_size(candidate) <= max_chars:
423
+ low = middle + 1
424
+ else:
425
+ high = middle - 1
426
+ bounded[detail_key] = text[:max(0, high)]
427
+ return bounded
428
+
429
+ for key in ("kind", "role", "project_id", "file_id", "qualified_name", "signature"):
430
+ candidate = {**bounded, key: item[key]}
431
+ if encoded_size(candidate) <= max_chars:
432
+ bounded = candidate
433
+ return bounded
434
+
435
+ def _source_excerpt(self, source_range: SourceRange, *, context_lines: int) -> tuple[str, str, int]:
436
+ cached = self._index_cache.entries.get(source_range.path) if self._index_cache is not None else None
437
+ if cached is not None:
438
+ lines = cached.source.splitlines()
439
+ else:
440
+ path = Path(source_range.path)
441
+ source_path = path if path.is_absolute() else self.root / path
442
+ try:
443
+ lines = source_path.read_text(encoding="utf-8", errors="replace").splitlines()
444
+ except OSError:
445
+ return "", source_range.path, source_range.start_line + 1
446
+ start = max(0, source_range.start_line - context_lines)
447
+ end = min(len(lines), source_range.end_line + context_lines + 1)
448
+ text = "\n".join(lines[start:end])
449
+ return text, source_range.path, start + 1
450
+
451
+ def _trace_response(self, request: AgentRequest) -> AgentResponse:
452
+ relation = request.relation
453
+ references = self.snapshot.references
454
+ aliases = self.snapshot.symbol_aliases_by_id.get(request.target_id, ())
455
+ request_target_ids = tuple(dict.fromkeys(item.target_id for item in aliases))
456
+ if not request_target_ids:
457
+ request_target_ids = (request.target_id,)
458
+ request_target_id_set = set(request_target_ids)
459
+ if relation == "references":
460
+ selected = [item for item in references if item.target_id in request_target_id_set]
461
+ return self._page_response(request, [_reference_mapping(item) for item in selected])
462
+ if relation in {"callers", "used_by"}:
463
+ target_ids = {
464
+ item.source_id for item in references if item.target_id in request_target_id_set
465
+ }
466
+ target_ids.update(
467
+ item.source_id
468
+ for item in self.snapshot.relations
469
+ if relation == "used_by" and item.target_id in request_target_id_set
470
+ )
471
+ caller_symbols = [
472
+ self.snapshot.symbols_by_id[item] for item in target_ids if item in self.snapshot.symbols_by_id
473
+ ]
474
+ return self._page_response(request, [_symbol_mapping(item) for item in caller_symbols])
475
+ if relation in {"callees", "uses"}:
476
+ target_ids = {
477
+ item.target_id
478
+ for item in references
479
+ if item.source_id in request_target_id_set and item.target_id
480
+ }
481
+ target_ids.update(
482
+ item.target_id
483
+ for item in self.snapshot.relations
484
+ if relation == "uses"
485
+ and item.source_id in request_target_id_set
486
+ and item.target_id
487
+ )
488
+ callee_symbols = [
489
+ self.snapshot.symbols_by_id[item] for item in target_ids if item in self.snapshot.symbols_by_id
490
+ ]
491
+ return self._page_response(request, [_symbol_mapping(item) for item in callee_symbols])
492
+
493
+ relations = tuple(
494
+ dict.fromkeys(
495
+ item
496
+ for target_id in request_target_ids
497
+ for item in (
498
+ self.snapshot.relations_by_source.get(target_id, ())
499
+ + self.snapshot.relations_by_target.get(target_id, ())
500
+ )
501
+ )
502
+ )
503
+ if relation:
504
+ relations = tuple(item for item in relations if item.kind == relation)
505
+ relation_items: list[dict[str, object]] = []
506
+ for item in relations:
507
+ source_symbol = self.snapshot.symbols_by_id.get(item.source_id)
508
+ target_symbol = self.snapshot.symbols_by_id.get(item.target_id)
509
+ relation_items.append(_relation_mapping(item, source_symbol, target_symbol))
510
+ return self._page_response(request, relation_items)
511
+
512
+ def _page_response(
513
+ self,
514
+ request: AgentRequest,
515
+ items: list[dict[str, object]],
516
+ focus: Focus | None = None,
517
+ ) -> AgentResponse:
518
+ start = self._decode_cursor(request)
519
+ selected: list[dict[str, object]] = []
520
+ used = 0
521
+ for item in items[start:start + request.max_items]:
522
+ size = len(json.dumps(item, sort_keys=True, separators=(",", ":")))
523
+ if used + size > request.max_chars:
524
+ if not selected:
525
+ bounded = self._bounded_page_item(item, request.max_chars)
526
+ selected.append(bounded)
527
+ used = len(json.dumps(bounded, sort_keys=True, separators=(",", ":")))
528
+ break
529
+ selected.append(item)
530
+ used += size
531
+ end = start + len(selected)
532
+ cursor = self._encode_cursor(request, end) if end < len(items) and selected else ""
533
+ return AgentResponse(
534
+ self.snapshot.revision,
535
+ {"items": selected},
536
+ focus=self._focus if focus is None else focus,
537
+ page=Page(
538
+ returned=len(selected),
539
+ total=len(items),
540
+ truncated=end < len(items),
541
+ next_cursor=cursor,
542
+ ),
543
+ context=Context(chars=used),
544
+ )
545
+
546
+ @staticmethod
547
+ def _bounded_page_item(item: dict[str, object], max_chars: int) -> dict[str, object]:
548
+ bounded: dict[str, object] = {"truncated": True}
549
+ identifying_fields = (
550
+ "target_id",
551
+ "name",
552
+ "language",
553
+ "citation",
554
+ "kind",
555
+ "role",
556
+ "project_id",
557
+ "file_id",
558
+ "source_id",
559
+ "qualified_name",
560
+ "code",
561
+ "resolution",
562
+ "target_name",
563
+ )
564
+ for key in identifying_fields:
565
+ if key not in item:
566
+ continue
567
+ candidate = {**bounded, key: item[key]}
568
+ if len(json.dumps(candidate, sort_keys=True, separators=(",", ":"))) <= max_chars:
569
+ bounded = candidate
570
+ return bounded
571
+
572
+ def _fingerprint(self, request: AgentRequest) -> str:
573
+ data = request.to_mapping()
574
+ data.pop("cursor")
575
+ return json.dumps(data, sort_keys=True, separators=(",", ":"))
576
+
577
+ def _encode_cursor(self, request: AgentRequest, offset: int) -> str:
578
+ payload = {"revision": self.snapshot.revision, "request": self._fingerprint(request), "offset": offset}
579
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
580
+ return base64.urlsafe_b64encode(raw).decode("ascii")
581
+
582
+ def _decode_cursor(self, request: AgentRequest) -> int:
583
+ if not request.cursor:
584
+ return 0
585
+ try:
586
+ data = json.loads(base64.urlsafe_b64decode(request.cursor.encode("ascii")).decode("utf-8"))
587
+ if data["revision"] != self.snapshot.revision or data["request"] != self._fingerprint(request):
588
+ raise ProtocolError("cursor does not match this workspace or request")
589
+ offset = data["offset"]
590
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
591
+ raise ProtocolError("cursor offset is invalid")
592
+ return cast(int, offset)
593
+ except ProtocolError:
594
+ raise
595
+ except (binascii.Error, KeyError, TypeError, UnicodeDecodeError, ValueError, json.JSONDecodeError) as exc:
596
+ raise ProtocolError("cursor is invalid") from exc
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+
6
+
7
+ class Language(str, Enum):
8
+ PYTHON = "python"
9
+ RUST = "rust"
10
+ CSHARP = "csharp"
11
+ DELPHI = "delphi"
12
+ CPP = "cpp"
13
+
14
+
15
+ class Resolution(str, Enum):
16
+ COMPLETE = "complete"
17
+ SOUND_PARTIAL = "sound_partial"
18
+ AMBIGUOUS = "ambiguous"
19
+ UNRESOLVED = "unresolved"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class SourceRange:
24
+ path: str
25
+ start_line: int
26
+ start_character: int
27
+ end_line: int
28
+ end_character: int
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Project:
33
+ project_id: str
34
+ name: str
35
+ root: str
36
+ languages: tuple[Language, ...] = ()
37
+ kind: str = "synthetic"
38
+ include_paths: tuple[str, ...] = ()
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class SourceFile:
43
+ file_id: str
44
+ path: str
45
+ language: Language
46
+ project_id: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Symbol:
51
+ target_id: str
52
+ name: str
53
+ qualified_name: str
54
+ kind: str
55
+ language: Language
56
+ range: SourceRange
57
+ project_id: str
58
+ file_id: str
59
+ signature: str = ""
60
+ native_kind: str = ""
61
+ parent_id: str = ""
62
+ name_range: SourceRange | None = None
63
+ role: str = "definition"
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Reference:
68
+ source_id: str
69
+ target_id: str
70
+ name: str
71
+ language: Language
72
+ range: SourceRange
73
+ resolution: Resolution = Resolution.COMPLETE
74
+ reference_kind: str = "reference"
75
+ qualifier: str = ""
76
+ import_path: str = ""
77
+ imported_symbols: tuple[tuple[str, str], ...] = ()
78
+ module_alias: str = ""
79
+ wildcard_import: bool = False
80
+ project_wide: bool = False
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Relation:
85
+ source_id: str
86
+ target_id: str
87
+ kind: str
88
+ resolution: Resolution = Resolution.COMPLETE
89
+ evidence: tuple[SourceRange, ...] = ()
90
+ target_name: str = ""
91
+ target_library: str = ""
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Problem:
96
+ code: str
97
+ message: str
98
+ severity: str = "warning"
99
+ language: Language | None = None
100
+ range: SourceRange | None = None
101
+ metadata: dict[str, object] = field(default_factory=dict)