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,914 @@
1
+ """PHP 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_php (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
+ PHP: class/function/method 'name' field (name node) + declaration_list/compound_statement
24
+ body; namespace_use_declaration → namespace_use_clause → qualified_name (last 'name');
25
+ namespace_use_group: the group { ... } is a 'namespace_use_group' child of the
26
+ namespace_use_declaration — must descend into it to find clause children;
27
+ aliased use clause: namespace_use_clause → qualified_name + 'as' + name (alias);
28
+ function_call_expression 'function' field = 'name' node (bare); comment node covers
29
+ //, #, and /* */ blocks.
30
+ PHP attributes: attribute_list appears as FIRST CHILD of the declaration node (not
31
+ prev_sibling) — the prev_sibling branch for attribute_list in phpdoc lookup is dead code.
32
+ enum_declaration body is 'enum_declaration_list' which may contain method_declaration nodes.
33
+ """
34
+
35
+ import logging
36
+ from pathlib import Path
37
+
38
+ from tree_sitter import Node
39
+
40
+ import seam.config as config
41
+
42
+ # A3 Slice 5: PHP field-access edges + field symbols (sorts before graph_* imports).
43
+ from seam.indexer.field_access_ext2 import (
44
+ collect_field_symbols_php,
45
+ extract_field_accesses_php,
46
+ )
47
+
48
+ # All shared types from the leaf module (no cycle — graph_common has no seam deps).
49
+ from seam.indexer.graph_common import (
50
+ Comment,
51
+ Edge,
52
+ Symbol,
53
+ _block_comment_lines,
54
+ _find_enclosing_function,
55
+ _make_symbol,
56
+ _match_marker,
57
+ _text,
58
+ )
59
+
60
+ # Scope-inference: shared resolver from ext, PHP helpers from ext2.
61
+ from seam.indexer.graph_scope_infer_ext import param_types_via_recorder, resolve_receiver_type_ext
62
+ from seam.indexer.graph_scope_infer_ext2 import (
63
+ _PHP_SELF_NAMES,
64
+ collect_composition_types_php,
65
+ record_php_local_types,
66
+ record_php_param_types,
67
+ scan_class_fields_php,
68
+ )
69
+
70
+ # signatures_ext is the leaf enrichment module for Phase 9 languages.
71
+ from seam.indexer.signatures_ext import _extract_php as _sig_php
72
+
73
+ logger = logging.getLogger(__name__)
74
+
75
+ # ── PHP doc-comment helpers ───────────────────────────────────────────────────
76
+
77
+
78
+ def _php_phpdoc_comment(decl_node: Node) -> str | None:
79
+ """Capture a PHP phpdoc comment (/** ... */) immediately above the declaration.
80
+
81
+ Walks prev_sibling collecting comment nodes. Only block comments (/** */)
82
+ directly adjacent (no blank line) qualify as phpdoc. Line comments (// , #)
83
+ are also captured as leading doc if they're adjacent.
84
+
85
+ WHY no attribute_list prev_sibling branch: PHP attributes (#[...]) are the
86
+ FIRST CHILD of the declaration node itself, not a separate prev_sibling.
87
+ A prev_sibling attribute_list never appears in practice — the branch was dead
88
+ code and has been removed.
89
+
90
+ Returns the combined text, stripped of /* */ and * decorations, or None.
91
+ """
92
+ current = decl_node.prev_sibling
93
+
94
+ while current is not None:
95
+ if current.type == "comment":
96
+ raw = _text(current)
97
+ # Check adjacency: comment must end on the row just before decl start.
98
+ decl_start_row = decl_node.start_point[0]
99
+ comment_end_row = current.end_point[0]
100
+ if comment_end_row + 1 != decl_start_row:
101
+ break # gap — not adjacent
102
+
103
+ if raw.startswith("/**"):
104
+ # phpdoc block: strip /** */ decorations and return.
105
+ return _php_strip_phpdoc(raw)
106
+ if raw.startswith("//") or raw.startswith("#"):
107
+ # Single line comment — use as doc if it's immediately above.
108
+ body = raw.lstrip("/").lstrip("#").strip()
109
+ return body if body else None
110
+ break # unknown comment type
111
+ else:
112
+ break
113
+
114
+ return None
115
+
116
+
117
+ def _php_strip_phpdoc(raw: str) -> str | None:
118
+ """Strip /** ... */ decorations from a PHP phpdoc block.
119
+
120
+ Returns clean text (lines joined with newline), or None if empty.
121
+ """
122
+ lines: list[str] = []
123
+ for line in raw.splitlines():
124
+ s = line.strip()
125
+ if s.startswith("/**"):
126
+ s = s[3:].strip()
127
+ elif s.startswith("*/"):
128
+ s = s[2:].strip()
129
+ elif s.startswith("*"):
130
+ s = s[1:].strip()
131
+ if s:
132
+ lines.append(s)
133
+ return "\n".join(lines) if lines else None
134
+
135
+
136
+ # ── PHP symbol extraction ──────────────────────────────────────────────────────
137
+
138
+
139
+ def _extract_symbols_php(root: Node, filepath: Path) -> list[Symbol]:
140
+ """Walk a PHP AST and extract class, interface, trait, enum, function, and method symbols.
141
+
142
+ Kind mapping (Phase 9 spec):
143
+ class_declaration → class
144
+ interface_declaration → interface
145
+ trait_declaration → interface (named mixin — closest fit in closed vocabulary)
146
+ enum_declaration → type
147
+ function_definition → function (top-level)
148
+ method_declaration (inside class/interface/trait/enum) → method ('Class.method')
149
+
150
+ PHP AST root is 'program' which contains php_tag, namespace_definition,
151
+ namespace_use_declaration, class_declaration, function_definition, etc.
152
+
153
+ NEVER raises — all exceptions caught and logged at DEBUG level.
154
+ """
155
+ symbols: list[Symbol] = []
156
+ file_str = str(filepath)
157
+
158
+ try:
159
+ _walk_php_symbols(root, file_str, symbols, class_name=None)
160
+ except Exception as exc: # noqa: BLE001
161
+ logger.debug("_extract_symbols_php: unexpected error for %s: %r", filepath, exc)
162
+
163
+ return symbols
164
+
165
+
166
+ def _walk_php_symbols(
167
+ node: Node,
168
+ file_str: str,
169
+ symbols: list[Symbol],
170
+ class_name: str | None,
171
+ ) -> None:
172
+ """Recursive DFS walker for PHP symbol extraction.
173
+
174
+ class_name: enclosing class/interface/trait/enum name for method qualification.
175
+ """
176
+ ntype = node.type
177
+
178
+ if ntype in ("class_declaration", "interface_declaration", "trait_declaration"):
179
+ _handle_php_class_like(node, file_str, symbols, ntype)
180
+ return # handled recursively inside
181
+
182
+ if ntype == "enum_declaration":
183
+ _handle_php_enum(node, file_str, symbols)
184
+ return # handled recursively inside (recurses into enum body for methods)
185
+
186
+ if ntype == "function_definition" and class_name is None:
187
+ # Top-level function (not inside a class body).
188
+ _handle_php_function(node, file_str, symbols)
189
+ return
190
+
191
+ if ntype == "method_declaration" and class_name is not None:
192
+ _handle_php_method(node, file_str, symbols, class_name)
193
+ return
194
+
195
+ for child in node.children:
196
+ _walk_php_symbols(child, file_str, symbols, class_name)
197
+
198
+
199
+ def _handle_php_class_like(
200
+ node: Node,
201
+ file_str: str,
202
+ symbols: list[Symbol],
203
+ ntype: str,
204
+ ) -> None:
205
+ """Emit a class/interface/trait symbol and recurse into its body."""
206
+ name_node = node.child_by_field_name("name")
207
+ if name_node is None:
208
+ # Fallback: find first 'name' child
209
+ for child in node.children:
210
+ if child.type == "name":
211
+ name_node = child
212
+ break
213
+ if name_node is None:
214
+ return
215
+
216
+ name = _text(name_node)
217
+ doc = _php_phpdoc_comment(node)
218
+
219
+ if ntype == "class_declaration":
220
+ kind = "class"
221
+ elif ntype == "interface_declaration":
222
+ kind = "interface"
223
+ else: # trait_declaration → interface per spec
224
+ kind = "interface"
225
+
226
+ fields = _sig_php(node, name, config.SEAM_MAX_SIGNATURE_LEN)
227
+ symbols.append(
228
+ _make_symbol(
229
+ name,
230
+ kind,
231
+ file_str,
232
+ node,
233
+ doc,
234
+ signature=fields["signature"],
235
+ decorators=fields["decorators"],
236
+ is_exported=fields["is_exported"],
237
+ visibility=fields["visibility"],
238
+ qualified_name=name,
239
+ )
240
+ )
241
+
242
+ # A3 Slice 5: emit field symbols for PHP property_declaration nodes (classes only).
243
+ if ntype == "class_declaration" and config.SEAM_FIELD_ACCESS_EDGES == "on":
244
+ for qual_name, field_line in collect_field_symbols_php(node, name):
245
+ symbols.append(Symbol(
246
+ name=qual_name,
247
+ kind="field",
248
+ file=file_str,
249
+ start_line=field_line,
250
+ end_line=field_line,
251
+ docstring=None,
252
+ signature=None,
253
+ decorators=[],
254
+ is_exported=None,
255
+ visibility=None,
256
+ qualified_name=qual_name,
257
+ ))
258
+
259
+ # Recurse into declaration_list body with this class name set.
260
+ for child in node.children:
261
+ _walk_php_symbols(child, file_str, symbols, class_name=name)
262
+
263
+
264
+ def _handle_php_enum(node: Node, file_str: str, symbols: list[Symbol]) -> None:
265
+ """Emit a PHP enum symbol (kind='type') and recurse into its body for methods.
266
+
267
+ WHY recurse: PHP enums can contain method_declaration nodes inside their
268
+ enum_declaration_list body. These methods must be qualified 'EnumName.method'.
269
+ """
270
+ name_node = node.child_by_field_name("name")
271
+ if name_node is None:
272
+ for child in node.children:
273
+ if child.type == "name":
274
+ name_node = child
275
+ break
276
+ if name_node is None:
277
+ return
278
+
279
+ name = _text(name_node)
280
+ doc = _php_phpdoc_comment(node)
281
+ fields = _sig_php(node, name, config.SEAM_MAX_SIGNATURE_LEN)
282
+ symbols.append(
283
+ _make_symbol(
284
+ name,
285
+ "type",
286
+ file_str,
287
+ node,
288
+ doc,
289
+ signature=fields["signature"],
290
+ decorators=fields["decorators"],
291
+ is_exported=fields["is_exported"],
292
+ visibility=fields["visibility"],
293
+ qualified_name=name,
294
+ )
295
+ )
296
+
297
+ # Recurse into enum body with enum name as class context so methods are qualified.
298
+ for child in node.children:
299
+ _walk_php_symbols(child, file_str, symbols, class_name=name)
300
+
301
+
302
+ def _handle_php_function(node: Node, file_str: str, symbols: list[Symbol]) -> None:
303
+ """Emit a top-level PHP function symbol (kind='function')."""
304
+ name_node = node.child_by_field_name("name")
305
+ if name_node is None:
306
+ for child in node.children:
307
+ if child.type == "name":
308
+ name_node = child
309
+ break
310
+ if name_node is None:
311
+ return
312
+
313
+ name = _text(name_node)
314
+ doc = _php_phpdoc_comment(node)
315
+ fields = _sig_php(node, name, config.SEAM_MAX_SIGNATURE_LEN)
316
+ symbols.append(
317
+ _make_symbol(
318
+ name,
319
+ "function",
320
+ file_str,
321
+ node,
322
+ doc,
323
+ signature=fields["signature"],
324
+ decorators=fields["decorators"],
325
+ is_exported=fields["is_exported"],
326
+ visibility=fields["visibility"],
327
+ qualified_name=name,
328
+ )
329
+ )
330
+
331
+
332
+ def _handle_php_method(
333
+ node: Node,
334
+ file_str: str,
335
+ symbols: list[Symbol],
336
+ class_name: str,
337
+ ) -> None:
338
+ """Emit a PHP method_declaration symbol (kind='method', qualified 'Class.method')."""
339
+ name_node = node.child_by_field_name("name")
340
+ if name_node is None:
341
+ for child in node.children:
342
+ if child.type == "name":
343
+ name_node = child
344
+ break
345
+ if name_node is None:
346
+ return
347
+
348
+ name = _text(name_node)
349
+ qualified = f"{class_name}.{name}"
350
+ doc = _php_phpdoc_comment(node)
351
+ fields = _sig_php(node, qualified, config.SEAM_MAX_SIGNATURE_LEN)
352
+ symbols.append(
353
+ _make_symbol(
354
+ qualified,
355
+ "method",
356
+ file_str,
357
+ node,
358
+ doc,
359
+ signature=fields["signature"],
360
+ decorators=fields["decorators"],
361
+ is_exported=fields["is_exported"],
362
+ visibility=fields["visibility"],
363
+ qualified_name=qualified,
364
+ )
365
+ )
366
+
367
+
368
+ # ── PHP edge extraction ────────────────────────────────────────────────────────
369
+
370
+
371
+ def _extract_edges_php(root: Node, filepath: Path) -> list[Edge]:
372
+ """Extract import and call edges from a PHP AST.
373
+
374
+ Import heuristic:
375
+ namespace_use_declaration → namespace_use_clause → qualified_name
376
+ → last 'name' segment (e.g. 'use App\\Models\\User' → 'User')
377
+ namespace_use_group: descend into group to find clauses
378
+ Aliased use: 'use App\\Models\\User as U' → target = 'User' (real name)
379
+
380
+ Call heuristic:
381
+ function_call_expression where 'function' field is a 'name' node → bare call.
382
+ member_call_expression ($this->m, $obj->m) → capture receiver.
383
+ scoped_call_expression (ClassName::m) → capture class as receiver.
384
+
385
+ Tier B B5: when SEAM_TYPE_INFERENCE is on, member calls are resolved to
386
+ 'Type.method' qualified targets using per-method scope (class property types +
387
+ param type hints + local `new ClassName()` assignments).
388
+
389
+ NEVER raises. Returns [] on any failure.
390
+ """
391
+ edges: list[Edge] = []
392
+ file_str = str(filepath)
393
+ file_stem = filepath.stem
394
+ infer = config.SEAM_TYPE_INFERENCE == "on"
395
+ composition_on = config.SEAM_COMPOSITION_EDGES == "on"
396
+ field_access_on = config.SEAM_FIELD_ACCESS_EDGES == "on"
397
+
398
+ try:
399
+ _walk_php_edges(
400
+ root, file_str, file_stem, edges, infer, composition_on, field_access_on,
401
+ None, {}, {}
402
+ )
403
+ except Exception as exc: # noqa: BLE001
404
+ logger.debug("_extract_edges_php: unexpected error for %s: %r", filepath, exc)
405
+
406
+ return edges
407
+
408
+
409
+ def _walk_php_edges(
410
+ node: Node,
411
+ file_str: str,
412
+ file_stem: str,
413
+ edges: list[Edge],
414
+ infer: bool,
415
+ composition_on: bool,
416
+ field_access_on: bool,
417
+ class_name: str | None,
418
+ class_fields: dict[str, str],
419
+ var_types: dict[str, str],
420
+ ) -> None:
421
+ """Recursive walker for PHP edge extraction with type-inference context threading.
422
+ composition_on gates holds edge emission (Slice #79).
423
+ field_access_on gates A3 Slice 5 reads/writes edge emission.
424
+ """
425
+ ntype = node.type
426
+
427
+ if ntype == "namespace_use_declaration":
428
+ _handle_php_use_declaration(node, file_str, file_stem, edges)
429
+ return # no need to recurse into use declaration
430
+
431
+ if ntype == "class_declaration":
432
+ # Update class context and pre-scan property types.
433
+ new_class: str | None = None
434
+ cn = node.child_by_field_name("name")
435
+ if cn is not None:
436
+ new_class = _text(cn).strip() or None
437
+ # Slice #79: emit holds edges for PHP class properties + ctor params.
438
+ if composition_on and new_class:
439
+ _handle_php_class_holds(node, new_class, file_str, edges)
440
+ new_fields = scan_class_fields_php(node) if infer else {}
441
+ body = node.child_by_field_name("body")
442
+ if body is not None:
443
+ for child in body.children:
444
+ _walk_php_edges(
445
+ child, file_str, file_stem, edges, infer, composition_on, field_access_on,
446
+ new_class, new_fields, dict(new_fields)
447
+ )
448
+ return
449
+
450
+ if ntype == "method_declaration":
451
+ # New method scope: inherit class fields, bind type-hinted params.
452
+ new_types: dict[str, str] = dict(class_fields)
453
+ if infer:
454
+ record_php_param_types(node, new_types)
455
+ # 'uses' edges: method references plain user types as params. Config is read
456
+ # inline (not threaded) because _walk_php_edges recurses through many call sites.
457
+ if config.SEAM_PARAM_EDGES == "on" and class_name is not None:
458
+ _mn = node.child_by_field_name("name") or next(
459
+ (c for c in node.children if c.type == "name"), None
460
+ )
461
+ if _mn is not None:
462
+ _m = _text(_mn)
463
+ if _m:
464
+ _src = f"{class_name}.{_m}"
465
+ for ptype, pline in param_types_via_recorder(
466
+ node, record_php_param_types, "php"
467
+ ):
468
+ edges.append(Edge(
469
+ source=_src, target=ptype, kind="uses",
470
+ file=file_str, line=pline, confidence="INFERRED", receiver=None,
471
+ ))
472
+ body = node.child_by_field_name("body")
473
+ if body is not None:
474
+ # A3 Slice 5: emit reads/writes field-access edges for $this->field accesses.
475
+ if field_access_on and class_name is not None:
476
+ method_name_node = node.child_by_field_name("name")
477
+ if method_name_node is None:
478
+ for c in node.children:
479
+ if c.type == "name":
480
+ method_name_node = c
481
+ break
482
+ if method_name_node is not None:
483
+ method_name = _text(method_name_node)
484
+ if method_name:
485
+ source_fn = f"{class_name}.{method_name}"
486
+ for src, tgt, mode, line in extract_field_accesses_php(
487
+ body, source_fn, class_name, new_types
488
+ ):
489
+ edges.append(Edge(
490
+ source=src,
491
+ target=tgt,
492
+ kind=mode,
493
+ file=file_str,
494
+ line=line,
495
+ confidence="INFERRED",
496
+ receiver=None,
497
+ ))
498
+ for child in body.children:
499
+ _walk_php_edges(
500
+ child, file_str, file_stem, edges, infer, composition_on, field_access_on,
501
+ class_name, class_fields, new_types
502
+ )
503
+ return
504
+
505
+ if infer and ntype == "expression_statement":
506
+ record_php_local_types(node, var_types)
507
+ # Still recurse to catch nested calls.
508
+
509
+ # Tier B B6: object_creation_expression (new Foo()) → instantiates edge.
510
+ # 'name' child = the constructed type (plain name or qualified_name for \NS\Foo).
511
+ # Dynamic new ($className()) has a variable_name child — skip gracefully.
512
+ if ntype == "object_creation_expression":
513
+ _handle_php_new_expression(node, file_str, edges)
514
+ # Recurse to catch nested news in constructor arguments.
515
+ for child in node.children:
516
+ _walk_php_edges(
517
+ child, file_str, file_stem, edges, infer, composition_on, field_access_on,
518
+ class_name, class_fields, var_types
519
+ )
520
+ return # already recursed above
521
+
522
+ if ntype == "function_call_expression":
523
+ _handle_php_call(node, file_str, file_stem, edges)
524
+ # Still recurse — calls can be nested inside argument lists
525
+
526
+ elif ntype in ("member_call_expression", "nullsafe_member_call_expression"):
527
+ # Tier B B2 + B5: $obj->method() — capture receiver and try to qualify.
528
+ _handle_php_member_call_infer(node, file_str, edges, infer, class_name, var_types)
529
+ # Still recurse for nested calls
530
+
531
+ elif ntype == "scoped_call_expression":
532
+ # Tier B B2: ClassName::method() — capture class name as receiver.
533
+ _handle_php_scoped_call(node, file_str, edges)
534
+ # Still recurse for nested calls
535
+
536
+ for child in node.children:
537
+ _walk_php_edges(
538
+ child, file_str, file_stem, edges, infer, composition_on, field_access_on,
539
+ class_name, class_fields, var_types
540
+ )
541
+
542
+
543
+ def _handle_php_new_expression(
544
+ node: Node,
545
+ file_str: str,
546
+ edges: list[Edge],
547
+ ) -> None:
548
+ """Emit an instantiates edge from a PHP object_creation_expression node.
549
+
550
+ Tier B B6: new Foo() → kind='instantiates', target='Foo'.
551
+ For namespaced new (new \\NS\\Bar()) the last segment 'Bar' is the target.
552
+ Dynamic new ($className()) uses variable_name child — skipped gracefully (no edge).
553
+ Never raises.
554
+ """
555
+ try:
556
+ source = _find_enclosing_function(node, "php")
557
+ if source is None:
558
+ return
559
+ # Children of object_creation_expression: [new, name_node, arguments_node]
560
+ # name_node can be 'name' (plain), 'qualified_name' (namespaced), or
561
+ # 'variable_name' (dynamic — skip).
562
+ for child in node.children:
563
+ if child.type == "name":
564
+ type_name = _text(child)
565
+ if type_name:
566
+ edges.append(Edge(
567
+ source=source,
568
+ target=type_name,
569
+ kind="instantiates",
570
+ file=file_str,
571
+ line=node.start_point[0] + 1,
572
+ confidence="INFERRED",
573
+ receiver=None,
574
+ ))
575
+ return
576
+ if child.type == "qualified_name":
577
+ # \NS\Bar → last 'name' child is 'Bar'
578
+ last_name: str | None = None
579
+ for gc in child.children:
580
+ if gc.type == "name":
581
+ last_name = _text(gc)
582
+ if last_name:
583
+ edges.append(Edge(
584
+ source=source,
585
+ target=last_name,
586
+ kind="instantiates",
587
+ file=file_str,
588
+ line=node.start_point[0] + 1,
589
+ confidence="INFERRED",
590
+ receiver=None,
591
+ ))
592
+ return
593
+ # variable_name or other dynamic class — skip gracefully (no edge).
594
+ if child.type == "variable_name":
595
+ return
596
+ except Exception: # noqa: BLE001
597
+ pass
598
+
599
+
600
+ def _handle_php_use_declaration(
601
+ node: Node,
602
+ file_str: str,
603
+ file_stem: str,
604
+ edges: list[Edge],
605
+ ) -> None:
606
+ """Extract import edges from a PHP namespace_use_declaration node.
607
+
608
+ Handles three forms:
609
+ use App\\Models\\User; → target = 'User'
610
+ use App\\Models\\User as U; → target = 'User' (real exported name, not alias)
611
+ use App\\{Foo, Bar}; → targets = ['Foo', 'Bar'] (grouped use)
612
+
613
+ WHY grouped use: namespace_use_group is a direct child of namespace_use_declaration.
614
+ The group contains namespace_use_clause children — must descend into it.
615
+ """
616
+ line = node.start_point[0] + 1
617
+ for child in node.named_children:
618
+ if child.type == "namespace_use_clause":
619
+ target = _php_use_clause_exported_name(child)
620
+ if target:
621
+ edges.append(
622
+ Edge(
623
+ source=file_stem,
624
+ target=target,
625
+ kind="import",
626
+ file=file_str,
627
+ line=line,
628
+ confidence="INFERRED",
629
+ receiver=None,
630
+ )
631
+ )
632
+ elif child.type == "namespace_use_group":
633
+ # Grouped use: use App\{Foo, Bar} — descend into the group's clauses.
634
+ for clause in child.named_children:
635
+ if clause.type == "namespace_use_clause":
636
+ target = _php_use_clause_exported_name(clause)
637
+ if target:
638
+ edges.append(
639
+ Edge(
640
+ source=file_stem,
641
+ target=target,
642
+ kind="import",
643
+ file=file_str,
644
+ line=line,
645
+ confidence="INFERRED",
646
+ receiver=None,
647
+ )
648
+ )
649
+
650
+
651
+ def _php_use_clause_exported_name(clause_node: Node) -> str | None:
652
+ """Extract the real exported name from a PHP namespace_use_clause.
653
+
654
+ For a plain clause (use App\\Models\\User): returns 'User' (last name segment).
655
+ For an aliased clause (use App\\Models\\User as U): returns 'User' (the real
656
+ exported name — consistent with TS/Rust alias convention where the import edge
657
+ targets the real symbol, not the local alias).
658
+
659
+ namespace_use_clause → qualified_name → last 'name' child
660
+ → (optional) 'as' + name (alias — ignored for edge target)
661
+ """
662
+ for child in clause_node.named_children:
663
+ if child.type == "qualified_name":
664
+ # Walk the qualified_name to find the last 'name' child.
665
+ last_name = None
666
+ for qchild in child.children:
667
+ if qchild.type == "name":
668
+ last_name = _text(qchild)
669
+ return last_name
670
+ if child.type == "name":
671
+ # Bare name (e.g. from grouped use: `use App\{Foo, Bar}`)
672
+ return _text(child)
673
+ return None
674
+
675
+
676
+ def _handle_php_call(
677
+ node: Node,
678
+ file_str: str,
679
+ file_stem: str,
680
+ edges: list[Edge],
681
+ ) -> None:
682
+ """Emit a call edge from a PHP function_call_expression node.
683
+
684
+ function_call_expression: 'function' field is a 'name' node → bare call.
685
+ Skip if function field is a variable ($fn()) or complex expression.
686
+ """
687
+ func_node = node.child_by_field_name("function")
688
+ if func_node is None or func_node.type != "name":
689
+ return # variable call or complex expression — skip
690
+
691
+ target = _text(func_node)
692
+ source = _find_enclosing_function(node, "php")
693
+ if source is not None:
694
+ edges.append(
695
+ Edge(
696
+ source=source,
697
+ target=target,
698
+ kind="call",
699
+ file=file_str,
700
+ line=node.start_point[0] + 1,
701
+ confidence="INFERRED",
702
+ receiver=None,
703
+ )
704
+ )
705
+
706
+
707
+ def _handle_php_member_call(
708
+ node: Node,
709
+ file_str: str,
710
+ edges: list[Edge],
711
+ ) -> None:
712
+ """Emit a call edge from a PHP member_call_expression node (Tier B B2).
713
+
714
+ Handles $obj->method() and $obj?->method() (nullsafe).
715
+ Captures the receiver text ($obj, $this, etc.) and the method name.
716
+ Gracefully skips when the name field is absent or not a plain 'name' node.
717
+ Never raises.
718
+ """
719
+ try:
720
+ # object field = receiver expression ($obj, $this, variable_name, etc.)
721
+ obj_node = node.child_by_field_name("object")
722
+ # name field = method name (a 'name' node in the PHP grammar)
723
+ name_node = node.child_by_field_name("name")
724
+ if name_node is None or name_node.type != "name":
725
+ return # complex dynamic dispatch — skip gracefully
726
+
727
+ target = _text(name_node)
728
+ recv_text: str | None = _text(obj_node) if obj_node is not None else None
729
+ source = _find_enclosing_function(node, "php")
730
+ if source is not None:
731
+ edges.append(
732
+ Edge(
733
+ source=source,
734
+ target=target,
735
+ kind="call",
736
+ file=file_str,
737
+ line=node.start_point[0] + 1,
738
+ confidence="INFERRED",
739
+ receiver=recv_text,
740
+ )
741
+ )
742
+ except Exception: # noqa: BLE001
743
+ pass # never raise from the extractor
744
+
745
+
746
+ def _handle_php_member_call_infer(
747
+ node: Node,
748
+ file_str: str,
749
+ edges: list[Edge],
750
+ infer: bool,
751
+ class_name: str | None,
752
+ var_types: dict[str, str],
753
+ ) -> None:
754
+ """Emit a call edge from PHP member_call_expression with optional type inference (B5).
755
+
756
+ When infer=True, resolves $obj→method() receiver to 'Type.method' using var_types.
757
+ Fallback: emits the bare method name with raw receiver (same as B2 behavior).
758
+ Never raises.
759
+ """
760
+ try:
761
+ obj_node = node.child_by_field_name("object")
762
+ name_node = node.child_by_field_name("name")
763
+ if name_node is None or name_node.type != "name":
764
+ return
765
+
766
+ method_name = _text(name_node)
767
+ recv_text: str | None = _text(obj_node) if obj_node is not None else None
768
+
769
+ # B5: resolve receiver to type → qualify the target.
770
+ final_target = method_name
771
+ if infer and recv_text is not None:
772
+ resolved_type = resolve_receiver_type_ext(
773
+ recv_text, class_name, var_types, _PHP_SELF_NAMES
774
+ )
775
+ if resolved_type:
776
+ final_target = f"{resolved_type}.{method_name}"
777
+
778
+ source = _find_enclosing_function(node, "php")
779
+ if source is not None:
780
+ edges.append(
781
+ Edge(
782
+ source=source,
783
+ target=final_target,
784
+ kind="call",
785
+ file=file_str,
786
+ line=node.start_point[0] + 1,
787
+ confidence="INFERRED",
788
+ receiver=recv_text,
789
+ )
790
+ )
791
+ except Exception: # noqa: BLE001
792
+ pass # never raise from the extractor
793
+
794
+
795
+ def _handle_php_scoped_call(
796
+ node: Node,
797
+ file_str: str,
798
+ edges: list[Edge],
799
+ ) -> None:
800
+ """Emit a call edge from a PHP scoped_call_expression node (Tier B B2 + B5).
801
+
802
+ Handles ClassName::method() (static dispatch).
803
+ Because the class name is literally present in the AST (no scope lookup needed),
804
+ the target is immediately qualified as 'ClassName.method' (B5 intent: consistent
805
+ with Java method_invocation and C# invocation_expression qualification).
806
+ The class_name is also stored as receiver for provenance.
807
+ Gracefully skips when nodes are absent or not a plain 'name'. Never raises.
808
+ """
809
+ try:
810
+ # scoped_call_expression children: name(class) :: name(method) arguments
811
+ # The grammar does NOT use named fields — walk children directly.
812
+ names = [c for c in node.children if c.type == "name"]
813
+ if len(names) < 2:
814
+ return # unexpected shape — degrade gracefully
815
+ class_name = _text(names[0])
816
+ method_name = _text(names[1])
817
+ if not class_name or not method_name:
818
+ return
819
+
820
+ source = _find_enclosing_function(node, "php")
821
+ if source is not None:
822
+ # B5: qualify the target directly — the class is explicit in the AST
823
+ # (conservatism contract satisfied: type is stated, not inferred from scope).
824
+ qualified_target = f"{class_name}.{method_name}"
825
+ edges.append(
826
+ Edge(
827
+ source=source,
828
+ target=qualified_target,
829
+ kind="call",
830
+ file=file_str,
831
+ line=node.start_point[0] + 1,
832
+ confidence="INFERRED",
833
+ receiver=class_name,
834
+ )
835
+ )
836
+ except Exception: # noqa: BLE001
837
+ pass # never raise from the extractor
838
+
839
+
840
+ def _handle_php_class_holds(
841
+ class_node: Node, class_name: str, file_str: str, edges: list[Edge]
842
+ ) -> None:
843
+ """Emit holds edges for each typed property/ctor-param in a PHP class.
844
+
845
+ Delegates to collect_composition_types_php for (held_type, line) pairs and
846
+ emits one Edge per unique pair. Never raises (backstop try/except).
847
+ """
848
+ try:
849
+ for held_type, held_line in collect_composition_types_php(class_node):
850
+ edges.append(
851
+ Edge(
852
+ source=class_name,
853
+ target=held_type,
854
+ kind="holds",
855
+ file=file_str,
856
+ line=held_line,
857
+ confidence="INFERRED",
858
+ receiver=None,
859
+ )
860
+ )
861
+ except Exception as exc: # noqa: BLE001
862
+ logger.debug("_handle_php_class_holds: failed: %r", exc)
863
+
864
+
865
+ # ── PHP comment extraction ─────────────────────────────────────────────────────
866
+
867
+
868
+ def _extract_comments_php(root: Node, filepath: Path) -> list[Comment]:
869
+ """Walk a PHP AST and extract semantic comment markers (WHY/HACK/NOTE/TODO/FIXME).
870
+
871
+ PHP comment node types:
872
+ comment — covers // line, # line, and /* */ /* */ /** */ block comments.
873
+ All variants are scanned; phpdoc blocks use _block_comment_lines for multi-line.
874
+
875
+ NEVER raises. Returns [] on any failure.
876
+ """
877
+ comments: list[Comment] = []
878
+
879
+ try:
880
+ _walk_php_comments(root, comments)
881
+ except Exception as exc: # noqa: BLE001
882
+ logger.debug("_extract_comments_php: unexpected error: %r", exc)
883
+
884
+ return comments
885
+
886
+
887
+ def _walk_php_comments(node: Node, comments: list[Comment]) -> None:
888
+ """Recursive walker for PHP comment nodes."""
889
+ if node.type == "comment":
890
+ raw = _text(node)
891
+ base_row = node.start_point[0] + 1
892
+
893
+ if raw.startswith("/*"):
894
+ # Block comment (/** */ or /* */) — scan each line.
895
+ for offset, body in _block_comment_lines(raw):
896
+ result = _match_marker(body)
897
+ if result is not None:
898
+ marker, text = result
899
+ comments.append(Comment(marker=marker, text=text, line=base_row + offset))
900
+ elif raw.startswith("//"):
901
+ body = raw[2:].strip()
902
+ result = _match_marker(body)
903
+ if result is not None:
904
+ marker, text = result
905
+ comments.append(Comment(marker=marker, text=text, line=base_row))
906
+ elif raw.startswith("#"):
907
+ body = raw[1:].strip()
908
+ result = _match_marker(body)
909
+ if result is not None:
910
+ marker, text = result
911
+ comments.append(Comment(marker=marker, text=text, line=base_row))
912
+
913
+ for child in node.children:
914
+ _walk_php_comments(child, comments)