coderay 1.0.7__tar.gz → 1.0.8__tar.gz

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 (59) hide show
  1. {coderay-1.0.7/src/coderay.egg-info → coderay-1.0.8}/PKG-INFO +9 -2
  2. {coderay-1.0.7 → coderay-1.0.8}/README.md +8 -1
  3. {coderay-1.0.7 → coderay-1.0.8}/pyproject.toml +1 -1
  4. coderay-1.0.8/src/coderay/graph/_handlers/__init__.py +21 -0
  5. coderay-1.0.8/src/coderay/graph/_handlers/assignments.py +192 -0
  6. coderay-1.0.8/src/coderay/graph/_handlers/calls.py +277 -0
  7. coderay-1.0.8/src/coderay/graph/_handlers/definitions.py +171 -0
  8. coderay-1.0.8/src/coderay/graph/_handlers/imports.py +157 -0
  9. coderay-1.0.8/src/coderay/graph/_handlers/type_resolution.py +248 -0
  10. coderay-1.0.8/src/coderay/graph/_utils.py +53 -0
  11. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/graph/builder.py +64 -11
  12. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/graph/code_graph.py +79 -195
  13. coderay-1.0.8/src/coderay/graph/extractor.py +334 -0
  14. coderay-1.0.8/src/coderay/graph/identifiers.py +55 -0
  15. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/mcp_server/server.py +9 -4
  16. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/pipeline/indexer.py +1 -4
  17. {coderay-1.0.7 → coderay-1.0.8/src/coderay.egg-info}/PKG-INFO +9 -2
  18. {coderay-1.0.7 → coderay-1.0.8}/src/coderay.egg-info/SOURCES.txt +8 -0
  19. coderay-1.0.7/src/coderay/graph/extractor.py +0 -661
  20. {coderay-1.0.7 → coderay-1.0.8}/LICENSE +0 -0
  21. {coderay-1.0.7 → coderay-1.0.8}/setup.cfg +0 -0
  22. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/__init__.py +0 -0
  23. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/chunking/__init__.py +0 -0
  24. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/chunking/chunker.py +0 -0
  25. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/cli/__init__.py +0 -0
  26. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/cli/commands.py +0 -0
  27. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/__init__.py +0 -0
  28. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/config.py +0 -0
  29. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/lock.py +0 -0
  30. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/models.py +0 -0
  31. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/timing.py +0 -0
  32. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/utils.py +0 -0
  33. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/embedding/__init__.py +0 -0
  34. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/embedding/base.py +0 -0
  35. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/embedding/local.py +0 -0
  36. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/graph/__init__.py +0 -0
  37. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/mcp_server/__init__.py +0 -0
  38. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/mcp_server/errors.py +0 -0
  39. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/parsing/base.py +0 -0
  40. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/parsing/languages.py +0 -0
  41. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/pipeline/__init__.py +0 -0
  42. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/pipeline/watcher.py +0 -0
  43. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/retrieval/__init__.py +0 -0
  44. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/retrieval/boosting.py +0 -0
  45. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/retrieval/models.py +0 -0
  46. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/retrieval/search.py +0 -0
  47. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/skeleton/__init__.py +0 -0
  48. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/skeleton/extractor.py +0 -0
  49. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/state/__init__.py +0 -0
  50. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/state/machine.py +0 -0
  51. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/state/version.py +0 -0
  52. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/storage/__init__.py +0 -0
  53. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/storage/lancedb.py +0 -0
  54. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/vcs/__init__.py +0 -0
  55. {coderay-1.0.7 → coderay-1.0.8}/src/coderay/vcs/git.py +0 -0
  56. {coderay-1.0.7 → coderay-1.0.8}/src/coderay.egg-info/dependency_links.txt +0 -0
  57. {coderay-1.0.7 → coderay-1.0.8}/src/coderay.egg-info/entry_points.txt +0 -0
  58. {coderay-1.0.7 → coderay-1.0.8}/src/coderay.egg-info/requires.txt +0 -0
  59. {coderay-1.0.7 → coderay-1.0.8}/src/coderay.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderay
