seam-code 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seam/__init__.py +3 -0
- seam/_data/schema.sql +225 -0
- seam/_web/assets/index-BL_tqprR.js +216 -0
- seam/_web/assets/index-GTKUhVyD.css +1 -0
- seam/_web/index.html +13 -0
- seam/analysis/__init__.py +14 -0
- seam/analysis/affected.py +254 -0
- seam/analysis/builtins.py +966 -0
- seam/analysis/byte_budget.py +217 -0
- seam/analysis/changes.py +709 -0
- seam/analysis/cluster_naming.py +260 -0
- seam/analysis/clustering.py +216 -0
- seam/analysis/confidence.py +699 -0
- seam/analysis/embeddings.py +195 -0
- seam/analysis/flows.py +708 -0
- seam/analysis/impact.py +444 -0
- seam/analysis/imports.py +994 -0
- seam/analysis/imports_ext.py +780 -0
- seam/analysis/imports_resolve.py +176 -0
- seam/analysis/processes.py +453 -0
- seam/analysis/relevance.py +155 -0
- seam/analysis/rwr.py +129 -0
- seam/analysis/staleness.py +328 -0
- seam/analysis/steer.py +282 -0
- seam/analysis/synthesis.py +253 -0
- seam/analysis/synthesis_channels.py +433 -0
- seam/analysis/testpaths.py +103 -0
- seam/analysis/traversal.py +470 -0
- seam/cli/__init__.py +0 -0
- seam/cli/install.py +232 -0
- seam/cli/main.py +2602 -0
- seam/cli/output.py +137 -0
- seam/cli/read.py +244 -0
- seam/cli/serve.py +145 -0
- seam/config.py +551 -0
- seam/indexer/__init__.py +0 -0
- seam/indexer/cluster_index.py +425 -0
- seam/indexer/db.py +496 -0
- seam/indexer/embedding_index.py +183 -0
- seam/indexer/field_access.py +536 -0
- seam/indexer/field_access_c_cpp.py +643 -0
- seam/indexer/field_access_ext.py +708 -0
- seam/indexer/field_access_ext2.py +408 -0
- seam/indexer/field_access_go_rust.py +737 -0
- seam/indexer/field_access_php_swift.py +888 -0
- seam/indexer/field_access_ts.py +626 -0
- seam/indexer/graph.py +321 -0
- seam/indexer/graph_c.py +562 -0
- seam/indexer/graph_c_cpp.py +39 -0
- seam/indexer/graph_common.py +644 -0
- seam/indexer/graph_cpp.py +615 -0
- seam/indexer/graph_csharp.py +651 -0
- seam/indexer/graph_go.py +723 -0
- seam/indexer/graph_go_rust.py +39 -0
- seam/indexer/graph_java.py +689 -0
- seam/indexer/graph_java_csharp.py +38 -0
- seam/indexer/graph_php.py +914 -0
- seam/indexer/graph_python.py +628 -0
- seam/indexer/graph_ruby.py +748 -0
- seam/indexer/graph_rust.py +653 -0
- seam/indexer/graph_scope_infer.py +902 -0
- seam/indexer/graph_scope_infer_ext.py +723 -0
- seam/indexer/graph_scope_infer_ext2.py +992 -0
- seam/indexer/graph_swift.py +1014 -0
- seam/indexer/graph_swift_infer.py +515 -0
- seam/indexer/graph_typescript.py +663 -0
- seam/indexer/migrations.py +816 -0
- seam/indexer/parser.py +204 -0
- seam/indexer/pipeline.py +197 -0
- seam/indexer/signatures.py +634 -0
- seam/indexer/signatures_ext.py +780 -0
- seam/indexer/sync.py +287 -0
- seam/indexer/synthesis_index.py +291 -0
- seam/indexer/tokenize.py +79 -0
- seam/installer/__init__.py +67 -0
- seam/installer/claude.py +97 -0
- seam/installer/codex.py +94 -0
- seam/installer/core.py +127 -0
- seam/installer/cursor.py +61 -0
- seam/installer/guide.py +110 -0
- seam/installer/jsonfile.py +85 -0
- seam/installer/markdownfile.py +146 -0
- seam/installer/tomlfile.py +72 -0
- seam/query/__init__.py +0 -0
- seam/query/clusters.py +206 -0
- seam/query/comments.py +217 -0
- seam/query/context.py +293 -0
- seam/query/engine.py +940 -0
- seam/query/fts.py +328 -0
- seam/query/names.py +470 -0
- seam/query/pack.py +433 -0
- seam/query/semantic.py +339 -0
- seam/query/structure.py +727 -0
- seam/server/__init__.py +0 -0
- seam/server/graph_api.py +437 -0
- seam/server/handler_common.py +323 -0
- seam/server/impact_handler.py +615 -0
- seam/server/mcp.py +556 -0
- seam/server/tools.py +697 -0
- seam/server/trace_handler.py +184 -0
- seam/server/web.py +922 -0
- seam/watcher/__init__.py +0 -0
- seam/watcher/__main__.py +56 -0
- seam/watcher/daemon.py +237 -0
- seam_code-0.3.0.dist-info/METADATA +318 -0
- seam_code-0.3.0.dist-info/RECORD +109 -0
- seam_code-0.3.0.dist-info/WHEEL +4 -0
- seam_code-0.3.0.dist-info/entry_points.txt +2 -0
- seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|