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,628 @@
1
+ """Python symbol and edge extraction from tree-sitter ASTs.
2
+
3
+ LAYER: imports from graph_common (leaf), graph_scope_infer (leaf), signatures (leaf) — never from graph.py.
4
+
5
+ LAYERING:
6
+ graph_common (leaf — no seam deps)
7
+ graph_scope_infer (leaf — Python+TS receiver-type inference)
8
+
9
+ graph_python (this file — Python-only symbol/edge extraction)
10
+
11
+ graph.py (dispatcher; imports this module's public extractors)
12
+
13
+ WHY split from graph.py: graph.py exceeded 1000 lines. Python extraction is a coherent
14
+ leaf unit and is split following the Phase 9 precedent (graph_go_rust.py, etc.).
15
+
16
+ Contract:
17
+ - Accept a tree-sitter Node + filepath.
18
+ - Return a list — never raise, never return None.
19
+ - Edges carry confidence='INFERRED' by default.
20
+ """
21
+
22
+ from pathlib import Path
23
+
24
+ from tree_sitter import Node
25
+
26
+ import seam.config as config
27
+ from seam.indexer.field_access import (
28
+ collect_field_symbols_python,
29
+ extract_field_accesses_python,
30
+ )
31
+ from seam.indexer.graph_common import (
32
+ Comment,
33
+ Edge,
34
+ Symbol,
35
+ _base_type_name,
36
+ _find_enclosing_function,
37
+ _make_symbol,
38
+ _match_marker,
39
+ _node_name,
40
+ _text,
41
+ )
42
+ from seam.indexer.graph_scope_infer import (
43
+ _PY_BUILTIN_TYPES,
44
+ _PY_SELF_NAMES,
45
+ collect_composition_types_python,
46
+ collect_param_types_python,
47
+ record_py_local_types,
48
+ record_py_param_types,
49
+ resolve_receiver_type,
50
+ scan_class_fields_python,
51
+ )
52
+ from seam.indexer.signatures import extract_node_fields
53
+
54
+ # ── Python docstring helper ────────────────────────────────────────────────────
55
+
56
+
57
+ def _py_docstring(func_or_class_node: Node) -> str | None:
58
+ """Extract Python docstring: first expression_statement(string) in body.
59
+
60
+ Returns the docstring CONTENT (without surrounding quotes), or None.
61
+ Uses tree-sitter `string_content` child node — avoids stripping legitimate
62
+ leading/trailing quote characters from the docstring text.
63
+ """
64
+ body = func_or_class_node.child_by_field_name("body")
65
+ if body is None or not body.children:
66
+ return None
67
+ first = body.children[0]
68
+ if first.type != "expression_statement" or not first.children:
69
+ return None
70
+ expr = first.children[0]
71
+ if expr.type != "string":
72
+ return None
73
+ for child in expr.children:
74
+ if child.type == "string_content":
75
+ return _text(child).strip()
76
+ return None
77
+
78
+
79
+ # ── Python extraction ──────────────────────────────────────────────────────────
80
+
81
+
82
+ def _extract_symbols_python(root: Node, filepath: Path) -> list[Symbol]:
83
+ """Walk a Python AST and extract function, class, and method symbols.
84
+
85
+ A3: Also emits kind='field' symbols for class-level annotated fields and
86
+ first self.x = ... assignments in __init__, when SEAM_FIELD_ACCESS_EDGES='on'.
87
+ """
88
+ symbols: list[Symbol] = []
89
+ file_str = str(filepath)
90
+ field_access_on = config.SEAM_FIELD_ACCESS_EDGES == "on"
91
+
92
+ def _walk(node: Node, class_name: str | None = None) -> None:
93
+ """Recursively walk AST, tracking class context for method qualification."""
94
+ if node.type == "function_definition":
95
+ name = _node_name(node)
96
+ if name:
97
+ kind = "method" if class_name else "function"
98
+ qualified = f"{class_name}.{name}" if class_name else name
99
+ doc = _py_docstring(node)
100
+ fields = extract_node_fields(
101
+ node,
102
+ "python",
103
+ qualified_name=qualified,
104
+ max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
105
+ )
106
+ symbols.append(
107
+ _make_symbol(
108
+ qualified,
109
+ kind,
110
+ file_str,
111
+ node,
112
+ doc,
113
+ signature=fields["signature"],
114
+ decorators=fields["decorators"],
115
+ is_exported=fields["is_exported"],
116
+ visibility=fields["visibility"],
117
+ qualified_name=qualified,
118
+ )
119
+ )
120
+ body = node.child_by_field_name("body")
121
+ if body:
122
+ for child in body.children:
123
+ _walk(child, None)
124
+
125
+ elif node.type == "decorated_definition":
126
+ definition = node.child_by_field_name("definition")
127
+ if definition and definition.type == "function_definition":
128
+ name = _node_name(definition)
129
+ if name:
130
+ kind = "method" if class_name else "function"
131
+ qualified = f"{class_name}.{name}" if class_name else name
132
+ doc = _py_docstring(definition)
133
+ fields = extract_node_fields(
134
+ node,
135
+ "python",
136
+ qualified_name=qualified,
137
+ max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
138
+ )
139
+ symbols.append(
140
+ _make_symbol(
141
+ qualified,
142
+ kind,
143
+ file_str,
144
+ node,
145
+ doc,
146
+ signature=fields["signature"],
147
+ decorators=fields["decorators"],
148
+ is_exported=fields["is_exported"],
149
+ visibility=fields["visibility"],
150
+ qualified_name=qualified,
151
+ )
152
+ )
153
+ body = definition.child_by_field_name("body")
154
+ if body:
155
+ for child in body.children:
156
+ _walk(child, None)
157
+ elif definition and definition.type == "class_definition":
158
+ name = _node_name(definition)
159
+ if name:
160
+ doc = _py_docstring(definition)
161
+ fields = extract_node_fields(
162
+ node,
163
+ "python",
164
+ qualified_name=name,
165
+ max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
166
+ )
167
+ symbols.append(
168
+ _make_symbol(
169
+ name,
170
+ "class",
171
+ file_str,
172
+ node,
173
+ doc,
174
+ signature=fields["signature"],
175
+ decorators=fields["decorators"],
176
+ is_exported=fields["is_exported"],
177
+ visibility=fields["visibility"],
178
+ qualified_name=name,
179
+ )
180
+ )
181
+ body = definition.child_by_field_name("body")
182
+ if body:
183
+ for child in body.children:
184
+ _walk(child, name)
185
+
186
+ elif node.type == "class_definition":
187
+ name = _node_name(node)
188
+ if name:
189
+ doc = _py_docstring(node)
190
+ fields = extract_node_fields(
191
+ node,
192
+ "python",
193
+ qualified_name=name,
194
+ max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
195
+ )
196
+ symbols.append(
197
+ _make_symbol(
198
+ name,
199
+ "class",
200
+ file_str,
201
+ node,
202
+ doc,
203
+ signature=fields["signature"],
204
+ decorators=fields["decorators"],
205
+ is_exported=fields["is_exported"],
206
+ visibility=fields["visibility"],
207
+ qualified_name=name,
208
+ )
209
+ )
210
+
211
+ # A3: Emit field symbols for this class when feature is on.
212
+ # Collects annotated class-level fields (x: Type) and first
213
+ # self.x = ... assignments in __init__. Deduped by (class, field).
214
+ # WHY emit field symbols here alongside the class symbol: we need the
215
+ # class name context ('name') which is only available at the class_definition
216
+ # node. Doing it in a separate post-pass over symbols would require
217
+ # re-parsing or re-walking.
218
+ if field_access_on:
219
+ for qualified_field, field_line in collect_field_symbols_python(node, name):
220
+ # WHY build Symbol manually instead of using _make_symbol:
221
+ # _make_symbol expects a tree-sitter Node for start/end lines.
222
+ # collect_field_symbols_python returns explicit (name, line) pairs
223
+ # because the field may come from __init__ (a child node of the
224
+ # class body, not the class node itself). Building Symbol directly
225
+ # with the exact line avoids needing a dummy or sentinel node.
226
+ symbols.append(Symbol(
227
+ name=qualified_field,
228
+ kind="field",
229
+ file=file_str,
230
+ start_line=field_line,
231
+ end_line=field_line,
232
+ docstring=None,
233
+ signature=None,
234
+ decorators=[],
235
+ is_exported=None,
236
+ visibility=None,
237
+ qualified_name=qualified_field,
238
+ ))
239
+
240
+ body = node.child_by_field_name("body")
241
+ if body:
242
+ for child in body.children:
243
+ _walk(child, name)
244
+ else:
245
+ for child in node.children:
246
+ _walk(child, class_name)
247
+
248
+ for child in root.children:
249
+ _walk(child, None)
250
+
251
+ return symbols
252
+
253
+
254
+ def _extract_edges_python(root: Node, filepath: Path) -> list[Edge]:
255
+ """Extract import and call edges from a Python AST.
256
+
257
+ Import heuristic:
258
+ - import X → target = 'X' (dotted_name as-is)
259
+ - from X import Y → target = 'Y' for each name after 'import' keyword
260
+
261
+ Call heuristic (Tier B B4 enhanced):
262
+ - call node where function is a bare identifier (`foo()`) → target = identifier
263
+ - call node where function is an attribute (`mod.fn()`, `self.m()`, `a.b.c()`)
264
+ → when SEAM_TYPE_INFERENCE=on and receiver type is known in scope, target =
265
+ 'Type.method' (qualified); otherwise target = the rightmost identifier (bare).
266
+ - source = nearest enclosing function/method (skip if none)
267
+
268
+ B6: PascalCase bare calls (no receiver) → 'instantiates' edges.
269
+ Guard: stdlib/typing builtins excluded via _PY_BUILTIN_TYPES to avoid false positives.
270
+ """
271
+ edges: list[Edge] = []
272
+ file_str = str(filepath)
273
+ file_stem = filepath.stem
274
+
275
+ emit_inheritance = config.SEAM_INHERITANCE_EDGES == "on"
276
+ type_inference_on = config.SEAM_TYPE_INFERENCE == "on"
277
+ composition_on = config.SEAM_COMPOSITION_EDGES == "on"
278
+ field_access_on = config.SEAM_FIELD_ACCESS_EDGES == "on"
279
+ param_edges_on = config.SEAM_PARAM_EDGES == "on"
280
+
281
+ def _emit_import_edges(node: Node) -> None:
282
+ if node.type == "import_statement":
283
+ for child in node.children:
284
+ if child.type in ("dotted_name", "aliased_import"):
285
+ target_node = child.child_by_field_name("name") or child
286
+ edges.append(Edge(
287
+ source=file_stem,
288
+ target=_text(target_node),
289
+ kind="import",
290
+ file=file_str,
291
+ line=node.start_point[0] + 1,
292
+ confidence="INFERRED",
293
+ receiver=None,
294
+ ))
295
+ elif node.type == "import_from_statement":
296
+ found_import_kw = False
297
+ for child in node.children:
298
+ if child.type == "import":
299
+ found_import_kw = True
300
+ continue
301
+ if found_import_kw:
302
+ if child.type in ("dotted_name", "identifier"):
303
+ edges.append(Edge(
304
+ source=file_stem,
305
+ target=_text(child),
306
+ kind="import",
307
+ file=file_str,
308
+ line=node.start_point[0] + 1,
309
+ confidence="INFERRED",
310
+ receiver=None,
311
+ ))
312
+ elif child.type == "aliased_import":
313
+ name_node = child.child_by_field_name("name")
314
+ if name_node:
315
+ edges.append(Edge(
316
+ source=file_stem,
317
+ target=_text(name_node),
318
+ kind="import",
319
+ file=file_str,
320
+ line=node.start_point[0] + 1,
321
+ confidence="INFERRED",
322
+ receiver=None,
323
+ ))
324
+
325
+ def _emit_call_edge(
326
+ node: Node,
327
+ class_name: str | None,
328
+ var_types: dict[str, str],
329
+ ) -> None:
330
+ """Emit a call edge for a Python 'call' node using scope inference.
331
+
332
+ Two-stage decision (Tier B B4 + B6):
333
+ 1. Classify the call shape (bare identifier vs attribute):
334
+ - `foo()` → bare call (receiver_text=None)
335
+ - `obj.method()` → attribute call (receiver_text='obj')
336
+ 2. Emit the right edge kind:
337
+ - Bare PascalCase → 'instantiates' (B6); not 'call', because
338
+ seam_query needs to distinguish object-construction from
339
+ method calls, and callee_node is NOT stored at callee level.
340
+ - Attribute call → 'call' with target qualified to Type.method
341
+ when the receiver type is known; bare method name when unknown.
342
+ receiver_text is ALWAYS stored in the edge so the Tier A read
343
+ path (names.py) and future passes have the raw text even when
344
+ type resolution succeeded (prevents information loss).
345
+ """
346
+ func_child = node.child_by_field_name("function")
347
+ callee_node: Node | None = None
348
+ receiver_text: str | None = None
349
+
350
+ if func_child and func_child.type == "identifier":
351
+ callee_node = func_child
352
+ elif func_child and func_child.type == "attribute":
353
+ # attribute node: object='self'/'obj'/… + attribute='method_name'
354
+ callee_node = func_child.child_by_field_name("attribute")
355
+ object_node = func_child.child_by_field_name("object")
356
+ if object_node is not None:
357
+ receiver_text = _text(object_node)
358
+
359
+ if callee_node is None or callee_node.type != "identifier":
360
+ return
361
+ source = _find_enclosing_function(node, "python")
362
+ if source is None:
363
+ # Top-level code with no enclosing named scope — drop the edge;
364
+ # source would be the file stem which conflates all module-level calls.
365
+ return
366
+
367
+ method_name = _text(callee_node)
368
+ target = method_name
369
+
370
+ # B6: PascalCase bare call (no receiver) → 'instantiates' edge.
371
+ # WHY 'instantiates' not 'call': a bare PascalCase name in Python is
372
+ # overwhelmingly a constructor call (Foo(), MyClass()), not a function named
373
+ # with an acronym. 'instantiates' allows seam_query to distinguish
374
+ # construction from regular calls. Early-return: no receiver to store.
375
+ # Guard: _PY_BUILTIN_TYPES excludes Exception, list, dict, etc. which would
376
+ # otherwise produce thousands of false instantiates edges to stdlib types.
377
+ if (
378
+ receiver_text is None
379
+ and method_name
380
+ and method_name[0].isupper()
381
+ and method_name not in _PY_BUILTIN_TYPES
382
+ ):
383
+ edges.append(Edge(
384
+ source=source,
385
+ target=method_name,
386
+ kind="instantiates",
387
+ file=file_str,
388
+ line=node.start_point[0] + 1,
389
+ confidence="INFERRED",
390
+ receiver=None,
391
+ ))
392
+ return
393
+
394
+ # B4: receiver-type inference for attribute calls.
395
+ # WHY only when resolved: the conservatism contract forbids emitting a wrong
396
+ # qualified edge. When resolve_receiver_type returns None (unknown/optional/
397
+ # generic/chained), we keep the bare method name — Tier A can still elevate
398
+ # unambiguous bare names at read time, and the raw receiver_text is stored.
399
+ if type_inference_on and receiver_text is not None:
400
+ resolved_type = resolve_receiver_type(
401
+ receiver_text, class_name, var_types, _PY_SELF_NAMES
402
+ )
403
+ if resolved_type is not None:
404
+ target = f"{resolved_type}.{method_name}"
405
+
406
+ # Always store receiver_text even when the target was already qualified.
407
+ # WHY: the raw receiver is useful for debugging mis-resolutions and for
408
+ # future inference passes that re-process edges without a full re-index.
409
+ edges.append(Edge(
410
+ source=source,
411
+ target=target,
412
+ kind="call",
413
+ file=file_str,
414
+ line=node.start_point[0] + 1,
415
+ confidence="INFERRED",
416
+ receiver=receiver_text,
417
+ ))
418
+
419
+ def _walk_function_body(func_node: Node, class_name: str | None, class_fields: dict[str, str]) -> None:
420
+ # Start with a COPY of class_fields, not a reference. WHY: param/local bindings
421
+ # must not leak back into class_fields across methods (each function has an
422
+ # independent scope). class_fields is the order-independent Layer 1 pre-scan;
423
+ # var_types is the per-function Layer 2 scope that adds params and locals on top.
424
+ var_types: dict[str, str] = dict(class_fields)
425
+ record_py_param_types(func_node, var_types)
426
+
427
+ # 'uses' edges: this function references plain user types as parameters.
428
+ # Hooked here (not in the body walk) because the function node carries the
429
+ # full signature; source is qualified Class.method (or bare top-level fn).
430
+ if param_edges_on:
431
+ name_node = func_node.child_by_field_name("name")
432
+ if name_node is not None:
433
+ fn_name = _text(name_node)
434
+ source_fn = f"{class_name}.{fn_name}" if class_name else fn_name
435
+ for ptype, pline in collect_param_types_python(func_node):
436
+ edges.append(Edge(
437
+ source=source_fn,
438
+ target=ptype,
439
+ kind="uses",
440
+ file=file_str,
441
+ line=pline,
442
+ confidence="INFERRED",
443
+ receiver=None,
444
+ ))
445
+
446
+ body = func_node.child_by_field_name("body")
447
+ if body is None:
448
+ return
449
+ for stmt in body.children:
450
+ record_py_local_types(stmt, var_types)
451
+ _walk_stmt(stmt, class_name, var_types, class_fields)
452
+
453
+ # A3: Emit reads/writes edges for field accesses in this function body.
454
+ # Done AFTER the main stmt walk so that var_types is fully populated
455
+ # (record_py_local_types runs incrementally during the stmt walk above).
456
+ # WHY separate pass: fully-populated var_types gives higher-quality receiver
457
+ # resolution than an interleaved walk.
458
+ if field_access_on:
459
+ # Build the qualified source name from the function's own name field.
460
+ # WHY not _find_enclosing_function: that walks UP the parent chain from a
461
+ # child node, which would land on the function itself — identical result
462
+ # but needs a child node as input. Computing directly from the name field
463
+ # is simpler and avoids passing an arbitrary child node.
464
+ name_node = func_node.child_by_field_name("name")
465
+ if name_node is not None:
466
+ fn_name = _text(name_node)
467
+ source_fn = f"{class_name}.{fn_name}" if class_name else fn_name
468
+ for _src, target_field, mode, fa_line in extract_field_accesses_python(
469
+ body, source_fn, class_name, var_types
470
+ ):
471
+ edges.append(Edge(
472
+ source=source_fn,
473
+ target=target_field,
474
+ kind=mode,
475
+ file=file_str,
476
+ line=fa_line,
477
+ confidence="INFERRED",
478
+ receiver=None,
479
+ ))
480
+
481
+ def _walk_stmt(
482
+ node: Node,
483
+ class_name: str | None,
484
+ var_types: dict[str, str],
485
+ class_fields: dict[str, str],
486
+ ) -> None:
487
+ if node.type in ("import_statement", "import_from_statement"):
488
+ _emit_import_edges(node)
489
+ return
490
+
491
+ if node.type == "call":
492
+ _emit_call_edge(node, class_name, var_types)
493
+
494
+ if node.type in ("function_definition", "decorated_definition"):
495
+ inner_fn = node
496
+ if node.type == "decorated_definition":
497
+ inner_fn = node.child_by_field_name("definition") or node
498
+ if inner_fn.type == "function_definition":
499
+ _walk_function_body(inner_fn, class_name, class_fields)
500
+ return
501
+
502
+ if node.type == "class_definition":
503
+ _walk_class(node)
504
+ return
505
+
506
+ for child in node.children:
507
+ _walk_stmt(child, class_name, var_types, class_fields)
508
+
509
+ def _walk_class(class_node: Node) -> None:
510
+ cls_name = _node_name(class_node)
511
+
512
+ if emit_inheritance and cls_name:
513
+ bases = class_node.child_by_field_name("superclasses")
514
+ if bases is not None:
515
+ for base_child in bases.named_children:
516
+ base_target = _base_type_name(base_child)
517
+ if base_target:
518
+ edges.append(Edge(
519
+ source=cls_name,
520
+ target=base_target,
521
+ kind="extends",
522
+ file=file_str,
523
+ line=class_node.start_point[0] + 1,
524
+ confidence="INFERRED",
525
+ receiver=None,
526
+ ))
527
+
528
+ # Pre-scan class body for field type bindings BEFORE walking methods.
529
+ # WHY: a method defined above a field declaration should still be able to
530
+ # resolve that field's type (e.g. DI patterns store injected objects as
531
+ # annotated class attributes). This is Layer 1 of the two-layer scope model.
532
+ # When SEAM_TYPE_INFERENCE=off the dict stays empty — inference is skipped
533
+ # entirely, producing the same bare-target edges as pre-Tier-B.
534
+ class_fields: dict[str, str] = {}
535
+ if type_inference_on and cls_name:
536
+ class_fields = scan_class_fields_python(class_node)
537
+
538
+ # Slice #77: emit composition (holds) edges.
539
+ # WHY here: this is the single natural place where we have both the class name
540
+ # (cls_name) and the full class AST node. The collector handles dedup internally
541
+ # so we don't risk double-emitting even if the same type appears as both a class
542
+ # field and an __init__ parameter.
543
+ if composition_on and cls_name:
544
+ for held_type, held_line in collect_composition_types_python(class_node):
545
+ edges.append(Edge(
546
+ source=cls_name,
547
+ target=held_type,
548
+ kind="holds",
549
+ file=file_str,
550
+ line=held_line,
551
+ confidence="INFERRED",
552
+ receiver=None,
553
+ ))
554
+
555
+ body = class_node.child_by_field_name("body")
556
+ if body is None:
557
+ return
558
+ for child in body.children:
559
+ if child.type == "function_definition":
560
+ _walk_function_body(child, cls_name, class_fields)
561
+ elif child.type == "decorated_definition":
562
+ inner = child.child_by_field_name("definition")
563
+ if inner and inner.type == "function_definition":
564
+ _walk_function_body(inner, cls_name, class_fields)
565
+ elif inner and inner.type == "class_definition":
566
+ _walk_class(inner)
567
+ else:
568
+ _walk_stmt(child, cls_name, {}, class_fields)
569
+ elif child.type == "class_definition":
570
+ _walk_class(child)
571
+ else:
572
+ _walk_stmt(child, cls_name, class_fields, class_fields)
573
+
574
+ def _walk_toplevel(node: Node) -> None:
575
+ if node.type in ("import_statement", "import_from_statement"):
576
+ _emit_import_edges(node)
577
+ elif node.type == "class_definition":
578
+ _walk_class(node)
579
+ elif node.type == "function_definition":
580
+ _walk_function_body(node, None, {})
581
+ elif node.type == "decorated_definition":
582
+ inner = node.child_by_field_name("definition")
583
+ if inner and inner.type == "function_definition":
584
+ _walk_function_body(inner, None, {})
585
+ elif inner and inner.type == "class_definition":
586
+ _walk_class(inner)
587
+ else:
588
+ for child in node.children:
589
+ _walk_toplevel(child)
590
+ else:
591
+ for child in node.children:
592
+ _walk_toplevel(child)
593
+
594
+ for child in root.children:
595
+ _walk_toplevel(child)
596
+
597
+ return edges
598
+
599
+
600
+ def _extract_comments_python(root: Node, filepath: Path) -> list[Comment]:
601
+ """Walk a Python AST and collect matched semantic comment nodes (WHY/HACK/NOTE/TODO/FIXME).
602
+
603
+ Python has a single 'comment' node type (lines starting with '#').
604
+ Strips the leading '#' prefix before marker matching.
605
+ """
606
+ comments: list[Comment] = []
607
+
608
+ def _walk(node: Node) -> None:
609
+ if node.type == "comment":
610
+ raw = _text(node)
611
+ body = raw.lstrip("#").strip()
612
+ result = _match_marker(body)
613
+ if result is not None:
614
+ marker, text = result
615
+ comments.append(
616
+ Comment(
617
+ marker=marker,
618
+ text=text,
619
+ line=node.start_point[0] + 1,
620
+ )
621
+ )
622
+ for child in node.children:
623
+ _walk(child)
624
+
625
+ for child in root.children:
626
+ _walk(child)
627
+
628
+ return comments