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.
Files changed (126) hide show
  1. coretrace_python/__init__.py +4 -0
  2. coretrace_python/__main__.py +4 -0
  3. coretrace_python/abstract/__init__.py +45 -0
  4. coretrace_python/abstract/constants.py +226 -0
  5. coretrace_python/abstract/heap.py +252 -0
  6. coretrace_python/abstract/ranges.py +285 -0
  7. coretrace_python/abstract/values.py +56 -0
  8. coretrace_python/analysis/__init__.py +31 -0
  9. coretrace_python/analysis/manager.py +165 -0
  10. coretrace_python/analysis/provider.py +73 -0
  11. coretrace_python/bundled/dependency/dependency_policy/dependency_policy.py +46 -0
  12. coretrace_python/bundled/dependency/dependency_policy/plugin.toml +9 -0
  13. coretrace_python/bundled/dependency/reachable_vulnerability/plugin.toml +9 -0
  14. coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +56 -0
  15. coretrace_python/bundled/dependency/sample_advisories/plugin.toml +9 -0
  16. coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py +109 -0
  17. coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml +9 -0
  18. coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +43 -0
  19. coretrace_python/bundled/models/cli/cli_models.py +35 -0
  20. coretrace_python/bundled/models/cli/plugin.toml +9 -0
  21. coretrace_python/bundled/models/credentials/credential_models.py +43 -0
  22. coretrace_python/bundled/models/credentials/plugin.toml +9 -0
  23. coretrace_python/bundled/models/django/django_models.py +123 -0
  24. coretrace_python/bundled/models/django/plugin.toml +9 -0
  25. coretrace_python/bundled/models/fastapi/fastapi_models.py +29 -0
  26. coretrace_python/bundled/models/fastapi/plugin.toml +9 -0
  27. coretrace_python/bundled/models/flask/flask_models.py +53 -0
  28. coretrace_python/bundled/models/flask/plugin.toml +9 -0
  29. coretrace_python/bundled/models/http_clients/http_client_models.py +43 -0
  30. coretrace_python/bundled/models/http_clients/plugin.toml +9 -0
  31. coretrace_python/bundled/models/python_stdlib/plugin.toml +9 -0
  32. coretrace_python/bundled/models/python_stdlib/python_stdlib.py +68 -0
  33. coretrace_python/bundled/models/sqlalchemy/plugin.toml +9 -0
  34. coretrace_python/bundled/models/sqlalchemy/sqlalchemy_models.py +47 -0
  35. coretrace_python/bundled/secrets/config_secrets/config_secrets.py +38 -0
  36. coretrace_python/bundled/secrets/config_secrets/plugin.toml +9 -0
  37. coretrace_python/bundled/secrets/hardcoded_secrets/hardcoded_secrets.py +19 -0
  38. coretrace_python/bundled/secrets/hardcoded_secrets/plugin.toml +9 -0
  39. coretrace_python/bundled/security/command_injection/command_injection.py +17 -0
  40. coretrace_python/bundled/security/command_injection/plugin.toml +9 -0
  41. coretrace_python/bundled/security/insecure_deserialization/insecure_deserialization.py +17 -0
  42. coretrace_python/bundled/security/insecure_deserialization/plugin.toml +9 -0
  43. coretrace_python/bundled/security/open_redirect/open_redirect.py +17 -0
  44. coretrace_python/bundled/security/open_redirect/plugin.toml +9 -0
  45. coretrace_python/bundled/security/path_traversal/path_traversal.py +17 -0
  46. coretrace_python/bundled/security/path_traversal/plugin.toml +9 -0
  47. coretrace_python/bundled/security/plaintext_credentials/plaintext_credentials.py +21 -0
  48. coretrace_python/bundled/security/plaintext_credentials/plugin.toml +9 -0
  49. coretrace_python/bundled/security/sql_injection/plugin.toml +9 -0
  50. coretrace_python/bundled/security/sql_injection/sql_injection.py +17 -0
  51. coretrace_python/bundled/security/ssrf/plugin.toml +9 -0
  52. coretrace_python/bundled/security/ssrf/ssrf.py +17 -0
  53. coretrace_python/bundled/security/xss/plugin.toml +9 -0
  54. coretrace_python/bundled/security/xss/xss.py +17 -0
  55. coretrace_python/bundled/syntax/dangerous_eval/dangerous_eval.py +19 -0
  56. coretrace_python/bundled/syntax/dangerous_eval/plugin.toml +9 -0
  57. coretrace_python/bundled/syntax/flask_debug/flask_debug.py +63 -0
  58. coretrace_python/bundled/syntax/flask_debug/plugin.toml +9 -0
  59. coretrace_python/bundled/syntax/missing_timeout/missing_timeout.py +51 -0
  60. coretrace_python/bundled/syntax/missing_timeout/plugin.toml +9 -0
  61. coretrace_python/bundled/syntax/weak_crypto/plugin.toml +9 -0
  62. coretrace_python/bundled/syntax/weak_crypto/weak_crypto.py +19 -0
  63. coretrace_python/cache.py +310 -0
  64. coretrace_python/cfg/__init__.py +46 -0
  65. coretrace_python/cfg/builder.py +589 -0
  66. coretrace_python/cfg/dominance.py +183 -0
  67. coretrace_python/cfg/model.py +166 -0
  68. coretrace_python/cli.py +216 -0
  69. coretrace_python/dataflow/__init__.py +29 -0
  70. coretrace_python/dataflow/lattice.py +78 -0
  71. coretrace_python/dataflow/solver.py +96 -0
  72. coretrace_python/dependency/__init__.py +44 -0
  73. coretrace_python/dependency/advisories.py +168 -0
  74. coretrace_python/dependency/correlation.py +87 -0
  75. coretrace_python/dependency/graph.py +274 -0
  76. coretrace_python/dependency/policy.py +65 -0
  77. coretrace_python/dependency/sbom.py +65 -0
  78. coretrace_python/engine.py +688 -0
  79. coretrace_python/findings/__init__.py +13 -0
  80. coretrace_python/findings/coverage.py +45 -0
  81. coretrace_python/findings/model.py +49 -0
  82. coretrace_python/findings/refutation.py +427 -0
  83. coretrace_python/frontend/__init__.py +18 -0
  84. coretrace_python/frontend/ast_adapter.py +447 -0
  85. coretrace_python/frontend/parser.py +23 -0
  86. coretrace_python/hir/__init__.py +5 -0
  87. coretrace_python/hir/nodes.py +584 -0
  88. coretrace_python/hir/visitors.py +36 -0
  89. coretrace_python/interprocedural/__init__.py +49 -0
  90. coretrace_python/interprocedural/callgraph.py +291 -0
  91. coretrace_python/interprocedural/modulegraph.py +218 -0
  92. coretrace_python/interprocedural/summaries.py +463 -0
  93. coretrace_python/ir/__init__.py +5 -0
  94. coretrace_python/ir/defuse.py +74 -0
  95. coretrace_python/ir/lowering.py +575 -0
  96. coretrace_python/ir/model.py +481 -0
  97. coretrace_python/ir/printer.py +201 -0
  98. coretrace_python/ir/ssa.py +277 -0
  99. coretrace_python/plugins/__init__.py +58 -0
  100. coretrace_python/plugins/api.py +153 -0
  101. coretrace_python/plugins/detectors.py +114 -0
  102. coretrace_python/plugins/loader.py +84 -0
  103. coretrace_python/plugins/manifest.py +112 -0
  104. coretrace_python/plugins/registry.py +34 -0
  105. coretrace_python/plugins/secrets.py +330 -0
  106. coretrace_python/reporters/__init__.py +22 -0
  107. coretrace_python/reporters/json_format.py +48 -0
  108. coretrace_python/reporters/report.py +23 -0
  109. coretrace_python/reporters/sarif.py +70 -0
  110. coretrace_python/reporters/text.py +24 -0
  111. coretrace_python/semantic/__init__.py +9 -0
  112. coretrace_python/semantic/identity.py +39 -0
  113. coretrace_python/semantic/imports.py +131 -0
  114. coretrace_python/semantic/scopes.py +473 -0
  115. coretrace_python/semantic/symbols.py +87 -0
  116. coretrace_python/source/__init__.py +13 -0
  117. coretrace_python/source/manager.py +81 -0
  118. coretrace_python/source/model.py +57 -0
  119. coretrace_python/taint/__init__.py +55 -0
  120. coretrace_python/taint/engine.py +800 -0
  121. coretrace_python/taint/models.py +317 -0
  122. coretrace_python/taint/routes.py +99 -0
  123. coretrace_python_analyzer-0.1.0.dist-info/METADATA +74 -0
  124. coretrace_python_analyzer-0.1.0.dist-info/RECORD +126 -0
  125. coretrace_python_analyzer-0.1.0.dist-info/WHEEL +4 -0
  126. coretrace_python_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,39 @@
