compass-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. compass_cli/__init__.py +0 -0
  2. compass_cli/commands/__init__.py +0 -0
  3. compass_cli/commands/ingest.py +310 -0
  4. compass_cli/commands/mcp.py +25 -0
  5. compass_cli/commands/query.py +181 -0
  6. compass_cli/commands/serve.py +123 -0
  7. compass_cli/commands/setup.py +171 -0
  8. compass_cli/display.py +231 -0
  9. compass_cli/errors.py +30 -0
  10. compass_cli/main.py +54 -0
  11. compass_cli/progress.py +191 -0
  12. compass_cli/result_views.py +389 -0
  13. compass_cli/types.py +41 -0
  14. compass_code-0.1.0.dist-info/METADATA +72 -0
  15. compass_code-0.1.0.dist-info/RECORD +137 -0
  16. compass_code-0.1.0.dist-info/WHEEL +4 -0
  17. compass_code-0.1.0.dist-info/entry_points.txt +2 -0
  18. compass_core/__init__.py +1 -0
  19. compass_core/contract/__init__.py +6 -0
  20. compass_core/contract/errors.py +116 -0
  21. compass_core/contract/models.py +204 -0
  22. compass_core/contract/params.py +99 -0
  23. compass_core/contract/results.py +370 -0
  24. compass_core/contract/serialize.py +59 -0
  25. compass_core/contract/types.py +76 -0
  26. compass_core/errors.py +148 -0
  27. compass_core/graph/__init__.py +0 -0
  28. compass_core/graph/db.py +205 -0
  29. compass_core/graph/schema.py +165 -0
  30. compass_core/graph/stored_ids.py +133 -0
  31. compass_core/graph/writer.py +482 -0
  32. compass_core/index/__init__.py +1 -0
  33. compass_core/index/freshness.py +55 -0
  34. compass_core/index/locate.py +155 -0
  35. compass_core/index/metadata.py +107 -0
  36. compass_core/ir/__init__.py +0 -0
  37. compass_core/ir/graph.py +65 -0
  38. compass_core/ir/models.py +75 -0
  39. compass_core/languages/__init__.py +1 -0
  40. compass_core/languages/base.py +26 -0
  41. compass_core/languages/python/__init__.py +6 -0
  42. compass_core/languages/python/extractor.py +670 -0
  43. compass_core/languages/python/import_resolver.py +107 -0
  44. compass_core/languages/registry.py +89 -0
  45. compass_core/logger.py +42 -0
  46. compass_core/phases/__init__.py +0 -0
  47. compass_core/phases/base.py +58 -0
  48. compass_core/phases/discovery.py +167 -0
  49. compass_core/phases/parse.py +80 -0
  50. compass_core/phases/persistence.py +33 -0
  51. compass_core/phases/pipeline.py +152 -0
  52. compass_core/phases/resolution.py +572 -0
  53. compass_core/phases/symbol_table.py +36 -0
  54. compass_core/phases/validation.py +76 -0
  55. compass_core/progress.py +45 -0
  56. compass_core/py.typed +0 -0
  57. compass_core/query/__init__.py +1 -0
  58. compass_core/query/cypher.py +92 -0
  59. compass_core/query/detail.py +241 -0
  60. compass_core/query/graph_map.py +127 -0
  61. compass_core/query/impact.py +273 -0
  62. compass_core/query/impact_walk.py +352 -0
  63. compass_core/query/overview.py +221 -0
  64. compass_core/query/references.py +235 -0
  65. compass_core/query/rows.py +378 -0
  66. compass_core/query/search.py +183 -0
  67. compass_core/query/status.py +49 -0
  68. compass_core/query/walk_queries.py +202 -0
  69. compass_core/render.py +408 -0
  70. compass_core/settings.py +61 -0
  71. compass_core/shared_types.py +78 -0
  72. compass_mcp/__init__.py +0 -0
  73. compass_mcp/guide.py +45 -0
  74. compass_mcp/py.typed +0 -0
  75. compass_mcp/server.py +261 -0
  76. compass_server/__init__.py +5 -0
  77. compass_server/errors.py +84 -0
  78. compass_server/main.py +42 -0
  79. compass_server/py.typed +0 -0
  80. compass_server/routers/__init__.py +0 -0
  81. compass_server/routers/query.py +182 -0
  82. compass_server/settings.py +37 -0
  83. compass_server/static/404.html +1 -0
  84. compass_server/static/_next/static/chunks/1629039a.eed2a06658ec4f9c.js +1 -0
  85. compass_server/static/_next/static/chunks/1ff282bf-bb7c483245264fc9.js +1 -0
  86. compass_server/static/_next/static/chunks/223.f881a22f446259bf.js +508 -0
  87. compass_server/static/_next/static/chunks/51fa5c19.6396973222c9d6fd.js +485 -0
  88. compass_server/static/_next/static/chunks/556.443c1682b6b20128.js +1 -0
  89. compass_server/static/_next/static/chunks/684-1aa0170f59baf77e.js +1 -0
  90. compass_server/static/_next/static/chunks/92e71660.3a42d4f70d017951.js +342 -0
  91. compass_server/static/_next/static/chunks/937-8692224bb6d1b605.js +1 -0
  92. compass_server/static/_next/static/chunks/app/_not-found/page-2c96ddf295eb6a86.js +1 -0
  93. compass_server/static/_next/static/chunks/app/layout-4bd0ba4a7d18aff9.js +1 -0
  94. compass_server/static/_next/static/chunks/app/page-5376a0a98bbd1faa.js +1 -0
  95. compass_server/static/_next/static/chunks/framework-96dcd958cfe08bd4.js +1 -0
  96. compass_server/static/_next/static/chunks/main-6dee8e626b16731a.js +1 -0
  97. compass_server/static/_next/static/chunks/main-app-460e8bc670818718.js +1 -0
  98. compass_server/static/_next/static/chunks/pages/_app-638a3e7363f92b80.js +1 -0
  99. compass_server/static/_next/static/chunks/pages/_error-6a72dbbb45cae743.js +1 -0
  100. compass_server/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  101. compass_server/static/_next/static/chunks/webpack-597057bf48ce4111.js +1 -0
  102. compass_server/static/_next/static/css/1b0fbfd8dd653f37.css +3 -0
  103. compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_buildManifest.js +1 -0
  104. compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_ssgManifest.js +1 -0
  105. compass_server/static/_next/static/media/0086f8992871c45b-s.woff2 +0 -0
  106. compass_server/static/_next/static/media/034d78ad42e9620c-s.woff2 +0 -0
  107. compass_server/static/_next/static/media/03b436aa846269de-s.woff2 +0 -0
  108. compass_server/static/_next/static/media/0484562807a97172-s.p.woff2 +0 -0
  109. compass_server/static/_next/static/media/08aedeceaf1dcd57-s.woff2 +0 -0
  110. compass_server/static/_next/static/media/0aa834ed78bf6d07-s.woff2 +0 -0
  111. compass_server/static/_next/static/media/29a4aea02fdee119-s.woff2 +0 -0
  112. compass_server/static/_next/static/media/29e7bbdce9332268-s.woff2 +0 -0
  113. compass_server/static/_next/static/media/36008747766f78c6-s.woff2 +0 -0
  114. compass_server/static/_next/static/media/39969fcf98a3026e-s.woff2 +0 -0
  115. compass_server/static/_next/static/media/4de1fea1a954a5b6-s.p.woff2 +0 -0
  116. compass_server/static/_next/static/media/5c0c2bcbaa4149ca-s.p.woff2 +0 -0
  117. compass_server/static/_next/static/media/67957d42bae0796d-s.woff2 +0 -0
  118. compass_server/static/_next/static/media/6d38f9d4e0f4772b-s.woff2 +0 -0
  119. compass_server/static/_next/static/media/6d664cce900333ee-s.p.woff2 +0 -0
  120. compass_server/static/_next/static/media/886030b0b59bc5a7-s.woff2 +0 -0
  121. compass_server/static/_next/static/media/8888a3826f4a3af4-s.p.woff2 +0 -0
  122. compass_server/static/_next/static/media/939c4f875ee75fbb-s.woff2 +0 -0
  123. compass_server/static/_next/static/media/a1386beebedccca4-s.woff2 +0 -0
  124. compass_server/static/_next/static/media/ab00a911ac2adf48-s.woff2 +0 -0
  125. compass_server/static/_next/static/media/b957ea75a84b6ea7-s.p.woff2 +0 -0
  126. compass_server/static/_next/static/media/bb3ef058b751a6ad-s.p.woff2 +0 -0
  127. compass_server/static/_next/static/media/c3bc380753a8436c-s.woff2 +0 -0
  128. compass_server/static/_next/static/media/cce080f35d014443-s.woff2 +0 -0
  129. compass_server/static/_next/static/media/db911767852bc875-s.woff2 +0 -0
  130. compass_server/static/_next/static/media/eafabf029ad39a43-s.p.woff2 +0 -0
  131. compass_server/static/_next/static/media/f10b8e9d91f3edcb-s.woff2 +0 -0
  132. compass_server/static/_next/static/media/f911b923c6adde36-s.woff2 +0 -0
  133. compass_server/static/_next/static/media/fe0777f1195381cb-s.woff2 +0 -0
  134. compass_server/static/index.html +1 -0
  135. compass_server/static/index.txt +25 -0
  136. compass_server/web_bundle.py +27 -0
  137. compass_server/web_limits.py +48 -0
