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,748 @@
1
+ """Ruby symbol, edge, and comment extraction from tree-sitter ASTs.
2
+
3
+ LAYER: imports from graph_common (leaf) and graph_scope_infer_ext[2] (leaf) — never from graph.py.
4
+
5
+ LAYERING:
6
+ graph_common (leaf — no seam deps)
7
+
8
+ graph_ruby (this file)
9
+
10
+ graph.py (imports this module's public extractors at top level)
11
+
12
+ WHY split from graph_ruby_php.py: graph_ruby_php.py reached the 1000-line
13
+ limit; splitting each language into its own module keeps all files within
14
+ limits and maintains the top-level-only import rule.
15
+
16
+ All extractor functions follow the same contract:
17
+ - Accept a tree-sitter Node + filepath.
18
+ - Return a list — never raise, never return None.
19
+ - Edges carry confidence='INFERRED' by default (whole-index resolution
20
+ at read time handles EXTRACTED/AMBIGUOUS/INFERRED for cross-file edges).
21
+
22
+ VERIFIED GRAMMAR FACTS (AST-dumped before coding):
23
+ Ruby: module/class → constant child (not 'name' field); method → identifier child;
24
+ singleton_method → [def, self, ".", identifier, ...]; bare call → 'method' field
25
+ with no 'receiver' field; comment → '#' line comments only.
26
+ class/module body: comment nodes can appear BETWEEN the name (constant child)
27
+ and the body_statement — the signature builder must skip them.
28
+ """
29
+
30
+ import logging
31
+ from pathlib import Path
32
+
33
+ from tree_sitter import Node
34
+
35
+ import seam.config as config
36
+
37
+ # A3 Slice 5: Ruby field-access edges + field symbols (sorts before graph_* imports).
38
+ from seam.indexer.field_access_ext2 import (
39
+ collect_field_symbols_ruby,
40
+ extract_field_accesses_ruby,
41
+ )
42
+
43
+ # All shared types from the leaf module (no cycle — graph_common has no seam deps).
44
+ from seam.indexer.graph_common import (
45
+ Comment,
46
+ Edge,
47
+ Symbol,
48
+ _find_enclosing_function,
49
+ _make_symbol,
50
+ _match_marker,
51
+ _text,
52
+ )
53
+
54
+ # Scope-inference: shared resolver from ext, Ruby helpers from ext2.
55
+ from seam.indexer.graph_scope_infer_ext import resolve_receiver_type_ext
56
+ from seam.indexer.graph_scope_infer_ext2 import (
57
+ _RUBY_SELF_NAMES,
58
+ collect_composition_types_ruby,
59
+ record_ruby_local_types,
60
+ scan_class_fields_ruby,
61
+ )
62
+
63
+ # signatures_ext is the leaf enrichment module for Phase 9 languages.
64
+ from seam.indexer.signatures_ext import _extract_ruby as _sig_ruby
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+ # ── Ruby doc-comment helpers ──────────────────────────────────────────────────
69
+
70
+
71
+ def _ruby_doc_comment(decl_node: Node) -> str | None:
72
+ """Capture a Ruby doc-comment: contiguous # lines immediately above the node.
73
+
74
+ WHY: Ruby has no dedicated doc-comment syntax — leading # blocks serve as docs.
75
+ """
76
+ lines: list[str] = []
77
+ current = decl_node.prev_sibling
78
+
79
+ while current is not None and current.type == "comment":
80
+ raw = _text(current)
81
+ # Adjacency: comment end_row + 1 == decl start_row (or next sibling start).
82
+ next_node = current.next_sibling
83
+ if next_node is not None:
84
+ end_row = current.end_point[0]
85
+ next_start_row = next_node.start_point[0]
86
+ if end_row + 1 != next_start_row:
87
+ break # blank line gap — stop collecting
88
+
89
+ # Strip '#' prefix and leading whitespace.
90
+ body = raw.lstrip("#").strip()
91
+ lines.append(body)
92
+ current = current.prev_sibling
93
+
94
+ if not lines:
95
+ return None
96
+ # Lines collected bottom-up; reverse to source order.
97
+ return "\n".join(reversed(lines))
98
+
99
+
100
+ # ── Ruby symbol extraction ────────────────────────────────────────────────────
101
+
102
+
103
+ def _extract_symbols_ruby(root: Node, filepath: Path) -> list[Symbol]:
104
+ """Walk a Ruby AST and extract class, module, method, and singleton method symbols.
105
+
106
+ Kind mapping (Phase 9 spec):
107
+ module → class (named container — closest fit in the closed vocabulary; per ADR)
108
+ class → class
109
+ method inside a class/module → method, qualified 'Container.method'
110
+ singleton_method (def self.X) inside class/module → method 'Container.X'
111
+ method at top-level (no class/module ancestor) → function
112
+
113
+ Traversal: DFS from root, tracking current class/module name as we descend.
114
+ body_statement holds the child nodes inside a class/module body.
115
+
116
+ NEVER raises — all exceptions caught and logged at DEBUG level.
117
+ """
118
+ symbols: list[Symbol] = []
119
+ file_str = str(filepath)
120
+
121
+ try:
122
+ _walk_ruby(root, filepath, file_str, symbols, class_name=None)
123
+ except Exception as exc: # noqa: BLE001
124
+ logger.debug("_extract_symbols_ruby: unexpected error for %s: %r", filepath, exc)
125
+
126
+ return symbols
127
+
128
+
129
+ def _ruby_class_or_module_name(node: Node) -> str | None:
130
+ """Extract the name from a Ruby class or module node (first 'constant' child)."""
131
+ # The class/module name is the first 'constant' child.
132
+ for child in node.children:
133
+ if child.type == "constant":
134
+ return _text(child)
135
+ return None
136
+
137
+
138
+ def _ruby_singleton_method_name(node: Node) -> str | None:
139
+ """Extract the method name from a Ruby singleton_method node.
140
+
141
+ Structure: [def, self, ".", identifier, ...]. Name is the identifier after the dot.
142
+ """
143
+ # Children: [def, self, ".", identifier, method_parameters?, body_statement, end]
144
+ found_dot = False
145
+ for child in node.children:
146
+ if child.type == ".":
147
+ found_dot = True
148
+ continue
149
+ if found_dot and child.type == "identifier":
150
+ return _text(child)
151
+ return None
152
+
153
+
154
+ def _walk_ruby(
155
+ node: Node,
156
+ filepath: Path,
157
+ file_str: str,
158
+ symbols: list[Symbol],
159
+ class_name: str | None,
160
+ ) -> None:
161
+ """Recursive DFS walker for Ruby AST nodes.
162
+
163
+ class_name: enclosing class/module name (for method qualification).
164
+ """
165
+ if node.type == "class":
166
+ _handle_ruby_class(node, filepath, file_str, symbols)
167
+ return # handled recursively inside
168
+
169
+ if node.type == "module":
170
+ _handle_ruby_module(node, filepath, file_str, symbols)
171
+ return # handled recursively inside
172
+
173
+ if node.type == "method":
174
+ _handle_ruby_method(node, filepath, file_str, symbols, class_name)
175
+ # No return — recurse into method body for nested defs (unusual but safe)
176
+
177
+ if node.type == "singleton_method":
178
+ _handle_ruby_singleton_method(node, filepath, file_str, symbols, class_name)
179
+ # No return — recurse for nested content
180
+
181
+ for child in node.children:
182
+ _walk_ruby(child, filepath, file_str, symbols, class_name)
183
+
184
+
185
+ def _handle_ruby_class(
186
+ node: Node,
187
+ filepath: Path,
188
+ file_str: str,
189
+ symbols: list[Symbol],
190
+ ) -> None:
191
+ """Emit a class symbol and recurse into its body with the class name set."""
192
+ name = _ruby_class_or_module_name(node)
193
+ if not name:
194
+ return
195
+
196
+ doc = _ruby_doc_comment(node)
197
+ fields = _sig_ruby(node, name, config.SEAM_MAX_SIGNATURE_LEN)
198
+ symbols.append(
199
+ _make_symbol(
200
+ name,
201
+ "class",
202
+ file_str,
203
+ node,
204
+ doc,
205
+ signature=fields["signature"],
206
+ decorators=fields["decorators"],
207
+ is_exported=fields["is_exported"],
208
+ visibility=fields["visibility"],
209
+ qualified_name=name,
210
+ )
211
+ )
212
+
213
+ # A3 Slice 5: emit field symbols for Ruby @ivar first-assignment sites.
214
+ if config.SEAM_FIELD_ACCESS_EDGES == "on":
215
+ for qual_name, field_line in collect_field_symbols_ruby(node, name):
216
+ symbols.append(Symbol(
217
+ name=qual_name,
218
+ kind="field",
219
+ file=file_str,
220
+ start_line=field_line,
221
+ end_line=field_line,
222
+ docstring=None,
223
+ signature=None,
224
+ decorators=[],
225
+ is_exported=None,
226
+ visibility=None,
227
+ qualified_name=qual_name,
228
+ ))
229
+
230
+ # Recurse into body with this class name as context.
231
+ for child in node.children:
232
+ _walk_ruby(child, filepath, file_str, symbols, class_name=name)
233
+
234
+
235
+ def _handle_ruby_module(
236
+ node: Node,
237
+ filepath: Path,
238
+ file_str: str,
239
+ symbols: list[Symbol],
240
+ ) -> None:
241
+ """Emit a module symbol (kind='class') and recurse into body with module name set."""
242
+ name = _ruby_class_or_module_name(node)
243
+ if not name:
244
+ return
245
+
246
+ doc = _ruby_doc_comment(node)
247
+ fields = _sig_ruby(node, name, config.SEAM_MAX_SIGNATURE_LEN)
248
+ symbols.append(
249
+ _make_symbol(
250
+ name,
251
+ "class", # Per spec: module → class (closest fit in closed vocabulary)
252
+ file_str,
253
+ node,
254
+ doc,
255
+ signature=fields["signature"],
256
+ decorators=fields["decorators"],
257
+ is_exported=fields["is_exported"],
258
+ visibility=fields["visibility"],
259
+ qualified_name=name,
260
+ )
261
+ )
262
+
263
+ # Recurse into module body with module name as context.
264
+ for child in node.children:
265
+ _walk_ruby(child, filepath, file_str, symbols, class_name=name)
266
+
267
+
268
+ def _handle_ruby_method(
269
+ node: Node,
270
+ filepath: Path,
271
+ file_str: str,
272
+ symbols: list[Symbol],
273
+ class_name: str | None,
274
+ ) -> None:
275
+ """Emit a method or function symbol from a Ruby 'method' (def) node.
276
+
277
+ If inside a class/module (class_name set) → kind='method', name='Class.method'.
278
+ At top level (class_name=None) → kind='function', name=method_name.
279
+ """
280
+ # The name is an 'identifier' child (first identifier after 'def').
281
+ name: str | None = None
282
+ for child in node.children:
283
+ if child.type == "identifier":
284
+ name = _text(child)
285
+ break
286
+ if not name:
287
+ return
288
+
289
+ doc = _ruby_doc_comment(node)
290
+
291
+ if class_name is not None:
292
+ qualified = f"{class_name}.{name}"
293
+ fields = _sig_ruby(node, qualified, config.SEAM_MAX_SIGNATURE_LEN)
294
+ symbols.append(
295
+ _make_symbol(
296
+ qualified,
297
+ "method",
298
+ file_str,
299
+ node,
300
+ doc,
301
+ signature=fields["signature"],
302
+ decorators=fields["decorators"],
303
+ is_exported=fields["is_exported"],
304
+ visibility=fields["visibility"],
305
+ qualified_name=qualified,
306
+ )
307
+ )
308
+ else:
309
+ fields = _sig_ruby(node, name, config.SEAM_MAX_SIGNATURE_LEN)
310
+ symbols.append(
311
+ _make_symbol(
312
+ name,
313
+ "function",
314
+ file_str,
315
+ node,
316
+ doc,
317
+ signature=fields["signature"],
318
+ decorators=fields["decorators"],
319
+ is_exported=fields["is_exported"],
320
+ visibility=fields["visibility"],
321
+ qualified_name=name,
322
+ )
323
+ )
324
+
325
+
326
+ def _handle_ruby_singleton_method(
327
+ node: Node,
328
+ filepath: Path,
329
+ file_str: str,
330
+ symbols: list[Symbol],
331
+ class_name: str | None,
332
+ ) -> None:
333
+ """Emit a singleton_method (def self.X) symbol.
334
+
335
+ singleton_method inside a class/module: name='Class.X', kind='method'.
336
+ singleton_method at top level (unusual): name='X', kind='function'.
337
+ """
338
+ name = _ruby_singleton_method_name(node)
339
+ if not name:
340
+ return
341
+
342
+ doc = _ruby_doc_comment(node)
343
+
344
+ if class_name is not None:
345
+ qualified = f"{class_name}.{name}"
346
+ fields = _sig_ruby(node, qualified, config.SEAM_MAX_SIGNATURE_LEN)
347
+ symbols.append(
348
+ _make_symbol(
349
+ qualified,
350
+ "method",
351
+ file_str,
352
+ node,
353
+ doc,
354
+ signature=fields["signature"],
355
+ decorators=fields["decorators"],
356
+ is_exported=fields["is_exported"],
357
+ visibility=fields["visibility"],
358
+ qualified_name=qualified,
359
+ )
360
+ )
361
+ else:
362
+ fields = _sig_ruby(node, name, config.SEAM_MAX_SIGNATURE_LEN)
363
+ symbols.append(
364
+ _make_symbol(
365
+ name,
366
+ "function",
367
+ file_str,
368
+ node,
369
+ doc,
370
+ signature=fields["signature"],
371
+ decorators=fields["decorators"],
372
+ is_exported=fields["is_exported"],
373
+ visibility=fields["visibility"],
374
+ qualified_name=name,
375
+ )
376
+ )
377
+
378
+
379
+ # ── Ruby edge extraction ───────────────────────────────────────────────────────
380
+
381
+
382
+ def _ruby_method_qualified_name(method_node: Node, class_name: str) -> str | None:
383
+ """Return the qualified method name ('ClassName.method') from a Ruby method/singleton_method.
384
+
385
+ For 'method' nodes: name is the first 'identifier' child.
386
+ For 'singleton_method' nodes: name is the identifier after the '.' separator.
387
+ Used by the field-access extractor to set source_fn on emitted edges.
388
+ Returns None if the name cannot be determined (graceful skip).
389
+ """
390
+ ntype = method_node.type
391
+ if ntype == "method":
392
+ for child in method_node.children:
393
+ if child.type == "identifier":
394
+ method_name = _text(child)
395
+ if method_name:
396
+ return f"{class_name}.{method_name}"
397
+ elif ntype == "singleton_method":
398
+ singleton_name = _ruby_singleton_method_name(method_node)
399
+ if singleton_name:
400
+ return f"{class_name}.{singleton_name}"
401
+ return None
402
+
403
+
404
+ def _extract_edges_ruby(root: Node, filepath: Path) -> list[Edge]:
405
+ """Extract import and call edges from a Ruby AST.
406
+
407
+ Import heuristic:
408
+ require('x') → target = 'x'
409
+ require_relative('./x') → target = basename stem of 'x' (strip dir + .rb)
410
+
411
+ Call heuristic:
412
+ call node where 'method' field is identifier → call edge.
413
+ require/require_relative calls are handled as imports — excluded from calls.
414
+
415
+ Tier B B5: when SEAM_TYPE_INFERENCE is on, receiver calls are resolved to
416
+ 'Type.method' qualified targets using per-function scope (class ivar bindings +
417
+ local variable constructor calls). Ruby has no static type annotations — we infer
418
+ from `var = ClassName.new` patterns.
419
+
420
+ NEVER raises. Returns [] on any failure.
421
+ """
422
+ edges: list[Edge] = []
423
+ file_str = str(filepath)
424
+ file_stem = filepath.stem
425
+ infer = config.SEAM_TYPE_INFERENCE == "on"
426
+ composition_on = config.SEAM_COMPOSITION_EDGES == "on"
427
+ field_access_on = config.SEAM_FIELD_ACCESS_EDGES == "on"
428
+
429
+ try:
430
+ _walk_ruby_edges(
431
+ root, file_str, file_stem, edges, infer, composition_on, field_access_on,
432
+ None, {}, {}
433
+ )
434
+ except Exception as exc: # noqa: BLE001
435
+ logger.debug("_extract_edges_ruby: unexpected error for %s: %r", filepath, exc)
436
+
437
+ return edges
438
+
439
+
440
+ def _walk_ruby_edges(
441
+ node: Node,
442
+ file_str: str,
443
+ file_stem: str,
444
+ edges: list[Edge],
445
+ infer: bool,
446
+ composition_on: bool,
447
+ field_access_on: bool,
448
+ class_name: str | None,
449
+ class_fields: dict[str, str],
450
+ var_types: dict[str, str],
451
+ ) -> None:
452
+ """Recursive walker for Ruby edge extraction.
453
+
454
+ Threads class_name + class_fields (ivar types from initialize) + var_types
455
+ (local var types from constructor calls in the current method body).
456
+ composition_on gates holds edge emission (Slice #79).
457
+ field_access_on gates A3 Slice 5 reads/writes edge emission.
458
+ """
459
+ ntype = node.type
460
+
461
+ if ntype == "class":
462
+ # Update class context and pre-scan ivar types from initialize.
463
+ new_class: str | None = None
464
+ name_node = node.child_by_field_name("name")
465
+ if name_node is not None:
466
+ new_class = _text(name_node).strip() or None
467
+ # Slice #79: emit holds edges for Ruby class ivar assignments in initialize.
468
+ if composition_on and new_class:
469
+ _handle_ruby_class_holds(node, new_class, file_str, edges)
470
+ new_fields = scan_class_fields_ruby(node) if infer else {}
471
+ body = node.child_by_field_name("body")
472
+ if body is not None:
473
+ for child in body.children:
474
+ _walk_ruby_edges(
475
+ child, file_str, file_stem, edges, infer, composition_on, field_access_on,
476
+ new_class, new_fields, dict(new_fields)
477
+ )
478
+ return
479
+
480
+ if ntype in ("method", "singleton_method"):
481
+ # New method scope: inherit class fields (ivars), empty local vars.
482
+ new_types: dict[str, str] = dict(class_fields)
483
+ body = node.child_by_field_name("body")
484
+ if body is not None:
485
+ # A3 Slice 5: emit reads/writes field-access edges for @ivar accesses.
486
+ if field_access_on and class_name is not None:
487
+ source_fn = _ruby_method_qualified_name(node, class_name)
488
+ if source_fn is not None:
489
+ for src, tgt, mode, line in extract_field_accesses_ruby(
490
+ body, source_fn, class_name
491
+ ):
492
+ edges.append(Edge(
493
+ source=src,
494
+ target=tgt,
495
+ kind=mode,
496
+ file=file_str,
497
+ line=line,
498
+ confidence="INFERRED",
499
+ receiver=None,
500
+ ))
501
+ for child in body.children:
502
+ _walk_ruby_edges(
503
+ child, file_str, file_stem, edges, infer, composition_on, field_access_on,
504
+ class_name, class_fields, new_types
505
+ )
506
+ return
507
+
508
+ if infer and ntype == "assignment":
509
+ record_ruby_local_types(node, var_types)
510
+ # Still recurse to catch nested calls.
511
+
512
+ if ntype == "call":
513
+ _handle_ruby_call(node, file_str, file_stem, edges, infer, class_name, var_types)
514
+ # Still recurse — calls can be nested
515
+
516
+ for child in node.children:
517
+ _walk_ruby_edges(
518
+ child, file_str, file_stem, edges, infer, composition_on, field_access_on,
519
+ class_name, class_fields, var_types
520
+ )
521
+
522
+
523
+ def _handle_ruby_call(
524
+ node: Node,
525
+ file_str: str,
526
+ file_stem: str,
527
+ edges: list[Edge],
528
+ infer: bool,
529
+ class_name: str | None,
530
+ var_types: dict[str, str],
531
+ ) -> None:
532
+ """Emit import or call edges from a Ruby call node.
533
+
534
+ A 'call' node can be either a bare function call (no receiver) or a method
535
+ call on an object (with receiver). We emit edges for both — bare calls get
536
+ receiver=None; receiver calls get the raw receiver text.
537
+
538
+ Tier B B5: when infer=True, resolves receiver text to type name → qualified target.
539
+ require/require_relative are handled as import edges (not call edges).
540
+ """
541
+ # 'method' field = the function/method identifier being called
542
+ method_node = node.child_by_field_name("method")
543
+ if method_node is None or method_node.type != "identifier":
544
+ return
545
+
546
+ method_name = _text(method_node)
547
+
548
+ # 'receiver' field = the object receiving the call (obj.foo → receiver='obj')
549
+ receiver_node = node.child_by_field_name("receiver")
550
+
551
+ if method_name == "require":
552
+ # Import edge for require 'x' — extract the string argument.
553
+ if receiver_node is not None:
554
+ return # skip receiver calls like obj.require
555
+ target = _ruby_require_target(node)
556
+ if target:
557
+ edges.append(
558
+ Edge(
559
+ source=file_stem,
560
+ target=target,
561
+ kind="import",
562
+ file=file_str,
563
+ line=node.start_point[0] + 1,
564
+ confidence="INFERRED",
565
+ receiver=None,
566
+ )
567
+ )
568
+ return
569
+
570
+ if method_name == "require_relative":
571
+ # Import edge for require_relative './x' → target = basename stem.
572
+ if receiver_node is not None:
573
+ return
574
+ target = _ruby_require_relative_target(node)
575
+ if target:
576
+ edges.append(
577
+ Edge(
578
+ source=file_stem,
579
+ target=target,
580
+ kind="import",
581
+ file=file_str,
582
+ line=node.start_point[0] + 1,
583
+ confidence="INFERRED",
584
+ receiver=None,
585
+ )
586
+ )
587
+ return
588
+
589
+ # Tier B B6: Foo.new → instantiates edge.
590
+ # In Ruby, class names are constants (PascalCase) — tree-sitter represents them
591
+ # as 'constant' nodes. A call where method='new' and receiver is a 'constant'
592
+ # node is a constructor call. Lowercase receiver.new (identifier) is NOT a class
593
+ # constructor (e.g. obj.new is unusual Ruby — no instantiates edge emitted).
594
+ if method_name == "new" and receiver_node is not None and receiver_node.type == "constant":
595
+ type_name = _text(receiver_node)
596
+ if type_name:
597
+ source = _find_enclosing_function(node, "ruby")
598
+ if source is not None:
599
+ edges.append(
600
+ Edge(
601
+ source=source,
602
+ target=type_name,
603
+ kind="instantiates",
604
+ file=file_str,
605
+ line=node.start_point[0] + 1,
606
+ confidence="INFERRED",
607
+ receiver=None,
608
+ )
609
+ )
610
+ return # Do not also emit a call edge for Foo.new
611
+
612
+ # Tier B B2 + B5: emit call edges for bare and receiver calls.
613
+ # Receiver calls are resolved to qualified 'Type.method' when type is known.
614
+ recv_text: str | None = None
615
+ if receiver_node is not None:
616
+ recv_text = _text(receiver_node)
617
+
618
+ # B5: resolve receiver to type → qualify the target.
619
+ final_target = method_name
620
+ if infer and recv_text is not None:
621
+ resolved_type = resolve_receiver_type_ext(
622
+ recv_text, class_name, var_types, _RUBY_SELF_NAMES
623
+ )
624
+ if resolved_type:
625
+ final_target = f"{resolved_type}.{method_name}"
626
+
627
+ source = _find_enclosing_function(node, "ruby")
628
+ if source is not None:
629
+ edges.append(
630
+ Edge(
631
+ source=source,
632
+ target=final_target,
633
+ kind="call",
634
+ file=file_str,
635
+ line=node.start_point[0] + 1,
636
+ confidence="INFERRED",
637
+ receiver=recv_text,
638
+ )
639
+ )
640
+
641
+
642
+ def _ruby_require_target(call_node: Node) -> str | None:
643
+ """Extract the require target from a Ruby require('x') call node.
644
+
645
+ Returns the string content 'x', or None if the argument cannot be parsed.
646
+ """
647
+ arg_list = call_node.child_by_field_name("arguments")
648
+ if arg_list is None:
649
+ return None
650
+ for child in arg_list.named_children:
651
+ if child.type == "string":
652
+ content = _ruby_string_content(child)
653
+ if content:
654
+ return Path(content).stem # strip directory prefix and extension
655
+ return None
656
+
657
+
658
+ def _ruby_require_relative_target(call_node: Node) -> str | None:
659
+ """Extract the stem from a require_relative './path/x' call node.
660
+
661
+ Returns 'x' (basename without extension), or None if not parseable.
662
+ """
663
+ arg_list = call_node.child_by_field_name("arguments")
664
+ if arg_list is None:
665
+ return None
666
+ for child in arg_list.named_children:
667
+ if child.type == "string":
668
+ content = _ruby_string_content(child)
669
+ if content:
670
+ # Strip leading ./ or ../ and return stem.
671
+ return Path(content).stem
672
+ return None
673
+
674
+
675
+ def _ruby_string_content(string_node: Node) -> str | None:
676
+ """Extract the text content from a Ruby string node.
677
+
678
+ Ruby string nodes contain a 'string_content' child with the actual text.
679
+ Falls back to stripping quotes from the full text if no string_content found.
680
+ """
681
+ for child in string_node.named_children:
682
+ if child.type == "string_content":
683
+ return _text(child)
684
+ # Fallback: strip surrounding quotes from full text
685
+ raw = _text(string_node)
686
+ return raw.strip("'\"")
687
+
688
+
689
+ def _handle_ruby_class_holds(
690
+ class_node: Node, class_name: str, file_str: str, edges: list[Edge]
691
+ ) -> None:
692
+ """Emit holds edges for a Ruby class based on initialize ivar assignments.
693
+
694
+ Delegates to collect_composition_types_ruby for (held_type, line) pairs and
695
+ emits one Edge per unique pair. Never raises (backstop try/except).
696
+ """
697
+ try:
698
+ for held_type, held_line in collect_composition_types_ruby(class_node):
699
+ edges.append(
700
+ Edge(
701
+ source=class_name,
702
+ target=held_type,
703
+ kind="holds",
704
+ file=file_str,
705
+ line=held_line,
706
+ confidence="INFERRED",
707
+ receiver=None,
708
+ )
709
+ )
710
+ except Exception as exc: # noqa: BLE001
711
+ logger.debug("_handle_ruby_class_holds: failed: %r", exc)
712
+
713
+
714
+ # ── Ruby comment extraction ────────────────────────────────────────────────────
715
+
716
+
717
+ def _extract_comments_ruby(root: Node, filepath: Path) -> list[Comment]:
718
+ """Walk a Ruby AST and extract semantic comment markers (WHY/HACK/NOTE/TODO/FIXME).
719
+
720
+ Ruby has only one comment node type: 'comment' (# line comment).
721
+ Each comment's text is scanned after stripping the '#' prefix.
722
+
723
+ NEVER raises. Returns [] on any failure.
724
+ """
725
+ comments: list[Comment] = []
726
+
727
+ try:
728
+ _walk_ruby_comments(root, comments)
729
+ except Exception as exc: # noqa: BLE001
730
+ logger.debug("_extract_comments_ruby: unexpected error: %r", exc)
731
+
732
+ return comments
733
+
734
+
735
+ def _walk_ruby_comments(node: Node, comments: list[Comment]) -> None:
736
+ """Recursive walker for Ruby comment nodes."""
737
+ if node.type == "comment":
738
+ raw = _text(node)
739
+ base_row = node.start_point[0] + 1
740
+ # Strip '#' prefix (one or more) and any leading spaces.
741
+ body = raw.lstrip("#").strip()
742
+ result = _match_marker(body)
743
+ if result is not None:
744
+ marker, text = result
745
+ comments.append(Comment(marker=marker, text=text, line=base_row))
746
+
747
+ for child in node.children:
748
+ _walk_ruby_comments(child, comments)