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.
- {coderay-1.0.7/src/coderay.egg-info → coderay-1.0.8}/PKG-INFO +9 -2
- {coderay-1.0.7 → coderay-1.0.8}/README.md +8 -1
- {coderay-1.0.7 → coderay-1.0.8}/pyproject.toml +1 -1
- coderay-1.0.8/src/coderay/graph/_handlers/__init__.py +21 -0
- coderay-1.0.8/src/coderay/graph/_handlers/assignments.py +192 -0
- coderay-1.0.8/src/coderay/graph/_handlers/calls.py +277 -0
- coderay-1.0.8/src/coderay/graph/_handlers/definitions.py +171 -0
- coderay-1.0.8/src/coderay/graph/_handlers/imports.py +157 -0
- coderay-1.0.8/src/coderay/graph/_handlers/type_resolution.py +248 -0
- coderay-1.0.8/src/coderay/graph/_utils.py +53 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/graph/builder.py +64 -11
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/graph/code_graph.py +79 -195
- coderay-1.0.8/src/coderay/graph/extractor.py +334 -0
- coderay-1.0.8/src/coderay/graph/identifiers.py +55 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/mcp_server/server.py +9 -4
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/pipeline/indexer.py +1 -4
- {coderay-1.0.7 → coderay-1.0.8/src/coderay.egg-info}/PKG-INFO +9 -2
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay.egg-info/SOURCES.txt +8 -0
- coderay-1.0.7/src/coderay/graph/extractor.py +0 -661
- {coderay-1.0.7 → coderay-1.0.8}/LICENSE +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/setup.cfg +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/chunking/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/chunking/chunker.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/cli/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/cli/commands.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/config.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/lock.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/models.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/timing.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/core/utils.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/embedding/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/embedding/base.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/embedding/local.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/graph/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/mcp_server/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/mcp_server/errors.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/parsing/base.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/parsing/languages.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/pipeline/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/pipeline/watcher.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/retrieval/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/retrieval/boosting.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/retrieval/models.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/retrieval/search.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/skeleton/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/skeleton/extractor.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/state/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/state/machine.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/state/version.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/storage/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/storage/lancedb.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/vcs/__init__.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay/vcs/git.py +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay.egg-info/dependency_links.txt +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay.egg-info/entry_points.txt +0 -0
- {coderay-1.0.7 → coderay-1.0.8}/src/coderay.egg-info/requires.txt +0 -0
- {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.
|
|
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 |
|
|
@@ -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
|
+
)
|