coretrace-python-analyzer 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.
- coretrace_python/__init__.py +4 -0
- coretrace_python/__main__.py +4 -0
- coretrace_python/abstract/__init__.py +45 -0
- coretrace_python/abstract/constants.py +226 -0
- coretrace_python/abstract/heap.py +252 -0
- coretrace_python/abstract/ranges.py +285 -0
- coretrace_python/abstract/values.py +56 -0
- coretrace_python/analysis/__init__.py +31 -0
- coretrace_python/analysis/manager.py +165 -0
- coretrace_python/analysis/provider.py +73 -0
- coretrace_python/bundled/dependency/dependency_policy/dependency_policy.py +46 -0
- coretrace_python/bundled/dependency/dependency_policy/plugin.toml +9 -0
- coretrace_python/bundled/dependency/reachable_vulnerability/plugin.toml +9 -0
- coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +56 -0
- coretrace_python/bundled/dependency/sample_advisories/plugin.toml +9 -0
- coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py +109 -0
- coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml +9 -0
- coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +43 -0
- coretrace_python/bundled/models/cli/cli_models.py +35 -0
- coretrace_python/bundled/models/cli/plugin.toml +9 -0
- coretrace_python/bundled/models/credentials/credential_models.py +43 -0
- coretrace_python/bundled/models/credentials/plugin.toml +9 -0
- coretrace_python/bundled/models/django/django_models.py +123 -0
- coretrace_python/bundled/models/django/plugin.toml +9 -0
- coretrace_python/bundled/models/fastapi/fastapi_models.py +29 -0
- coretrace_python/bundled/models/fastapi/plugin.toml +9 -0
- coretrace_python/bundled/models/flask/flask_models.py +53 -0
- coretrace_python/bundled/models/flask/plugin.toml +9 -0
- coretrace_python/bundled/models/http_clients/http_client_models.py +43 -0
- coretrace_python/bundled/models/http_clients/plugin.toml +9 -0
- coretrace_python/bundled/models/python_stdlib/plugin.toml +9 -0
- coretrace_python/bundled/models/python_stdlib/python_stdlib.py +68 -0
- coretrace_python/bundled/models/sqlalchemy/plugin.toml +9 -0
- coretrace_python/bundled/models/sqlalchemy/sqlalchemy_models.py +47 -0
- coretrace_python/bundled/secrets/config_secrets/config_secrets.py +38 -0
- coretrace_python/bundled/secrets/config_secrets/plugin.toml +9 -0
- coretrace_python/bundled/secrets/hardcoded_secrets/hardcoded_secrets.py +19 -0
- coretrace_python/bundled/secrets/hardcoded_secrets/plugin.toml +9 -0
- coretrace_python/bundled/security/command_injection/command_injection.py +17 -0
- coretrace_python/bundled/security/command_injection/plugin.toml +9 -0
- coretrace_python/bundled/security/insecure_deserialization/insecure_deserialization.py +17 -0
- coretrace_python/bundled/security/insecure_deserialization/plugin.toml +9 -0
- coretrace_python/bundled/security/open_redirect/open_redirect.py +17 -0
- coretrace_python/bundled/security/open_redirect/plugin.toml +9 -0
- coretrace_python/bundled/security/path_traversal/path_traversal.py +17 -0
- coretrace_python/bundled/security/path_traversal/plugin.toml +9 -0
- coretrace_python/bundled/security/plaintext_credentials/plaintext_credentials.py +21 -0
- coretrace_python/bundled/security/plaintext_credentials/plugin.toml +9 -0
- coretrace_python/bundled/security/sql_injection/plugin.toml +9 -0
- coretrace_python/bundled/security/sql_injection/sql_injection.py +17 -0
- coretrace_python/bundled/security/ssrf/plugin.toml +9 -0
- coretrace_python/bundled/security/ssrf/ssrf.py +17 -0
- coretrace_python/bundled/security/xss/plugin.toml +9 -0
- coretrace_python/bundled/security/xss/xss.py +17 -0
- coretrace_python/bundled/syntax/dangerous_eval/dangerous_eval.py +19 -0
- coretrace_python/bundled/syntax/dangerous_eval/plugin.toml +9 -0
- coretrace_python/bundled/syntax/flask_debug/flask_debug.py +63 -0
- coretrace_python/bundled/syntax/flask_debug/plugin.toml +9 -0
- coretrace_python/bundled/syntax/missing_timeout/missing_timeout.py +51 -0
- coretrace_python/bundled/syntax/missing_timeout/plugin.toml +9 -0
- coretrace_python/bundled/syntax/weak_crypto/plugin.toml +9 -0
- coretrace_python/bundled/syntax/weak_crypto/weak_crypto.py +19 -0
- coretrace_python/cache.py +310 -0
- coretrace_python/cfg/__init__.py +46 -0
- coretrace_python/cfg/builder.py +589 -0
- coretrace_python/cfg/dominance.py +183 -0
- coretrace_python/cfg/model.py +166 -0
- coretrace_python/cli.py +216 -0
- coretrace_python/dataflow/__init__.py +29 -0
- coretrace_python/dataflow/lattice.py +78 -0
- coretrace_python/dataflow/solver.py +96 -0
- coretrace_python/dependency/__init__.py +44 -0
- coretrace_python/dependency/advisories.py +168 -0
- coretrace_python/dependency/correlation.py +87 -0
- coretrace_python/dependency/graph.py +274 -0
- coretrace_python/dependency/policy.py +65 -0
- coretrace_python/dependency/sbom.py +65 -0
- coretrace_python/engine.py +688 -0
- coretrace_python/findings/__init__.py +13 -0
- coretrace_python/findings/coverage.py +45 -0
- coretrace_python/findings/model.py +49 -0
- coretrace_python/findings/refutation.py +427 -0
- coretrace_python/frontend/__init__.py +18 -0
- coretrace_python/frontend/ast_adapter.py +447 -0
- coretrace_python/frontend/parser.py +23 -0
- coretrace_python/hir/__init__.py +5 -0
- coretrace_python/hir/nodes.py +584 -0
- coretrace_python/hir/visitors.py +36 -0
- coretrace_python/interprocedural/__init__.py +49 -0
- coretrace_python/interprocedural/callgraph.py +291 -0
- coretrace_python/interprocedural/modulegraph.py +218 -0
- coretrace_python/interprocedural/summaries.py +463 -0
- coretrace_python/ir/__init__.py +5 -0
- coretrace_python/ir/defuse.py +74 -0
- coretrace_python/ir/lowering.py +575 -0
- coretrace_python/ir/model.py +481 -0
- coretrace_python/ir/printer.py +201 -0
- coretrace_python/ir/ssa.py +277 -0
- coretrace_python/plugins/__init__.py +58 -0
- coretrace_python/plugins/api.py +153 -0
- coretrace_python/plugins/detectors.py +114 -0
- coretrace_python/plugins/loader.py +84 -0
- coretrace_python/plugins/manifest.py +112 -0
- coretrace_python/plugins/registry.py +34 -0
- coretrace_python/plugins/secrets.py +330 -0
- coretrace_python/reporters/__init__.py +22 -0
- coretrace_python/reporters/json_format.py +48 -0
- coretrace_python/reporters/report.py +23 -0
- coretrace_python/reporters/sarif.py +70 -0
- coretrace_python/reporters/text.py +24 -0
- coretrace_python/semantic/__init__.py +9 -0
- coretrace_python/semantic/identity.py +39 -0
- coretrace_python/semantic/imports.py +131 -0
- coretrace_python/semantic/scopes.py +473 -0
- coretrace_python/semantic/symbols.py +87 -0
- coretrace_python/source/__init__.py +13 -0
- coretrace_python/source/manager.py +81 -0
- coretrace_python/source/model.py +57 -0
- coretrace_python/taint/__init__.py +55 -0
- coretrace_python/taint/engine.py +800 -0
- coretrace_python/taint/models.py +317 -0
- coretrace_python/taint/routes.py +99 -0
- coretrace_python_analyzer-0.1.0.dist-info/METADATA +74 -0
- coretrace_python_analyzer-0.1.0.dist-info/RECORD +126 -0
- coretrace_python_analyzer-0.1.0.dist-info/WHEEL +4 -0
- coretrace_python_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Intra-module call graph (architecture §20).
|
|
2
|
+
|
|
3
|
+
Every call site of every analysable function resolves, through the SSA form, to a
|
|
4
|
+
``KnownFunction`` defined at module level, an ``ExternalSymbol`` reached through imports
|
|
5
|
+
or builtins, or ``UnknownTarget`` (parameters, attributes, methods) until type inference
|
|
6
|
+
and framework models narrow it down.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from typing import ClassVar
|
|
15
|
+
|
|
16
|
+
from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis
|
|
17
|
+
from coretrace_python.cfg import CFGError
|
|
18
|
+
from coretrace_python.hir import nodes
|
|
19
|
+
from coretrace_python.ir.lowering import LoweringError, analyzable_functions, qualified_name
|
|
20
|
+
from coretrace_python.ir.model import (
|
|
21
|
+
Call,
|
|
22
|
+
FunctionIR,
|
|
23
|
+
GetAttr,
|
|
24
|
+
GetItem,
|
|
25
|
+
Global,
|
|
26
|
+
MakeFunction,
|
|
27
|
+
Symbol,
|
|
28
|
+
Value,
|
|
29
|
+
WithEnter,
|
|
30
|
+
)
|
|
31
|
+
from coretrace_python.ir.ssa import SSAAnalysis
|
|
32
|
+
from coretrace_python.semantic.scopes import BindingKind, ScopeAnalysis, ScopeTable
|
|
33
|
+
from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId, SymbolTable
|
|
34
|
+
from coretrace_python.source import SourceSpan
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class KnownFunction:
|
|
39
|
+
name: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ExternalSymbol:
|
|
44
|
+
symbol: SymbolId
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class UnknownTarget:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
Target = KnownFunction | ExternalSymbol | UnknownTarget
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class CallSite:
|
|
57
|
+
caller: str
|
|
58
|
+
location: SourceSpan
|
|
59
|
+
target: Target
|
|
60
|
+
arguments: int
|
|
61
|
+
keywords: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CallGraph:
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
definitions: Mapping[str, nodes.Function],
|
|
68
|
+
sites: Mapping[str, tuple[CallSite, ...]],
|
|
69
|
+
unsupported: frozenset[str],
|
|
70
|
+
symbols: Mapping[str, Mapping[Value, SymbolId]] | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._symbols = {name: MappingProxyType(dict(found)) for name, found in (symbols or {}).items()}
|
|
73
|
+
self.definitions: Mapping[str, nodes.Function] = MappingProxyType(dict(definitions))
|
|
74
|
+
# A graph rebuilt from cached call sites has sites but no definitions.
|
|
75
|
+
self.functions = tuple(dict.fromkeys((*definitions, *sites)))
|
|
76
|
+
self._names = {function.span: name for name, function in definitions.items()}
|
|
77
|
+
self.unsupported = unsupported
|
|
78
|
+
self._sites = MappingProxyType(dict(sites))
|
|
79
|
+
self._targets = {
|
|
80
|
+
(site.caller, site.location): site.target for found in sites.values() for site in found
|
|
81
|
+
}
|
|
82
|
+
callers: dict[str, set[str]] = {name: set() for name in definitions}
|
|
83
|
+
for found in sites.values():
|
|
84
|
+
for site in found:
|
|
85
|
+
if isinstance(site.target, KnownFunction):
|
|
86
|
+
callers.setdefault(site.target.name, set()).add(site.caller)
|
|
87
|
+
self._callers = {name: frozenset(found) for name, found in callers.items()}
|
|
88
|
+
|
|
89
|
+
def name_of(self, function: nodes.Function) -> str:
|
|
90
|
+
return self._names[function.span]
|
|
91
|
+
|
|
92
|
+
def symbols(self, name: str) -> Mapping[Value, SymbolId]:
|
|
93
|
+
"""Values of ``name`` that denote a symbol, including derived call-chain symbols."""
|
|
94
|
+
|
|
95
|
+
return self._symbols.get(name, MappingProxyType({}))
|
|
96
|
+
|
|
97
|
+
def sites(self, caller: str) -> tuple[CallSite, ...]:
|
|
98
|
+
return self._sites.get(caller, ())
|
|
99
|
+
|
|
100
|
+
def target_at(self, caller: str, location: SourceSpan) -> Target:
|
|
101
|
+
return self._targets.get((caller, location), UnknownTarget())
|
|
102
|
+
|
|
103
|
+
def callees(self, caller: str) -> frozenset[str]:
|
|
104
|
+
return frozenset(
|
|
105
|
+
site.target.name for site in self.sites(caller) if isinstance(site.target, KnownFunction)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def callers(self, name: str) -> frozenset[str]:
|
|
109
|
+
return self._callers.get(name, frozenset())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def derive_symbols(
|
|
113
|
+
function: FunctionIR, initial: Mapping[Value, SymbolId] | None = None
|
|
114
|
+
) -> dict[Value, SymbolId]:
|
|
115
|
+
"""Symbols of values: ``Symbol`` results, attributes and items of symbol values,
|
|
116
|
+
results of calling a symbol (``sqlite3.connect(p)`` denotes ``python.sqlite3.connect``)
|
|
117
|
+
and the values a ``with`` on such a result binds. Known functions derive nothing;
|
|
118
|
+
parameters only through ``initial``, their annotated classes."""
|
|
119
|
+
|
|
120
|
+
symbols: dict[Value, SymbolId] = dict(initial or {})
|
|
121
|
+
changed = True
|
|
122
|
+
while changed:
|
|
123
|
+
changed = False
|
|
124
|
+
for block in function.blocks:
|
|
125
|
+
for instruction in block.instructions:
|
|
126
|
+
if instruction.result is None or instruction.result in symbols:
|
|
127
|
+
continue
|
|
128
|
+
symbol: SymbolId | None = None
|
|
129
|
+
if isinstance(instruction, Symbol):
|
|
130
|
+
symbol = instruction.symbol_id
|
|
131
|
+
elif isinstance(instruction, GetAttr) and instruction.object in symbols:
|
|
132
|
+
symbol = symbols[instruction.object].attribute(instruction.attribute)
|
|
133
|
+
elif isinstance(instruction, GetItem) and instruction.object in symbols:
|
|
134
|
+
# An item of a symbol-denoted container (``request.files['f']``)
|
|
135
|
+
# carries the container's symbol, so its methods resolve.
|
|
136
|
+
symbol = symbols[instruction.object]
|
|
137
|
+
elif isinstance(instruction, Call | WithEnter):
|
|
138
|
+
origin = (
|
|
139
|
+
instruction.callee if isinstance(instruction, Call) else instruction.context
|
|
140
|
+
)
|
|
141
|
+
symbol = symbols.get(origin)
|
|
142
|
+
if symbol is not None:
|
|
143
|
+
symbols[instruction.result] = symbol
|
|
144
|
+
changed = True
|
|
145
|
+
return symbols
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_targets(
|
|
149
|
+
function: FunctionIR,
|
|
150
|
+
scopes: ScopeTable,
|
|
151
|
+
known: frozenset[str],
|
|
152
|
+
parameters: Mapping[Value, SymbolId] | None = None,
|
|
153
|
+
nested: frozenset[str] = frozenset(),
|
|
154
|
+
classes: Mapping[str, frozenset[str]] | None = None,
|
|
155
|
+
owner: str | None = None,
|
|
156
|
+
typed: Mapping[Value, str] | None = None,
|
|
157
|
+
) -> tuple[dict[Value, Target], dict[Value, SymbolId]]:
|
|
158
|
+
"""Map every callee value of ``function`` to its target, and every symbol value.
|
|
159
|
+
``nested`` names the functions defined inside this one (``outer.inner``);
|
|
160
|
+
``classes`` maps the module's classes to their methods, ``owner`` is the class of a
|
|
161
|
+
method and ``typed`` the parameters annotated with a module class. ``App(x)`` is a
|
|
162
|
+
call to ``App.__init__``, ``app.run()`` and ``self.run()`` calls to ``App.run``."""
|
|
163
|
+
|
|
164
|
+
module = scopes.module_scope
|
|
165
|
+
symbols = derive_symbols(function, parameters)
|
|
166
|
+
targets: dict[Value, Target] = {
|
|
167
|
+
value: ExternalSymbol(symbol) for value, symbol in symbols.items()
|
|
168
|
+
}
|
|
169
|
+
classes = classes or {}
|
|
170
|
+
instance_of: dict[Value, str] = dict(typed or {})
|
|
171
|
+
if owner is not None and function.parameters:
|
|
172
|
+
instance_of[function.parameters[0]] = owner
|
|
173
|
+
defs = {i.result: i for block in function.blocks for i in block.instructions if i.result}
|
|
174
|
+
for block in function.blocks:
|
|
175
|
+
for instruction in block.instructions:
|
|
176
|
+
if isinstance(instruction, MakeFunction):
|
|
177
|
+
qualified = f"{function.name}.{instruction.name}"
|
|
178
|
+
if qualified in nested:
|
|
179
|
+
targets[instruction.result] = KnownFunction(qualified)
|
|
180
|
+
elif isinstance(instruction, Global):
|
|
181
|
+
binding = module.bindings.get(instruction.name)
|
|
182
|
+
if binding is None:
|
|
183
|
+
continue
|
|
184
|
+
if binding.kind is BindingKind.FUNCTION and instruction.name in known:
|
|
185
|
+
targets[instruction.result] = KnownFunction(instruction.name)
|
|
186
|
+
elif binding.kind is BindingKind.CLASS and "__init__" in classes.get(instruction.name, ()):
|
|
187
|
+
targets[instruction.result] = KnownFunction(f"{instruction.name}.__init__")
|
|
188
|
+
elif isinstance(instruction, Call):
|
|
189
|
+
callee = defs.get(instruction.callee)
|
|
190
|
+
if isinstance(callee, Global) and callee.name in classes:
|
|
191
|
+
instance_of[instruction.result] = callee.name
|
|
192
|
+
elif isinstance(instruction, GetAttr) and instruction.object in instance_of:
|
|
193
|
+
method = f"{instance_of[instruction.object]}.{instruction.attribute}"
|
|
194
|
+
if method in nested:
|
|
195
|
+
targets[instruction.result] = KnownFunction(method)
|
|
196
|
+
return targets, symbols
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _annotated(
|
|
200
|
+
function: nodes.Function, ssa: FunctionIR, scopes: ScopeTable, table: SymbolTable
|
|
201
|
+
) -> dict[Value, SymbolId]:
|
|
202
|
+
"""Parameters annotated with a resolvable class denote that class (``db: Session``)."""
|
|
203
|
+
|
|
204
|
+
scope = scopes.scope_for(function)
|
|
205
|
+
enclosing = scope.parent if scope.parent is not None else scope.id
|
|
206
|
+
found: dict[Value, SymbolId] = {}
|
|
207
|
+
# Captured variables follow the explicit parameters and carry no annotation.
|
|
208
|
+
for value, parameter in zip(ssa.parameters, function.parameters, strict=False):
|
|
209
|
+
if parameter.annotation is None:
|
|
210
|
+
continue
|
|
211
|
+
symbol = table.resolve_expression(enclosing, parameter.annotation)
|
|
212
|
+
if symbol is not None:
|
|
213
|
+
found[value] = symbol
|
|
214
|
+
return found
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _typed_with_module_classes(
|
|
218
|
+
function: nodes.Function, ssa: FunctionIR, classes: Mapping[str, frozenset[str]]
|
|
219
|
+
) -> dict[Value, str]:
|
|
220
|
+
"""Parameters annotated with a class of this module (``a: App``)."""
|
|
221
|
+
|
|
222
|
+
found: dict[Value, str] = {}
|
|
223
|
+
for value, parameter in zip(ssa.parameters, function.parameters, strict=False):
|
|
224
|
+
annotation = parameter.annotation
|
|
225
|
+
if isinstance(annotation, nodes.Name) and annotation.identifier in classes:
|
|
226
|
+
found[value] = annotation.identifier
|
|
227
|
+
return found
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class CallGraphAnalysis(Analysis[CallGraph]):
|
|
231
|
+
name: ClassVar[str] = "interprocedural.callgraph"
|
|
232
|
+
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({SSAAnalysis, ScopeAnalysis, SymbolAnalysis})
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def compute(cls, ctx: AnalysisContext) -> CallGraph:
|
|
236
|
+
scopes = ctx.get(ScopeAnalysis)
|
|
237
|
+
table = ctx.get(SymbolAnalysis)
|
|
238
|
+
definitions: dict[str, nodes.Function] = {}
|
|
239
|
+
for function in analyzable_functions(ctx.module):
|
|
240
|
+
# A property and its setter, or a redefinition, share a qualified name; each
|
|
241
|
+
# definition still needs a name of its own.
|
|
242
|
+
base = unique = qualified_name(scopes, function)
|
|
243
|
+
ordinal = 2
|
|
244
|
+
while unique in definitions:
|
|
245
|
+
unique = f"{base}__{ordinal}"
|
|
246
|
+
ordinal += 1
|
|
247
|
+
definitions[unique] = function
|
|
248
|
+
known = frozenset(
|
|
249
|
+
name for name, function in definitions.items() if function in ctx.module.body
|
|
250
|
+
)
|
|
251
|
+
classes = {
|
|
252
|
+
statement.name: frozenset(m.name for m in statement.body if isinstance(m, nodes.Function))
|
|
253
|
+
for statement in ctx.module.body
|
|
254
|
+
if isinstance(statement, nodes.Class)
|
|
255
|
+
}
|
|
256
|
+
sites: dict[str, tuple[CallSite, ...]] = {}
|
|
257
|
+
symbols: dict[str, Mapping[Value, SymbolId]] = {}
|
|
258
|
+
unsupported: set[str] = set()
|
|
259
|
+
for name, function in definitions.items():
|
|
260
|
+
try:
|
|
261
|
+
ssa = ctx.get(SSAAnalysis, function)
|
|
262
|
+
except (LoweringError, CFGError):
|
|
263
|
+
unsupported.add(name)
|
|
264
|
+
sites[name] = ()
|
|
265
|
+
continue
|
|
266
|
+
owner = name.rsplit(".", 1)[0] if "." in name and name.rsplit(".", 1)[0] in classes else None
|
|
267
|
+
targets, symbols[name] = resolve_targets(
|
|
268
|
+
ssa,
|
|
269
|
+
scopes,
|
|
270
|
+
known,
|
|
271
|
+
_annotated(function, ssa, scopes, table),
|
|
272
|
+
frozenset(definitions),
|
|
273
|
+
classes,
|
|
274
|
+
owner,
|
|
275
|
+
_typed_with_module_classes(function, ssa, classes),
|
|
276
|
+
)
|
|
277
|
+
found: list[CallSite] = []
|
|
278
|
+
for block in ssa.blocks:
|
|
279
|
+
for instruction in block.instructions:
|
|
280
|
+
if isinstance(instruction, Call):
|
|
281
|
+
found.append(
|
|
282
|
+
CallSite(
|
|
283
|
+
name,
|
|
284
|
+
instruction.location,
|
|
285
|
+
targets.get(instruction.callee, UnknownTarget()),
|
|
286
|
+
len(instruction.arguments),
|
|
287
|
+
len(instruction.keywords),
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
sites[name] = tuple(found)
|
|
291
|
+
return CallGraph(definitions, sites, frozenset(unsupported), symbols)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Module graph and project-wide summary index (architecture §21).
|
|
2
|
+
|
|
3
|
+
A project is a directory of Python files. Each file is one module named after its
|
|
4
|
+
packages (``app/helpers.py`` is ``app.helpers``); the graph records which project modules
|
|
5
|
+
each module imports. Functions defined in the project get project symbols
|
|
6
|
+
(``python.app.helpers.run``) whose summaries live in a ``SummaryIndex`` that the engine
|
|
7
|
+
provides to every module's manager, so calls into other files are analysed through
|
|
8
|
+
summaries rather than by retaining every module's PyIR.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import MappingProxyType
|
|
17
|
+
|
|
18
|
+
from coretrace_python.hir import nodes
|
|
19
|
+
from coretrace_python.interprocedural.summaries import ProjectSummaries, SummaryIndex
|
|
20
|
+
from coretrace_python.semantic.imports import ImportTable
|
|
21
|
+
from coretrace_python.semantic.symbols import SymbolId
|
|
22
|
+
from coretrace_python.source import SourceFile, SourceManager
|
|
23
|
+
|
|
24
|
+
IGNORED_DIRECTORIES = frozenset({"__pycache__", "node_modules", "venv", "build", "dist", "site-packages"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def discover_files(root: Path, pattern: str = "*") -> tuple[Path, ...]:
|
|
28
|
+
"""Every file matching ``pattern`` under ``root``, skipping hidden and tooling
|
|
29
|
+
directories and virtual environments, recognised by the ``pyvenv.cfg`` at their root
|
|
30
|
+
whatever their name."""
|
|
31
|
+
|
|
32
|
+
found: list[Path] = []
|
|
33
|
+
environments: dict[Path, bool] = {}
|
|
34
|
+
for path in sorted(root.rglob(pattern)):
|
|
35
|
+
if not path.is_file():
|
|
36
|
+
continue
|
|
37
|
+
relative = path.relative_to(root)
|
|
38
|
+
if any(p.startswith(".") or p in IGNORED_DIRECTORIES for p in relative.parts[:-1]):
|
|
39
|
+
continue
|
|
40
|
+
if any(_is_environment(root / Path(*relative.parts[:depth]), environments) for depth in range(1, len(relative.parts))):
|
|
41
|
+
continue
|
|
42
|
+
found.append(path)
|
|
43
|
+
return tuple(found)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def discover_sources(
|
|
47
|
+
root: Path, manager: SourceManager, errors: list[tuple[Path, str]] | None = None
|
|
48
|
+
) -> tuple[SourceFile, ...]:
|
|
49
|
+
"""Load every ``.py`` file under ``root`` (see ``discover_files``). A file that cannot
|
|
50
|
+
be read or decoded is skipped and reported in ``errors``: Python could not import it
|
|
51
|
+
either."""
|
|
52
|
+
|
|
53
|
+
found: list[SourceFile] = []
|
|
54
|
+
for path in discover_files(root, "*.py"):
|
|
55
|
+
try:
|
|
56
|
+
found.append(manager.load_file(path))
|
|
57
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
58
|
+
if errors is None:
|
|
59
|
+
raise
|
|
60
|
+
errors.append((path, str(error)))
|
|
61
|
+
return tuple(found)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_environment(directory: Path, known: dict[Path, bool]) -> bool:
|
|
65
|
+
if directory not in known:
|
|
66
|
+
known[directory] = (directory / "pyvenv.cfg").is_file()
|
|
67
|
+
return known[directory]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def project_symbol(module_name: str, qualified_name: str) -> SymbolId:
|
|
71
|
+
"""The canonical symbol of a project function. A module whose name is not an
|
|
72
|
+
identifier cannot be imported by that name, but its functions still need stable
|
|
73
|
+
symbols: invalid characters become underscores."""
|
|
74
|
+
|
|
75
|
+
module = ".".join(_component(part) for part in module_name.split("."))
|
|
76
|
+
return SymbolId(f"python.{module}.{qualified_name}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _component(part: str) -> str:
|
|
80
|
+
if part.isidentifier():
|
|
81
|
+
return part
|
|
82
|
+
cleaned = "".join(c if (c.isalnum() or c == "_") else "_" for c in part)
|
|
83
|
+
return cleaned if cleaned.isidentifier() else f"_{cleaned}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ModuleGraph:
|
|
88
|
+
_sources: Mapping[str, SourceFile]
|
|
89
|
+
_imports: Mapping[str, frozenset[str]]
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def modules(self) -> tuple[str, ...]:
|
|
93
|
+
return tuple(sorted(self._sources))
|
|
94
|
+
|
|
95
|
+
def source(self, name: str) -> SourceFile:
|
|
96
|
+
return self._sources[name]
|
|
97
|
+
|
|
98
|
+
def imports(self, name: str) -> frozenset[str]:
|
|
99
|
+
return self._imports.get(name, frozenset())
|
|
100
|
+
|
|
101
|
+
def importers(self, name: str) -> frozenset[str]:
|
|
102
|
+
return frozenset(m for m, imported in self._imports.items() if name in imported)
|
|
103
|
+
|
|
104
|
+
def schedule(self) -> tuple[tuple[frozenset[str], ...], ...]:
|
|
105
|
+
"""Strongly connected components in waves: every component of a wave imports,
|
|
106
|
+
outside itself, only components of earlier waves, so one wave can be analysed
|
|
107
|
+
in parallel and a component's imports are final when it starts (§29)."""
|
|
108
|
+
|
|
109
|
+
names = frozenset(self._sources)
|
|
110
|
+
edges = {name: sorted(m for m in self.imports(name) if m in names) for name in names}
|
|
111
|
+
component_of: dict[str, int] = {}
|
|
112
|
+
components: list[frozenset[str]] = []
|
|
113
|
+
index: dict[str, int] = {}
|
|
114
|
+
low: dict[str, int] = {}
|
|
115
|
+
stack: list[str] = []
|
|
116
|
+
|
|
117
|
+
def visit(name: str) -> None:
|
|
118
|
+
index[name] = low[name] = len(index)
|
|
119
|
+
stack.append(name)
|
|
120
|
+
for imported in edges[name]:
|
|
121
|
+
if imported not in index:
|
|
122
|
+
visit(imported)
|
|
123
|
+
low[name] = min(low[name], low[imported])
|
|
124
|
+
elif imported in stack:
|
|
125
|
+
low[name] = min(low[name], index[imported])
|
|
126
|
+
if low[name] == index[name]:
|
|
127
|
+
members: list[str] = []
|
|
128
|
+
while True:
|
|
129
|
+
member = stack.pop()
|
|
130
|
+
members.append(member)
|
|
131
|
+
if member == name:
|
|
132
|
+
break
|
|
133
|
+
for member in members:
|
|
134
|
+
component_of[member] = len(components)
|
|
135
|
+
components.append(frozenset(members))
|
|
136
|
+
|
|
137
|
+
for name in sorted(names):
|
|
138
|
+
if name not in index:
|
|
139
|
+
visit(name)
|
|
140
|
+
|
|
141
|
+
depth: dict[int, int] = {}
|
|
142
|
+
|
|
143
|
+
def level(component: int) -> int:
|
|
144
|
+
if component not in depth:
|
|
145
|
+
below = {
|
|
146
|
+
component_of[imported]
|
|
147
|
+
for member in components[component]
|
|
148
|
+
for imported in edges[member]
|
|
149
|
+
if component_of[imported] != component
|
|
150
|
+
}
|
|
151
|
+
depth[component] = 1 + max((level(c) for c in below), default=-1)
|
|
152
|
+
return depth[component]
|
|
153
|
+
|
|
154
|
+
waves: dict[int, list[frozenset[str]]] = {}
|
|
155
|
+
for number in range(len(components)):
|
|
156
|
+
waves.setdefault(level(number), []).append(components[number])
|
|
157
|
+
return tuple(
|
|
158
|
+
tuple(sorted(waves[wave], key=min)) for wave in sorted(waves)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_module_graph(
|
|
163
|
+
sources: Mapping[str, SourceFile],
|
|
164
|
+
modules: Mapping[str, nodes.Module],
|
|
165
|
+
imports: Mapping[str, ImportTable],
|
|
166
|
+
) -> ModuleGraph:
|
|
167
|
+
"""Edges from each module to the project modules it imports, in any scope."""
|
|
168
|
+
|
|
169
|
+
names = frozenset(sources)
|
|
170
|
+
edges: dict[str, frozenset[str]] = {}
|
|
171
|
+
for name, module in modules.items():
|
|
172
|
+
candidates: set[str] = set()
|
|
173
|
+
dotted_tops: set[str] = set()
|
|
174
|
+
for statement in _statements(module.body):
|
|
175
|
+
if isinstance(statement, nodes.Import):
|
|
176
|
+
for alias in statement.names:
|
|
177
|
+
candidates.add(alias.name)
|
|
178
|
+
if "." in alias.name:
|
|
179
|
+
dotted_tops.add(alias.name.partition(".")[0])
|
|
180
|
+
for symbol in imports[name].all_symbols():
|
|
181
|
+
path = symbol.canonical_name.removeprefix("python.")
|
|
182
|
+
# ``import app.config`` binds ``app``; the statement already named the module.
|
|
183
|
+
if path not in dotted_tops:
|
|
184
|
+
candidates.add(path)
|
|
185
|
+
# The most specific project module each import names; ``app.helpers`` implies
|
|
186
|
+
# the ``app`` package, which is not an edge worth recording.
|
|
187
|
+
found = {
|
|
188
|
+
next((m for m in _prefixes(candidate) if m in names), None) for candidate in candidates
|
|
189
|
+
}
|
|
190
|
+
edges[name] = frozenset(m for m in found if m is not None and m != name)
|
|
191
|
+
return ModuleGraph(MappingProxyType(dict(sources)), MappingProxyType(edges))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _prefixes(dotted: str) -> list[str]:
|
|
195
|
+
parts = dotted.split(".")
|
|
196
|
+
return [".".join(parts[:length]) for length in range(len(parts), 0, -1)]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _statements(body: Iterable[nodes.Statement]) -> Iterable[nodes.Statement]:
|
|
200
|
+
for statement in body:
|
|
201
|
+
yield statement
|
|
202
|
+
for attribute in ("body", "orelse", "finalbody"):
|
|
203
|
+
nested = getattr(statement, attribute, None)
|
|
204
|
+
if isinstance(nested, tuple):
|
|
205
|
+
yield from _statements(nested)
|
|
206
|
+
if isinstance(statement, nodes.Try):
|
|
207
|
+
for handler in statement.handlers:
|
|
208
|
+
yield from _statements(handler.body)
|
|
209
|
+
|
|
210
|
+
__all__ = [
|
|
211
|
+
"IGNORED_DIRECTORIES",
|
|
212
|
+
"ModuleGraph",
|
|
213
|
+
"ProjectSummaries",
|
|
214
|
+
"SummaryIndex",
|
|
215
|
+
"build_module_graph",
|
|
216
|
+
"discover_sources",
|
|
217
|
+
"project_symbol",
|
|
218
|
+
]
|