node-walk 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.
- node_walk/__init__.py +3 -0
- node_walk/analysis/__init__.py +12 -0
- node_walk/analysis/base.py +205 -0
- node_walk/analysis/python/__init__.py +10 -0
- node_walk/analysis/python/analyzer.py +44 -0
- node_walk/analysis/python/scope.py +44 -0
- node_walk/analysis/python/visitor.py +559 -0
- node_walk/analysis/python_analyzer.py +14 -0
- node_walk/cli/__init__.py +1 -0
- node_walk/cli/main.py +560 -0
- node_walk/indexer.py +188 -0
- node_walk/ir/__init__.py +39 -0
- node_walk/ir/enums.py +61 -0
- node_walk/ir/models.py +100 -0
- node_walk/query/__init__.py +1 -0
- node_walk/query/engine.py +368 -0
- node_walk/storage/__init__.py +13 -0
- node_walk/storage/base.py +79 -0
- node_walk/storage/repository.py +14 -0
- node_walk/storage/schema.py +130 -0
- node_walk/storage/sqlite_store.py +285 -0
- node_walk-0.1.0.dist-info/METADATA +107 -0
- node_walk-0.1.0.dist-info/RECORD +26 -0
- node_walk-0.1.0.dist-info/WHEEL +4 -0
- node_walk-0.1.0.dist-info/entry_points.txt +4 -0
- node_walk-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AST visitor — walks a Tree-sitter parse tree and collects
|
|
3
|
+
symbols and relationships for a single Python source file.
|
|
4
|
+
|
|
5
|
+
This module contains:
|
|
6
|
+
- Module-level helper functions (node text, line numbers, docstrings)
|
|
7
|
+
- Constants (ABC/Protocol base names, tree-sitter parser setup)
|
|
8
|
+
- SymbolCollector — the stateful visitor class
|
|
9
|
+
|
|
10
|
+
Resolution strategy
|
|
11
|
+
-------------------
|
|
12
|
+
CONTAINS Always resolved (structural, not semantic).
|
|
13
|
+
IMPORTS Emitted as UNRESOLVED; resolved in the cross-file pass (Indexer).
|
|
14
|
+
CALLS Best-effort: exact FQN match → RESOLVED; simple name match →
|
|
15
|
+
PROBABLE; no match → UNRESOLVED (target_id = "").
|
|
16
|
+
EXTENDS /
|
|
17
|
+
IMPLEMENTS Emitted as UNRESOLVED; resolved in the cross-file pass.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import tree_sitter_python as tspython
|
|
26
|
+
from tree_sitter import Language as TSLanguage, Node, Parser
|
|
27
|
+
|
|
28
|
+
from node_walk.analysis.python.scope import Scope
|
|
29
|
+
from node_walk.ir.enums import Language, RelationshipType, ResolutionStatus, SymbolKind
|
|
30
|
+
from node_walk.ir.models import (
|
|
31
|
+
AnalysisResult,
|
|
32
|
+
FileInfo,
|
|
33
|
+
Relationship,
|
|
34
|
+
SourceLocation,
|
|
35
|
+
Symbol,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Tree-sitter parser (module-level singleton — safe to share across threads
|
|
40
|
+
# since parse() is called per-file and returns a new tree each time)
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
_PY_LANGUAGE = TSLanguage(tspython.language())
|
|
44
|
+
_PARSER = Parser(_PY_LANGUAGE)
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Constants
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# Base class names that mark a class as an interface/protocol
|
|
51
|
+
_ABC_BASES: frozenset[str] = frozenset({"ABC", "ABCMeta"})
|
|
52
|
+
_PROTOCOL_BASES: frozenset[str] = frozenset({"Protocol"})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Helper functions
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def node_text(node: Node, source: bytes) -> str:
|
|
61
|
+
"""Return the UTF-8 text of a tree-sitter node."""
|
|
62
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def start_line(node: Node) -> int:
|
|
66
|
+
"""1-indexed start line of a node."""
|
|
67
|
+
return node.start_point[0] + 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def end_line(node: Node) -> int:
|
|
71
|
+
"""1-indexed end line of a node."""
|
|
72
|
+
return node.end_point[0] + 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def first_docstring(node: Node, source: bytes) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Extract the first string literal from a block body node.
|
|
78
|
+
Returns an empty string if none is found.
|
|
79
|
+
"""
|
|
80
|
+
for child in node.children:
|
|
81
|
+
if child.type == "expression_statement":
|
|
82
|
+
for grandchild in child.children:
|
|
83
|
+
if grandchild.type in ("string", "concatenated_string"):
|
|
84
|
+
raw = node_text(grandchild, source)
|
|
85
|
+
for q in ('"""', "'''", '"', "'"):
|
|
86
|
+
if raw.startswith(q) and raw.endswith(q) and len(raw) >= 2 * len(q):
|
|
87
|
+
return raw[len(q) : -len(q)].strip()
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def derive_module_qname(file_path: str) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Derive a dotted module qualified name from an absolute file path.
|
|
94
|
+
|
|
95
|
+
Walks up the directory tree while __init__.py exists (i.e., while
|
|
96
|
+
we are inside a package). Falls back to the file stem otherwise.
|
|
97
|
+
|
|
98
|
+
Example: ``/repo/myapp/services/users.py`` → ``myapp.services.users``
|
|
99
|
+
"""
|
|
100
|
+
p = Path(file_path)
|
|
101
|
+
parts: list[str] = [p.stem if p.suffix == ".py" else p.name]
|
|
102
|
+
current = p.parent
|
|
103
|
+
|
|
104
|
+
while (current / "__init__.py").exists():
|
|
105
|
+
parts.append(current.name)
|
|
106
|
+
current = current.parent
|
|
107
|
+
|
|
108
|
+
parts.reverse()
|
|
109
|
+
return ".".join(parts)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# AST visitor
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class SymbolCollector:
|
|
118
|
+
"""
|
|
119
|
+
Stateful visitor that walks a Tree-sitter Python AST and produces
|
|
120
|
+
lists of Symbol and Relationship objects.
|
|
121
|
+
|
|
122
|
+
Usage::
|
|
123
|
+
|
|
124
|
+
collector = SymbolCollector(file_info, source_bytes)
|
|
125
|
+
collector.visit(tree.root_node)
|
|
126
|
+
result = AnalysisResult(
|
|
127
|
+
file=file_info,
|
|
128
|
+
symbols=collector.symbols,
|
|
129
|
+
relationships=collector.relationships,
|
|
130
|
+
)
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(self, file_info: FileInfo, source_bytes: bytes) -> None:
|
|
134
|
+
self.file_info = file_info
|
|
135
|
+
self.source = source_bytes
|
|
136
|
+
self.symbols: list[Symbol] = []
|
|
137
|
+
self.relationships: list[Relationship] = []
|
|
138
|
+
|
|
139
|
+
# Fast lookup: simple name → Symbol (for in-file resolution)
|
|
140
|
+
self._local_by_name: dict[str, Symbol] = {}
|
|
141
|
+
# Fast lookup: qualified name → Symbol (for in-file resolution)
|
|
142
|
+
self._local_by_qname: dict[str, Symbol] = {}
|
|
143
|
+
|
|
144
|
+
self._scope = Scope()
|
|
145
|
+
self._module_qname = derive_module_qname(file_info.path)
|
|
146
|
+
self._file_symbol = self._make_file_symbol()
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# Entry point
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def visit(self, root: Node) -> None:
|
|
153
|
+
"""Walk the module root node and collect everything."""
|
|
154
|
+
self._visit_module(root)
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# Module
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _visit_module(self, node: Node) -> None:
|
|
161
|
+
doc = first_docstring(node, self.source)
|
|
162
|
+
name = self._module_qname.split(".")[-1] if self._module_qname else "__main__"
|
|
163
|
+
|
|
164
|
+
module_sym = Symbol(
|
|
165
|
+
id=self._file_symbol.id, # module and file share the same id
|
|
166
|
+
name=name,
|
|
167
|
+
qualified_name=self._module_qname or "__main__",
|
|
168
|
+
kind=SymbolKind.MODULE,
|
|
169
|
+
language=Language.PYTHON,
|
|
170
|
+
file_id=self.file_info.id,
|
|
171
|
+
start_line=1,
|
|
172
|
+
end_line=end_line(node),
|
|
173
|
+
docstring=doc,
|
|
174
|
+
)
|
|
175
|
+
self.symbols.append(self._file_symbol)
|
|
176
|
+
self._register(module_sym, skip_append=True) # shares file_symbol's slot
|
|
177
|
+
|
|
178
|
+
self._scope.push(module_sym)
|
|
179
|
+
for child in node.children:
|
|
180
|
+
self._visit_top_level(child)
|
|
181
|
+
self._scope.pop()
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# Top-level statement dispatch
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _visit_top_level(self, node: Node) -> None:
|
|
188
|
+
t = node.type
|
|
189
|
+
if t in ("import_statement", "import_from_statement"):
|
|
190
|
+
self._handle_import(node)
|
|
191
|
+
elif t == "class_definition":
|
|
192
|
+
self._handle_class(node)
|
|
193
|
+
elif t in ("function_definition", "decorated_definition"):
|
|
194
|
+
self._handle_function_or_decorated(node, parent_sym=self._scope.current)
|
|
195
|
+
elif t == "expression_statement":
|
|
196
|
+
for child in node.children:
|
|
197
|
+
if child.type == "assignment":
|
|
198
|
+
self._handle_assignment(child)
|
|
199
|
+
break
|
|
200
|
+
elif t == "assignment":
|
|
201
|
+
self._handle_assignment(node)
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
# Imports
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def _handle_import(self, node: Node) -> None:
|
|
208
|
+
"""Handle ``import x`` and ``from x import y`` statements."""
|
|
209
|
+
loc = SourceLocation(file_id=self.file_info.id, line=start_line(node))
|
|
210
|
+
file_sym = self._file_symbol
|
|
211
|
+
|
|
212
|
+
if node.type == "import_statement":
|
|
213
|
+
for child in node.children:
|
|
214
|
+
if child.type == "dotted_name":
|
|
215
|
+
self._emit_import(file_sym, node_text(child, self.source), loc)
|
|
216
|
+
elif child.type == "aliased_import":
|
|
217
|
+
for sub in child.children:
|
|
218
|
+
if sub.type == "dotted_name":
|
|
219
|
+
self._emit_import(file_sym, node_text(sub, self.source), loc)
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
elif node.type == "import_from_statement":
|
|
223
|
+
module_parts: list[str] = []
|
|
224
|
+
imported_names: list[str] = []
|
|
225
|
+
reading_module = True
|
|
226
|
+
|
|
227
|
+
for child in node.children:
|
|
228
|
+
if child.type in ("from", "import"):
|
|
229
|
+
if child.type == "import":
|
|
230
|
+
reading_module = False
|
|
231
|
+
continue
|
|
232
|
+
if reading_module:
|
|
233
|
+
if child.type in ("dotted_name", "relative_import"):
|
|
234
|
+
module_parts.append(node_text(child, self.source))
|
|
235
|
+
else:
|
|
236
|
+
if child.type == "dotted_name":
|
|
237
|
+
imported_names.append(node_text(child, self.source))
|
|
238
|
+
elif child.type == "aliased_import":
|
|
239
|
+
for sub in child.children:
|
|
240
|
+
if sub.type == "dotted_name":
|
|
241
|
+
imported_names.append(node_text(sub, self.source))
|
|
242
|
+
break
|
|
243
|
+
elif child.type == "wildcard_import":
|
|
244
|
+
imported_names.append("*")
|
|
245
|
+
|
|
246
|
+
module_str = ".".join(module_parts)
|
|
247
|
+
if imported_names:
|
|
248
|
+
for name in imported_names:
|
|
249
|
+
full = (
|
|
250
|
+
f"{module_str}.{name}"
|
|
251
|
+
if module_str and name != "*"
|
|
252
|
+
else module_str or name
|
|
253
|
+
)
|
|
254
|
+
self._emit_import(file_sym, full, loc)
|
|
255
|
+
elif module_str:
|
|
256
|
+
self._emit_import(file_sym, module_str, loc)
|
|
257
|
+
|
|
258
|
+
def _emit_import(self, source_sym: Symbol, target_name: str, loc: SourceLocation) -> None:
|
|
259
|
+
self.relationships.append(
|
|
260
|
+
Relationship(
|
|
261
|
+
source_id=source_sym.id,
|
|
262
|
+
target_id="",
|
|
263
|
+
type=RelationshipType.IMPORTS,
|
|
264
|
+
source_location=loc,
|
|
265
|
+
resolution=ResolutionStatus.UNRESOLVED,
|
|
266
|
+
metadata={"target_name": target_name},
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
# Classes
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def _handle_class(self, node: Node) -> None:
|
|
275
|
+
name_node = node.child_by_field_name("name")
|
|
276
|
+
if not name_node:
|
|
277
|
+
return
|
|
278
|
+
name = node_text(name_node, self.source)
|
|
279
|
+
|
|
280
|
+
bases = self._extract_base_names(node)
|
|
281
|
+
is_interface = bool(_ABC_BASES & set(bases) or _PROTOCOL_BASES & set(bases))
|
|
282
|
+
kind = SymbolKind.INTERFACE if is_interface else SymbolKind.CLASS
|
|
283
|
+
|
|
284
|
+
parent_sym = self._scope.current
|
|
285
|
+
qname = f"{self._scope.qualified_prefix(self._module_qname)}.{name}"
|
|
286
|
+
|
|
287
|
+
body_node = node.child_by_field_name("body")
|
|
288
|
+
doc = first_docstring(body_node, self.source) if body_node else ""
|
|
289
|
+
|
|
290
|
+
class_sym = Symbol(
|
|
291
|
+
name=name,
|
|
292
|
+
qualified_name=qname,
|
|
293
|
+
kind=kind,
|
|
294
|
+
language=Language.PYTHON,
|
|
295
|
+
file_id=self.file_info.id,
|
|
296
|
+
start_line=start_line(node),
|
|
297
|
+
end_line=end_line(node),
|
|
298
|
+
parent_id=parent_sym.id if parent_sym else None,
|
|
299
|
+
docstring=doc,
|
|
300
|
+
)
|
|
301
|
+
self._register(class_sym)
|
|
302
|
+
if parent_sym:
|
|
303
|
+
self._emit_contains(parent_sym.id, class_sym.id, start_line(node))
|
|
304
|
+
|
|
305
|
+
# Inheritance edges (unresolved at this stage)
|
|
306
|
+
for base_name in bases:
|
|
307
|
+
if base_name in _ABC_BASES | _PROTOCOL_BASES:
|
|
308
|
+
continue
|
|
309
|
+
rel_type = RelationshipType.IMPLEMENTS if is_interface else RelationshipType.EXTENDS
|
|
310
|
+
self.relationships.append(
|
|
311
|
+
Relationship(
|
|
312
|
+
source_id=class_sym.id,
|
|
313
|
+
target_id="",
|
|
314
|
+
type=rel_type,
|
|
315
|
+
resolution=ResolutionStatus.UNRESOLVED,
|
|
316
|
+
metadata={"target_name": base_name},
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if body_node:
|
|
321
|
+
self._scope.push(class_sym)
|
|
322
|
+
for child in body_node.children:
|
|
323
|
+
ct = child.type
|
|
324
|
+
if ct in ("function_definition", "decorated_definition"):
|
|
325
|
+
self._handle_function_or_decorated(child, parent_sym=class_sym)
|
|
326
|
+
elif ct == "class_definition":
|
|
327
|
+
self._handle_class(child)
|
|
328
|
+
elif ct in ("expression_statement", "assignment"):
|
|
329
|
+
self._handle_class_field(child, class_sym)
|
|
330
|
+
self._scope.pop()
|
|
331
|
+
|
|
332
|
+
def _extract_base_names(self, class_node: Node) -> list[str]:
|
|
333
|
+
"""Return simple base class names for a class_definition node."""
|
|
334
|
+
bases: list[str] = []
|
|
335
|
+
args_node = class_node.child_by_field_name("superclasses")
|
|
336
|
+
if not args_node:
|
|
337
|
+
return bases
|
|
338
|
+
for child in args_node.children:
|
|
339
|
+
if child.type in ("identifier", "attribute"):
|
|
340
|
+
bases.append(node_text(child, self.source).split(".")[-1])
|
|
341
|
+
return bases
|
|
342
|
+
|
|
343
|
+
def _handle_class_field(self, node: Node, class_sym: Symbol) -> None:
|
|
344
|
+
"""Handle class-level assignments (FIELD / CONSTANT symbols)."""
|
|
345
|
+
# Unwrap expression_statement if needed
|
|
346
|
+
target_node = node
|
|
347
|
+
if node.type == "expression_statement":
|
|
348
|
+
for child in node.children:
|
|
349
|
+
if child.type == "assignment":
|
|
350
|
+
target_node = child
|
|
351
|
+
break
|
|
352
|
+
else:
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
for target_name in self._extract_assignment_targets(target_node):
|
|
356
|
+
if not target_name.isidentifier():
|
|
357
|
+
continue
|
|
358
|
+
kind = SymbolKind.CONSTANT if target_name.isupper() else SymbolKind.FIELD
|
|
359
|
+
sym = Symbol(
|
|
360
|
+
name=target_name,
|
|
361
|
+
qualified_name=f"{class_sym.qualified_name}.{target_name}",
|
|
362
|
+
kind=kind,
|
|
363
|
+
language=Language.PYTHON,
|
|
364
|
+
file_id=self.file_info.id,
|
|
365
|
+
start_line=start_line(node),
|
|
366
|
+
end_line=end_line(node),
|
|
367
|
+
parent_id=class_sym.id,
|
|
368
|
+
)
|
|
369
|
+
self._register(sym)
|
|
370
|
+
self._emit_contains(class_sym.id, sym.id, start_line(node))
|
|
371
|
+
|
|
372
|
+
# ------------------------------------------------------------------
|
|
373
|
+
# Functions and methods
|
|
374
|
+
# ------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def _handle_function_or_decorated(
|
|
377
|
+
self, node: Node, parent_sym: Symbol | None
|
|
378
|
+
) -> None:
|
|
379
|
+
if node.type == "decorated_definition":
|
|
380
|
+
for child in node.children:
|
|
381
|
+
if child.type == "function_definition":
|
|
382
|
+
self._handle_function(child, parent_sym)
|
|
383
|
+
return
|
|
384
|
+
if child.type == "class_definition":
|
|
385
|
+
self._handle_class(child)
|
|
386
|
+
return
|
|
387
|
+
else:
|
|
388
|
+
self._handle_function(node, parent_sym)
|
|
389
|
+
|
|
390
|
+
def _handle_function(self, node: Node, parent_sym: Symbol | None) -> None:
|
|
391
|
+
name_node = node.child_by_field_name("name")
|
|
392
|
+
if not name_node:
|
|
393
|
+
return
|
|
394
|
+
name = node_text(name_node, self.source)
|
|
395
|
+
|
|
396
|
+
is_method = parent_sym is not None and parent_sym.kind in (
|
|
397
|
+
SymbolKind.CLASS,
|
|
398
|
+
SymbolKind.INTERFACE,
|
|
399
|
+
)
|
|
400
|
+
kind = SymbolKind.METHOD if is_method else SymbolKind.FUNCTION
|
|
401
|
+
is_async = any(c.type == "async" for c in node.children)
|
|
402
|
+
|
|
403
|
+
params_node = node.child_by_field_name("parameters")
|
|
404
|
+
return_node = node.child_by_field_name("return_type")
|
|
405
|
+
sig_parts = []
|
|
406
|
+
if params_node:
|
|
407
|
+
sig_parts.append(node_text(params_node, self.source))
|
|
408
|
+
if return_node:
|
|
409
|
+
sig_parts.append(f"-> {node_text(return_node, self.source)}")
|
|
410
|
+
signature = " ".join(sig_parts)
|
|
411
|
+
|
|
412
|
+
qname = f"{self._scope.qualified_prefix(self._module_qname)}.{name}"
|
|
413
|
+
body_node = node.child_by_field_name("body")
|
|
414
|
+
doc = first_docstring(body_node, self.source) if body_node else ""
|
|
415
|
+
|
|
416
|
+
func_sym = Symbol(
|
|
417
|
+
name=name,
|
|
418
|
+
qualified_name=qname,
|
|
419
|
+
kind=kind,
|
|
420
|
+
language=Language.PYTHON,
|
|
421
|
+
file_id=self.file_info.id,
|
|
422
|
+
start_line=start_line(node),
|
|
423
|
+
end_line=end_line(node),
|
|
424
|
+
signature=signature,
|
|
425
|
+
parent_id=parent_sym.id if parent_sym else self._file_symbol.id,
|
|
426
|
+
docstring=doc,
|
|
427
|
+
is_async=is_async,
|
|
428
|
+
)
|
|
429
|
+
self._register(func_sym)
|
|
430
|
+
|
|
431
|
+
container = parent_sym or self._file_symbol
|
|
432
|
+
self._emit_contains(container.id, func_sym.id, start_line(node))
|
|
433
|
+
|
|
434
|
+
if body_node:
|
|
435
|
+
self._scope.push(func_sym)
|
|
436
|
+
self._collect_calls(body_node, func_sym)
|
|
437
|
+
for child in body_node.children:
|
|
438
|
+
if child.type in ("function_definition", "decorated_definition"):
|
|
439
|
+
self._handle_function_or_decorated(child, parent_sym=func_sym)
|
|
440
|
+
elif child.type == "class_definition":
|
|
441
|
+
self._handle_class(child)
|
|
442
|
+
self._scope.pop()
|
|
443
|
+
|
|
444
|
+
# ------------------------------------------------------------------
|
|
445
|
+
# Call sites
|
|
446
|
+
# ------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def _collect_calls(self, node: Node, caller_sym: Symbol) -> None:
|
|
449
|
+
"""Recursively find all call expressions within a function/method body."""
|
|
450
|
+
if node.type == "call":
|
|
451
|
+
self._handle_call(node, caller_sym)
|
|
452
|
+
for child in node.children:
|
|
453
|
+
if child.type not in ("function_definition", "class_definition", "decorated_definition"):
|
|
454
|
+
self._collect_calls(child, caller_sym)
|
|
455
|
+
|
|
456
|
+
def _handle_call(self, node: Node, caller_sym: Symbol) -> None:
|
|
457
|
+
func_node = node.child_by_field_name("function")
|
|
458
|
+
if not func_node:
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
call_text = node_text(func_node, self.source)
|
|
462
|
+
callee_name = call_text.split(".")[-1]
|
|
463
|
+
loc = SourceLocation(
|
|
464
|
+
file_id=self.file_info.id,
|
|
465
|
+
line=start_line(node),
|
|
466
|
+
col=node.start_point[1],
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
target_id = ""
|
|
470
|
+
resolution = ResolutionStatus.UNRESOLVED
|
|
471
|
+
|
|
472
|
+
fqn_match = self._local_by_qname.get(call_text)
|
|
473
|
+
name_match = self._local_by_name.get(callee_name)
|
|
474
|
+
if fqn_match:
|
|
475
|
+
target_id = fqn_match.id
|
|
476
|
+
resolution = ResolutionStatus.RESOLVED
|
|
477
|
+
elif name_match:
|
|
478
|
+
target_id = name_match.id
|
|
479
|
+
resolution = ResolutionStatus.PROBABLE
|
|
480
|
+
|
|
481
|
+
self.relationships.append(
|
|
482
|
+
Relationship(
|
|
483
|
+
source_id=caller_sym.id,
|
|
484
|
+
target_id=target_id,
|
|
485
|
+
type=RelationshipType.CALLS,
|
|
486
|
+
source_location=loc,
|
|
487
|
+
resolution=resolution,
|
|
488
|
+
metadata={"call_text": call_text, "callee_name": callee_name},
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# ------------------------------------------------------------------
|
|
493
|
+
# Module-level assignments
|
|
494
|
+
# ------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
def _handle_assignment(self, node: Node) -> None:
|
|
497
|
+
"""Handle module-level VARIABLE / CONSTANT symbols."""
|
|
498
|
+
for target_name in self._extract_assignment_targets(node):
|
|
499
|
+
if not target_name.isidentifier():
|
|
500
|
+
continue
|
|
501
|
+
kind = SymbolKind.CONSTANT if target_name.isupper() else SymbolKind.VARIABLE
|
|
502
|
+
qname = f"{self._module_qname}.{target_name}" if self._module_qname else target_name
|
|
503
|
+
sym = Symbol(
|
|
504
|
+
name=target_name,
|
|
505
|
+
qualified_name=qname,
|
|
506
|
+
kind=kind,
|
|
507
|
+
language=Language.PYTHON,
|
|
508
|
+
file_id=self.file_info.id,
|
|
509
|
+
start_line=start_line(node),
|
|
510
|
+
end_line=end_line(node),
|
|
511
|
+
parent_id=self._file_symbol.id,
|
|
512
|
+
)
|
|
513
|
+
self._register(sym)
|
|
514
|
+
self._emit_contains(self._file_symbol.id, sym.id, start_line(node))
|
|
515
|
+
|
|
516
|
+
# ------------------------------------------------------------------
|
|
517
|
+
# Private helpers
|
|
518
|
+
# ------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
def _register(self, sym: Symbol, skip_append: bool = False) -> None:
|
|
521
|
+
if not skip_append:
|
|
522
|
+
self.symbols.append(sym)
|
|
523
|
+
self._local_by_name[sym.name] = sym
|
|
524
|
+
self._local_by_qname[sym.qualified_name] = sym
|
|
525
|
+
|
|
526
|
+
def _emit_contains(self, source_id: str, target_id: str, line: int) -> None:
|
|
527
|
+
self.relationships.append(
|
|
528
|
+
Relationship(
|
|
529
|
+
source_id=source_id,
|
|
530
|
+
target_id=target_id,
|
|
531
|
+
type=RelationshipType.CONTAINS,
|
|
532
|
+
source_location=SourceLocation(file_id=self.file_info.id, line=line),
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def _make_file_symbol(self) -> Symbol:
|
|
537
|
+
return Symbol(
|
|
538
|
+
name=Path(self.file_info.path).name,
|
|
539
|
+
qualified_name=self.file_info.path,
|
|
540
|
+
kind=SymbolKind.FILE,
|
|
541
|
+
language=Language.PYTHON,
|
|
542
|
+
file_id=self.file_info.id,
|
|
543
|
+
start_line=1,
|
|
544
|
+
end_line=1,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _extract_assignment_targets(node: Node) -> list[str]:
|
|
549
|
+
targets: list[str] = []
|
|
550
|
+
for child in node.children:
|
|
551
|
+
if child.type == "identifier":
|
|
552
|
+
text = child.text
|
|
553
|
+
if text:
|
|
554
|
+
targets.append(text.decode("utf-8", errors="replace"))
|
|
555
|
+
elif child.type == "pattern_list":
|
|
556
|
+
for sub in child.children:
|
|
557
|
+
if sub.type == "identifier" and sub.text:
|
|
558
|
+
targets.append(sub.text.decode("utf-8", errors="replace"))
|
|
559
|
+
return [t for t in targets if t]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
node_walk.analysis.python_analyzer — backward-compatibility shim.
|
|
3
|
+
|
|
4
|
+
The Python analyzer now lives in the node_walk.analysis.python sub-package:
|
|
5
|
+
- node_walk.analysis.python.analyzer (PythonAnalyzer)
|
|
6
|
+
- node_walk.analysis.python.visitor (SymbolCollector + helpers)
|
|
7
|
+
- node_walk.analysis.python.scope (Scope)
|
|
8
|
+
|
|
9
|
+
This module re-exports PythonAnalyzer so existing imports keep working.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from node_walk.analysis.python.analyzer import PythonAnalyzer
|
|
13
|
+
|
|
14
|
+
__all__ = ["PythonAnalyzer"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI — Typer-based command-line interface."""
|