3
- Version: 1.0.7
3
+ Version: 1.0.8
4
4
  Summary: X-ray your codebase — semantic search, code graphs, file skeletons, and MCP server
5
5
  Author-email: Bogdan Copocean <bogdancopocean@gmail.com>
6
6
  License-Expression: MIT
@@ -119,7 +119,10 @@ Add to `~/.claude/claude_code_config.json` or Cursor MCP settings:
119
119
  "mcpServers": {
120
120
  "coderay": {
121
121
  "command": "/path/to/your/.venv/bin/coderay-mcp",
122
- "args": []
122
+ "args": [],
123
+ "env": {
124
+ "CODERAY_INDEX_DIR": "${workspaceFolder}/.index"
125
+ }
123
126
  }
124
127
  }
125
128
  }
@@ -127,6 +130,10 @@ Add to `~/.claude/claude_code_config.json` or Cursor MCP settings:
127
130
 
128
131
  Replace `/path/to/your/.venv/bin/coderay-mcp` with the output of `which coderay-mcp`.
129
132
 
133
+ **Important:** Set `CODERAY_INDEX_DIR` so the MCP server finds the index and graph
134
+ in your project. Cursor interpolates `${workspaceFolder}` to the workspace root.
135
+ Run `coderay build` (or `coderay watch`) from the project root first.
136
+
130
137
  ## CLI reference
131
138
 
132
139
  | Command | Description |
@@ -70,7 +70,10 @@ Add to `~/.claude/claude_code_config.json` or Cursor MCP settings:
70
70
  "mcpServers": {
71
71
  "coderay": {
72
72
  "command": "/path/to/your/.venv/bin/coderay-mcp",
73
- "args": []
73
+ "args": [],
74
+ "env": {
75
+ "CODERAY_INDEX_DIR": "${workspaceFolder}/.index"
76
+ }
74
77
  }
75
78
  }
76
79
  }
@@ -78,6 +81,10 @@ Add to `~/.claude/claude_code_config.json` or Cursor MCP settings:
78
81
 
79
82
  Replace `/path/to/your/.venv/bin/coderay-mcp` with the output of `which coderay-mcp`.
80
83
 
84
+ **Important:** Set `CODERAY_INDEX_DIR` so the MCP server finds the index and graph
85
+ in your project. Cursor interpolates `${workspaceFolder}` to the workspace root.
86
+ Run `coderay build` (or `coderay watch`) from the project root first.
87
+
81
88
  ## CLI reference
82
89
 
83
90
  | Command | Description |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "coderay"
7
- version = "1.0.7"
7
+ version = "1.0.8"
8
8
  description = "X-ray your codebase — semantic search, code graphs, file skeletons, and MCP server"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,21 @@
