reachscan 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.
reachscan/__init__.py ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """Analysis helpers for finding enrichment and multi-capability risk inference."""
2
+
3
+ from .finding_enrichment import enrich_finding
4
+ from .impact import analyze_combined_capabilities
5
+
6
+ __all__ = ["enrich_finding", "analyze_combined_capabilities"]
@@ -0,0 +1,85 @@
1
+ """Helpers to attach user-facing explanations and impacts to detector findings."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ from reachscan.reachability import REACHABILITY_FIELDS
6
+
7
+ CAPABILITY_DETAILS: Dict[str, Dict[str, str]] = {
8
+ "EXECUTE": {
9
+ "risk_level": "high",
10
+ "explanation": "This code can execute shell or interpreter commands on the host.",
11
+ "impact": "A malicious prompt could run destructive commands, install malware, or alter the environment.",
12
+ },
13
+ "SEND": {
14
+ "risk_level": "high",
15
+ "explanation": "This code can send data over the network to external services.",
16
+ "impact": "Sensitive local data could be transmitted to untrusted endpoints.",
17
+ },
18
+ "READ": {
19
+ "risk_level": "medium",
20
+ "explanation": "This code can read local files from the filesystem.",
21
+ "impact": "Private files may be exposed to later processing or exfiltration.",
22
+ },
23
+ "WRITE": {
24
+ "risk_level": "high",
25
+ "explanation": "This code can write, modify, move, or delete files.",
26
+ "impact": "An unsafe prompt could tamper with project files or remove important data.",
27
+ },
28
+ "SECRETS": {
29
+ "risk_level": "high",
30
+ "explanation": "This code accesses secrets or credential sources.",
31
+ "impact": "Credentials may be disclosed and used for unauthorized access.",
32
+ },
33
+ "DYNAMIC": {
34
+ "risk_level": "high",
35
+ "explanation": "This code performs dynamic execution or runtime code loading.",
36
+ "impact": "Untrusted input could become executable code, increasing compromise risk.",
37
+ },
38
+ "AUTONOMY": {
39
+ "risk_level": "medium",
40
+ "explanation": "This code can schedule or continue actions without direct user interaction.",
41
+ "impact": "Risky behavior may repeat in the background after the initiating prompt.",
42
+ },
43
+ }
44
+
45
+ DEFAULT_DETAILS = {
46
+ "risk_level": "medium",
47
+ "explanation": "This code exposes a potentially sensitive capability.",
48
+ "impact": "If abused, this behavior can expand the blast radius of prompt injection.",
49
+ }
50
+
51
+
52
+ def enrich_finding(finding: Dict[str, Any]) -> Dict[str, Any]:
53
+ """
54
+ Return a normalized finding with explanation, impact, and risk_level populated.
55
+
56
+ Existing fields provided by detectors are preserved.
57
+ """
58
+ capability = finding.get("capability")
59
+ detail = CAPABILITY_DETAILS.get(capability, DEFAULT_DETAILS)
60
+ enriched = dict(finding)
61
+
62
+ # Detectors currently emit CapabilityFinding defaults (None/"medium").
63
+ # Replace missing/default values with capability-specific metadata.
64
+ if not enriched.get("explanation"):
65
+ enriched["explanation"] = detail["explanation"]
66
+ if not enriched.get("impact"):
67
+ enriched["impact"] = detail["impact"]
68
+
69
+ current_risk = enriched.get("risk_level")
70
+ if not current_risk or str(current_risk).lower() == "medium":
71
+ enriched["risk_level"] = detail["risk_level"]
72
+
73
+ # Reachability fields — None until the reachability analysis pass runs.
74
+ # These are always present in the schema so downstream tooling can rely
75
+ # on the keys existing. The reachability pass writes the real values.
76
+ for field_name in REACHABILITY_FIELDS:
77
+ enriched.setdefault(field_name, None)
78
+
79
+ # Finding ID fields — None here, overwritten by scanner.py immediately
80
+ # after enrichment using make_finding_id(). Defined as defaults so the
81
+ # schema is complete even in unit tests that call enrich_finding() directly.
82
+ enriched.setdefault("finding_id", None) # compact SHA-1 hash
83
+ enriched.setdefault("finding_ref", None) # human-readable label
84
+
85
+ return enriched
@@ -0,0 +1,81 @@
1
+ """Multi-capability reasoning for higher-level behavioral risks."""
2
+
3
+ from typing import Any, Dict, Iterable, List, Set
4
+
5
+
6
+ def _capability_set(findings: Iterable[Dict[str, Any]]) -> Set[str]:
7
+ return {f.get("capability") for f in findings if f.get("capability")}
8
+
9
+
10
+ def _has_destructive_write(findings: Iterable[Dict[str, Any]]) -> bool:
11
+ destructive_tokens = ("remove", "unlink", "rename", "replace", "delete")
12
+ for finding in findings:
13
+ if finding.get("capability") != "WRITE":
14
+ continue
15
+ evidence = str(finding.get("evidence", "")).lower()
16
+ if any(token in evidence for token in destructive_tokens):
17
+ return True
18
+ return False
19
+
20
+
21
+ def analyze_combined_capabilities(findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
22
+ """
23
+ Infer higher-level risks by combining capabilities across findings.
24
+ """
25
+ caps = _capability_set(findings)
26
+ risks: List[Dict[str, Any]] = []
27
+
28
+ def add_risk(
29
+ risk_id: str,
30
+ title: str,
31
+ severity: str,
32
+ why: str,
33
+ required_capabilities: Set[str],
34
+ ) -> None:
35
+ risks.append(
36
+ {
37
+ "id": risk_id,
38
+ "title": title,
39
+ "severity": severity,
40
+ "why": why,
41
+ "capabilities_triggered": sorted(required_capabilities),
42
+ }
43
+ )
44
+
45
+ if {"SEND", "WRITE"}.issubset(caps):
46
+ add_risk(
47
+ "data_exfiltration",
48
+ "Data Exfiltration Risk",
49
+ "high",
50
+ "The code can both access/change local files and send data externally.",
51
+ {"SEND", "WRITE"},
52
+ )
53
+
54
+ if {"EXECUTE", "SEND"}.issubset(caps):
55
+ add_risk(
56
+ "remote_control",
57
+ "Remote Control Risk",
58
+ "high",
59
+ "The code can execute commands and communicate over the network.",
60
+ {"EXECUTE", "SEND"},
61
+ )
62
+
63
+ if {"READ", "SEND"}.issubset(caps):
64
+ add_risk(
65
+ "secret_leak",
66
+ "Secret Leakage Risk",
67
+ "high",
68
+ "The code can read local files and transmit their contents externally.",
69
+ {"READ", "SEND"},
70
+ )
71
+
72
+ if {"EXECUTE", "WRITE"}.issubset(caps) and _has_destructive_write(findings):
73
+ add_risk(
74
+ "destructive_agent",
75
+ "Destructive Agent Risk",
76
+ "high",
77
+ "The code can execute commands and perform destructive file actions.",
78
+ {"EXECUTE", "WRITE"},
79
+ )
80
+
81
+ return risks
@@ -0,0 +1,474 @@
1
+ """
2
+ Intra-project call graph data model and construction.
3
+
4
+ This module defines the type contracts used by the call graph builder and the
5
+ reachability analyser, and provides the construction logic (build_call_graph).
6
+
7
+ Type contracts (used by Step 4 reachability analysis):
8
+ FunctionNode — (abs_file_path, qualified_name): canonical function identifier
9
+ CallGraph — adjacency map: FunctionNode → set of called FunctionNodes
10
+ LinenoIndex — per-file {start_lineno → qualified_name}; includes 0 → "<module>"
11
+ sentinel so all findings have a containing "function" context
12
+ ImportMap — per-file {local_name → source_file} for project-local imports
13
+ ReexportMap — project-wide {exported_name → source_file} from __init__.py
14
+
15
+ Qualified name conventions (follows __qualname__):
16
+ top-level function : "process_auth"
17
+ class method : "MyTool._run"
18
+ nested function : bare name (e.g. "inner"), NOT in project_functions
19
+
20
+ Construction algorithm — three phases:
21
+
22
+ Phase 1 — __init__.py pre-pass:
23
+ Collect one level of relative re-exports from every __init__.py.
24
+ Used so that `from mypkg import process_auth` resolves even when
25
+ process_auth lives in mypkg/utils.py and is re-exported via __init__.py.
26
+
27
+ Phase 2 — Per-file analysis:
28
+ For each .py file, parse the AST and:
29
+ a) Collect top-level imports that resolve within the project boundary
30
+ → ImportMap[file][local_name] = source_file
31
+ b) Collect exportable function names (top-level functions + immediate class
32
+ methods — things that can be imported by other project files)
33
+ → project_functions[file] = {qualified_name, ...}
34
+
35
+ Phase 3 — Edge extraction:
36
+ _FileVisitor walks each file's AST using NodeVisitor and:
37
+ - Records every function/method start line in LinenoIndex (including nested
38
+ functions; sentinel 0 → "<module>" covers code outside all functions).
39
+ - On each ast.Call, tries to resolve the callee to a project FunctionNode
40
+ using the four-step resolution order below and adds a directed edge.
41
+
42
+ Call resolution order (inside _FileVisitor, per call site):
43
+ 1. Bare name in same file's project_functions → (file, name)
44
+ 2. Bare name in file's ImportMap → (source_file, name) if exported
45
+ 3. self.attr() in active class context → (file, ClassName.attr)
46
+ 4. obj.attr() where obj is a simple Name in ImportMap
47
+ → (source_file, attr) if exported
48
+ Otherwise: unresolvable → edge dropped silently
49
+
50
+ Known limitations (v1):
51
+ - Nested functions appear in LinenoIndex but not in project_functions.
52
+ Findings inside them get UNKNOWN reachability (static analysis limitation).
53
+ - Module-level findings get UNKNOWN via the "<module>" sentinel (not in graph).
54
+ - Star imports (from x import *) are not resolved.
55
+ - Type-based method dispatch (obj.method() where obj is not self and is not a
56
+ directly imported module) is not resolved.
57
+ - Dynamic calls (getattr, __import__, etc.) produce no edges.
58
+ - Only top-level module-level imports are examined; imports inside function
59
+ bodies are not tracked.
60
+ - Graph may contain cycles (mutual recursion) — Step 4 BFS must track visited
61
+ nodes to avoid infinite loops.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import ast
67
+ import warnings
68
+ from pathlib import Path
69
+ from typing import Dict, List, Optional, Set, Tuple
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Type aliases
73
+ # ---------------------------------------------------------------------------
74
+
75
+ # (abs_file_path, qualified_name) — e.g. ("/abs/path/tools.py", "MyTool._run")
76
+ FunctionNode = Tuple[str, str]
77
+
78
+ # Adjacency map: each FunctionNode → set of FunctionNodes it calls in-project.
79
+ CallGraph = Dict[FunctionNode, Set[FunctionNode]]
80
+
81
+ # Per-file map: start_lineno → qualified function name.
82
+ # Sentinel 0 → "<module>" is always present to cover module-level code.
83
+ LinenoIndex = Dict[str, Dict[int, str]]
84
+
85
+ # Per-file map: local import name → resolved abs source file path.
86
+ ImportMap = Dict[str, Dict[str, str]]
87
+
88
+ # Project-wide map: exported name → source file (from __init__.py pre-pass).
89
+ ReexportMap = Dict[str, str]
90
+
91
+ UNRESOLVABLE = "<unresolvable>"
92
+ MODULE_LEVEL = "<module>" # sentinel: code that lives outside any function
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Public API
97
+ # ---------------------------------------------------------------------------
98
+
99
+ def build_call_graph(
100
+ py_files: List[Path],
101
+ root: Path,
102
+ ) -> Tuple[CallGraph, LinenoIndex, ImportMap]:
103
+ """
104
+ Build the intra-project call graph, lineno index, and import map.
105
+
106
+ Args:
107
+ py_files: Pre-filtered list of .py files to analyse (same list used
108
+ by the scanner for detector runs). Paths are resolved to
109
+ their canonical absolute form internally.
110
+ root: Project root directory. Used to enforce the project boundary
111
+ when resolving import paths.
112
+
113
+ Returns a three-tuple (graph, lineno, imp_map):
114
+ graph: CallGraph — FunctionNode → set of callee FunctionNodes.
115
+ Every function definition in every file has at least an empty
116
+ set entry so callers can iterate nodes safely.
117
+ lineno: LinenoIndex — per-file {start_lineno → qualified_name}.
118
+ Includes nested functions and the 0 → "<module>" sentinel.
119
+ imp_map: ImportMap — per-file {local_name → abs_source_file} for
120
+ project-local imports resolved at the top-level of each file.
121
+ """
122
+ root = Path(root).resolve()
123
+ files = [Path(f).resolve() for f in py_files]
124
+
125
+ # Phase 1: one-level re-export map from __init__.py relative imports
126
+ reexport_map = _build_reexport_map(files, root)
127
+
128
+ # Phase 2: per-file import maps and exportable function names
129
+ trees: Dict[str, ast.AST] = {}
130
+ imp_map: ImportMap = {}
131
+ project_functions: Dict[str, Set[str]] = {} # file → {qualified_name}
132
+
133
+ for f in files:
134
+ fstr = str(f)
135
+ try:
136
+ content = f.read_text(encoding="utf-8", errors="ignore")
137
+ with warnings.catch_warnings():
138
+ warnings.simplefilter("ignore", SyntaxWarning)
139
+ tree = ast.parse(content)
140
+ except Exception:
141
+ continue
142
+
143
+ trees[fstr] = tree
144
+ imp_map[fstr] = _collect_file_imports(f, tree, root, reexport_map)
145
+ project_functions[fstr] = _collect_exportable_names(tree)
146
+
147
+ # Phase 3: call edge extraction and lineno index construction
148
+ graph: CallGraph = {}
149
+ lineno: LinenoIndex = {}
150
+
151
+ for fstr, tree in trees.items():
152
+ visitor = _FileVisitor(
153
+ file=fstr,
154
+ file_imports=imp_map.get(fstr, {}),
155
+ project_functions=project_functions,
156
+ )
157
+ visitor.visit(tree)
158
+
159
+ for fn, callees in visitor.graph.items():
160
+ graph.setdefault(fn, set()).update(callees)
161
+
162
+ lineno[fstr] = visitor.lineno_index
163
+
164
+ return graph, lineno, imp_map
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Phase 1: __init__.py re-export pre-pass
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def _build_reexport_map(files: List[Path], root: Path) -> ReexportMap:
172
+ """
173
+ Build a project-wide {exported_name → source_file} map.
174
+
175
+ Walks every __init__.py in the file list and follows relative imports one
176
+ level deep. Only relative imports (level > 0) are followed; absolute
177
+ imports in __init__.py are ignored (they are third-party or stdlib).
178
+ """
179
+ reexport_map: ReexportMap = {}
180
+
181
+ for f in files:
182
+ if f.name != "__init__.py":
183
+ continue
184
+ try:
185
+ content = f.read_text(encoding="utf-8", errors="ignore")
186
+ with warnings.catch_warnings():
187
+ warnings.simplefilter("ignore", SyntaxWarning)
188
+ tree = ast.parse(content)
189
+ except Exception:
190
+ continue
191
+
192
+ for node in tree.body:
193
+ if not isinstance(node, ast.ImportFrom) or node.level == 0:
194
+ continue # only relative imports
195
+
196
+ module = node.module or ""
197
+
198
+ for alias in node.names:
199
+ if alias.name == "*":
200
+ continue
201
+
202
+ # Determine the module to resolve:
203
+ # "from .utils import foo" → module="utils", resolve "utils"
204
+ # "from . import utils" → module="", resolve alias.name="utils"
205
+ sub = module if module else alias.name
206
+ source_file = _resolve_module_to_file(sub, root, f, node.level)
207
+ if source_file is None:
208
+ continue
209
+
210
+ exported_name = alias.asname if alias.asname else alias.name
211
+ reexport_map[exported_name] = source_file
212
+
213
+ return reexport_map
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Module → file path resolution
218
+ # ---------------------------------------------------------------------------
219
+
220
+ def _resolve_module_to_file(
221
+ module_str: str,
222
+ root: Path,
223
+ current_file: Path,
224
+ level: int = 0,
225
+ ) -> Optional[str]:
226
+ """
227
+ Resolve a module specifier to an absolute .py file path within root.
228
+
229
+ level=0 absolute: "from mypackage.utils import foo"
230
+ level=1 relative: "from . import foo" (same package as current_file)
231
+ level=2 relative: "from .. import foo" (parent package)
232
+
233
+ Tries candidate.py first, then candidate/__init__.py.
234
+ Returns None if the path does not exist or falls outside root.
235
+ """
236
+ if not module_str:
237
+ return None
238
+
239
+ if level > 0:
240
+ base = current_file.parent
241
+ for _ in range(level - 1):
242
+ base = base.parent
243
+ else:
244
+ base = root
245
+
246
+ rel = Path(module_str.replace(".", "/"))
247
+ candidate = base / rel
248
+
249
+ for path in (candidate.with_suffix(".py"), candidate / "__init__.py"):
250
+ if path.is_file():
251
+ try:
252
+ resolved = path.resolve()
253
+ resolved.relative_to(root) # verify within project boundary
254
+ return str(resolved)
255
+ except ValueError:
256
+ pass
257
+
258
+ return None
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Phase 2 helpers
263
+ # ---------------------------------------------------------------------------
264
+
265
+ def _collect_file_imports(
266
+ file: Path,
267
+ tree: ast.AST,
268
+ root: Path,
269
+ reexport_map: ReexportMap,
270
+ ) -> Dict[str, str]:
271
+ """
272
+ Return {local_name: abs_source_file} for top-level imports that resolve
273
+ within the project boundary.
274
+
275
+ Only top-level (module-level) import statements are examined; imports
276
+ inside function or class bodies are not tracked.
277
+
278
+ For names that resolve to a package __init__.py, the reexport_map is
279
+ checked first so the mapping points to the actual defining file rather
280
+ than the re-exporting __init__.py.
281
+ """
282
+ result: Dict[str, str] = {}
283
+
284
+ for node in tree.body:
285
+ if isinstance(node, ast.ImportFrom):
286
+ if node.module == "__future__":
287
+ continue
288
+
289
+ module = node.module or ""
290
+ level = node.level
291
+
292
+ if level > 0 and not module:
293
+ # "from . import name1, name2" — each alias is itself a submodule
294
+ for alias in node.names:
295
+ if alias.name == "*":
296
+ continue
297
+ source_file = _resolve_module_to_file(alias.name, root, file, level)
298
+ local = alias.asname if alias.asname else alias.name
299
+ if source_file is not None:
300
+ result[local] = source_file
301
+ else:
302
+ # "from [dots]module import name1, name2"
303
+ source_file = _resolve_module_to_file(module, root, file, level)
304
+ for alias in node.names:
305
+ if alias.name == "*":
306
+ continue
307
+ local = alias.asname if alias.asname else alias.name
308
+ if source_file is not None:
309
+ # Prefer reexport source (more specific than __init__.py)
310
+ result[local] = reexport_map.get(local, source_file)
311
+ elif local in reexport_map:
312
+ result[local] = reexport_map[local]
313
+
314
+ elif isinstance(node, ast.Import):
315
+ for alias in node.names:
316
+ source_file = _resolve_module_to_file(alias.name, root, file, 0)
317
+ if source_file is not None:
318
+ local = alias.asname if alias.asname else alias.name.split(".")[0]
319
+ result[local] = source_file
320
+
321
+ return result
322
+
323
+
324
+ def _collect_exportable_names(tree: ast.AST) -> Set[str]:
325
+ """
326
+ Return qualified names of functions importable from this file:
327
+ - top-level functions: "process_auth"
328
+ - immediate class methods: "MyTool._run"
329
+
330
+ Nested functions (defined inside other functions) are excluded — they
331
+ cannot be imported and are handled separately in the lineno index.
332
+ """
333
+ names: Set[str] = set()
334
+
335
+ for node in tree.body:
336
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
337
+ names.add(node.name)
338
+ elif isinstance(node, ast.ClassDef):
339
+ for item in node.body:
340
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
341
+ names.add(f"{node.name}.{item.name}")
342
+
343
+ return names
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # Phase 3: single-file AST visitor
348
+ # ---------------------------------------------------------------------------
349
+
350
+ class _FileVisitor(ast.NodeVisitor):
351
+ """
352
+ Single-file AST visitor that builds call graph edges and a lineno index.
353
+
354
+ Context tracking:
355
+ _class_stack — names of enclosing ClassDef nodes (innermost last)
356
+ _func_stack — qualified names of enclosing function/method definitions
357
+ _func_depth — nesting depth (0 = not inside any function)
358
+
359
+ Qualified name convention:
360
+ - Class method at _func_depth==0: "ClassName.method"
361
+ - All other functions (top-level or nested): bare "name"
362
+
363
+ Outputs:
364
+ graph — {FunctionNode: set(FunctionNode)} — edges from this file
365
+ lineno_index — {start_lineno: qualified_name}; sentinel 0 → "<module>"
366
+ """
367
+
368
+ def __init__(
369
+ self,
370
+ file: str,
371
+ file_imports: Dict[str, str], # local_name → source_file
372
+ project_functions: Dict[str, Set[str]], # source_file → {qualified_name}
373
+ ) -> None:
374
+ self._file = file
375
+ self._file_imports = file_imports
376
+ self._project_functions = project_functions
377
+
378
+ self._class_stack: List[str] = []
379
+ self._func_stack: List[str] = []
380
+ self._func_depth: int = 0
381
+
382
+ self.graph: Dict[FunctionNode, Set[FunctionNode]] = {}
383
+ # Sentinel ensures every file has a MODULE_LEVEL entry for bisect safety
384
+ self.lineno_index: Dict[int, str] = {0: MODULE_LEVEL}
385
+
386
+ # -- Class and function boundaries ---------------------------------------
387
+
388
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
389
+ self._class_stack.append(node.name)
390
+ self.generic_visit(node)
391
+ self._class_stack.pop()
392
+
393
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
394
+ self._visit_func(node)
395
+
396
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
397
+ self._visit_func(node)
398
+
399
+ def _visit_func(self, node) -> None:
400
+ # Qualified name:
401
+ # - Class method at the top level of its class body → "ClassName.method"
402
+ # - Everything else (top-level fn, nested fn) → bare "name"
403
+ if self._class_stack and self._func_depth == 0:
404
+ qual = f"{self._class_stack[-1]}.{node.name}"
405
+ else:
406
+ qual = node.name
407
+
408
+ self.lineno_index[node.lineno] = qual
409
+ fn: FunctionNode = (self._file, qual)
410
+ self.graph.setdefault(fn, set())
411
+
412
+ self._func_stack.append(qual)
413
+ self._func_depth += 1
414
+ self.generic_visit(node)
415
+ self._func_depth -= 1
416
+ self._func_stack.pop()
417
+
418
+ # -- Call sites ---------------------------------------------------------
419
+
420
+ def visit_Call(self, node: ast.Call) -> None:
421
+ if self._func_stack:
422
+ caller: FunctionNode = (self._file, self._func_stack[-1])
423
+ callee = self._resolve_call(node)
424
+ if callee is not None:
425
+ self.graph.setdefault(caller, set()).add(callee)
426
+ self.generic_visit(node)
427
+
428
+ def _resolve_call(self, node: ast.Call) -> Optional[FunctionNode]:
429
+ func = node.func
430
+
431
+ # Resolution 1 & 2: bare call — foo()
432
+ if isinstance(func, ast.Name):
433
+ return self._resolve_name(func.id)
434
+
435
+ if isinstance(func, ast.Attribute):
436
+ attr = func.attr
437
+ value = func.value
438
+
439
+ # Resolution 3: self.method()
440
+ if isinstance(value, ast.Name) and value.id == "self":
441
+ return self._resolve_self_method(attr)
442
+
443
+ # Resolution 4: module.func() where module is a simple Name
444
+ if isinstance(value, ast.Name):
445
+ return self._resolve_module_attr(value.id, attr)
446
+
447
+ return None
448
+
449
+ def _resolve_name(self, name: str) -> Optional[FunctionNode]:
450
+ """Resolutions 1 & 2: same file first, then imported file."""
451
+ # Resolution 1: defined in this file
452
+ if name in self._project_functions.get(self._file, set()):
453
+ return (self._file, name)
454
+ # Resolution 2: imported from another project file
455
+ source = self._file_imports.get(name)
456
+ if source and name in self._project_functions.get(source, set()):
457
+ return (source, name)
458
+ return None
459
+
460
+ def _resolve_self_method(self, attr: str) -> Optional[FunctionNode]:
461
+ """Resolution 3: self.attr() within the current class."""
462
+ if not self._class_stack:
463
+ return None
464
+ qual = f"{self._class_stack[-1]}.{attr}"
465
+ if qual in self._project_functions.get(self._file, set()):
466
+ return (self._file, qual)
467
+ return None
468
+
469
+ def _resolve_module_attr(self, obj_name: str, attr: str) -> Optional[FunctionNode]:
470
+ """Resolution 4: obj.attr() where obj is an imported project module."""
471
+ source = self._file_imports.get(obj_name)
472
+ if source and attr in self._project_functions.get(source, set()):
473
+ return (source, attr)
474
+ return None