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,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)
|