1
+ """Handler mixins for graph extraction.
2
+
3
+ Each mixin provides handlers for a specific AST node category. The main
4
+ parser (GraphTreeSitterParser) composes them via multiple inheritance.
5
+ Mixin order: Import, TypeResolution, Definition, Assignment, Call, BaseParser.
6
+ TypeResolution must come before Definition and Assignment (they use its methods).
7
+ """
8
+
9
+ from coderay.graph._handlers.assignments import AssignmentHandlerMixin
10
+ from coderay.graph._handlers.calls import CallHandlerMixin
11
+ from coderay.graph._handlers.definitions import DefinitionHandlerMixin
12
+ from coderay.graph._handlers.imports import ImportHandlerMixin
13
+ from coderay.graph._handlers.type_resolution import TypeResolutionMixin
14
+
15
+ __all__ = [
16
+ "AssignmentHandlerMixin",
17
+ "CallHandlerMixin",
18
+ "DefinitionHandlerMixin",
19
+ "ImportHandlerMixin",
20
+ "TypeResolutionMixin",
21
+ ]
@@ -0,0 +1,192 @@
1
+ """Assignment and with-statement handling for graph extraction.
2
+
3
+ Tracks variable bindings so later calls resolve correctly:
4
+ - x = y → alias (y.resolve() used when x is called)
5
+ - x = obj.attr → alias when obj resolves
6
+ - x = factory() → instance from factory's return type
7
+ - self.attr = param → instance from param's type hint (injection)
8
+ - with cm() as x → instance from cm.__enter__ return type
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ TSNode = Any
16
+
17
+
18
+ class AssignmentHandlerMixin:
19
+ """Handles assignments and with statements for instance/alias tracking."""
20
+
21
+ def _handle_assignment(self, node: TSNode, *, scope_stack: list[str]) -> None:
22
+ """Track aliases and instance types for call resolution.
23
+
24
+ Handles: alias (x = y), attribute alias (x = obj.attr), factory
25
+ return type (x = factory()), constructor/setter injection (self.attr = param).
26
+ """
27
+ children = node.children
28
+ if len(children) < 3:
29
+ return # Need at least: target, "=", value
30
+
31
+ lhs = children[0]
32
+ rhs = children[-1] # [-1] handles "x: int = 5" (annotated)
33
+
34
+ # Constructor/setter injection: self.storage = storage (storage: StoragePort)
35
+ if lhs.type == "attribute" and rhs.type == "identifier":
36
+ lhs_text = self.node_text(lhs)
37
+ if lhs_text.startswith(("self.", "this.")):
38
+ rhs_name = self.node_text(rhs)
39
+ func_node = self._get_enclosing_function_node(node)
40
+ if func_node:
41
+ type_hint = self._get_parameter_type_hint(func_node, rhs_name)
42
+ if type_hint:
43
+ self._file_ctx.register_instance(lhs_text, type_hint)
44
+ class_qualified = self._find_enclosing_class_from_node(
45
+ func_node
46
+ )
47
+ if class_qualified:
48
+ attr_name = lhs_text.split(".", 1)[1].split(".")[0]
49
+ self._file_ctx.register_class_attribute(
50
+ class_qualified, attr_name, type_hint
51
+ )
52
+ return
53
+
54
+ # Tuple unpacking: a, b = get_pair()
55
+ if lhs.type in ("pattern_list", "tuple_pattern", "list_pattern"):
56
+ self._handle_tuple_unpacking(lhs, rhs)
57
+ return
58
+
59
+ if lhs.type != "identifier":
60
+ return
61
+
62
+ lhs_name = self.node_text(lhs)
63
+
64
+ if rhs.type == "identifier":
65
+ # Simple alias: my_func = imported_func
66
+ rhs_name = self.node_text(rhs)
67
+ resolved = self._file_ctx.resolve(rhs_name)
68
+ if resolved:
69
+ self._file_ctx.register_alias(lhs_name, resolved)
70
+ elif rhs.type == "attribute":
71
+ # x = obj.attr: alias when obj resolves (e.g. path_func = Path)
72
+ rhs_text = self.node_text(rhs)
73
+ parts = rhs_text.split(".")
74
+ if len(parts) == 2:
75
+ prefix, attr = parts
76
+ prefix_resolved = self._file_ctx.resolve(prefix)
77
+ if prefix_resolved:
78
+ self._file_ctx.register_alias(
79
+ lhs_name, f"{prefix_resolved}::{attr}"
80
+ )
81
+ elif rhs.type in self._ctx.lang_cfg.graph.call_types:
82
+ # functools.partial: p = partial(foo, 1); p() → alias to foo
83
+ callee_node = rhs.child_by_field_name("function")
84
+ if callee_node:
85
+ callee_name = self.node_text(callee_node).strip()
86
+ if callee_name:
87
+ if callee_name == "partial" or callee_name.endswith(".partial"):
88
+ first_arg = self._get_first_call_arg(rhs)
89
+ if first_arg:
90
+ resolved = self._file_ctx.resolve(first_arg)
91
+ if resolved:
92
+ self._file_ctx.register_alias(lhs_name, resolved)
93
+ return
94
+ return_type = self._get_function_return_type(callee_name)
95
+ if return_type:
96
+ self._file_ctx.register_instance(lhs_name, return_type)
97
+
98
+ def _handle_with_statement(self, node: TSNode, *, scope_stack: list[str]) -> None:
99
+ """Track ``with cm() as var:`` — register var with __enter__ return type."""
100
+ for child in node.children:
101
+ if child.type in ("with_clause", "with_clauses"):
102
+ for item in child.children:
103
+ if item.type == "with_item":
104
+ self._process_with_item(item)
105
+
106
+ def _process_with_item(self, item: TSNode) -> None:
107
+ """Register the ``as`` target with the context manager's __enter__ type."""
108
+ value = item.child_by_field_name("value")
109
+ if not value:
110
+ return
111
+ # with x as y: value is as_pattern with alias; with x(): value is call
112
+ if value.type == "as_pattern":
113
+ target_node = value.child_by_field_name("alias")
114
+ call_node = next(
115
+ (
116
+ c
117
+ for c in value.named_children
118
+ if c.type in self._ctx.lang_cfg.graph.call_types
119
+ ),
120
+ None,
121
+ )
122
+ else:
123
+ # with x(): no "as" target — we don't register
124
+ target_node = value if value.type == "as_pattern_target" else None
125
+ call_node = (
126
+ value if value.type in self._ctx.lang_cfg.graph.call_types else None
127
+ )
128
+ if not call_node or not target_node:
129
+ return
130
+ var_name = self.node_text(target_node)
131
+ if not var_name or var_name == "_":
132
+ return
133
+ callee_node = call_node.child_by_field_name("function")
134
+ cm_name = self.node_text(callee_node).strip() if callee_node else ""
135
+ if not cm_name:
136
+ return
137
+ enter_return = self._get_function_return_type(f"{cm_name}.__enter__")
138
+ if enter_return:
139
+ self._file_ctx.register_instance(var_name, enter_return)
140
+
141
+ def _handle_tuple_unpacking(self, lhs: TSNode, rhs: TSNode) -> None:
142
+ """Track a, b = func() — register each unpacked name from return type."""
143
+ # Collect identifiers from pattern_list
144
+ identifiers: list[str] = []
145
+ for child in lhs.children:
146
+ if child.type == "identifier":
147
+ name = self.node_text(child)
148
+ if name and name != "_":
149
+ identifiers.append(name)
150
+ if not identifiers:
151
+ return
152
+
153
+ # RHS must be a call
154
+ if rhs.type not in self._ctx.lang_cfg.graph.call_types:
155
+ return
156
+ callee_node = rhs.child_by_field_name("function")
157
+ if not callee_node:
158
+ return
159
+ callee_name = self.node_text(callee_node).strip()
160
+ if not callee_name:
161
+ return
162
+
163
+ # Get raw type text from function definition
164
+ func_node = (
165
+ self._find_method_in_class(*callee_name.split(".", 1))
166
+ if "." in callee_name
167
+ else self._find_top_level_function(callee_name)
168
+ )
169
+ if not func_node:
170
+ return
171
+ type_node = func_node.child_by_field_name(
172
+ "return_type"
173
+ ) or func_node.child_by_field_name("type")
174
+ if not type_node:
175
+ return
176
+
177
+ type_args = self._extract_tuple_type_args(type_node)
178
+ for i, name in enumerate(identifiers):
179
+ if i < len(type_args):
180
+ self._file_ctx.register_alias(name, type_args[i])
181
+
182
+ def _get_first_call_arg(self, call_node: TSNode) -> str | None:
183
+ """Extract the first argument identifier from a call (e.g. partial(foo, 1))."""
184
+ arg_list = call_node.child_by_field_name("arguments") or next(
185
+ (c for c in call_node.children if c.type == "argument_list"), None
186
+ )
187
+ if not arg_list:
188
+ return None
189
+ for child in arg_list.named_children:
190
+ if child.type in ("identifier", "dotted_name", "attribute"):
191
+ return self.node_text(child)
192
+ return None
@@ -0,0 +1,277 @@
1
+ """Call handling for graph extraction.
2
+
3
+ Creates CALLS edges (caller -> callee) from resolved call expressions.
4
+ Resolves callees via FileContext (instance tracking, aliases, class attrs).
5
+ Also tracks x = SomeClass() for instantiation. Filters out builtins and
6
+ excluded modules.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import builtins
12
+ from typing import Any
13
+
14
+ from coderay.core.models import EdgeKind, GraphEdge
15
+ from coderay.parsing.languages import get_supported_extensions
16
+
17
+ TSNode = Any
18
+
19
+ # Builtins (print, len, etc.) are filtered from CALLS edges
20
+ _PYTHON_BUILTINS: frozenset[str] = frozenset(
21
+ name for name in dir(builtins) if not name.startswith("_")
22
+ )
23
+
24
+
25
+ class CallHandlerMixin:
26
+ """Handles call expressions: CALLS edges and instantiation tracking."""
27
+
28
+ def _caller_id_from_scope(self, scope_stack: list[str]) -> str:
29
+ """Return the caller node ID for the given scope stack."""
30
+ if scope_stack:
31
+ return f"{self.file_path}::{'.'.join(scope_stack)}"
32
+ return self._module_id
33
+
34
+ def _handle_call(self, node: TSNode, *, scope_stack: list[str]) -> None:
35
+ """Create a CALLS edge from the enclosing scope to the resolved callee."""
36
+ caller_id = self._caller_id_from_scope(scope_stack)
37
+
38
+ callee_node = node.child_by_field_name("function")
39
+ if callee_node is None:
40
+ return
41
+ raw_callee = self.node_text(callee_node)
42
+ if not raw_callee:
43
+ return
44
+
45
+ callee_targets = self._resolve_callee_targets(raw_callee, scope_stack)
46
+
47
+ for callee_name in callee_targets:
48
+ if self._is_excluded(callee_name, raw_callee):
49
+ continue
50
+ if callee_name:
51
+ self._edges.append(
52
+ GraphEdge(
53
+ source=caller_id,
54
+ target=callee_name,
55
+ kind=EdgeKind.CALLS,
56
+ )
57
+ )
58
+
59
+ self._maybe_track_instantiation(node, raw_callee)
60
+
61
+ def _resolve_callee_targets(self, raw: str, scope_stack: list[str]) -> list[str]:
62
+ """Resolve a callee expression to a qualified target for CALLS edges."""
63
+ result = self._resolve_super_targets(raw, scope_stack)
64
+ if result is not None:
65
+ return result
66
+ result = self._resolve_self_targets(raw, scope_stack)
67
+ if result is not None:
68
+ return result
69
+ parts = raw.split(".")
70
+ if len(parts) == 1:
71
+ return self._resolve_simple_name_targets(raw)
72
+ return self._resolve_chain_targets(raw)
73
+
74
+ def _resolve_super_targets(
75
+ self, raw: str, scope_stack: list[str]
76
+ ) -> list[str] | None:
77
+ """Resolve super().method() to parent class method."""
78
+ if not raw.startswith("super()."):
79
+ return None
80
+ method = raw[len("super().") :]
81
+ parent_target = self._resolve_super_call(scope_stack, method)
82
+ return [parent_target] if parent_target else [method]
83
+
84
+ def _resolve_self_targets(
85
+ self, raw: str, scope_stack: list[str]
86
+ ) -> list[str] | None:
87
+ """Resolve self.method() or self.attr.method() via instance/class attrs."""
88
+ if not raw.startswith(("self.", "this.")):
89
+ return None
90
+ suffix = raw.split(".", 1)[1]
91
+ parts = suffix.split(".")
92
+ method = parts[-1]
93
+
94
+ if len(parts) == 1:
95
+ class_qualified = self._find_enclosing_class(scope_stack)
96
+ if class_qualified:
97
+ return [f"{self.file_path}::{class_qualified}.{method}"]
98
+
99
+ prefix = "self." if raw.startswith("self.") else "this."
100
+ instance_key = prefix + ".".join(parts[:-1])
101
+ class_ref = self._file_ctx.resolve_instance(instance_key)
102
+ if not class_ref:
103
+ class_qualified = self._find_enclosing_class(scope_stack)
104
+ if class_qualified and len(parts) == 2:
105
+ attr_name = parts[0]
106
+ class_ref = self._file_ctx.resolve_class_attribute(
107
+ class_qualified, attr_name
108
+ )
109
+ if class_ref:
110
+ return [f"{class_ref}.{method}"]
111
+ return [method]
112
+
113
+ def _resolve_simple_name_targets(self, raw: str) -> list[str]:
114
+ """Resolve simple name func() or obj() via alias/import/instance."""
115
+ name = raw
116
+ instance_class = self._file_ctx.resolve_instance(name)
117
+ if instance_class:
118
+ return [f"{instance_class}.__call__"]
119
+ resolved = self._file_ctx.resolve(name)
120
+ return [resolved] if resolved else [name]
121
+
122
+ def _resolve_chain_targets(self, raw: str) -> list[str]:
123
+ """Resolve obj.method(), obj.attr.method(), or obj.attr() chains."""
124
+ parts = raw.split(".")
125
+ obj_name = parts[0]
126
+ method_name = parts[-1]
127
+
128
+ if len(parts) > 2:
129
+ chain = ".".join(parts[:-1])
130
+ chain_refs = self._file_ctx.resolve_chain(chain)
131
+ if chain_refs:
132
+ return [f"{ref}.{method_name}" for ref in chain_refs]
133
+
134
+ method_targets = self._file_ctx.resolve_method_calls(obj_name, method_name)
135
+ if method_targets:
136
+ return method_targets
137
+
138
+ obj_resolved = self._file_ctx.resolve(obj_name)
139
+ if obj_resolved:
140
+ tail = ".".join(parts[1:])
141
+ _, ext = obj_resolved.rsplit(".", 1) if "." in obj_resolved else ("", "")
142
+ supported = get_supported_extensions()
143
+ if ext and f".{ext}" in supported:
144
+ return [f"{obj_resolved}::{tail}"]
145
+ return [f"{obj_resolved}.{tail}"]
146
+
147
+ return [method_name]
148
+
149
+ def _is_excluded(self, resolved: str, raw: str) -> bool:
150
+ """Check whether a resolved callee belongs to an excluded module."""
151
+ # Excluded: typing, abc, __future__; also bare builtins (print, len)
152
+ if "::" in resolved:
153
+ module_part = resolved.split("::")[0]
154
+ if module_part in self._excluded_modules:
155
+ return True
156
+ bare = raw.rsplit(".", 1)[-1]
157
+ return resolved == bare and bare in _PYTHON_BUILTINS
158
+
159
+ def _resolve_super_call(self, scope_stack: list[str], method: str) -> str | None:
160
+ """Resolve super().method() to the parent class's method."""
161
+ class_qualified = self._find_enclosing_class(scope_stack)
162
+ if not class_qualified:
163
+ return None
164
+ # Find the class node and its first base class
165
+ base_name = self._get_first_base_class(class_qualified)
166
+ if not base_name:
167
+ return None
168
+ base_resolved = self._file_ctx.resolve(base_name)
169
+ if not base_resolved:
170
+ base_resolved = f"{self.file_path}::{base_name}"
171
+ return f"{base_resolved}.{method}"
172
+
173
+ def _get_first_base_class(self, class_qualified: str) -> str | None:
174
+ """Get the first base class name for a class in the current file."""
175
+ tree = self.get_tree()
176
+ target_class = class_qualified.split(".")[-1]
177
+
178
+ for node in tree.root_node.children:
179
+ if node.type not in self._ctx.lang_cfg.class_scope_types:
180
+ continue
181
+ name_node = node.child_by_field_name("name") or (
182
+ node.named_children[0] if node.named_children else None
183
+ )
184
+ if not name_node or self.node_text(name_node) != target_class:
185
+ continue
186
+ for child in node.children:
187
+ if child.type not in ("argument_list", "superclass", "extends_clause"):
188
+ continue
189
+ bases = self._get_base_classes_from_arg_list(child)
190
+ if bases:
191
+ return bases[0]
192
+ break
193
+ return None
194
+
195
+ def _find_enclosing_class(self, scope_stack: list[str]) -> str | None:
196
+ """Find the innermost enclosing class name from the scope stack.
197
+
198
+ scope_stack = ["Outer", "Inner", "method"] → "Outer.Inner"
199
+ """
200
+ for i in range(len(scope_stack) - 1, -1, -1):
201
+ if self._file_ctx.is_class(scope_stack[i]):
202
+ return ".".join(scope_stack[: i + 1])
203
+ return None
204
+
205
+ def _maybe_track_instantiation(self, call_node: TSNode, raw_callee: str) -> None:
206
+ """Track ``x = SomeClass()`` or ``self.attr = SomeClass()`` as instance.
207
+
208
+ Called after _handle_call; checks if this call is the RHS of an
209
+ assignment. If so, registers the LHS as an instance of the callee.
210
+ """
211
+ parent = call_node.parent
212
+ if parent is None or parent.type != "assignment":
213
+ return # Not an assignment RHS
214
+
215
+ lhs = parent.children[0] if parent.children else None
216
+ if lhs is None:
217
+ return
218
+
219
+ if lhs.type == "identifier":
220
+ var_name = self.node_text(lhs)
221
+ elif lhs.type == "attribute":
222
+ var_name = self.node_text(lhs)
223
+ if not var_name.startswith(("self.", "this.")):
224
+ return # Only track self.attr = X(), not other.attr = X()
225
+ else:
226
+ return
227
+
228
+ callee_base = raw_callee.rsplit(".", 1)[-1] # "ApiClient.create" → "create"
229
+ if not callee_base:
230
+ return
231
+
232
+ resolved = self._file_ctx.resolve(callee_base)
233
+ is_known_class = self._file_ctx.is_class(callee_base)
234
+ is_likely_class = bool(callee_base[0].isupper() and resolved is not None)
235
+
236
+ # Only register if this looks like a class instantiation
237
+ if is_known_class or is_likely_class:
238
+ self._file_ctx.register_instance(var_name, resolved or callee_base)
239
+ # Also register for class (enables service.client.get resolution)
240
+ if var_name.startswith(("self.", "this.")):
241
+ func_node = self._get_enclosing_function_node(call_node)
242
+ if func_node:
243
+ class_qualified = self._find_enclosing_class_from_node(func_node)
244
+ if class_qualified:
245
+ attr_name = var_name.split(".", 1)[1].split(".")[0]
246
+ self._file_ctx.register_class_attribute(
247
+ class_qualified, attr_name, resolved or callee_base
248
+ )
249
+
250
+ def _handle_decorator(self, node: TSNode, *, scope_stack: list[str]) -> None:
251
+ """Create a CALLS edge from the enclosing scope to the decorator target.
252
+
253
+ @my_decorator and @my_decorator() both imply a call at definition time.
254
+ """
255
+ text = self.node_text(node).strip()
256
+ if not text or not text.startswith("@"):
257
+ return
258
+ # Strip @ and any trailing call parens: @foo.bar(args) -> foo.bar
259
+ target_raw = text[1:].strip()
260
+ if "(" in target_raw:
261
+ target_raw = target_raw[: target_raw.index("(")].strip()
262
+ if not target_raw:
263
+ return
264
+
265
+ callee_targets = self._resolve_callee_targets(target_raw, scope_stack)
266
+ caller_id = self._caller_id_from_scope(scope_stack)
267
+ for callee_name in callee_targets:
268
+ if self._is_excluded(callee_name, target_raw):
269
+ continue
270
+ if callee_name:
271
+ self._edges.append(
272
+ GraphEdge(
273
+ source=caller_id,
274
+ target=callee_name,
275
+ kind=EdgeKind.CALLS,
276
+ )
277
+ )