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 +0 -0
- reachscan/analysis/__init__.py +6 -0
- reachscan/analysis/finding_enrichment.py +85 -0
- reachscan/analysis/impact.py +81 -0
- reachscan/call_graph.py +474 -0
- reachscan/cli.py +85 -0
- reachscan/detectors/__init__.py +6 -0
- reachscan/detectors/autonomy.py +154 -0
- reachscan/detectors/base.py +60 -0
- reachscan/detectors/dynamic_exec.py +125 -0
- reachscan/detectors/file_access.py +169 -0
- reachscan/detectors/network.py +218 -0
- reachscan/detectors/registry.py +110 -0
- reachscan/detectors/secrets.py +242 -0
- reachscan/detectors/shell_exec.py +32 -0
- reachscan/py_entry_points.py +612 -0
- reachscan/reachability.py +325 -0
- reachscan/reporters/__init__.py +0 -0
- reachscan/reporters/json_reporter.py +19 -0
- reachscan/reporters/text_reporter.py +204 -0
- reachscan/scanner.py +344 -0
- reachscan/schema.py +92 -0
- reachscan/source_loader.py +388 -0
- reachscan/ts_entry_points.py +426 -0
- reachscan/utils.py +0 -0
- reachscan-0.1.0.dist-info/METADATA +329 -0
- reachscan-0.1.0.dist-info/RECORD +31 -0
- reachscan-0.1.0.dist-info/WHEEL +5 -0
- reachscan-0.1.0.dist-info/entry_points.txt +2 -0
- reachscan-0.1.0.dist-info/licenses/LICENSE +90 -0
- reachscan-0.1.0.dist-info/top_level.txt +1 -0
reachscan/__init__.py
ADDED
|
File without changes
|
|
@@ -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
|
reachscan/call_graph.py
ADDED
|
@@ -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
|