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,634 @@
1
+ """Node-field extractor for Phase 4 — signature, decorators, is_exported, visibility, qualified_name.
2
+
3
+ LAYER: pure leaf module — imports only stdlib + tree_sitter.
4
+ Must NOT import from any other seam.indexer module (same contract as graph_common.py).
5
+
6
+ Entry point:
7
+ extract_node_fields(node, language, qualified_name=None) -> NodeFields
8
+
9
+ NEVER raises. On any extraction failure, returns the field as None / empty list
10
+ so the caller (graph.py / graph_go_rust.py) can still emit the symbol.
11
+
12
+ Per-language rules (PRD §Implementation Decisions):
13
+ signature : declaration header normalized to one line, truncated to max len.
14
+ decorators : Python/TS verbatim decorator nodes; Go/Rust → always [].
15
+ is_exported : TS/JS: export keyword; Go: uppercase first letter; Rust: pub/pub(crate);
16
+ Python: no single-underscore prefix (heuristic; __all__ is out of scope).
17
+ visibility : Rust: pub/pub(crate)/none → public/crate/private;
18
+ TS/JS: public/private/protected modifiers;
19
+ Python: private if underscore-prefix, else public;
20
+ Go: derived from capitalization.
21
+ qualified_name : passed in from the caller (already resolved by graph.py scope-walking).
22
+ """
23
+
24
+ import logging
25
+ from typing import Any, TypedDict
26
+
27
+ from tree_sitter import Node
28
+
29
+ # Phase 9: import the new-language stub extractors from the companion leaf module.
30
+ # signatures_ext is a leaf (no seam deps) so importing it here does not create a cycle.
31
+ from seam.indexer.signatures_ext import (
32
+ _extract_c,
33
+ _extract_cpp,
34
+ _extract_csharp,
35
+ _extract_java,
36
+ _extract_php,
37
+ _extract_ruby,
38
+ _extract_swift,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # Fallback when callers omit max_signature_len (e.g. unit tests calling extract_node_fields
44
+ # directly). The real limit comes from seam.config.SEAM_MAX_SIGNATURE_LEN, which callers
45
+ # (graph.py / graph_go_rust.py) read and pass in as a parameter. Keeping config reading
46
+ # in the caller rather than here preserves this module's leaf property: a leaf must not
47
+ # import from any other seam module, including seam.config. Parameter threading is the
48
+ # only way to honour both the config-centralisation rule and the leaf-purity rule.
49
+ # 300 must match the seam.config default so tests and prod see the same truncation point.
50
+ _DEFAULT_MAX_SIG_LEN = 300
51
+
52
+
53
+ class NodeFields(TypedDict):
54
+ """Five enrichment fields extracted per symbol node (all nullable)."""
55
+
56
+ signature: str | None # declaration header, single line, truncated
57
+ decorators: list[str] # verbatim decorator strings (Python/TS); [] otherwise
58
+ is_exported: bool | None # True = public API; None = unknown (unsupported language)
59
+ visibility: str | None # "public" | "private" | "protected" | "crate" | None
60
+ qualified_name: str | None # "ClassName.method" or plain name; None for top-level unknown
61
+
62
+
63
+ # ── Safe text helper (mirrors graph_common._text without importing it) ────────
64
+
65
+
66
+ def _text(node: Node | None) -> str:
67
+ """Safely decode a tree-sitter node's text bytes to str."""
68
+ if node is None:
69
+ return ""
70
+ raw = node.text
71
+ if raw is None:
72
+ return ""
73
+ return raw.decode("utf-8", errors="replace")
74
+
75
+
76
+ def _normalize_to_one_line(text: str) -> str:
77
+ """Collapse any embedded newlines and multiple spaces into a single space."""
78
+ return " ".join(text.split())
79
+
80
+
81
+ def _truncate(text: str, max_len: int) -> str:
82
+ """Truncate text to max_len characters, appending '...' if truncated."""
83
+ if len(text) <= max_len:
84
+ return text
85
+ return text[: max_len - 3] + "..."
86
+
87
+
88
+ # ── Python extraction ─────────────────────────────────────────────────────────
89
+
90
+
91
+ def _py_is_exported(name: str) -> bool:
92
+ """Python export heuristic: name NOT starting with underscore → exported."""
93
+ return not name.startswith("_")
94
+
95
+
96
+ def _py_visibility(name: str) -> str:
97
+ """Python visibility: underscore prefix → private; else public."""
98
+ return "private" if name.startswith("_") else "public"
99
+
100
+
101
+ def _py_signature(node: Node) -> str | None:
102
+ """Extract Python function/class declaration header as a one-line string.
103
+
104
+ For function_definition: "def name(params) -> return_type"
105
+ For class_definition: "class Name(Bases)"
106
+ """
107
+ try:
108
+ node_type = node.type
109
+
110
+ # decorated_definition wraps the inner function_definition; recurse to extract
111
+ # the signature from the actual declaration, not the decorator wrapper node.
112
+ if node_type == "decorated_definition":
113
+ inner = node.child_by_field_name("definition")
114
+ if inner is not None:
115
+ return _py_signature(inner)
116
+ return None
117
+
118
+ if node_type == "function_definition":
119
+ name_node = node.child_by_field_name("name")
120
+ params_node = node.child_by_field_name("parameters")
121
+ return_node = node.child_by_field_name("return_type")
122
+
123
+ name = _text(name_node)
124
+ params = _text(params_node)
125
+ parts = [f"def {name}{params}"]
126
+ if return_node is not None:
127
+ # WHY removeprefix not the strip-char-set method:
128
+ # The strip-charset variant treats the argument as a SET of characters to
129
+ # strip, not a prefix string. removeprefix("->") strips only the exact
130
+ # two-char prefix once — correct for return-type nodes whose text starts
131
+ # with "->" (the arrow is part of the node's text in tree-sitter Python).
132
+ ret = _text(return_node).removeprefix("->").strip()
133
+ parts.append(f"-> {ret}")
134
+ return _normalize_to_one_line(" ".join(parts))
135
+
136
+ if node_type == "class_definition":
137
+ name_node = node.child_by_field_name("name")
138
+ superclasses_node = node.child_by_field_name("superclasses")
139
+ name = _text(name_node)
140
+ if superclasses_node:
141
+ bases = _text(superclasses_node)
142
+ return _normalize_to_one_line(f"class {name}{bases}")
143
+ return _normalize_to_one_line(f"class {name}")
144
+
145
+ except Exception as exc: # noqa: BLE001
146
+ # Log at DEBUG so extraction failures surface during development without
147
+ # polluting production logs — callers emit the symbol with signature=None.
148
+ logger.debug("_py_signature extraction failed for node.type=%r: %s", node.type, exc)
149
+ return None
150
+
151
+
152
+ def _py_decorators(node: Node) -> list[str]:
153
+ """Extract Python decorator texts from a decorated_definition node."""
154
+ if node.type != "decorated_definition":
155
+ return []
156
+ decorators: list[str] = []
157
+ try:
158
+ for child in node.children:
159
+ if child.type == "decorator":
160
+ text = _text(child).strip()
161
+ if text:
162
+ decorators.append(text)
163
+ except Exception as exc: # noqa: BLE001
164
+ logger.debug("_py_decorators extraction failed: %s", exc)
165
+ return decorators
166
+
167
+
168
+ def _extract_python(node: Node, qualified_name: str | None, max_sig_len: int) -> NodeFields:
169
+ """Extract all five fields for a Python node."""
170
+ try:
171
+ # Determine the effective name for export/visibility check
172
+ effective_node = node
173
+ if node.type == "decorated_definition":
174
+ inner = node.child_by_field_name("definition")
175
+ if inner is not None:
176
+ effective_node = inner
177
+
178
+ name_node = effective_node.child_by_field_name("name")
179
+ name = _text(name_node) if name_node else ""
180
+
181
+ sig = _py_signature(node)
182
+ if sig is not None:
183
+ sig = _truncate(sig, max_sig_len)
184
+
185
+ return NodeFields(
186
+ signature=sig,
187
+ decorators=_py_decorators(node),
188
+ is_exported=_py_is_exported(name) if name else None,
189
+ visibility=_py_visibility(name) if name else None,
190
+ qualified_name=qualified_name,
191
+ )
192
+ except Exception: # noqa: BLE001
193
+ return _safe_defaults(qualified_name)
194
+
195
+
196
+ # ── TypeScript / JavaScript extraction ───────────────────────────────────────
197
+
198
+
199
+ _TS_VISIBILITY_KEYWORDS: frozenset[str] = frozenset({"public", "private", "protected"})
200
+
201
+
202
+ def _ts_is_exported(node: Node) -> bool:
203
+ """TypeScript/JS export detection: check if parent is export_statement.
204
+
205
+ Covers:
206
+ export function foo() {}
207
+ export class Bar {}
208
+ export interface X {}
209
+ export default function foo() {}
210
+ export default class Foo {}
211
+
212
+ All of these produce an export_statement parent in the tree-sitter grammar.
213
+ The previous code had a duplicate dead branch (two identical checks) — removed.
214
+ """
215
+ try:
216
+ parent = node.parent
217
+ if parent is None:
218
+ return False
219
+ # export function/class/interface/default: parent is export_statement.
220
+ if parent.type == "export_statement":
221
+ return True
222
+ return False
223
+ except Exception: # noqa: BLE001
224
+ return False
225
+
226
+
227
+ def _ts_visibility(node: Node) -> str | None:
228
+ """TypeScript visibility from access modifier keywords on a method/property."""
229
+ try:
230
+ for child in node.children:
231
+ if child.type in ("public", "private", "protected"):
232
+ return child.type
233
+ # Some tree-sitter grammars store access modifiers as accessibility_modifier
234
+ if child.type == "accessibility_modifier":
235
+ text = _text(child).strip()
236
+ if text in _TS_VISIBILITY_KEYWORDS:
237
+ return text
238
+ except Exception: # noqa: BLE001
239
+ pass
240
+ return None
241
+
242
+
243
+ def _ts_decorators(node: Node) -> list[str]:
244
+ """Extract TypeScript/JS decorator nodes from the node's parent context.
245
+
246
+ In the tree-sitter TypeScript grammar, decorators appear as sibling nodes
247
+ before the class/method declaration, or as decorator nodes attached directly.
248
+ This walks prev_sibling for decorator nodes.
249
+ """
250
+ decorators: list[str] = []
251
+ try:
252
+ current = node.prev_sibling
253
+ while current is not None and current.type == "decorator":
254
+ text = _text(current).strip()
255
+ if text:
256
+ decorators.insert(0, text) # prepend to preserve order
257
+ current = current.prev_sibling
258
+ except Exception: # noqa: BLE001
259
+ pass
260
+ return decorators
261
+
262
+
263
+ def _ts_signature(node: Node) -> str | None:
264
+ """Extract TypeScript/JS declaration header as one-line string."""
265
+ try:
266
+ ntype = node.type
267
+
268
+ if ntype == "function_declaration":
269
+ name_node = node.child_by_field_name("name")
270
+ params_node = node.child_by_field_name("parameters")
271
+ ret_node = node.child_by_field_name("return_type")
272
+ name = _text(name_node)
273
+ params = _text(params_node)
274
+ parts = [f"function {name}{params}"]
275
+ if ret_node:
276
+ parts.append(f": {_text(ret_node).lstrip(':').strip()}")
277
+ return _normalize_to_one_line("".join(parts))
278
+
279
+ if ntype == "method_definition":
280
+ name_node = node.child_by_field_name("name")
281
+ params_node = node.child_by_field_name("parameters")
282
+ ret_node = node.child_by_field_name("return_type")
283
+ name = _text(name_node)
284
+ params = _text(params_node)
285
+ parts = [f"{name}{params}"]
286
+ if ret_node:
287
+ parts.append(f": {_text(ret_node).lstrip(':').strip()}")
288
+ return _normalize_to_one_line("".join(parts))
289
+
290
+ if ntype == "class_declaration":
291
+ name_node = node.child_by_field_name("name")
292
+ # type_parameters and class_heritage (implements/extends)
293
+ type_params = node.child_by_field_name("type_parameters")
294
+ heritage = None
295
+ for child in node.children:
296
+ if child.type == "class_heritage":
297
+ heritage = child
298
+ break
299
+ name = _text(name_node)
300
+ sig = f"class {name}"
301
+ if type_params:
302
+ sig += _text(type_params)
303
+ if heritage:
304
+ sig += f" {_text(heritage)}"
305
+ return _normalize_to_one_line(sig)
306
+
307
+ if ntype == "interface_declaration":
308
+ name_node = node.child_by_field_name("name")
309
+ type_params = node.child_by_field_name("type_parameters")
310
+ name = _text(name_node)
311
+ sig = f"interface {name}"
312
+ if type_params:
313
+ sig += _text(type_params)
314
+ return _normalize_to_one_line(sig)
315
+
316
+ if ntype == "type_alias_declaration":
317
+ name_node = node.child_by_field_name("name")
318
+ name = _text(name_node)
319
+ return _normalize_to_one_line(f"type {name}")
320
+
321
+ except Exception: # noqa: BLE001
322
+ pass
323
+ return None
324
+
325
+
326
+ def _extract_typescript(node: Node, qualified_name: str | None, max_sig_len: int) -> NodeFields:
327
+ """Extract all five fields for a TypeScript/JavaScript node."""
328
+ try:
329
+ sig = _ts_signature(node)
330
+ if sig is not None:
331
+ sig = _truncate(sig, max_sig_len)
332
+
333
+ is_exported = _ts_is_exported(node)
334
+ visibility = _ts_visibility(node)
335
+
336
+ return NodeFields(
337
+ signature=sig,
338
+ decorators=_ts_decorators(node),
339
+ is_exported=is_exported,
340
+ visibility=visibility,
341
+ qualified_name=qualified_name,
342
+ )
343
+ except Exception: # noqa: BLE001
344
+ return _safe_defaults(qualified_name)
345
+
346
+
347
+ # ── Go extraction ─────────────────────────────────────────────────────────────
348
+
349
+
350
+ def _go_is_exported(name: str) -> bool:
351
+ """Go export rule: capitalized first letter → exported."""
352
+ return bool(name) and name[0].isupper()
353
+
354
+
355
+ def _go_visibility(name: str) -> str:
356
+ """Go visibility from capitalization."""
357
+ return "public" if _go_is_exported(name) else "private"
358
+
359
+
360
+ def _go_signature(node: Node) -> str | None:
361
+ """Extract Go declaration header as one-line string."""
362
+ try:
363
+ ntype = node.type
364
+
365
+ if ntype == "function_declaration":
366
+ name_node = node.child_by_field_name("name")
367
+ params_node = node.child_by_field_name("parameters")
368
+ result_node = node.child_by_field_name("result")
369
+ name = _text(name_node)
370
+ params = _text(params_node)
371
+ sig = f"func {name}{params}"
372
+ if result_node:
373
+ sig += f" {_text(result_node)}"
374
+ return _normalize_to_one_line(sig)
375
+
376
+ if ntype == "method_declaration":
377
+ # func (recv RecvType) MethodName(params) result
378
+ recv_node = node.child_by_field_name("receiver")
379
+ name_node = node.child_by_field_name("name")
380
+ params_node = node.child_by_field_name("parameters")
381
+ result_node = node.child_by_field_name("result")
382
+ recv = _text(recv_node) if recv_node else ""
383
+ name = _text(name_node)
384
+ params = _text(params_node)
385
+ sig = f"func {recv} {name}{params}"
386
+ if result_node:
387
+ sig += f" {_text(result_node)}"
388
+ return _normalize_to_one_line(sig)
389
+
390
+ if ntype == "type_declaration":
391
+ # For type declarations we extract the first type_spec or type_alias
392
+ for child in node.named_children:
393
+ if child.type in ("type_spec", "type_alias"):
394
+ name_node = child.child_by_field_name("name")
395
+ type_node = child.child_by_field_name("type")
396
+ if name_node:
397
+ name = _text(name_node)
398
+ if type_node:
399
+ type_kind = type_node.type.replace("_type", "").replace("_", " ")
400
+ return _normalize_to_one_line(f"type {name} {type_kind}")
401
+ return _normalize_to_one_line(f"type {name}")
402
+
403
+ except Exception: # noqa: BLE001
404
+ pass
405
+ return None
406
+
407
+
408
+ def _extract_go(node: Node, qualified_name: str | None, max_sig_len: int) -> NodeFields:
409
+ """Extract all five fields for a Go node."""
410
+ try:
411
+ # Determine name for export check
412
+ name_node = node.child_by_field_name("name")
413
+ name = _text(name_node) if name_node else ""
414
+
415
+ # For type_declaration, name is inside the type_spec
416
+ if node.type == "type_declaration":
417
+ for child in node.named_children:
418
+ if child.type in ("type_spec", "type_alias"):
419
+ inner_name = child.child_by_field_name("name")
420
+ name = _text(inner_name) if inner_name else ""
421
+ break
422
+
423
+ sig = _go_signature(node)
424
+ if sig is not None:
425
+ sig = _truncate(sig, max_sig_len)
426
+
427
+ return NodeFields(
428
+ signature=sig,
429
+ # Go has no decorator syntax; unlike Python @decorator or TS @Decorator,
430
+ # struct tags and //go:generate directives are not captured here.
431
+ decorators=[],
432
+ is_exported=_go_is_exported(name) if name else None,
433
+ visibility=_go_visibility(name) if name else None,
434
+ qualified_name=qualified_name,
435
+ )
436
+ except Exception: # noqa: BLE001
437
+ return _safe_defaults(qualified_name)
438
+
439
+
440
+ # ── Rust extraction ───────────────────────────────────────────────────────────
441
+
442
+
443
+ def _rust_has_pub(node: Node) -> bool:
444
+ """Check if a Rust node has a `pub` or `pub(...)` visibility modifier."""
445
+ try:
446
+ for child in node.children:
447
+ if child.type in ("visibility_modifier",):
448
+ return True
449
+ except Exception: # noqa: BLE001
450
+ pass
451
+ return False
452
+
453
+
454
+ def _rust_visibility(node: Node) -> str:
455
+ """Rust visibility from pub modifier.
456
+
457
+ pub → 'public'
458
+ pub(crate) → 'crate'
459
+ (none) → 'private'
460
+ """
461
+ try:
462
+ for child in node.children:
463
+ if child.type == "visibility_modifier":
464
+ text = _text(child).strip()
465
+ if "crate" in text:
466
+ return "crate"
467
+ return "public"
468
+ except Exception: # noqa: BLE001
469
+ pass
470
+ return "private"
471
+
472
+
473
+ def _rust_signature(node: Node) -> str | None:
474
+ """Extract Rust declaration header as one-line string."""
475
+ try:
476
+ ntype = node.type
477
+
478
+ if ntype in ("function_item", "function_signature_item"):
479
+ # Build: [pub] fn name(params) -> return_type
480
+ parts: list[str] = []
481
+ for child in node.children:
482
+ # Stop at the body block (declaration_list or block)
483
+ if child.type in ("block", "declaration_list"):
484
+ break
485
+ text = _text(child).strip()
486
+ if text:
487
+ parts.append(text)
488
+ return _normalize_to_one_line(" ".join(parts))
489
+
490
+ if ntype == "struct_item":
491
+ parts = []
492
+ for child in node.children:
493
+ if child.type in ("field_declaration_list", "ordered_field_declaration_list"):
494
+ break
495
+ text = _text(child).strip()
496
+ if text:
497
+ parts.append(text)
498
+ return _normalize_to_one_line(" ".join(parts))
499
+
500
+ if ntype == "enum_item":
501
+ parts = []
502
+ for child in node.children:
503
+ if child.type == "enum_variant_list":
504
+ break
505
+ text = _text(child).strip()
506
+ if text:
507
+ parts.append(text)
508
+ return _normalize_to_one_line(" ".join(parts))
509
+
510
+ if ntype == "trait_item":
511
+ parts = []
512
+ for child in node.children:
513
+ if child.type == "declaration_list":
514
+ break
515
+ text = _text(child).strip()
516
+ if text:
517
+ parts.append(text)
518
+ return _normalize_to_one_line(" ".join(parts))
519
+
520
+ except Exception: # noqa: BLE001
521
+ pass
522
+ return None
523
+
524
+
525
+ def _extract_rust(node: Node, qualified_name: str | None, max_sig_len: int) -> NodeFields:
526
+ """Extract all five fields for a Rust node."""
527
+ try:
528
+ vis = _rust_visibility(node)
529
+ is_exported = vis in ("public", "crate")
530
+
531
+ sig = _rust_signature(node)
532
+ if sig is not None:
533
+ sig = _truncate(sig, max_sig_len)
534
+
535
+ return NodeFields(
536
+ signature=sig,
537
+ # Rust #[...] attributes are not decorators in the Python/TS sense and are
538
+ # intentionally excluded per PRD: they appear before the item, would require
539
+ # separate attribute_item walking, and rarely appear in symbol-level queries.
540
+ decorators=[],
541
+ is_exported=is_exported,
542
+ visibility=vis,
543
+ qualified_name=qualified_name,
544
+ )
545
+ except Exception: # noqa: BLE001
546
+ return _safe_defaults(qualified_name)
547
+
548
+
549
+ # ── Safe defaults ─────────────────────────────────────────────────────────────
550
+
551
+
552
+ def _safe_defaults(qualified_name: str | None = None) -> NodeFields:
553
+ """Return a safe NodeFields with all nulls/empty. Used on any extraction failure."""
554
+ return NodeFields(
555
+ signature=None,
556
+ decorators=[],
557
+ is_exported=None,
558
+ visibility=None,
559
+ qualified_name=qualified_name,
560
+ )
561
+
562
+
563
+ # ── Public entry point ────────────────────────────────────────────────────────
564
+
565
+
566
+ def extract_node_fields(
567
+ node: Any,
568
+ language: str,
569
+ qualified_name: str | None = None,
570
+ max_signature_len: int = _DEFAULT_MAX_SIG_LEN,
571
+ ) -> NodeFields:
572
+ """Extract the five enrichment fields from a tree-sitter symbol node.
573
+
574
+ Args:
575
+ node: A tree-sitter Node (or None/invalid — never raises).
576
+ language: 'python' | 'typescript' | 'javascript' | 'go' | 'rust'.
577
+ qualified_name: Already-resolved qualified name from the caller's scope-walker.
578
+ Passed through as-is into NodeFields.qualified_name.
579
+ max_signature_len: Maximum signature length in characters. Signatures longer
580
+ than this are truncated with '...'. Callers (graph.py /
581
+ graph_go_rust.py) read this from seam.config.SEAM_MAX_SIGNATURE_LEN
582
+ so there is a single config source of truth. The default here
583
+ (300) matches the seam.config default.
584
+
585
+ Returns:
586
+ NodeFields TypedDict. ALL fields are safe: signature may be None,
587
+ decorators is always a list (possibly empty), is_exported/visibility may be None.
588
+
589
+ NEVER raises: any extraction error returns _safe_defaults().
590
+ """
591
+ # Guard: None or non-Node input → safe defaults
592
+ if node is None:
593
+ return _safe_defaults(qualified_name)
594
+
595
+ if not isinstance(node, Node):
596
+ return _safe_defaults(qualified_name)
597
+
598
+ try:
599
+ if language == "python":
600
+ return _extract_python(node, qualified_name, max_signature_len)
601
+ elif language in ("typescript", "javascript"):
602
+ return _extract_typescript(node, qualified_name, max_signature_len)
603
+ elif language == "go":
604
+ return _extract_go(node, qualified_name, max_signature_len)
605
+ elif language == "rust":
606
+ return _extract_rust(node, qualified_name, max_signature_len)
607
+ # Phase 9 — new languages routed to signatures_ext (stubs return safe defaults)
608
+ elif language == "java":
609
+ return _extract_java(node, qualified_name, max_signature_len)
610
+ elif language == "csharp":
611
+ return _extract_csharp(node, qualified_name, max_signature_len)
612
+ elif language == "ruby":
613
+ return _extract_ruby(node, qualified_name, max_signature_len)
614
+ elif language == "c":
615
+ return _extract_c(node, qualified_name, max_signature_len)
616
+ elif language == "cpp":
617
+ return _extract_cpp(node, qualified_name, max_signature_len)
618
+ elif language == "php":
619
+ return _extract_php(node, qualified_name, max_signature_len)
620
+ # Phase 10 — Swift
621
+ elif language == "swift":
622
+ return _extract_swift(node, qualified_name, max_signature_len)
623
+ else:
624
+ # Unsupported language — safe defaults, not an error; callers may index
625
+ # languages not yet wired to an extractor.
626
+ return _safe_defaults(qualified_name)
627
+ except Exception: # noqa: BLE001
628
+ # Belt-and-suspenders: any unhandled exception → safe defaults
629
+ logger.debug(
630
+ "extract_node_fields: unhandled exception for language=%r node.type=%r",
631
+ language,
632
+ getattr(node, "type", "?"),
633
+ )
634
+ return _safe_defaults(qualified_name)