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.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. 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
+