seam-code 0.3.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.
- seam/__init__.py +3 -0
- seam/_data/schema.sql +225 -0
- seam/_web/assets/index-BL_tqprR.js +216 -0
- seam/_web/assets/index-GTKUhVyD.css +1 -0
- seam/_web/index.html +13 -0
- seam/analysis/__init__.py +14 -0
- seam/analysis/affected.py +254 -0
- seam/analysis/builtins.py +966 -0
- seam/analysis/byte_budget.py +217 -0
- seam/analysis/changes.py +709 -0
- seam/analysis/cluster_naming.py +260 -0
- seam/analysis/clustering.py +216 -0
- seam/analysis/confidence.py +699 -0
- seam/analysis/embeddings.py +195 -0
- seam/analysis/flows.py +708 -0
- seam/analysis/impact.py +444 -0
- seam/analysis/imports.py +994 -0
- seam/analysis/imports_ext.py +780 -0
- seam/analysis/imports_resolve.py +176 -0
- seam/analysis/processes.py +453 -0
- seam/analysis/relevance.py +155 -0
- seam/analysis/rwr.py +129 -0
- seam/analysis/staleness.py +328 -0
- seam/analysis/steer.py +282 -0
- seam/analysis/synthesis.py +253 -0
- seam/analysis/synthesis_channels.py +433 -0
- seam/analysis/testpaths.py +103 -0
- seam/analysis/traversal.py +470 -0
- seam/cli/__init__.py +0 -0
- seam/cli/install.py +232 -0
- seam/cli/main.py +2602 -0
- seam/cli/output.py +137 -0
- seam/cli/read.py +244 -0
- seam/cli/serve.py +145 -0
- seam/config.py +551 -0
- seam/indexer/__init__.py +0 -0
- seam/indexer/cluster_index.py +425 -0
- seam/indexer/db.py +496 -0
- seam/indexer/embedding_index.py +183 -0
- seam/indexer/field_access.py +536 -0
- seam/indexer/field_access_c_cpp.py +643 -0
- seam/indexer/field_access_ext.py +708 -0
- seam/indexer/field_access_ext2.py +408 -0
- seam/indexer/field_access_go_rust.py +737 -0
- seam/indexer/field_access_php_swift.py +888 -0
- seam/indexer/field_access_ts.py +626 -0
- seam/indexer/graph.py +321 -0
- seam/indexer/graph_c.py +562 -0
- seam/indexer/graph_c_cpp.py +39 -0
- seam/indexer/graph_common.py +644 -0
- seam/indexer/graph_cpp.py +615 -0
- seam/indexer/graph_csharp.py +651 -0
- seam/indexer/graph_go.py +723 -0
- seam/indexer/graph_go_rust.py +39 -0
- seam/indexer/graph_java.py +689 -0
- seam/indexer/graph_java_csharp.py +38 -0
- seam/indexer/graph_php.py +914 -0
- seam/indexer/graph_python.py +628 -0
- seam/indexer/graph_ruby.py +748 -0
- seam/indexer/graph_rust.py +653 -0
- seam/indexer/graph_scope_infer.py +902 -0
- seam/indexer/graph_scope_infer_ext.py +723 -0
- seam/indexer/graph_scope_infer_ext2.py +992 -0
- seam/indexer/graph_swift.py +1014 -0
- seam/indexer/graph_swift_infer.py +515 -0
- seam/indexer/graph_typescript.py +663 -0
- seam/indexer/migrations.py +816 -0
- seam/indexer/parser.py +204 -0
- seam/indexer/pipeline.py +197 -0
- seam/indexer/signatures.py +634 -0
- seam/indexer/signatures_ext.py +780 -0
- seam/indexer/sync.py +287 -0
- seam/indexer/synthesis_index.py +291 -0
- seam/indexer/tokenize.py +79 -0
- seam/installer/__init__.py +67 -0
- seam/installer/claude.py +97 -0
- seam/installer/codex.py +94 -0
- seam/installer/core.py +127 -0
- seam/installer/cursor.py +61 -0
- seam/installer/guide.py +110 -0
- seam/installer/jsonfile.py +85 -0
- seam/installer/markdownfile.py +146 -0
- seam/installer/tomlfile.py +72 -0
- seam/query/__init__.py +0 -0
- seam/query/clusters.py +206 -0
- seam/query/comments.py +217 -0
- seam/query/context.py +293 -0
- seam/query/engine.py +940 -0
- seam/query/fts.py +328 -0
- seam/query/names.py +470 -0
- seam/query/pack.py +433 -0
- seam/query/semantic.py +339 -0
- seam/query/structure.py +727 -0
- seam/server/__init__.py +0 -0
- seam/server/graph_api.py +437 -0
- seam/server/handler_common.py +323 -0
- seam/server/impact_handler.py +615 -0
- seam/server/mcp.py +556 -0
- seam/server/tools.py +697 -0
- seam/server/trace_handler.py +184 -0
- seam/server/web.py +922 -0
- seam/watcher/__init__.py +0 -0
- seam/watcher/__main__.py +56 -0
- seam/watcher/daemon.py +237 -0
- seam_code-0.3.0.dist-info/METADATA +318 -0
- seam_code-0.3.0.dist-info/RECORD +109 -0
- seam_code-0.3.0.dist-info/WHEEL +4 -0
- seam_code-0.3.0.dist-info/entry_points.txt +2 -0
- seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Field-access edge helper — Python field-access classification (façade module).
|
|
2
|
+
|
|
3
|
+
This file contains the Python field-access implementation and re-exports the
|
|
4
|
+
TypeScript/JS, Go, and Rust implementations from their dedicated leaf modules so
|
|
5
|
+
every caller can still use `from seam.indexer.field_access import <name>` unchanged.
|
|
6
|
+
|
|
7
|
+
LAYER: leaf — imports only stdlib + tree_sitter + graph_scope_infer +
|
|
8
|
+
graph_scope_infer_ext. Never imports from graph.py, db.py, or any non-leaf
|
|
9
|
+
seam module (field_access_ts and field_access_go_rust are peers at the same layer).
|
|
10
|
+
|
|
11
|
+
LAYERING:
|
|
12
|
+
graph_common (leaf — no seam deps)
|
|
13
|
+
graph_scope_infer (leaf — Python/TS/JS receiver-type inference)
|
|
14
|
+
graph_scope_infer_ext (leaf — Go/Rust receiver-type inference)
|
|
15
|
+
↑
|
|
16
|
+
field_access_ts (leaf — TypeScript/JS field-access implementation)
|
|
17
|
+
field_access_go_rust (leaf — Go + Rust field-access implementation)
|
|
18
|
+
field_access (this file — Python implementation + façade re-exports)
|
|
19
|
+
↑
|
|
20
|
+
graph_python (calls extract_field_accesses_python + collect_field_symbols_python)
|
|
21
|
+
graph_typescript (calls extract_field_accesses_typescript + collect_field_symbols_typescript)
|
|
22
|
+
graph_go (calls extract_field_accesses_go + collect_field_symbols_go)
|
|
23
|
+
graph_rust (calls extract_field_accesses_rust + collect_field_symbols_rust)
|
|
24
|
+
|
|
25
|
+
WHY split: the combined Python + TS/JS + Go + Rust implementation exceeded the
|
|
26
|
+
1000-line limit. TypeScript/JS lives in field_access_ts.py; Go + Rust live in
|
|
27
|
+
field_access_go_rust.py. All public names are re-exported here so no caller changes.
|
|
28
|
+
|
|
29
|
+
Python field-access:
|
|
30
|
+
self.x → reads (attribute not in call position)
|
|
31
|
+
self.x = v → writes (LHS of assignment)
|
|
32
|
+
self.x += 1 → writes (augmented_assignment target)
|
|
33
|
+
del self.x → writes (delete statement)
|
|
34
|
+
self.foo() → NOT a field access (call position — stays a 'call' edge)
|
|
35
|
+
|
|
36
|
+
NEVER RAISES: all public functions have a backstop try/except and return [] on error.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
|
|
41
|
+
from tree_sitter import Node
|
|
42
|
+
|
|
43
|
+
# Re-export TS/JS, Go, and Rust implementations so callers need no import changes.
|
|
44
|
+
# noqa: F401 — these are intentional re-exports for the façade pattern.
|
|
45
|
+
from seam.indexer.field_access_go_rust import ( # noqa: F401
|
|
46
|
+
collect_field_symbols_go,
|
|
47
|
+
collect_field_symbols_rust,
|
|
48
|
+
extract_field_accesses_go,
|
|
49
|
+
extract_field_accesses_rust,
|
|
50
|
+
)
|
|
51
|
+
from seam.indexer.field_access_ts import ( # noqa: F401
|
|
52
|
+
collect_field_symbols_typescript,
|
|
53
|
+
extract_field_accesses_typescript,
|
|
54
|
+
)
|
|
55
|
+
from seam.indexer.graph_common import _text
|
|
56
|
+
from seam.indexer.graph_scope_infer import (
|
|
57
|
+
_PY_SELF_NAMES,
|
|
58
|
+
resolve_receiver_type,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
# Return type for the public API: (source_fn, target_field, mode, line)
|
|
64
|
+
# mode is "reads" or "writes"
|
|
65
|
+
FieldAccess = tuple[str, str, str, int]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def extract_field_accesses_python(
|
|
69
|
+
func_body: Node,
|
|
70
|
+
source_fn: str,
|
|
71
|
+
class_name: str | None,
|
|
72
|
+
var_types: dict[str, str],
|
|
73
|
+
) -> list[FieldAccess]:
|
|
74
|
+
"""Extract field accesses (reads and writes) from a Python function body.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
func_body: The 'body' block Node of the function/method (suite or block).
|
|
78
|
+
source_fn: Qualified name of the enclosing function, e.g. 'Account.get'.
|
|
79
|
+
class_name: Enclosing class name (None for module-level functions).
|
|
80
|
+
var_types: Scope map: param/local name → type name (Layer 2 per-function scope,
|
|
81
|
+
already merged with class-level field bindings by the caller).
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of (source_fn, target_field, mode, line) tuples.
|
|
85
|
+
target_field = 'Type.field' when receiver resolves; bare 'field' when unknown.
|
|
86
|
+
mode = 'reads' | 'writes'.
|
|
87
|
+
line = 1-based source line of the attribute access.
|
|
88
|
+
Returns [] on any error.
|
|
89
|
+
|
|
90
|
+
Never raises.
|
|
91
|
+
"""
|
|
92
|
+
result: list[FieldAccess] = []
|
|
93
|
+
try:
|
|
94
|
+
_walk_body(func_body, source_fn, class_name, var_types, result)
|
|
95
|
+
except Exception as exc: # noqa: BLE001
|
|
96
|
+
logger.debug(
|
|
97
|
+
"extract_field_accesses_python: failed for source=%r: %r", source_fn, exc
|
|
98
|
+
)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _walk_body(
|
|
103
|
+
node: Node,
|
|
104
|
+
source_fn: str,
|
|
105
|
+
class_name: str | None,
|
|
106
|
+
var_types: dict[str, str],
|
|
107
|
+
result: list[FieldAccess],
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Recursively walk a function body collecting field accesses.
|
|
110
|
+
|
|
111
|
+
Handles nested if/for/while/try/with blocks by recursing into their children.
|
|
112
|
+
Does NOT recurse into nested function/class definitions (those have their own scope).
|
|
113
|
+
"""
|
|
114
|
+
for child in node.children:
|
|
115
|
+
_walk_stmt(child, source_fn, class_name, var_types, result)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _walk_stmt(
|
|
119
|
+
node: Node,
|
|
120
|
+
source_fn: str,
|
|
121
|
+
class_name: str | None,
|
|
122
|
+
var_types: dict[str, str],
|
|
123
|
+
result: list[FieldAccess],
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Walk a single statement node for field accesses.
|
|
126
|
+
|
|
127
|
+
Handles:
|
|
128
|
+
assignment / augmented_assignment / delete_statement at the top level.
|
|
129
|
+
expression_statement (for plain reads like `_ = self.x`).
|
|
130
|
+
Nested control-flow (if/for/while/try/with) by recursing into their bodies.
|
|
131
|
+
Does NOT recurse into nested function_definition or class_definition nodes.
|
|
132
|
+
"""
|
|
133
|
+
t = node.type
|
|
134
|
+
|
|
135
|
+
# Skip nested function/class definitions — they have their own scope.
|
|
136
|
+
if t in ("function_definition", "decorated_definition", "class_definition"):
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Assignment: LHS may be an attribute (write); RHS may contain reads.
|
|
140
|
+
if t == "assignment":
|
|
141
|
+
_handle_assignment(node, source_fn, class_name, var_types, result)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# Augmented assignment (+=, -=, *=, etc.): target is always a write.
|
|
145
|
+
if t == "augmented_assignment":
|
|
146
|
+
_handle_augmented_assignment(node, source_fn, class_name, var_types, result)
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# Delete statement: each deleted expression is a write.
|
|
150
|
+
if t == "delete_statement":
|
|
151
|
+
_handle_delete_statement(node, source_fn, class_name, var_types, result)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# For all other nodes: recurse, collecting reads from attribute expressions.
|
|
155
|
+
# This handles expression statements, return, if/elif/else, for, while, try, with.
|
|
156
|
+
for child in node.children:
|
|
157
|
+
_walk_stmt(child, source_fn, class_name, var_types, result)
|
|
158
|
+
|
|
159
|
+
# Collect reads from attribute access directly on this node (if it's an attribute).
|
|
160
|
+
# WHY here and not in a dedicated visitor: attribute nodes appear as children of
|
|
161
|
+
# many different parent node types (return, expression_statement, binary_op, etc.).
|
|
162
|
+
# Walking children then checking if this node itself is an attribute handles all cases
|
|
163
|
+
# without a complex visitor pattern.
|
|
164
|
+
# BUT: we must NOT collect from call-position attributes. That check is in _emit_read.
|
|
165
|
+
if t == "attribute":
|
|
166
|
+
_emit_read_if_not_in_call(node, source_fn, class_name, var_types, result)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _handle_assignment(
|
|
170
|
+
node: Node,
|
|
171
|
+
source_fn: str,
|
|
172
|
+
class_name: str | None,
|
|
173
|
+
var_types: dict[str, str],
|
|
174
|
+
result: list[FieldAccess],
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Handle an assignment node: LHS attribute → write; RHS attributes → reads."""
|
|
177
|
+
# LHS: left field of the assignment. May be an attribute (write) or a subscript etc.
|
|
178
|
+
left = node.child_by_field_name("left")
|
|
179
|
+
if left is not None and left.type == "attribute":
|
|
180
|
+
acc = _classify_attribute(left, class_name, var_types)
|
|
181
|
+
if acc is not None:
|
|
182
|
+
target, _recv = acc
|
|
183
|
+
result.append((source_fn, target, "writes", left.start_point[0] + 1))
|
|
184
|
+
|
|
185
|
+
# RHS: right field may contain reads.
|
|
186
|
+
right = node.child_by_field_name("right")
|
|
187
|
+
if right is not None:
|
|
188
|
+
_collect_reads_recursive(right, source_fn, class_name, var_types, result)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _handle_augmented_assignment(
|
|
192
|
+
node: Node,
|
|
193
|
+
source_fn: str,
|
|
194
|
+
class_name: str | None,
|
|
195
|
+
var_types: dict[str, str],
|
|
196
|
+
result: list[FieldAccess],
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Handle augmented assignment (+=, -=, etc.): target attribute → write.
|
|
199
|
+
|
|
200
|
+
In Python tree-sitter, augmented_assignment has a 'left' field child
|
|
201
|
+
and a 'right' field child. The left is the mutation target (write).
|
|
202
|
+
"""
|
|
203
|
+
left = node.child_by_field_name("left")
|
|
204
|
+
if left is not None and left.type == "attribute":
|
|
205
|
+
acc = _classify_attribute(left, class_name, var_types)
|
|
206
|
+
if acc is not None:
|
|
207
|
+
target, _recv = acc
|
|
208
|
+
result.append((source_fn, target, "writes", left.start_point[0] + 1))
|
|
209
|
+
|
|
210
|
+
# Also collect reads from the RHS (the value expression).
|
|
211
|
+
right = node.child_by_field_name("right")
|
|
212
|
+
if right is not None:
|
|
213
|
+
_collect_reads_recursive(right, source_fn, class_name, var_types, result)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _handle_delete_statement(
|
|
217
|
+
node: Node,
|
|
218
|
+
source_fn: str,
|
|
219
|
+
class_name: str | None,
|
|
220
|
+
var_types: dict[str, str],
|
|
221
|
+
result: list[FieldAccess],
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Handle del statement: each deleted attribute is a write."""
|
|
224
|
+
for child in node.children:
|
|
225
|
+
if child.type == "attribute":
|
|
226
|
+
acc = _classify_attribute(child, class_name, var_types)
|
|
227
|
+
if acc is not None:
|
|
228
|
+
target, _recv = acc
|
|
229
|
+
result.append((source_fn, target, "writes", child.start_point[0] + 1))
|
|
230
|
+
elif child.type not in ("del", ","):
|
|
231
|
+
# delete_statement may contain expression_list or other wrappers
|
|
232
|
+
for sub in child.children:
|
|
233
|
+
if sub.type == "attribute":
|
|
234
|
+
acc = _classify_attribute(sub, class_name, var_types)
|
|
235
|
+
if acc is not None:
|
|
236
|
+
target, _recv = acc
|
|
237
|
+
result.append((source_fn, target, "writes", sub.start_point[0] + 1))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _emit_read_if_not_in_call(
|
|
241
|
+
node: Node,
|
|
242
|
+
source_fn: str,
|
|
243
|
+
class_name: str | None,
|
|
244
|
+
var_types: dict[str, str],
|
|
245
|
+
result: list[FieldAccess],
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Emit a reads edge for an attribute node that is NOT in call position.
|
|
248
|
+
|
|
249
|
+
An attribute is in call position when it is the 'function' field of a parent
|
|
250
|
+
'call' node. We skip those (they are method calls, handled as 'call' edges).
|
|
251
|
+
|
|
252
|
+
WHY exclude call position: `self.foo()` produces an attribute node for `self.foo`
|
|
253
|
+
AND a call node for the invocation. If we emitted a 'reads' edge for `self.foo`
|
|
254
|
+
here, we would produce a field-read edge AND a call edge for the same expression —
|
|
255
|
+
overcounting the dependency and confusing blast-radius analysis. The 'call' edge
|
|
256
|
+
(already handled by _emit_call_edge) fully represents this relationship.
|
|
257
|
+
|
|
258
|
+
WHY start_point comparison instead of identity: tree-sitter creates fresh Python
|
|
259
|
+
Node objects on each field access call, so `func_field is node` is always False
|
|
260
|
+
even when both point to the same underlying C node. Comparing start_point (row,col)
|
|
261
|
+
is the correct identity test for tree-sitter Python bindings.
|
|
262
|
+
"""
|
|
263
|
+
parent = node.parent
|
|
264
|
+
if parent is not None and parent.type == "call":
|
|
265
|
+
# Check if THIS node is the 'function' field of the call — i.e., it is the callee.
|
|
266
|
+
func_field = parent.child_by_field_name("function")
|
|
267
|
+
if func_field is not None and func_field.start_point == node.start_point:
|
|
268
|
+
return # Call position — do not emit field read
|
|
269
|
+
|
|
270
|
+
acc = _classify_attribute(node, class_name, var_types)
|
|
271
|
+
if acc is not None:
|
|
272
|
+
target, _recv = acc
|
|
273
|
+
result.append((source_fn, target, "reads", node.start_point[0] + 1))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _collect_reads_recursive(
|
|
277
|
+
node: Node,
|
|
278
|
+
source_fn: str,
|
|
279
|
+
class_name: str | None,
|
|
280
|
+
var_types: dict[str, str],
|
|
281
|
+
result: list[FieldAccess],
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Recursively collect reads from an expression node.
|
|
284
|
+
|
|
285
|
+
Walks the expression tree and emits reads edges for attribute nodes that are
|
|
286
|
+
NOT in call position. Skips nested function/class definitions.
|
|
287
|
+
"""
|
|
288
|
+
t = node.type
|
|
289
|
+
if t in ("function_definition", "decorated_definition", "class_definition"):
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
if t == "attribute":
|
|
293
|
+
_emit_read_if_not_in_call(node, source_fn, class_name, var_types, result)
|
|
294
|
+
# Do NOT recurse into this attribute's children — the object part
|
|
295
|
+
# (e.g. 'self' in 'self.x') is not itself an attribute access target.
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
for child in node.children:
|
|
299
|
+
_collect_reads_recursive(child, source_fn, class_name, var_types, result)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _classify_attribute(
|
|
303
|
+
node: Node,
|
|
304
|
+
class_name: str | None,
|
|
305
|
+
var_types: dict[str, str],
|
|
306
|
+
) -> tuple[str, str | None] | None:
|
|
307
|
+
"""Resolve an attribute node to (qualified_target, receiver_text).
|
|
308
|
+
|
|
309
|
+
Returns None when the node has no attribute field (unexpected shape).
|
|
310
|
+
Returns (target, receiver_text) where:
|
|
311
|
+
- target = 'Type.field' when receiver resolves (EXTRACTED confidence)
|
|
312
|
+
- target = bare 'field' when unresolvable (AMBIGUOUS confidence)
|
|
313
|
+
- receiver_text = raw receiver text (e.g. 'self', 'client')
|
|
314
|
+
|
|
315
|
+
Conservatism contract (mirrors resolve_receiver_type):
|
|
316
|
+
NEVER emit a wrong qualified target. If the receiver type cannot be
|
|
317
|
+
confidently determined, return the bare field name.
|
|
318
|
+
|
|
319
|
+
WHY bare name instead of None on unresolvable: returning a bare 'field' name
|
|
320
|
+
keeps the edge in the graph as an AMBIGUOUS match. A qualified Type.field edge
|
|
321
|
+
is more precise, but a bare edge is still useful — seam_impact on 'balance'
|
|
322
|
+
will find all methods that write ANY field named 'balance' across all types.
|
|
323
|
+
Returning None would silently drop the edge entirely, losing real dependencies.
|
|
324
|
+
|
|
325
|
+
WHY all field-access edges carry confidence='INFERRED': the same field name can
|
|
326
|
+
appear across multiple types ('balance' in Account AND Transaction), so even a
|
|
327
|
+
qualified 'Account.balance' edge was inferred from a receiver type annotation —
|
|
328
|
+
we did not statically prove the receiver IS of type Account (only that it LOOKS
|
|
329
|
+
like one). INFERRED is consistent with how call edges on typed receivers and
|
|
330
|
+
holds edges are rated across all 12 languages.
|
|
331
|
+
|
|
332
|
+
Never raises (returns None on any exception).
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
obj_node = node.child_by_field_name("object")
|
|
336
|
+
attr_node = node.child_by_field_name("attribute")
|
|
337
|
+
if obj_node is None or attr_node is None:
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
field_name = _text(attr_node)
|
|
341
|
+
if not field_name:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
receiver_text = _text(obj_node)
|
|
345
|
+
|
|
346
|
+
# Resolve receiver to a type name using existing scope-inference.
|
|
347
|
+
resolved_type = resolve_receiver_type(
|
|
348
|
+
receiver_text, class_name, var_types, _PY_SELF_NAMES
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if resolved_type is not None:
|
|
352
|
+
# Qualified target: Type.field (EXTRACTED confidence at read time)
|
|
353
|
+
return f"{resolved_type}.{field_name}", receiver_text
|
|
354
|
+
else:
|
|
355
|
+
# Unresolvable receiver → bare field name (AMBIGUOUS at read time)
|
|
356
|
+
return field_name, receiver_text
|
|
357
|
+
|
|
358
|
+
except Exception as exc: # noqa: BLE001
|
|
359
|
+
logger.debug("_classify_attribute: failed: %r", exc)
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def collect_field_symbols_python(
|
|
364
|
+
class_node: Node,
|
|
365
|
+
class_name: str,
|
|
366
|
+
) -> list[tuple[str, int]]:
|
|
367
|
+
"""Collect (qualified_field_name, line) pairs from a Python class_definition.
|
|
368
|
+
|
|
369
|
+
Returns field symbols for:
|
|
370
|
+
1. Class-level annotated fields: 'x: Type' or 'x: Type = value'
|
|
371
|
+
(even when Type is a builtin — unlike holds, we index ALL fields)
|
|
372
|
+
2. self.x = ... assignment sites in __init__ (first occurrence only)
|
|
373
|
+
|
|
374
|
+
Dedup: by (class_name, field_name) — multiple assignments to same field → one entry.
|
|
375
|
+
Class-level declaration wins over __init__ assignment (class body scanned first).
|
|
376
|
+
|
|
377
|
+
WHY index fields as first-class symbols: without a symbol row for 'Account.balance',
|
|
378
|
+
seam_context('Account.balance') would return not-found even though reads/writes edges
|
|
379
|
+
reference it. A field symbol gives the symbol a location (file+line) and lets
|
|
380
|
+
seam_context surface field_readers/field_writers for it.
|
|
381
|
+
|
|
382
|
+
WHY scan __init__ for self.x: Python classes often declare fields only in __init__
|
|
383
|
+
with no class-level annotation (e.g. `self.balance = 0`). These fields have no
|
|
384
|
+
annotated_assignment node at class level, so the only way to discover them is from
|
|
385
|
+
assignment sites. First-occurrence wins to give a stable canonical line number.
|
|
386
|
+
|
|
387
|
+
Returns [] on any error. Never raises.
|
|
388
|
+
"""
|
|
389
|
+
try:
|
|
390
|
+
seen_fields: set[str] = set()
|
|
391
|
+
result: list[tuple[str, int]] = []
|
|
392
|
+
|
|
393
|
+
body = class_node.child_by_field_name("body")
|
|
394
|
+
if body is None:
|
|
395
|
+
return result
|
|
396
|
+
|
|
397
|
+
# Pass 1: class-level field annotations (x: Type or x: Type = value).
|
|
398
|
+
for stmt in body.children:
|
|
399
|
+
try:
|
|
400
|
+
_try_collect_class_annotation(stmt, class_name, seen_fields, result)
|
|
401
|
+
except Exception: # noqa: BLE001
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
# Pass 2: self.x = ... in __init__ (first occurrence by field name only).
|
|
405
|
+
for stmt in body.children:
|
|
406
|
+
try:
|
|
407
|
+
init_node = _get_init_function(stmt)
|
|
408
|
+
if init_node is None:
|
|
409
|
+
continue
|
|
410
|
+
_collect_init_assignments(init_node, class_name, seen_fields, result)
|
|
411
|
+
except Exception: # noqa: BLE001
|
|
412
|
+
pass
|
|
413
|
+
|
|
414
|
+
return result
|
|
415
|
+
|
|
416
|
+
except Exception as exc: # noqa: BLE001
|
|
417
|
+
logger.debug("collect_field_symbols_python: failed for class %r: %r", class_name, exc)
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _try_collect_class_annotation(
|
|
422
|
+
stmt: Node,
|
|
423
|
+
class_name: str,
|
|
424
|
+
seen: set[str],
|
|
425
|
+
result: list[tuple[str, int]],
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Try to extract a class-level field annotation from a statement.
|
|
428
|
+
|
|
429
|
+
Handles:
|
|
430
|
+
x: Type (assignment with 'type' field, no right side)
|
|
431
|
+
x: Type = value (assignment with 'type' field + right side)
|
|
432
|
+
x = value (plain assignment — NOT a typed annotation, skip)
|
|
433
|
+
|
|
434
|
+
NOTE: We accept ALL annotation types including builtins (unlike the holds
|
|
435
|
+
collector) because we want 'balance: int' to become a field symbol.
|
|
436
|
+
The field name is what matters, not the type.
|
|
437
|
+
"""
|
|
438
|
+
if stmt.type != "expression_statement" or not stmt.children:
|
|
439
|
+
return
|
|
440
|
+
inner = stmt.children[0]
|
|
441
|
+
if inner.type not in ("assignment", "annotated_assignment"):
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
# Must have a 'type' annotation field to count as a typed class field.
|
|
445
|
+
ann = inner.child_by_field_name("type")
|
|
446
|
+
if ann is None:
|
|
447
|
+
return # Plain assignment without annotation — skip
|
|
448
|
+
|
|
449
|
+
left = inner.child_by_field_name("left")
|
|
450
|
+
if left is None or left.type != "identifier":
|
|
451
|
+
return # Only simple name targets (not self.x: T at class level)
|
|
452
|
+
|
|
453
|
+
field_name = _text(left)
|
|
454
|
+
if not field_name:
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
qualified = f"{class_name}.{field_name}"
|
|
458
|
+
if qualified not in seen:
|
|
459
|
+
seen.add(qualified)
|
|
460
|
+
result.append((qualified, stmt.start_point[0] + 1))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _collect_init_assignments(
|
|
464
|
+
init_node: Node,
|
|
465
|
+
class_name: str,
|
|
466
|
+
seen: set[str],
|
|
467
|
+
result: list[tuple[str, int]],
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Scan __init__ body for self.x = ... assignments and collect as field symbols.
|
|
470
|
+
|
|
471
|
+
Only the FIRST occurrence of each (class_name, field_name) pair is emitted.
|
|
472
|
+
Uses a shallow walk — only direct statements in __init__ body, not nested scopes.
|
|
473
|
+
"""
|
|
474
|
+
body = init_node.child_by_field_name("body")
|
|
475
|
+
if body is None:
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
for stmt in body.children:
|
|
479
|
+
_try_collect_init_stmt(stmt, class_name, seen, result)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _try_collect_init_stmt(
|
|
483
|
+
stmt: Node,
|
|
484
|
+
class_name: str,
|
|
485
|
+
seen: set[str],
|
|
486
|
+
result: list[tuple[str, int]],
|
|
487
|
+
) -> None:
|
|
488
|
+
"""Try to collect a self.x = ... field assignment from a single statement."""
|
|
489
|
+
# Look for assignment statements in the __init__ body.
|
|
490
|
+
# Could be directly assignment, or wrapped in expression_statement.
|
|
491
|
+
node = stmt
|
|
492
|
+
if stmt.type == "expression_statement" and stmt.children:
|
|
493
|
+
node = stmt.children[0]
|
|
494
|
+
|
|
495
|
+
if node.type not in ("assignment", "augmented_assignment"):
|
|
496
|
+
# Also recurse one level for if/for blocks in __init__
|
|
497
|
+
# (shallow — only one level deep for common patterns)
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
left = node.child_by_field_name("left")
|
|
501
|
+
if left is None or left.type != "attribute":
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
obj_node = left.child_by_field_name("object")
|
|
505
|
+
attr_node = left.child_by_field_name("attribute")
|
|
506
|
+
if obj_node is None or attr_node is None:
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
receiver = _text(obj_node)
|
|
510
|
+
if receiver not in _PY_SELF_NAMES:
|
|
511
|
+
return # Only self.x = ... patterns
|
|
512
|
+
|
|
513
|
+
field_name = _text(attr_node)
|
|
514
|
+
if not field_name:
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
qualified = f"{class_name}.{field_name}"
|
|
518
|
+
if qualified not in seen:
|
|
519
|
+
seen.add(qualified)
|
|
520
|
+
result.append((qualified, stmt.start_point[0] + 1))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _get_init_function(stmt: Node) -> Node | None:
|
|
524
|
+
"""Return the function_definition node if stmt is a Python __init__ method."""
|
|
525
|
+
if stmt.type == "function_definition":
|
|
526
|
+
name_node = stmt.child_by_field_name("name")
|
|
527
|
+
if name_node is not None and _text(name_node) == "__init__":
|
|
528
|
+
return stmt
|
|
529
|
+
elif stmt.type == "decorated_definition":
|
|
530
|
+
inner = stmt.child_by_field_name("definition")
|
|
531
|
+
if inner is not None and inner.type == "function_definition":
|
|
532
|
+
name_node = inner.child_by_field_name("name")
|
|
533
|
+
if name_node is not None and _text(name_node) == "__init__":
|
|
534
|
+
return inner
|
|
535
|
+
return None
|
|
536
|
+
|