1
+ """Canonical symbol identities (architecture §4.3).
2
+
3
+ A ``SymbolId`` names a Python object independently of how a file spells it: every way
4
+ of importing ``os.system`` yields ``python.os.system``. Framework models may live in
5
+ their own namespaces, such as ``flask.request.args``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True, order=True)
14
+ class SymbolId:
15
+ """A stable, import-style-independent, namespace-qualified dotted name."""
16
+
17
+ canonical_name: str
18
+
19
+ def __post_init__(self) -> None:
20
+ components = self.canonical_name.split(".")
21
+ if len(components) < 2:
22
+ raise ValueError("a canonical symbol needs a namespace and a path")
23
+ if not all(component.isidentifier() for component in components):
24
+ raise ValueError(f"invalid canonical symbol: {self.canonical_name!r}")
25
+
26
+ @classmethod
27
+ def from_python_path(cls, path: str) -> SymbolId:
28
+ normalized_path = path.removeprefix("python.")
29
+ if not normalized_path or normalized_path.startswith("."):
30
+ raise ValueError("a Python symbol path cannot be empty")
31
+ return cls(f"python.{normalized_path}")
32
+
33
+ def attribute(self, name: str) -> SymbolId:
34
+ if not name or "." in name:
35
+ raise ValueError("an attribute name must be one non-empty component")
36
+ return SymbolId(f"{self.canonical_name}.{name}")
37
+
38
+ def __str__(self) -> str:
39
+ return self.canonical_name
@@ -0,0 +1,131 @@
1
+ """Import analysis: per-scope bindings from import statements (architecture §4.2).
2
+
3
+ No module is ever imported. Each binding maps the local name an import introduces to
4
+ the canonical path of the object it refers to, resolved statically from the statement
5
+ and, for relative imports, from the importing module's dotted name.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from types import MappingProxyType
12
+ from typing import ClassVar
13
+
14
+ from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis
15
+ from coretrace_python.hir import nodes
16
+ from coretrace_python.semantic.identity import SymbolId
17
+ from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeId, ScopeTable
18
+
19
+ _NO_BINDINGS: Mapping[str, SymbolId] = MappingProxyType({})
20
+
21
+
22
+ class ImportResolutionError(Exception):
23
+ """A source-located import that cannot be resolved statically."""
24
+
25
+
26
+ class ImportTable:
27
+ """Immutable import bindings and wildcard imports, keyed by scope."""
28
+
29
+ def __init__(
30
+ self,
31
+ bindings: Mapping[ScopeId, Mapping[str, SymbolId]],
32
+ wildcards: Mapping[ScopeId, tuple[SymbolId, ...]],
33
+ ) -> None:
34
+ self._bindings = MappingProxyType(
35
+ {scope_id: MappingProxyType(dict(found)) for scope_id, found in bindings.items()}
36
+ )
37
+ self._wildcards = MappingProxyType(dict(wildcards))
38
+
39
+ def bindings(self, scope_id: ScopeId) -> Mapping[str, SymbolId]:
40
+ return self._bindings.get(scope_id, _NO_BINDINGS)
41
+
42
+ def wildcards(self, scope_id: ScopeId) -> tuple[SymbolId, ...]:
43
+ return self._wildcards.get(scope_id, ())
44
+
45
+ def all_symbols(self) -> tuple[SymbolId, ...]:
46
+ """Every symbol any import in the module refers to, in every scope."""
47
+
48
+ found: list[SymbolId] = []
49
+ for bindings in self._bindings.values():
50
+ found.extend(bindings.values())
51
+ for wildcards in self._wildcards.values():
52
+ found.extend(wildcards)
53
+ return tuple(found)
54
+
55
+
56
+ class _Collector:
57
+ def __init__(self, module: nodes.Module, scopes: ScopeTable) -> None:
58
+ self.package = module.name if module.is_package else module.name.rpartition(".")[0]
59
+ self.scopes = scopes
60
+ self.bindings: dict[ScopeId, dict[str, SymbolId]] = {}
61
+ self.wildcards: dict[ScopeId, list[SymbolId]] = {}
62
+ self.body(module.body, scopes.module_scope.id)
63
+
64
+ def body(self, statements: tuple[nodes.Statement, ...], scope_id: ScopeId) -> None:
65
+ for statement in statements:
66
+ if isinstance(statement, nodes.Import):
67
+ for alias in statement.names:
68
+ # ``import a.b.c`` binds ``a``; ``import a.b.c as d`` binds the full path.
69
+ top_level = alias.name.partition(".")[0]
70
+ if alias.as_name is None:
71
+ self.bind(scope_id, top_level, top_level)
72
+ else:
73
+ self.bind(scope_id, alias.as_name, alias.name)
74
+ elif isinstance(statement, nodes.ImportFrom):
75
+ base = self.base_module(statement)
76
+ for alias in statement.names:
77
+ if alias.name == "*":
78
+ self.wildcards.setdefault(scope_id, []).append(
79
+ SymbolId.from_python_path(base)
80
+ )
81
+ else:
82
+ self.bind(scope_id, alias.as_name or alias.name, f"{base}.{alias.name}")
83
+ elif isinstance(statement, (nodes.Function, nodes.Class)):
84
+ self.body(statement.body, self.scopes.scope_for(statement).id)
85
+ elif isinstance(statement, nodes.If):
86
+ self.body(statement.body, scope_id)
87
+ self.body(statement.orelse, scope_id)
88
+ elif isinstance(statement, (nodes.While, nodes.For, nodes.With)):
89
+ self.body(statement.body, scope_id)
90
+ elif isinstance(statement, nodes.Try):
91
+ self.body(statement.body, scope_id)
92
+ for handler in statement.handlers:
93
+ self.body(handler.body, scope_id)
94
+ self.body(statement.orelse, scope_id)
95
+ self.body(statement.finalbody, scope_id)
96
+
97
+ def base_module(self, statement: nodes.ImportFrom) -> str:
98
+ if not statement.level:
99
+ assert statement.module is not None, "absolute import without a module"
100
+ return statement.module
101
+ parts = self.package.split(".") if self.package else []
102
+ kept = len(parts) - (statement.level - 1)
103
+ if kept < 1:
104
+ raise ImportResolutionError(
105
+ f"{statement.span.display()}: relative import beyond top-level package"
106
+ )
107
+ base = ".".join(parts[:kept])
108
+ return f"{base}.{statement.module}" if statement.module else base
109
+
110
+ def bind(self, scope_id: ScopeId, local_name: str, canonical_path: str) -> None:
111
+ symbol = SymbolId.from_python_path(canonical_path)
112
+ self.bindings.setdefault(scope_id, {})[local_name] = symbol
113
+
114
+
115
+ def analyze_imports(module: nodes.Module, scopes: ScopeTable) -> ImportTable:
116
+ """Collect every import binding of ``module`` without importing anything."""
117
+
118
+ collector = _Collector(module, scopes)
119
+ return ImportTable(
120
+ collector.bindings,
121
+ {scope_id: tuple(found) for scope_id, found in collector.wildcards.items()},
122
+ )
123
+
124
+
125
+ class ImportAnalysis(Analysis[ImportTable]):
126
+ name: ClassVar[str] = "semantic.imports"
127
+ requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({ScopeAnalysis})
128
+
129
+ @classmethod
130
+ def compute(cls, ctx: AnalysisContext) -> ImportTable:
131
+ return analyze_imports(ctx.module, ctx.get(ScopeAnalysis))
@@ -0,0 +1,473 @@
1
+ """Lexical scope analysis over PyHIR (architecture §4.1).
2
+
3
+ Python decides where a name lives per scope, not per statement: any binding anywhere in
4
+ a function makes the name local to the whole function, ``global`` and ``nonlocal``
5
+ redirect a name to another scope, class bodies are invisible to the functions they
6
+ contain, and comprehensions get a scope of their own. The analysis therefore runs in
7
+ two passes: collect every binding of every scope, then resolve names against the
8
+ finished tree.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ from types import MappingProxyType
17
+ from typing import ClassVar
18
+
19
+ from coretrace_python.analysis import Analysis, AnalysisContext
20
+ from coretrace_python.hir import nodes
21
+ from coretrace_python.source import SourceSpan
22
+
23
+
24
+ class ScopeKind(Enum):
25
+ MODULE = "module"
26
+ FUNCTION = "function"
27
+ CLASS = "class"
28
+ COMPREHENSION = "comprehension"
29
+
30
+
31
+ class BindingKind(Enum):
32
+ PARAMETER = "parameter"
33
+ LOCAL = "local"
34
+ IMPORT = "import"
35
+ FUNCTION = "function"
36
+ CLASS = "class"
37
+ GLOBAL = "global"
38
+ NONLOCAL = "nonlocal"
39
+
40
+
41
+ class ResolutionKind(Enum):
42
+ LOCAL = "local"
43
+ FREE = "free"
44
+ GLOBAL = "global"
45
+ UNBOUND = "unbound"
46
+
47
+
48
+ _REDIRECTIONS = frozenset({BindingKind.GLOBAL, BindingKind.NONLOCAL})
49
+
50
+
51
+ class ScopeError(Exception):
52
+ """A source-located scoping rule violation, such as an invalid ``nonlocal``."""
53
+
54
+
55
+ @dataclass(frozen=True, order=True)
56
+ class ScopeId:
57
+ """Stable identity of a scope: source ID, then ``name#ordinal`` per nesting level."""
58
+
59
+ value: str
60
+
61
+ def child(self, name: str, ordinal: int) -> ScopeId:
62
+ return ScopeId(f"{self.value}::{name}#{ordinal}")
63
+
64
+ def __str__(self) -> str:
65
+ return self.value
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Binding:
70
+ name: str
71
+ kind: BindingKind
72
+ span: SourceSpan
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Scope:
77
+ id: ScopeId
78
+ kind: ScopeKind
79
+ name: str
80
+ parent: ScopeId | None
81
+ span: SourceSpan
82
+ bindings: Mapping[str, Binding]
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Resolution:
87
+ kind: ResolutionKind
88
+ scope: ScopeId | None
89
+
90
+
91
+ class ScopeTable:
92
+ """Immutable scope tree with Python name resolution."""
93
+
94
+ def __init__(self, scopes: tuple[Scope, ...], spans: Mapping[SourceSpan, ScopeId]) -> None:
95
+ self._scopes: Mapping[ScopeId, Scope] = MappingProxyType({s.id: s for s in scopes})
96
+ children: dict[ScopeId, list[Scope]] = {scope.id: [] for scope in scopes}
97
+ for scope in scopes:
98
+ if scope.parent is not None:
99
+ children[scope.parent].append(scope)
100
+ self._children: Mapping[ScopeId, tuple[Scope, ...]] = MappingProxyType(
101
+ {scope_id: tuple(found) for scope_id, found in children.items()}
102
+ )
103
+ self._spans = MappingProxyType(dict(spans))
104
+ self._module = scopes[0]
105
+
106
+ @property
107
+ def module_scope(self) -> Scope:
108
+ return self._module
109
+
110
+ def scope(self, scope_id: ScopeId) -> Scope:
111
+ return self._scopes[scope_id]
112
+
113
+ def children(self, scope_id: ScopeId) -> tuple[Scope, ...]:
114
+ return self._children[scope_id]
115
+
116
+ def scope_for(self, node: nodes.Function | nodes.Class | nodes.Comprehension) -> Scope:
117
+ """Return the scope introduced by a definition or comprehension node."""
118
+
119
+ return self._scopes[self._spans[node.span]]
120
+
121
+ def resolve(self, scope_id: ScopeId, name: str) -> Resolution:
122
+ scope = self.scope(scope_id)
123
+ binding = scope.bindings.get(name)
124
+ if binding is not None:
125
+ if binding.kind is BindingKind.GLOBAL:
126
+ return self._global(name)
127
+ if binding.kind is BindingKind.NONLOCAL:
128
+ target = self._enclosing_function_binding(scope, name)
129
+ assert target is not None, "validated at construction"
130
+ return Resolution(ResolutionKind.FREE, target)
131
+ if scope.kind is ScopeKind.MODULE:
132
+ return Resolution(ResolutionKind.GLOBAL, scope.id)
133
+ return Resolution(ResolutionKind.LOCAL, scope.id)
134
+
135
+ current = self._parent(scope)
136
+ while current is not None:
137
+ if current.kind is ScopeKind.MODULE:
138
+ return self._global(name)
139
+ if current.kind is not ScopeKind.CLASS:
140
+ enclosing = current.bindings.get(name)
141
+ if enclosing is not None and enclosing.kind is BindingKind.GLOBAL:
142
+ return self._global(name)
143
+ if enclosing is not None and enclosing.kind is not BindingKind.NONLOCAL:
144
+ return Resolution(ResolutionKind.FREE, current.id)
145
+ current = self._parent(current)
146
+ return Resolution(ResolutionKind.UNBOUND, None)
147
+
148
+ def _global(self, name: str) -> Resolution:
149
+ if name in self._module.bindings:
150
+ return Resolution(ResolutionKind.GLOBAL, self._module.id)
151
+ return Resolution(ResolutionKind.UNBOUND, None)
152
+
153
+ def _parent(self, scope: Scope) -> Scope | None:
154
+ return None if scope.parent is None else self._scopes[scope.parent]
155
+
156
+ def _enclosing_function_binding(self, scope: Scope, name: str) -> ScopeId | None:
157
+ current = self._parent(scope)
158
+ while current is not None and current.kind is not ScopeKind.MODULE:
159
+ if current.kind is ScopeKind.FUNCTION:
160
+ binding = current.bindings.get(name)
161
+ if binding is not None and binding.kind not in _REDIRECTIONS:
162
+ return current.id
163
+ current = self._parent(current)
164
+ return None
165
+
166
+
167
+ # --------------------------------------------------------------------------- construction
168
+
169
+
170
+ class _ScopeBuilder:
171
+ def __init__(
172
+ self,
173
+ scope_id: ScopeId,
174
+ kind: ScopeKind,
175
+ name: str,
176
+ parent: _ScopeBuilder | None,
177
+ span: SourceSpan,
178
+ ) -> None:
179
+ self.id = scope_id
180
+ self.kind = kind
181
+ self.name = name
182
+ self.parent = parent
183
+ self.span = span
184
+ self.bindings: dict[str, Binding] = {}
185
+ self.child_count = 0
186
+ self.declarations: list[tuple[nodes.Nonlocal, str]] = []
187
+
188
+ def bind(self, name: str, kind: BindingKind, span: SourceSpan) -> None:
189
+ existing = self.bindings.get(name)
190
+ if existing is None:
191
+ self.bindings[name] = Binding(name, kind, span)
192
+ elif kind in (BindingKind.GLOBAL, BindingKind.NONLOCAL):
193
+ # Declarations win over ordinary bindings regardless of statement order.
194
+ self.bindings[name] = Binding(name, kind, span)
195
+
196
+ def child(self, kind: ScopeKind, name: str, span: SourceSpan) -> _ScopeBuilder:
197
+ builder = _ScopeBuilder(self.id.child(name, self.child_count), kind, name, self, span)
198
+ self.child_count += 1
199
+ return builder
200
+
201
+ def freeze(self) -> Scope:
202
+ return Scope(
203
+ id=self.id,
204
+ kind=self.kind,
205
+ name=self.name,
206
+ parent=None if self.parent is None else self.parent.id,
207
+ span=self.span,
208
+ bindings=MappingProxyType(dict(self.bindings)),
209
+ )
210
+
211
+
212
+ _COMPREHENSION_NAMES = {"list": "<listcomp>", "set": "<setcomp>", "generator": "<genexpr>", "dict": "<dictcomp>"}
213
+
214
+
215
+ class _Collector:
216
+ def __init__(self, module: nodes.Module) -> None:
217
+ self.module = _ScopeBuilder(
218
+ ScopeId(str(module.span.source_id)), ScopeKind.MODULE, "<module>", None, module.span
219
+ )
220
+ self.builders: list[_ScopeBuilder] = [self.module]
221
+ self.spans: dict[SourceSpan, ScopeId] = {}
222
+ self.body(module.body, self.module)
223
+
224
+ def body(self, statements: tuple[nodes.Statement, ...], scope: _ScopeBuilder) -> None:
225
+ for statement in statements:
226
+ self.statement(statement, scope)
227
+
228
+ def statement(self, node: nodes.Statement, scope: _ScopeBuilder) -> None:
229
+ if isinstance(node, nodes.Assign | nodes.AugAssign):
230
+ self.expression(node.value, scope)
231
+ self.target(node.target, scope)
232
+ elif isinstance(node, nodes.Assert):
233
+ self.expression(node.test, scope)
234
+ if node.message is not None:
235
+ self.expression(node.message, scope)
236
+ elif isinstance(node, nodes.With):
237
+ for item in node.items:
238
+ self.expression(item.context, scope)
239
+ if item.target is not None:
240
+ scope.bind(item.target.identifier, BindingKind.LOCAL, item.target.span)
241
+ self.body(node.body, scope)
242
+ elif isinstance(node, nodes.Try):
243
+ self.body(node.body, scope)
244
+ for handler in node.handlers:
245
+ if handler.type is not None:
246
+ self.expression(handler.type, scope)
247
+ if handler.name is not None:
248
+ scope.bind(handler.name, BindingKind.LOCAL, handler.span)
249
+ self.body(handler.body, scope)
250
+ self.body(node.orelse, scope)
251
+ self.body(node.finalbody, scope)
252
+ elif isinstance(node, nodes.EnterWith | nodes.ExitWith | nodes.EnterHandler):
253
+ pass
254
+ elif isinstance(node, nodes.Return):
255
+ if node.value is not None:
256
+ self.expression(node.value, scope)
257
+ elif isinstance(node, nodes.ExpressionStatement):
258
+ self.expression(node.expression, scope)
259
+ elif isinstance(node, nodes.Import):
260
+ for alias in node.names:
261
+ local_name = alias.as_name or alias.name.partition(".")[0]
262
+ scope.bind(local_name, BindingKind.IMPORT, alias.span)
263
+ elif isinstance(node, nodes.ImportFrom):
264
+ for alias in node.names:
265
+ if alias.name != "*":
266
+ scope.bind(alias.as_name or alias.name, BindingKind.IMPORT, alias.span)
267
+ elif isinstance(node, nodes.Global):
268
+ if scope.kind is not ScopeKind.MODULE:
269
+ for name in node.names:
270
+ scope.bind(name, BindingKind.GLOBAL, node.span)
271
+ self.module.bind(name, BindingKind.LOCAL, node.span)
272
+ elif isinstance(node, nodes.Nonlocal):
273
+ if scope.kind is ScopeKind.MODULE:
274
+ raise ScopeError(f"{node.span.display()}: nonlocal declaration at module level")
275
+ for name in node.names:
276
+ scope.bind(name, BindingKind.NONLOCAL, node.span)
277
+ scope.declarations.append((node, name))
278
+ elif isinstance(node, nodes.Function):
279
+ for decorator in node.decorators:
280
+ self.expression(decorator, scope)
281
+ for parameter in node.parameters:
282
+ if parameter.default is not None:
283
+ self.expression(parameter.default, scope)
284
+ scope.bind(node.name, BindingKind.FUNCTION, node.span)
285
+ function = self.open(scope, ScopeKind.FUNCTION, node.name, node.span)
286
+ for parameter in node.parameters:
287
+ function.bind(parameter.name, BindingKind.PARAMETER, parameter.span)
288
+ self.body(node.body, function)
289
+ elif isinstance(node, nodes.Class):
290
+ for decorator in node.decorators:
291
+ self.expression(decorator, scope)
292
+ for base in node.bases:
293
+ self.expression(base, scope)
294
+ scope.bind(node.name, BindingKind.CLASS, node.span)
295
+ self.body(node.body, self.open(scope, ScopeKind.CLASS, node.name, node.span))
296
+ elif isinstance(node, nodes.If | nodes.While):
297
+ self.expression(node.condition, scope)
298
+ self.body(node.body, scope)
299
+ self.body(node.orelse, scope)
300
+ elif isinstance(node, nodes.For):
301
+ self.expression(node.iterable, scope)
302
+ scope.bind(node.target.identifier, BindingKind.LOCAL, node.target.span)
303
+ self.body(node.body, scope)
304
+ self.body(node.orelse, scope)
305
+ elif isinstance(node, nodes.Match):
306
+ self.expression(node.subject, scope)
307
+ for case in node.cases:
308
+ self.pattern(case.pattern, scope)
309
+ if case.guard is not None:
310
+ self.expression(case.guard, scope)
311
+ self.body(case.body, scope)
312
+ elif isinstance(node, nodes.Raise):
313
+ if node.exception is not None:
314
+ self.expression(node.exception, scope)
315
+ if node.cause is not None:
316
+ self.expression(node.cause, scope)
317
+ elif isinstance(node, nodes.Delete):
318
+ for target in node.targets:
319
+ self.target(target, scope)
320
+ elif isinstance(node, nodes.Declaration):
321
+ scope.bind(node.name, BindingKind.LOCAL, node.span)
322
+ elif not isinstance(node, nodes.Pass | nodes.Break | nodes.Continue):
323
+ raise TypeError(f"unknown statement: {node!r}")
324
+
325
+ def expression(self, node: nodes.Expression, scope: _ScopeBuilder) -> None:
326
+ if isinstance(node, (nodes.Name, nodes.Constant)):
327
+ return
328
+ if isinstance(node, (nodes.BinaryOp, nodes.Compare)):
329
+ self.expression(node.left, scope)
330
+ self.expression(node.right, scope)
331
+ elif isinstance(node, nodes.UnaryOp):
332
+ self.expression(node.operand, scope)
333
+ elif isinstance(node, nodes.Attribute):
334
+ self.expression(node.value, scope)
335
+ elif isinstance(node, nodes.Subscript):
336
+ self.expression(node.value, scope)
337
+ self.expression(node.key, scope)
338
+ elif isinstance(node, nodes.Call):
339
+ self.expression(node.callee, scope)
340
+ for argument in node.arguments:
341
+ self.expression(argument, scope)
342
+ for keyword in node.keywords:
343
+ self.expression(keyword.value, scope)
344
+ elif isinstance(node, nodes.Comprehension):
345
+ self.comprehension(node, scope)
346
+ elif isinstance(node, nodes.BoolOp):
347
+ for value in node.values:
348
+ self.expression(value, scope)
349
+ elif isinstance(node, nodes.Await):
350
+ self.expression(node.value, scope)
351
+ elif isinstance(node, nodes.Yield):
352
+ if node.value is not None:
353
+ self.expression(node.value, scope)
354
+ elif isinstance(node, nodes.Tuple | nodes.List):
355
+ for element in node.elements:
356
+ self.expression(element, scope)
357
+ elif isinstance(node, nodes.Dict):
358
+ for key, value in node.items:
359
+ if key is not None:
360
+ self.expression(key, scope)
361
+ self.expression(value, scope)
362
+ elif isinstance(node, nodes.FormattedString):
363
+ for part in node.parts:
364
+ self.expression(part, scope)
365
+ elif isinstance(node, nodes.Slice):
366
+ for bound in (node.lower, node.upper, node.step):
367
+ if bound is not None:
368
+ self.expression(bound, scope)
369
+ elif isinstance(node, nodes.Starred):
370
+ self.expression(node.value, scope)
371
+ elif isinstance(node, nodes.Set):
372
+ for element in node.elements:
373
+ self.expression(element, scope)
374
+ elif isinstance(node, nodes.Conditional):
375
+ self.expression(node.test, scope)
376
+ self.expression(node.body, scope)
377
+ self.expression(node.orelse, scope)
378
+ elif isinstance(node, nodes.Lambda):
379
+ for parameter in node.parameters:
380
+ if parameter.default is not None:
381
+ self.expression(parameter.default, scope)
382
+ inner = self.open(scope, ScopeKind.FUNCTION, "<lambda>", node.span)
383
+ for parameter in node.parameters:
384
+ inner.bind(parameter.name, BindingKind.PARAMETER, parameter.span)
385
+ self.expression(node.body, inner)
386
+ else:
387
+ raise TypeError(f"unknown expression: {node!r}")
388
+
389
+ def pattern(self, node: nodes.Pattern, scope: _ScopeBuilder) -> None:
390
+ if isinstance(node, nodes.ValuePattern):
391
+ self.expression(node.value, scope)
392
+ elif isinstance(node, nodes.CapturePattern):
393
+ scope.bind(node.name, BindingKind.LOCAL, node.span)
394
+ if node.pattern is not None:
395
+ self.pattern(node.pattern, scope)
396
+ elif isinstance(node, nodes.OrPattern):
397
+ for alternative in node.alternatives:
398
+ self.pattern(alternative, scope)
399
+ elif isinstance(node, nodes.StarPattern):
400
+ if node.name is not None:
401
+ scope.bind(node.name, BindingKind.LOCAL, node.span)
402
+ elif isinstance(node, nodes.SequencePattern):
403
+ for sub in node.patterns:
404
+ self.pattern(sub, scope)
405
+ elif isinstance(node, nodes.MappingPattern):
406
+ for key in node.keys:
407
+ self.expression(key, scope)
408
+ for sub in node.patterns:
409
+ self.pattern(sub, scope)
410
+ if node.rest is not None:
411
+ scope.bind(node.rest, BindingKind.LOCAL, node.span)
412
+ elif isinstance(node, nodes.ClassPattern):
413
+ self.expression(node.cls, scope)
414
+ for sub in (*node.patterns, *node.keyword_patterns):
415
+ self.pattern(sub, scope)
416
+
417
+ def target(self, node: nodes.Target, scope: _ScopeBuilder) -> None:
418
+ if isinstance(node, nodes.Name):
419
+ scope.bind(node.identifier, BindingKind.LOCAL, node.span)
420
+ elif isinstance(node, nodes.Tuple):
421
+ for element in node.elements:
422
+ assert isinstance(element, nodes.Name | nodes.Attribute | nodes.Subscript | nodes.Tuple)
423
+ self.target(element, scope)
424
+ else:
425
+ self.expression(node, scope)
426
+
427
+ def comprehension(self, node: nodes.Comprehension, scope: _ScopeBuilder) -> None:
428
+ # The first iterable is evaluated in the enclosing scope; everything else runs
429
+ # inside the comprehension's own scope.
430
+ self.expression(node.generators[0].iterable, scope)
431
+ inner = self.open(scope, ScopeKind.COMPREHENSION, _COMPREHENSION_NAMES[node.kind], node.span)
432
+ for index, generator in enumerate(node.generators):
433
+ if index:
434
+ self.expression(generator.iterable, inner)
435
+ self.target(generator.target, inner)
436
+ for condition in generator.conditions:
437
+ self.expression(condition, inner)
438
+ if node.key is not None:
439
+ self.expression(node.key, inner)
440
+ self.expression(node.element, inner)
441
+
442
+ def open(
443
+ self, parent: _ScopeBuilder, kind: ScopeKind, name: str, span: SourceSpan
444
+ ) -> _ScopeBuilder:
445
+ builder = parent.child(kind, name, span)
446
+ self.builders.append(builder)
447
+ self.spans[span] = builder.id
448
+ return builder
449
+
450
+
451
+ def analyze_scopes(module: nodes.Module) -> ScopeTable:
452
+ """Build the scope tree of ``module`` and validate its declarations."""
453
+
454
+ collector = _Collector(module)
455
+ analysis = ScopeTable(
456
+ tuple(builder.freeze() for builder in collector.builders), collector.spans
457
+ )
458
+ for builder in collector.builders:
459
+ for declaration, name in builder.declarations:
460
+ scope = analysis.scope(builder.id)
461
+ if analysis._enclosing_function_binding(scope, name) is None:
462
+ raise ScopeError(
463
+ f"{declaration.span.display()}: no binding for nonlocal {name!r}"
464
+ )
465
+ return analysis
466
+
467
+
468
+ class ScopeAnalysis(Analysis[ScopeTable]):
469
+ name: ClassVar[str] = "semantic.scopes"
470
+
471
+ @classmethod
472
+ def compute(cls, ctx: AnalysisContext) -> ScopeTable:
473
+ return analyze_scopes(ctx.module)