@@ -0,0 +1,670 @@
1
+ """tree-sitter-python -> IR. Pure, side-effect-free: no DB calls, no module-
2
+ level parser state -- a fresh Language + Parser is created on every call so
3
+ this class is safe to use inside ProcessPoolExecutor worker processes
4
+ (tree-sitter Parser/Language objects don't pickle cleanly).
5
+
6
+ Moved from compass_core.parser.extractor (Task 5). The tree-sitter
7
+ node-walking helpers below (`_extract_signature` through `_unwrap_decorated`,
8
+ plus `_make_parser`, `_extract_imports`) move verbatim/near-verbatim -- their
9
+ internal logic (tree-sitter node type strings, byte-offset slicing) is
10
+ unchanged. The outer assembly (`_extract_classes`, `_extract_top_level_functions`
11
+ and the removed `_extract_class`/`_extract_function`/`_extract_methods`) is
12
+ adapted to build `compass_core.ir.models` types directly instead of a nested
13
+ `parser.models.ParsedClass`/`ParsedFunction` tree, with `Definition.id`
14
+ namespaced `py::{module_name}::{name}` (class/function) or
15
+ `py::{module_name}::{parent}.{name}` (method) at construction time.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ast
21
+ import hashlib
22
+ from pathlib import Path
23
+
24
+ import tree_sitter_python as tspython
25
+ from tree_sitter import Language, Node, Parser
26
+
27
+ from compass_core.ir.models import CallSite, Definition, ImportStatement, ParsedFile
28
+ from compass_core.shared_types import Language as SourceLanguage
29
+ from compass_core.shared_types import SymbolKind
30
+
31
+
32
+ class PythonExtractor:
33
+ language_id = SourceLanguage.PYTHON
34
+ file_extensions = {".py"}
35
+
36
+ def parse(self, source: bytes, file_path: Path) -> ParsedFile:
37
+ content_hash = hashlib.sha256(source).hexdigest()
38
+ module_name = _derive_module_name(file_path)
39
+
40
+ _, parser = _make_parser()
41
+ tree = parser.parse(source)
42
+ root = tree.root_node
43
+
44
+ imports = _extract_imports(source, root)
45
+ definitions: list[Definition] = []
46
+ call_sites: list[CallSite] = []
47
+ _extract_classes(source, root, module_name, str(file_path), definitions, call_sites)
48
+ _extract_top_level_functions(
49
+ source, root, module_name, str(file_path), definitions, call_sites
50
+ )
51
+
52
+ return ParsedFile(
53
+ path=str(file_path),
54
+ language=self.language_id,
55
+ module_name=module_name,
56
+ content_hash=content_hash,
57
+ definitions=definitions,
58
+ imports=imports,
59
+ call_sites=call_sites,
60
+ )
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Internal helpers
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def _make_parser() -> tuple[Language, Parser]:
69
+ """Create and return a fresh Language + Parser pair."""
70
+ lang = Language(tspython.language())
71
+ parser = Parser(lang)
72
+ return lang, parser
73
+
74
+
75
+ def _derive_module_name(path: Path) -> str:
76
+ """Convert a file path to a dotted Python module name.
77
+
78
+ By calling convention, ``file_path`` passed into
79
+ ``LanguageExtractor.parse`` (and therefore into this helper) is already
80
+ relative to the repository root -- relativizing an absolute path is
81
+ Phase 1/parse's responsibility (a later task), not this extractor's.
82
+ Given a root-relative path, every path component becomes a dotted
83
+ segment, with a trailing ``__init__`` component stripped so packages are
84
+ referenced by their directory name.
85
+
86
+ A leading ``src`` segment is also stripped: under the standard Python
87
+ src-layout (``packages/<pkg>/src/<import_name>/...``), imports are
88
+ written relative to ``src/``, not the repo root, so keeping ``src`` in
89
+ the dotted name would make every intra-repo import fail to resolve
90
+ against this module name (see PythonImportResolver._resolve_module).
91
+ Only the first ``src`` segment is stripped -- everything before it
92
+ (e.g. ``packages/core``) is dropped too, since it plays no part in the
93
+ actual import path.
94
+ """
95
+ parts = list(path.with_suffix("").parts)
96
+ if "src" in parts:
97
+ parts = parts[parts.index("src") + 1 :]
98
+ if parts and parts[-1] == "__init__":
99
+ parts = parts[:-1]
100
+ return ".".join(parts) if parts else path.stem
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Import extraction
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def _extract_imports(source_bytes: bytes, root_node: Node) -> list[ImportStatement]:
109
+ """Extract all import statements from the module-level AST node.
110
+
111
+ Handles both `import X` and `from X import Y` forms, including aliases
112
+ and wildcard imports.
113
+ """
114
+ imports: list[ImportStatement] = []
115
+ for node in root_node.children:
116
+ if node.type == "import_statement":
117
+ imports.extend(_parse_import_statement(node, source_bytes))
118
+ elif node.type == "import_from_statement":
119
+ imports.extend(_parse_import_from_statement(node, source_bytes))
120
+ return imports
121
+
122
+
123
+ def _parse_import_statement(node: Node, source_bytes: bytes) -> list[ImportStatement]:
124
+ """Parse `import X` and `import X as Y` nodes."""
125
+ result: list[ImportStatement] = []
126
+ line = node.start_point[0] + 1
127
+
128
+ for child in node.named_children:
129
+ if child.type == "dotted_name":
130
+ module = _node_text(child, source_bytes)
131
+ result.append(
132
+ ImportStatement(
133
+ module=module,
134
+ names=[],
135
+ alias=None,
136
+ is_relative=module.startswith("."),
137
+ is_from=False,
138
+ line=line,
139
+ )
140
+ )
141
+ elif child.type == "aliased_import":
142
+ # aliased_import: dotted_name "as" identifier
143
+ name_node = child.child_by_field_name("name")
144
+ alias_node = child.child_by_field_name("alias")
145
+ module = _node_text(name_node, source_bytes) if name_node else ""
146
+ result.append(
147
+ ImportStatement(
148
+ module=module,
149
+ names=[],
150
+ alias=_node_text(alias_node, source_bytes) if alias_node else None,
151
+ is_relative=module.startswith("."),
152
+ is_from=False,
153
+ line=line,
154
+ )
155
+ )
156
+ return result
157
+
158
+
159
+ def _parse_import_from_statement(node: Node, source_bytes: bytes) -> list[ImportStatement]:
160
+ """Parse `from X import Y` nodes including wildcards and aliases.
161
+
162
+ `ImportStatement` carries one `alias` field, so a statement renaming
163
+ several names at once (`from x import a as b, c as d`) can't be
164
+ represented as a single entry without losing all but the last alias.
165
+ Each aliased name is therefore split into its own `ImportStatement`;
166
+ plain (non-aliased) names stay grouped together in one, matching the
167
+ pre-existing shape for the common `from x import a, b` case.
168
+ """
169
+ line = node.start_point[0] + 1
170
+ module = ""
171
+
172
+ children = node.named_children
173
+ if not children:
174
+ return [
175
+ ImportStatement(
176
+ module=module, names=[], alias=None, is_relative=False, is_from=True, line=line
177
+ )
178
+ ]
179
+
180
+ # First named child is the module (dotted_name or relative_import)
181
+ first = children[0]
182
+ if first.type in ("dotted_name", "relative_import"):
183
+ module = _node_text(first, source_bytes)
184
+ is_relative = module.startswith(".") or first.type == "relative_import"
185
+
186
+ # Remaining children are the imported names
187
+ plain_names: list[str] = []
188
+ aliased: list[tuple[str, str]] = []
189
+ for child in children[1:]:
190
+ if child.type == "wildcard_import":
191
+ plain_names = ["*"]
192
+ elif child.type == "dotted_name":
193
+ plain_names.append(_node_text(child, source_bytes))
194
+ elif child.type == "aliased_import":
195
+ name_node = child.child_by_field_name("name")
196
+ alias_node = child.child_by_field_name("alias")
197
+ if name_node and alias_node:
198
+ aliased.append(
199
+ (_node_text(name_node, source_bytes), _node_text(alias_node, source_bytes))
200
+ )
201
+
202
+ statements: list[ImportStatement] = []
203
+ if plain_names:
204
+ statements.append(
205
+ ImportStatement(
206
+ module=module,
207
+ names=plain_names,
208
+ alias=None,
209
+ is_relative=is_relative,
210
+ is_from=True,
211
+ line=line,
212
+ )
213
+ )
214
+ for name, alias in aliased:
215
+ statements.append(
216
+ ImportStatement(
217
+ module=module,
218
+ names=[name],
219
+ alias=alias,
220
+ is_relative=is_relative,
221
+ is_from=True,
222
+ line=line,
223
+ )
224
+ )
225
+ if not statements:
226
+ statements.append(
227
+ ImportStatement(
228
+ module=module,
229
+ names=[],
230
+ alias=None,
231
+ is_relative=is_relative,
232
+ is_from=True,
233
+ line=line,
234
+ )
235
+ )
236
+ return statements
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Class extraction
241
+ # ---------------------------------------------------------------------------
242
+
243
+
244
+ def _extract_classes(
245
+ source_bytes: bytes,
246
+ root_node: Node,
247
+ module_name: str,
248
+ file_path: str,
249
+ out_definitions: list[Definition],
250
+ out_call_sites: list[CallSite],
251
+ ) -> None:
252
+ """Extract all top-level class definitions from the module, appending
253
+ Definition(kind=CLASS) and Definition(kind=METHOD) entries directly into
254
+ out_definitions, and each method's raw-call CallSite entries into
255
+ out_call_sites."""
256
+ for node in root_node.children:
257
+ if node.type == "class_definition":
258
+ _extract_class(
259
+ node, source_bytes, module_name, file_path, out_definitions, out_call_sites
260
+ )
261
+ elif node.type == "decorated_definition":
262
+ inner = _unwrap_decorated(node)
263
+ if inner and inner.type == "class_definition":
264
+ _extract_class(
265
+ inner, source_bytes, module_name, file_path, out_definitions, out_call_sites
266
+ )
267
+
268
+
269
+ def _extract_class(
270
+ node: Node,
271
+ source_bytes: bytes,
272
+ module_name: str,
273
+ file_path: str,
274
+ out_definitions: list[Definition],
275
+ out_call_sites: list[CallSite],
276
+ ) -> None:
277
+ """Build a Definition(kind=CLASS) from a class_definition AST node and
278
+ append it (and its methods) into out_definitions."""
279
+ name_node = node.child_by_field_name("name")
280
+ name = _node_text(name_node, source_bytes) if name_node else ""
281
+ class_id = f"py::{module_name}::{name}"
282
+
283
+ # Bases: argument_list child named "superclasses"
284
+ bases: list[str] = []
285
+ superclasses_node = node.child_by_field_name("superclasses")
286
+ if superclasses_node:
287
+ for child in superclasses_node.named_children:
288
+ if child.type in ("identifier", "attribute", "dotted_name"):
289
+ bases.append(_node_text(child, source_bytes))
290
+
291
+ body_node = node.child_by_field_name("body")
292
+ docstring = _extract_docstring(body_node, source_bytes) if body_node else None
293
+
294
+ # If wrapped in decorated_definition, source/line includes decorators
295
+ container = node.parent if node.parent and node.parent.type == "decorated_definition" else node
296
+ line_start = container.start_point[0] + 1
297
+ line_end = container.end_point[0] + 1
298
+ source = _slice_source(source_bytes, container.start_byte, container.end_byte)
299
+
300
+ out_definitions.append(
301
+ Definition(
302
+ id=class_id,
303
+ kind=SymbolKind.CLASS,
304
+ name=name,
305
+ file_path=file_path,
306
+ line_start=line_start,
307
+ line_end=line_end,
308
+ source=source,
309
+ docstring=docstring,
310
+ bases=bases,
311
+ parent_id=None,
312
+ signature=None,
313
+ decorators=[],
314
+ raw_calls=[],
315
+ )
316
+ )
317
+
318
+ if body_node:
319
+ _extract_methods(
320
+ source_bytes,
321
+ body_node,
322
+ module_name,
323
+ file_path,
324
+ name,
325
+ class_id,
326
+ out_definitions,
327
+ out_call_sites,
328
+ )
329
+
330
+
331
+ def _extract_methods(
332
+ source_bytes: bytes,
333
+ body_node: Node,
334
+ module_name: str,
335
+ file_path: str,
336
+ parent_class: str,
337
+ parent_id: str,
338
+ out_definitions: list[Definition],
339
+ out_call_sites: list[CallSite],
340
+ ) -> None:
341
+ """Extract all method definitions from a class body node."""
342
+ seen_ids: dict[str, int] = {}
343
+ for child in body_node.children:
344
+ if child.type == "function_definition":
345
+ _extract_function(
346
+ child,
347
+ source_bytes,
348
+ module_name,
349
+ file_path,
350
+ out_definitions,
351
+ out_call_sites,
352
+ is_method=True,
353
+ parent_class=parent_class,
354
+ parent_id=parent_id,
355
+ seen_ids=seen_ids,
356
+ )
357
+ elif child.type == "decorated_definition":
358
+ inner = _unwrap_decorated(child)
359
+ if inner and inner.type == "function_definition":
360
+ _extract_function(
361
+ inner,
362
+ source_bytes,
363
+ module_name,
364
+ file_path,
365
+ out_definitions,
366
+ out_call_sites,
367
+ is_method=True,
368
+ parent_class=parent_class,
369
+ parent_id=parent_id,
370
+ seen_ids=seen_ids,
371
+ )
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Function extraction
376
+ # ---------------------------------------------------------------------------
377
+
378
+
379
+ def _extract_top_level_functions(
380
+ source_bytes: bytes,
381
+ root_node: Node,
382
+ module_name: str,
383
+ file_path: str,
384
+ out_definitions: list[Definition],
385
+ out_call_sites: list[CallSite],
386
+ ) -> None:
387
+ """Extract all top-level (non-method) function definitions."""
388
+ seen_ids: dict[str, int] = {}
389
+ for node in root_node.children:
390
+ if node.type == "function_definition":
391
+ _extract_function(
392
+ node,
393
+ source_bytes,
394
+ module_name,
395
+ file_path,
396
+ out_definitions,
397
+ out_call_sites,
398
+ is_method=False,
399
+ parent_class=None,
400
+ parent_id=None,
401
+ seen_ids=seen_ids,
402
+ )
403
+ elif node.type == "decorated_definition":
404
+ inner = _unwrap_decorated(node)
405
+ if inner and inner.type == "function_definition":
406
+ _extract_function(
407
+ inner,
408
+ source_bytes,
409
+ module_name,
410
+ file_path,
411
+ out_definitions,
412
+ out_call_sites,
413
+ is_method=False,
414
+ seen_ids=seen_ids,
415
+ parent_class=None,
416
+ parent_id=None,
417
+ )
418
+
419
+
420
+ def _extract_function(
421
+ node: Node,
422
+ source_bytes: bytes,
423
+ module_name: str,
424
+ file_path: str,
425
+ out_definitions: list[Definition],
426
+ out_call_sites: list[CallSite],
427
+ is_method: bool = False,
428
+ parent_class: str | None = None,
429
+ parent_id: str | None = None,
430
+ seen_ids: dict[str, int] | None = None,
431
+ ) -> None:
432
+ """Build a Definition(kind=FUNCTION|METHOD) from a function_definition AST
433
+ node, appending it into out_definitions and its raw calls as CallSite
434
+ entries into out_call_sites.
435
+
436
+ `seen_ids`, when given, tracks how many definitions have already claimed
437
+ each base id within the enclosing scope (a class body or a module's
438
+ top level). Two defs can legitimately share a name+scope — most commonly
439
+ a `@property` getter and its `@x.setter`/`@x.deleter` — and without
440
+ disambiguation the later one would silently collide with (and overwrite)
441
+ the earlier one's node in the symbol table and graph. On a collision the
442
+ id is suffixed with the decorator that caused it (`#setter`, `#deleter`)
443
+ when recognizable, else a `#2`, `#3`, ... counter.
444
+ """
445
+ # If inside decorated_definition, use the wrapper for source/line bounds
446
+ container = node.parent if node.parent and node.parent.type == "decorated_definition" else node
447
+ decorators = _extract_decorators(container, source_bytes) if container is not node else []
448
+
449
+ name_node = node.child_by_field_name("name")
450
+ name = _node_text(name_node, source_bytes) if name_node else ""
451
+
452
+ if is_method and parent_class is not None:
453
+ base_id = f"py::{module_name}::{parent_class}.{name}"
454
+ else:
455
+ base_id = f"py::{module_name}::{name}"
456
+
457
+ def_id = base_id
458
+ if seen_ids is not None:
459
+ occurrence = seen_ids.get(base_id, 0)
460
+ if occurrence:
461
+ if any(d.endswith(".setter") for d in decorators):
462
+ def_id = f"{base_id}#setter"
463
+ elif any(d.endswith(".deleter") for d in decorators):
464
+ def_id = f"{base_id}#deleter"
465
+ else:
466
+ def_id = f"{base_id}#{occurrence + 1}"
467
+ seen_ids[base_id] = occurrence + 1
468
+
469
+ signature = _extract_signature(node, source_bytes)
470
+
471
+ body_node = node.child_by_field_name("body")
472
+ docstring = _extract_docstring(body_node, source_bytes) if body_node else None
473
+ raw_calls = _extract_raw_calls(body_node, source_bytes) if body_node else []
474
+
475
+ line_start = container.start_point[0] + 1
476
+ line_end = container.end_point[0] + 1
477
+ source = _slice_source(source_bytes, container.start_byte, container.end_byte)
478
+
479
+ out_definitions.append(
480
+ Definition(
481
+ id=def_id,
482
+ kind=SymbolKind.METHOD if is_method else SymbolKind.FUNCTION,
483
+ name=name,
484
+ file_path=file_path,
485
+ line_start=line_start,
486
+ line_end=line_end,
487
+ source=source,
488
+ docstring=docstring,
489
+ bases=[],
490
+ parent_id=parent_id,
491
+ signature=signature,
492
+ decorators=decorators,
493
+ raw_calls=raw_calls,
494
+ )
495
+ )
496
+
497
+ for call in raw_calls:
498
+ out_call_sites.append(
499
+ CallSite(
500
+ callee_name=call,
501
+ receiver_expr=None,
502
+ line=line_start,
503
+ enclosing_definition_id=def_id,
504
+ )
505
+ )
506
+
507
+
508
+ def _extract_signature(node: Node, source_bytes: bytes) -> str:
509
+ """Extract the def-line signature up to (not including) the body block.
510
+
511
+ Handles both single-line and multi-line signatures. Includes the return
512
+ type annotation if present.
513
+ """
514
+ body_node = node.child_by_field_name("body")
515
+ if body_node:
516
+ # Signature is everything from def to the byte before the body block
517
+ sig_bytes = source_bytes[node.start_byte : body_node.start_byte]
518
+ # Strip trailing whitespace/colon/newlines to get a clean def line
519
+ sig = sig_bytes.decode("utf-8", errors="replace").rstrip().rstrip(":")
520
+ return sig + ":"
521
+ # Fallback: first line of the node
522
+ full = source_bytes[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
523
+ return full.splitlines()[0]
524
+
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # Docstring extraction
528
+ # ---------------------------------------------------------------------------
529
+
530
+
531
+ def _extract_docstring(body_node: Node | None, source_bytes: bytes) -> str | None:
532
+ """Return the docstring of a function or class body, or None.
533
+
534
+ The docstring is the first statement when it is a bare string literal.
535
+ """
536
+ if body_node is None:
537
+ return None
538
+ for child in body_node.children:
539
+ if child.type == "expression_statement":
540
+ for grandchild in child.children:
541
+ if grandchild.type == "string":
542
+ raw = _node_text(grandchild, source_bytes)
543
+ return _strip_string_literal(raw)
544
+ break # only the first statement counts
545
+ elif child.type not in ("comment", "\n"):
546
+ break
547
+ return None
548
+
549
+
550
+ def _strip_string_literal(raw: str) -> str:
551
+ """Strip quotes from a Python string literal using ast.literal_eval."""
552
+ try:
553
+ value = ast.literal_eval(raw)
554
+ if isinstance(value, str):
555
+ return value
556
+ except (ValueError, SyntaxError):
557
+ pass
558
+ # Fallback: strip common quote styles
559
+ for prefix in ('"""', "'''", '"', "'"):
560
+ if raw.startswith(prefix) and raw.endswith(prefix) and len(raw) >= 2 * len(prefix):
561
+ return raw[len(prefix) : -len(prefix)]
562
+ return raw
563
+
564
+
565
+ # ---------------------------------------------------------------------------
566
+ # Call extraction
567
+ # ---------------------------------------------------------------------------
568
+
569
+
570
+ def _extract_raw_calls(body_node: Node, source_bytes: bytes) -> list[str]:
571
+ """Collect all call expression strings from a function body.
572
+
573
+ Walks the AST recursively. Does not descend into nested function or
574
+ class definitions to avoid polluting the caller's call list.
575
+ """
576
+ calls: list[str] = []
577
+ _walk_calls(body_node, source_bytes, calls)
578
+ return list(dict.fromkeys(calls)) # deduplicate while preserving order
579
+
580
+
581
+ def _walk_calls(node: Node, source_bytes: bytes, calls: list[str]) -> None:
582
+ """Recursively visit nodes, collecting call strings."""
583
+ for child in node.children:
584
+ if child.type in ("function_definition", "class_definition"):
585
+ # Don't cross into nested definitions
586
+ continue
587
+ if child.type == "call":
588
+ fn_node = child.child_by_field_name("function")
589
+ if fn_node:
590
+ call_str = _call_to_string(fn_node, source_bytes)
591
+ if call_str:
592
+ calls.append(call_str)
593
+ # Still descend into arguments (calls within calls)
594
+ args = child.child_by_field_name("arguments")
595
+ if args:
596
+ _walk_calls(args, source_bytes, calls)
597
+ else:
598
+ _walk_calls(child, source_bytes, calls)
599
+
600
+
601
+ def _call_to_string(fn_node: Node, source_bytes: bytes) -> str:
602
+ """Convert a call's function field node to a dotted string.
603
+
604
+ e.g. `self.save` for `self.save()`, `foo` for `foo()`.
605
+ """
606
+ if fn_node.type == "identifier":
607
+ return _node_text(fn_node, source_bytes)
608
+ if fn_node.type == "attribute":
609
+ obj_node = fn_node.child_by_field_name("object")
610
+ attr_node = fn_node.child_by_field_name("attribute")
611
+ if obj_node and attr_node:
612
+ obj = _call_to_string(obj_node, source_bytes)
613
+ attr = _node_text(attr_node, source_bytes)
614
+ return f"{obj}.{attr}"
615
+ # For other expressions (subscript, etc.) return the raw text
616
+ return _node_text(fn_node, source_bytes)
617
+
618
+
619
+ # ---------------------------------------------------------------------------
620
+ # Decorator extraction
621
+ # ---------------------------------------------------------------------------
622
+
623
+
624
+ def _extract_decorators(node: Node, source_bytes: bytes) -> list[str]:
625
+ """Extract decorator names from a decorated_definition node.
626
+
627
+ Args:
628
+ node: A ``decorated_definition`` AST node.
629
+ source_bytes: Raw file bytes for text slicing.
630
+
631
+ Returns:
632
+ List of decorator name strings without the leading ``@``.
633
+ """
634
+ decorators: list[str] = []
635
+ if node.type != "decorated_definition":
636
+ return decorators
637
+ for child in node.children:
638
+ if child.type == "decorator":
639
+ # decorator children: "@" followed by the decorator expression
640
+ # Skip the "@" token (anonymous child) and decode the rest
641
+ deco_text = _node_text(child, source_bytes).lstrip("@").strip()
642
+ # Strip trailing () if it's a decorator call like @app.route("/")
643
+ deco_name = deco_text.split("(")[0].strip()
644
+ decorators.append(deco_name)
645
+ return decorators
646
+
647
+
648
+ # ---------------------------------------------------------------------------
649
+ # Utility helpers
650
+ # ---------------------------------------------------------------------------
651
+
652
+
653
+ def _node_text(node: Node | None, source_bytes: bytes) -> str:
654
+ """Decode the source text covered by a tree-sitter node."""
655
+ if node is None:
656
+ return ""
657
+ return source_bytes[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
658
+
659
+
660
+ def _slice_source(source_bytes: bytes, start: int, end: int) -> str:
661
+ """Decode a byte slice from the source."""
662
+ return source_bytes[start:end].decode("utf-8", errors="replace")
663
+
664
+
665
+ def _unwrap_decorated(node: Node) -> Node | None:
666
+ """Return the inner function_definition or class_definition from a decorated_definition."""
667
+ for child in node.children:
668
+ if child.type in ("function_definition", "class_definition"):
669
+ return child
670
+ return None