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,663 @@
1
+ """TypeScript/JavaScript symbol and edge extraction from tree-sitter ASTs.
2
+
3
+ LAYER: imports from graph_common (leaf), graph_scope_infer (leaf), field_access (leaf),
4
+ signatures (leaf) — never from graph.py.
5
+
6
+ LAYERING:
7
+ graph_common (leaf — no seam deps)
8
+ graph_scope_infer (leaf — Python+TS receiver-type inference)
9
+ field_access (leaf — field-access read/write classification + field symbols)
10
+
11
+ graph_typescript (this file — TS/JS symbol/edge extraction)
12
+
13
+ graph.py (dispatcher; imports this module's public extractors)
14
+
15
+ WHY split from graph.py: graph.py exceeded 1000 lines. TypeScript/JS extraction is a
16
+ coherent leaf unit split following the Phase 9 precedent.
17
+
18
+ Contract:
19
+ - Accept a tree-sitter Node + filepath.
20
+ - Return a list — never raise, never return None.
21
+ - Edges carry confidence='INFERRED' by default.
22
+ """
23
+
24
+ from pathlib import Path
25
+
26
+ from tree_sitter import Node
27
+
28
+ import seam.config as config
29
+ from seam.indexer.field_access import (
30
+ collect_field_symbols_typescript,
31
+ extract_field_accesses_typescript,
32
+ )
33
+ from seam.indexer.graph_common import (
34
+ Comment,
35
+ Edge,
36
+ Symbol,
37
+ _base_type_name,
38
+ _block_comment_lines,
39
+ _find_enclosing_function,
40
+ _make_symbol,
41
+ _match_marker,
42
+ _text,
43
+ )
44
+ from seam.indexer.graph_scope_infer import (
45
+ _TS_SELF_NAMES,
46
+ collect_composition_types_typescript,
47
+ collect_param_types_typescript,
48
+ record_ts_local_types,
49
+ record_ts_param_types,
50
+ resolve_receiver_type,
51
+ scan_class_fields_typescript,
52
+ )
53
+ from seam.indexer.signatures import extract_node_fields
54
+
55
+ # ── JSDoc helper ───────────────────────────────────────────────────────────────
56
+
57
+
58
+ def _ts_jsdoc(symbol_node: Node) -> str | None:
59
+ """Extract leading JSDoc comment from the previous sibling node.
60
+
61
+ tree-sitter emits /** ... */ blocks as 'comment' nodes immediately
62
+ before the declaration. Only /** blocks qualify as JSDoc.
63
+ """
64
+ prev = symbol_node.prev_sibling
65
+ if prev is None or prev.type != "comment":
66
+ return None
67
+ comment_text = _text(prev)
68
+ if not comment_text.startswith("/**"):
69
+ return None
70
+ return comment_text
71
+
72
+
73
+ # ── TypeScript/JavaScript extraction ──────────────────────────────────────────
74
+
75
+
76
+ def _extract_symbols_typescript(root: Node, filepath: Path) -> list[Symbol]:
77
+ """Walk a TypeScript/TSX AST and extract all symbol types.
78
+
79
+ A3 Slice 2: when SEAM_FIELD_ACCESS_EDGES='on', also emits kind='field' symbols
80
+ for each class using collect_field_symbols_typescript (field declarations +
81
+ constructor this.x= + parameter properties).
82
+ """
83
+ symbols: list[Symbol] = []
84
+ file_str = str(filepath)
85
+ field_access_on = config.SEAM_FIELD_ACCESS_EDGES == "on"
86
+
87
+ def _walk(node: Node, class_name: str | None = None) -> None:
88
+ if node.type == "function_declaration":
89
+ name_node = node.child_by_field_name("name")
90
+ if name_node:
91
+ name = _text(name_node)
92
+ kind = "method" if class_name else "function"
93
+ qualified = f"{class_name}.{name}" if class_name else name
94
+ doc = _ts_jsdoc(node)
95
+ fields = extract_node_fields(
96
+ node,
97
+ "typescript",
98
+ qualified_name=qualified,
99
+ max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
100
+ )
101
+ symbols.append(
102
+ _make_symbol(
103
+ qualified,
104
+ kind,
105
+ file_str,
106
+ node,
107
+ doc,
108
+ signature=fields["signature"],
109
+ decorators=fields["decorators"],
110
+ is_exported=fields["is_exported"],
111
+ visibility=fields["visibility"],
112
+ qualified_name=qualified,
113
+ )
114
+ )
115
+ body = node.child_by_field_name("body")
116
+ if body:
117
+ for child in body.children:
118
+ _walk(child, None)
119
+
120
+ elif node.type == "method_definition":
121
+ name_node = node.child_by_field_name("name")
122
+ if name_node:
123
+ name = _text(name_node)
124
+ qualified = f"{class_name}.{name}" if class_name else name
125
+ doc = _ts_jsdoc(node)
126
+ fields = extract_node_fields(
127
+ node,
128
+ "typescript",
129
+ qualified_name=qualified,
130
+ max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
131
+ )
132
+ symbols.append(
133
+ _make_symbol(
134
+ qualified,
135
+ "method",
136
+ file_str,
137
+ node,
138
+ doc,
139
+ signature=fields["signature"],
140
+ decorators=fields["decorators"],
141
+ is_exported=fields["is_exported"],
142
+ visibility=fields["visibility"],
143
+ qualified_name=qualified,
144
+ )
145
+ )
146
+
147
+ elif node.type == "class_declaration":
148
+ name_node = node.child_by_field_name("name")
149
+ if name_node:
150
+ cls_name = _text(name_node)
151
+ doc = _ts_jsdoc(node)
152
+ fields = extract_node_fields(
153
+ node,
154
+ "typescript",
155
+ qualified_name=cls_name,
156
+ max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
157
+ )
158
+ symbols.append(
159
+ _make_symbol(
160
+ cls_name,
161
+ "class",
162
+ file_str,
163
+ node,
164
+ doc,
165
+ signature=fields["signature"],
166
+ decorators=fields["decorators"],
167
+ is_exported=fields["is_exported"],
168
+ visibility=fields["visibility"],
169
+ qualified_name=cls_name,
170
+ )
171
+ )
172
+
173
+ # A3 Slice 2: emit field symbols for this TS class.
174
+ # Mirrors the Python approach: collect (qualified_field, line) pairs
175
+ # from field declarations, constructor assignments, and param properties.
176
+ # Dedup is handled by collect_field_symbols_typescript.
177
+ # WHY Symbol(...) not _make_symbol: _make_symbol derives start/end lines
178
+ # from the node start_point, but field lines come from the collector.
179
+ # Using Symbol directly (as graph_python.py does) avoids a sentinel node.
180
+ if field_access_on and cls_name:
181
+ for qualified_field, field_line in collect_field_symbols_typescript(
182
+ node, cls_name
183
+ ):
184
+ symbols.append(Symbol(
185
+ name=qualified_field,
186
+ kind="field",
187
+ file=file_str,
188
+ start_line=field_line,
189
+ end_line=field_line,
190
+ docstring=None,
191
+ signature=None,
192
+ decorators=[],
193
+ is_exported=None,
194
+ visibility=None,
195
+ qualified_name=qualified_field,
196
+ ))
197
+
198
+ body = node.child_by_field_name("body")
199
+ if body:
200
+ for child in body.children:
201
+ _walk(child, cls_name)
202
+
203
+ elif node.type == "interface_declaration":
204
+ name_node = node.child_by_field_name("name")
205
+ if name_node:
206
+ name = _text(name_node)
207
+ doc = _ts_jsdoc(node)
208
+ fields = extract_node_fields(
209
+ node,
210
+ "typescript",
211
+ qualified_name=name,
212
+ max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
213
+ )
214
+ symbols.append(
215
+ _make_symbol(
216
+ name,
217
+ "interface",
218
+ file_str,
219
+ node,
220
+ doc,
221
+ signature=fields["signature"],
222
+ decorators=fields["decorators"],
223
+ is_exported=fields["is_exported"],
224
+ visibility=fields["visibility"],
225
+ qualified_name=name,
226
+ )
227
+ )
228
+
229
+ elif node.type == "type_alias_declaration":
230
+ name_node = node.child_by_field_name("name")
231
+ if name_node:
232
+ name = _text(name_node)
233
+ doc = _ts_jsdoc(node)
234
+ fields = extract_node_fields(
235
+ node,
236
+ "typescript",
237
+ qualified_name=name,
238
+ max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
239
+ )
240
+ symbols.append(
241
+ _make_symbol(
242
+ name,
243
+ "type",
244
+ file_str,
245
+ node,
246
+ doc,
247
+ signature=fields["signature"],
248
+ decorators=fields["decorators"],
249
+ is_exported=fields["is_exported"],
250
+ visibility=fields["visibility"],
251
+ qualified_name=name,
252
+ )
253
+ )
254
+
255
+ else:
256
+ for child in node.children:
257
+ _walk(child, class_name)
258
+
259
+ for child in root.children:
260
+ _walk(child, None)
261
+
262
+ return symbols
263
+
264
+
265
+ def _extract_edges_typescript(root: Node, filepath: Path) -> list[Edge]:
266
+ """Extract import and call edges from a TypeScript/TSX AST.
267
+
268
+ Import heuristic:
269
+ - default import X → target = 'X'
270
+ - named import { X, Y } → one edge per import_specifier (real name)
271
+ - aliased import { a as b } → target = 'a' (real name, not alias)
272
+ - namespace import * as ns → target = 'ns'
273
+
274
+ Call heuristic (Tier B B4 enhanced):
275
+ - identifier call_expression → source/target edge (bare, no receiver)
276
+ - member_expression call (obj.method()) → when SEAM_TYPE_INFERENCE=on and the
277
+ receiver type is known in scope, target = 'Type.method'; else bare 'method'.
278
+ - Scope: class field pre-scan (order-independent) + per-function param/local types.
279
+
280
+ B6: new_expression → 'instantiates' edges.
281
+ """
282
+ edges: list[Edge] = []
283
+ file_str = str(filepath)
284
+ file_stem = filepath.stem
285
+ emit_inheritance = config.SEAM_INHERITANCE_EDGES == "on"
286
+ type_inference_on = config.SEAM_TYPE_INFERENCE == "on"
287
+ composition_on = config.SEAM_COMPOSITION_EDGES == "on"
288
+ field_access_on = config.SEAM_FIELD_ACCESS_EDGES == "on"
289
+ param_edges_on = config.SEAM_PARAM_EDGES == "on"
290
+
291
+ def _emit_ts_inheritance(node: Node) -> None:
292
+ name_node = node.child_by_field_name("name")
293
+ if name_node is None:
294
+ return
295
+ src_name = _text(name_node)
296
+ line = node.start_point[0] + 1
297
+
298
+ def _emit_from_clause(clause: Node, kind: str) -> None:
299
+ for c in clause.named_children:
300
+ target = _base_type_name(c)
301
+ if target:
302
+ edges.append(Edge(
303
+ source=src_name,
304
+ target=target,
305
+ kind=kind,
306
+ file=file_str,
307
+ line=line,
308
+ confidence="INFERRED",
309
+ receiver=None,
310
+ ))
311
+
312
+ for child in node.children:
313
+ if child.type == "class_heritage":
314
+ for clause in child.children:
315
+ if clause.type == "extends_clause":
316
+ _emit_from_clause(clause, "extends")
317
+ elif clause.type == "implements_clause":
318
+ _emit_from_clause(clause, "implements")
319
+ elif child.type == "extends_type_clause":
320
+ _emit_from_clause(child, "extends")
321
+
322
+ def _emit_import_edges_ts(node: Node) -> None:
323
+ line = node.start_point[0] + 1
324
+ clause = None
325
+ for child in node.children:
326
+ if child.type == "import_clause":
327
+ clause = child
328
+ break
329
+ if not clause:
330
+ return
331
+ for clause_child in clause.children:
332
+ if clause_child.type == "identifier":
333
+ edges.append(Edge(
334
+ source=file_stem,
335
+ target=_text(clause_child),
336
+ kind="import",
337
+ file=file_str,
338
+ line=line,
339
+ confidence="INFERRED",
340
+ receiver=None,
341
+ ))
342
+ elif clause_child.type == "namespace_import":
343
+ for ns_child in clause_child.children:
344
+ if ns_child.type == "identifier":
345
+ edges.append(Edge(
346
+ source=file_stem,
347
+ target=_text(ns_child),
348
+ kind="import",
349
+ file=file_str,
350
+ line=line,
351
+ confidence="INFERRED",
352
+ receiver=None,
353
+ ))
354
+ break
355
+ elif clause_child.type == "named_imports":
356
+ for spec in clause_child.children:
357
+ if spec.type == "import_specifier":
358
+ name_node = spec.child_by_field_name("name")
359
+ if name_node is None and spec.children:
360
+ name_node = spec.children[0]
361
+ if name_node:
362
+ edges.append(Edge(
363
+ source=file_stem,
364
+ target=_text(name_node),
365
+ kind="import",
366
+ file=file_str,
367
+ line=line,
368
+ confidence="INFERRED",
369
+ receiver=None,
370
+ ))
371
+
372
+ def _emit_ts_instantiates(node: Node, class_name: str | None, language: str) -> None:
373
+ """Emit an instantiates edge from a TS/JS new_expression node."""
374
+ try:
375
+ source = _find_enclosing_function(node, language)
376
+ if source is None:
377
+ return
378
+ for child in node.children:
379
+ if child.type == "identifier":
380
+ type_name = _text(child)
381
+ if type_name:
382
+ edges.append(Edge(
383
+ source=source,
384
+ target=type_name,
385
+ kind="instantiates",
386
+ file=file_str,
387
+ line=node.start_point[0] + 1,
388
+ confidence="INFERRED",
389
+ receiver=None,
390
+ ))
391
+ return
392
+ except Exception: # noqa: BLE001
393
+ pass
394
+
395
+ def _emit_call_edge_ts(
396
+ node: Node,
397
+ class_name: str | None,
398
+ var_types: dict[str, str],
399
+ language: str,
400
+ ) -> None:
401
+ """Emit a call edge for a TS/JS call_expression node.
402
+
403
+ TS/JS does NOT need a PascalCase bare-call → 'instantiates' check here
404
+ because construction uses `new Foo(...)` (new_expression), handled separately
405
+ by _emit_ts_instantiates. Python has no 'new' keyword, so it must heuristic
406
+ on PascalCase; TS/JS has an explicit AST node for it.
407
+
408
+ receiver_text is stored in the edge even when the target was already qualified
409
+ — same reasoning as Python: preserves raw text for debugging and future passes.
410
+ """
411
+ func_child = node.child_by_field_name("function")
412
+ callee_node: Node | None = None
413
+ receiver_text: str | None = None
414
+
415
+ if func_child and func_child.type == "identifier":
416
+ callee_node = func_child
417
+ elif func_child and func_child.type == "member_expression":
418
+ # member_expression: object.property() — 'property' is the callee name.
419
+ prop = func_child.child_by_field_name("property")
420
+ obj = func_child.child_by_field_name("object")
421
+ if prop is not None and prop.type == "property_identifier":
422
+ callee_node = prop
423
+ if obj is not None:
424
+ receiver_text = _text(obj)
425
+
426
+ if callee_node is None:
427
+ return
428
+ source = _find_enclosing_function(node, language)
429
+ if source is None:
430
+ return
431
+
432
+ method_name = _text(callee_node)
433
+ target = method_name
434
+
435
+ # B4: qualify target to 'Type.method' when the receiver type is known.
436
+ # When resolve_receiver_type returns None (unknown, optional, generic,
437
+ # or chained), keep the bare method name — conservatism contract.
438
+ if type_inference_on and receiver_text is not None:
439
+ resolved_type = resolve_receiver_type(
440
+ receiver_text, class_name, var_types, _TS_SELF_NAMES
441
+ )
442
+ if resolved_type is not None:
443
+ target = f"{resolved_type}.{method_name}"
444
+
445
+ edges.append(Edge(
446
+ source=source,
447
+ target=target,
448
+ kind="call",
449
+ file=file_str,
450
+ line=node.start_point[0] + 1,
451
+ confidence="INFERRED",
452
+ receiver=receiver_text,
453
+ ))
454
+
455
+ def _walk_ts_function_body(
456
+ func_node: Node,
457
+ class_name: str | None,
458
+ class_fields: dict[str, str],
459
+ language: str,
460
+ ) -> None:
461
+ # Copy class_fields into a fresh per-function scope (Layer 2).
462
+ # WHY copy not reference: param/local bindings must not bleed between methods.
463
+ # class_fields is immutable from the caller's perspective (Layer 1 pre-scan).
464
+ var_types: dict[str, str] = dict(class_fields)
465
+ record_ts_param_types(func_node, var_types)
466
+
467
+ # 'uses' edges: this function references plain user types as parameters.
468
+ # source_fn derived exactly like the field-access block below (named function →
469
+ # qualified Class.method; anonymous arrow/expression → enclosing-function lookup).
470
+ if param_edges_on:
471
+ uses_source: str | None
472
+ uses_name_node = func_node.child_by_field_name("name")
473
+ if uses_name_node is not None:
474
+ uses_fn = _text(uses_name_node)
475
+ uses_source = f"{class_name}.{uses_fn}" if class_name else uses_fn
476
+ else:
477
+ uses_source = _find_enclosing_function(func_node, language)
478
+ if uses_source is not None:
479
+ for ptype, pline in collect_param_types_typescript(func_node):
480
+ edges.append(Edge(
481
+ source=uses_source,
482
+ target=ptype,
483
+ kind="uses",
484
+ file=file_str,
485
+ line=pline,
486
+ confidence="INFERRED",
487
+ receiver=None,
488
+ ))
489
+
490
+ body = func_node.child_by_field_name("body")
491
+ if body is None:
492
+ return
493
+ for stmt in body.children:
494
+ # Accumulate local type bindings BEFORE emitting call edges for this
495
+ # statement so that a call on the same line as a declaration can still
496
+ # see that declaration (e.g. `const e = new Engine(); e.start()`).
497
+ record_ts_local_types(stmt, var_types)
498
+ _walk_ts_stmt(stmt, class_name, var_types, class_fields, language)
499
+
500
+ # A3 Slice 2: emit reads/writes edges for field accesses in this function body.
501
+ # Done AFTER the main stmt walk so var_types is fully populated (record_ts_local_types
502
+ # runs incrementally during the stmt walk above). WHY separate pass: fully-populated
503
+ # var_types gives higher-quality receiver resolution than an interleaved walk.
504
+ # Mirrors graph_python.py's identical two-pass design for extract_field_accesses_python.
505
+ if field_access_on:
506
+ source_fn: str | None = None
507
+ name_node = func_node.child_by_field_name("name")
508
+ if name_node is not None:
509
+ fn_name = _text(name_node)
510
+ source_fn = f"{class_name}.{fn_name}" if class_name else fn_name
511
+ else:
512
+ # Anonymous function (arrow/expression) — use enclosing-function lookup.
513
+ source_fn = _find_enclosing_function(func_node, language)
514
+ if source_fn is not None and body is not None:
515
+ for _src, target_field, mode, fa_line in extract_field_accesses_typescript(
516
+ body, source_fn, class_name, var_types
517
+ ):
518
+ edges.append(Edge(
519
+ source=source_fn,
520
+ target=target_field,
521
+ kind=mode,
522
+ file=file_str,
523
+ line=fa_line,
524
+ confidence="INFERRED",
525
+ receiver=None,
526
+ ))
527
+
528
+ def _walk_ts_stmt(
529
+ node: Node,
530
+ class_name: str | None,
531
+ var_types: dict[str, str],
532
+ class_fields: dict[str, str],
533
+ language: str,
534
+ ) -> None:
535
+ if node.type == "import_statement":
536
+ _emit_import_edges_ts(node)
537
+ return
538
+
539
+ # interface_declaration: emit inheritance here (not in _walk_ts_class).
540
+ # class_declaration: inheritance is emitted inside _walk_ts_class — do NOT emit here
541
+ # to avoid double-emitting when a class is defined inside a function body.
542
+ if emit_inheritance and node.type == "interface_declaration":
543
+ _emit_ts_inheritance(node)
544
+
545
+ if node.type == "call_expression":
546
+ _emit_call_edge_ts(node, class_name, var_types, language)
547
+
548
+ if node.type == "new_expression":
549
+ _emit_ts_instantiates(node, class_name, language)
550
+
551
+ if node.type in ("function_declaration", "arrow_function", "function_expression"):
552
+ _walk_ts_function_body(node, class_name, class_fields, language)
553
+ return
554
+ if node.type == "method_definition":
555
+ _walk_ts_function_body(node, class_name, class_fields, language)
556
+ return
557
+ if node.type == "class_declaration":
558
+ _walk_ts_class(node, language)
559
+ return
560
+
561
+ for child in node.children:
562
+ _walk_ts_stmt(child, class_name, var_types, class_fields, language)
563
+
564
+ def _walk_ts_class(class_node: Node, language: str) -> None:
565
+ cls_name_node = class_node.child_by_field_name("name")
566
+ cls_name = _text(cls_name_node) if cls_name_node else None
567
+
568
+ if emit_inheritance and cls_name:
569
+ _emit_ts_inheritance(class_node)
570
+
571
+ class_fields: dict[str, str] = {}
572
+ if type_inference_on and cls_name:
573
+ class_fields = scan_class_fields_typescript(class_node)
574
+
575
+ # Slice #77: emit composition (holds) edges for this TS class.
576
+ # Mirrors the Python approach: collect deduped (type, line) pairs and emit
577
+ # one holds edge per unique held type. The collector handles dedup so both
578
+ # a field declaration and a ctor parameter of the same type produce one edge.
579
+ if composition_on and cls_name:
580
+ for held_type, held_line in collect_composition_types_typescript(class_node):
581
+ edges.append(Edge(
582
+ source=cls_name,
583
+ target=held_type,
584
+ kind="holds",
585
+ file=file_str,
586
+ line=held_line,
587
+ confidence="INFERRED",
588
+ receiver=None,
589
+ ))
590
+
591
+ body = class_node.child_by_field_name("body")
592
+ if body is None:
593
+ return
594
+ for child in body.children:
595
+ if child.type == "method_definition":
596
+ _walk_ts_function_body(child, cls_name, class_fields, language)
597
+ elif child.type in ("public_field_definition", "field_definition"):
598
+ pass
599
+ elif child.type == "class_declaration":
600
+ _walk_ts_class(child, language)
601
+ else:
602
+ _walk_ts_stmt(child, cls_name, class_fields, class_fields, language)
603
+
604
+ def _walk_ts_toplevel(node: Node, language: str) -> None:
605
+ if node.type == "import_statement":
606
+ _emit_import_edges_ts(node)
607
+ elif node.type == "class_declaration":
608
+ _walk_ts_class(node, language)
609
+ elif emit_inheritance and node.type == "interface_declaration":
610
+ _emit_ts_inheritance(node)
611
+ body = node.child_by_field_name("body")
612
+ if body:
613
+ for child in body.children:
614
+ _walk_ts_stmt(child, None, {}, {}, language)
615
+ elif node.type in ("function_declaration", "function_expression"):
616
+ _walk_ts_function_body(node, None, {}, language)
617
+ elif node.type == "lexical_declaration":
618
+ for child in node.children:
619
+ if child.type == "variable_declarator":
620
+ val = child.child_by_field_name("value")
621
+ if val and val.type in ("arrow_function", "function_expression"):
622
+ _walk_ts_function_body(val, None, {}, language)
623
+ else:
624
+ for child in node.children:
625
+ _walk_ts_toplevel(child, language)
626
+
627
+ lang = "typescript"
628
+ for child in root.children:
629
+ _walk_ts_toplevel(child, lang)
630
+
631
+ return edges
632
+
633
+
634
+ def _extract_comments_typescript(root: Node, filepath: Path) -> list[Comment]:
635
+ """Walk a TypeScript/JS AST and collect matched semantic comment nodes.
636
+
637
+ Handles both // line comments and /* */ block comments.
638
+ """
639
+ comments: list[Comment] = []
640
+
641
+ def _walk(node: Node) -> None:
642
+ if node.type == "comment":
643
+ raw = _text(node)
644
+ base_row = node.start_point[0] + 1
645
+ if raw.startswith("/*"):
646
+ for offset, body in _block_comment_lines(raw):
647
+ result = _match_marker(body)
648
+ if result is not None:
649
+ marker, text = result
650
+ comments.append(Comment(marker=marker, text=text, line=base_row + offset))
651
+ else:
652
+ body = raw.lstrip("/").strip() if raw.startswith("//") else raw.strip()
653
+ result = _match_marker(body)
654
+ if result is not None:
655
+ marker, text = result
656
+ comments.append(Comment(marker=marker, text=text, line=base_row))
657
+ for child in node.children:
658
+ _walk(child)
659
+
660
+ for child in root.children:
661
+ _walk(child)
662
+
663
+ return comments