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,628 @@
|
|
|
1
|
+
"""Python symbol and edge extraction from tree-sitter ASTs.
|
|
2
|
+
|
|
3
|
+
LAYER: imports from graph_common (leaf), graph_scope_infer (leaf), signatures (leaf) — never from graph.py.
|
|
4
|
+
|
|
5
|
+
LAYERING:
|
|
6
|
+
graph_common (leaf — no seam deps)
|
|
7
|
+
graph_scope_infer (leaf — Python+TS receiver-type inference)
|
|
8
|
+
↑
|
|
9
|
+
graph_python (this file — Python-only symbol/edge extraction)
|
|
10
|
+
↑
|
|
11
|
+
graph.py (dispatcher; imports this module's public extractors)
|
|
12
|
+
|
|
13
|
+
WHY split from graph.py: graph.py exceeded 1000 lines. Python extraction is a coherent
|
|
14
|
+
leaf unit and is split following the Phase 9 precedent (graph_go_rust.py, etc.).
|
|
15
|
+
|
|
16
|
+
Contract:
|
|
17
|
+
- Accept a tree-sitter Node + filepath.
|
|
18
|
+
- Return a list — never raise, never return None.
|
|
19
|
+
- Edges carry confidence='INFERRED' by default.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from tree_sitter import Node
|
|
25
|
+
|
|
26
|
+
import seam.config as config
|
|
27
|
+
from seam.indexer.field_access import (
|
|
28
|
+
collect_field_symbols_python,
|
|
29
|
+
extract_field_accesses_python,
|
|
30
|
+
)
|
|
31
|
+
from seam.indexer.graph_common import (
|
|
32
|
+
Comment,
|
|
33
|
+
Edge,
|
|
34
|
+
Symbol,
|
|
35
|
+
_base_type_name,
|
|
36
|
+
_find_enclosing_function,
|
|
37
|
+
_make_symbol,
|
|
38
|
+
_match_marker,
|
|
39
|
+
_node_name,
|
|
40
|
+
_text,
|
|
41
|
+
)
|
|
42
|
+
from seam.indexer.graph_scope_infer import (
|
|
43
|
+
_PY_BUILTIN_TYPES,
|
|
44
|
+
_PY_SELF_NAMES,
|
|
45
|
+
collect_composition_types_python,
|
|
46
|
+
collect_param_types_python,
|
|
47
|
+
record_py_local_types,
|
|
48
|
+
record_py_param_types,
|
|
49
|
+
resolve_receiver_type,
|
|
50
|
+
scan_class_fields_python,
|
|
51
|
+
)
|
|
52
|
+
from seam.indexer.signatures import extract_node_fields
|
|
53
|
+
|
|
54
|
+
# ── Python docstring helper ────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _py_docstring(func_or_class_node: Node) -> str | None:
|
|
58
|
+
"""Extract Python docstring: first expression_statement(string) in body.
|
|
59
|
+
|
|
60
|
+
Returns the docstring CONTENT (without surrounding quotes), or None.
|
|
61
|
+
Uses tree-sitter `string_content` child node — avoids stripping legitimate
|
|
62
|
+
leading/trailing quote characters from the docstring text.
|
|
63
|
+
"""
|
|
64
|
+
body = func_or_class_node.child_by_field_name("body")
|
|
65
|
+
if body is None or not body.children:
|
|
66
|
+
return None
|
|
67
|
+
first = body.children[0]
|
|
68
|
+
if first.type != "expression_statement" or not first.children:
|
|
69
|
+
return None
|
|
70
|
+
expr = first.children[0]
|
|
71
|
+
if expr.type != "string":
|
|
72
|
+
return None
|
|
73
|
+
for child in expr.children:
|
|
74
|
+
if child.type == "string_content":
|
|
75
|
+
return _text(child).strip()
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Python extraction ──────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _extract_symbols_python(root: Node, filepath: Path) -> list[Symbol]:
|
|
83
|
+
"""Walk a Python AST and extract function, class, and method symbols.
|
|
84
|
+
|
|
85
|
+
A3: Also emits kind='field' symbols for class-level annotated fields and
|
|
86
|
+
first self.x = ... assignments in __init__, when SEAM_FIELD_ACCESS_EDGES='on'.
|
|
87
|
+
"""
|
|
88
|
+
symbols: list[Symbol] = []
|
|
89
|
+
file_str = str(filepath)
|
|
90
|
+
field_access_on = config.SEAM_FIELD_ACCESS_EDGES == "on"
|
|
91
|
+
|
|
92
|
+
def _walk(node: Node, class_name: str | None = None) -> None:
|
|
93
|
+
"""Recursively walk AST, tracking class context for method qualification."""
|
|
94
|
+
if node.type == "function_definition":
|
|
95
|
+
name = _node_name(node)
|
|
96
|
+
if name:
|
|
97
|
+
kind = "method" if class_name else "function"
|
|
98
|
+
qualified = f"{class_name}.{name}" if class_name else name
|
|
99
|
+
doc = _py_docstring(node)
|
|
100
|
+
fields = extract_node_fields(
|
|
101
|
+
node,
|
|
102
|
+
"python",
|
|
103
|
+
qualified_name=qualified,
|
|
104
|
+
max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
|
|
105
|
+
)
|
|
106
|
+
symbols.append(
|
|
107
|
+
_make_symbol(
|
|
108
|
+
qualified,
|
|
109
|
+
kind,
|
|
110
|
+
file_str,
|
|
111
|
+
node,
|
|
112
|
+
doc,
|
|
113
|
+
signature=fields["signature"],
|
|
114
|
+
decorators=fields["decorators"],
|
|
115
|
+
is_exported=fields["is_exported"],
|
|
116
|
+
visibility=fields["visibility"],
|
|
117
|
+
qualified_name=qualified,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
body = node.child_by_field_name("body")
|
|
121
|
+
if body:
|
|
122
|
+
for child in body.children:
|
|
123
|
+
_walk(child, None)
|
|
124
|
+
|
|
125
|
+
elif node.type == "decorated_definition":
|
|
126
|
+
definition = node.child_by_field_name("definition")
|
|
127
|
+
if definition and definition.type == "function_definition":
|
|
128
|
+
name = _node_name(definition)
|
|
129
|
+
if name:
|
|
130
|
+
kind = "method" if class_name else "function"
|
|
131
|
+
qualified = f"{class_name}.{name}" if class_name else name
|
|
132
|
+
doc = _py_docstring(definition)
|
|
133
|
+
fields = extract_node_fields(
|
|
134
|
+
node,
|
|
135
|
+
"python",
|
|
136
|
+
qualified_name=qualified,
|
|
137
|
+
max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
|
|
138
|
+
)
|
|
139
|
+
symbols.append(
|
|
140
|
+
_make_symbol(
|
|
141
|
+
qualified,
|
|
142
|
+
kind,
|
|
143
|
+
file_str,
|
|
144
|
+
node,
|
|
145
|
+
doc,
|
|
146
|
+
signature=fields["signature"],
|
|
147
|
+
decorators=fields["decorators"],
|
|
148
|
+
is_exported=fields["is_exported"],
|
|
149
|
+
visibility=fields["visibility"],
|
|
150
|
+
qualified_name=qualified,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
body = definition.child_by_field_name("body")
|
|
154
|
+
if body:
|
|
155
|
+
for child in body.children:
|
|
156
|
+
_walk(child, None)
|
|
157
|
+
elif definition and definition.type == "class_definition":
|
|
158
|
+
name = _node_name(definition)
|
|
159
|
+
if name:
|
|
160
|
+
doc = _py_docstring(definition)
|
|
161
|
+
fields = extract_node_fields(
|
|
162
|
+
node,
|
|
163
|
+
"python",
|
|
164
|
+
qualified_name=name,
|
|
165
|
+
max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
|
|
166
|
+
)
|
|
167
|
+
symbols.append(
|
|
168
|
+
_make_symbol(
|
|
169
|
+
name,
|
|
170
|
+
"class",
|
|
171
|
+
file_str,
|
|
172
|
+
node,
|
|
173
|
+
doc,
|
|
174
|
+
signature=fields["signature"],
|
|
175
|
+
decorators=fields["decorators"],
|
|
176
|
+
is_exported=fields["is_exported"],
|
|
177
|
+
visibility=fields["visibility"],
|
|
178
|
+
qualified_name=name,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
body = definition.child_by_field_name("body")
|
|
182
|
+
if body:
|
|
183
|
+
for child in body.children:
|
|
184
|
+
_walk(child, name)
|
|
185
|
+
|
|
186
|
+
elif node.type == "class_definition":
|
|
187
|
+
name = _node_name(node)
|
|
188
|
+
if name:
|
|
189
|
+
doc = _py_docstring(node)
|
|
190
|
+
fields = extract_node_fields(
|
|
191
|
+
node,
|
|
192
|
+
"python",
|
|
193
|
+
qualified_name=name,
|
|
194
|
+
max_signature_len=config.SEAM_MAX_SIGNATURE_LEN,
|
|
195
|
+
)
|
|
196
|
+
symbols.append(
|
|
197
|
+
_make_symbol(
|
|
198
|
+
name,
|
|
199
|
+
"class",
|
|
200
|
+
file_str,
|
|
201
|
+
node,
|
|
202
|
+
doc,
|
|
203
|
+
signature=fields["signature"],
|
|
204
|
+
decorators=fields["decorators"],
|
|
205
|
+
is_exported=fields["is_exported"],
|
|
206
|
+
visibility=fields["visibility"],
|
|
207
|
+
qualified_name=name,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# A3: Emit field symbols for this class when feature is on.
|
|
212
|
+
# Collects annotated class-level fields (x: Type) and first
|
|
213
|
+
# self.x = ... assignments in __init__. Deduped by (class, field).
|
|
214
|
+
# WHY emit field symbols here alongside the class symbol: we need the
|
|
215
|
+
# class name context ('name') which is only available at the class_definition
|
|
216
|
+
# node. Doing it in a separate post-pass over symbols would require
|
|
217
|
+
# re-parsing or re-walking.
|
|
218
|
+
if field_access_on:
|
|
219
|
+
for qualified_field, field_line in collect_field_symbols_python(node, name):
|
|
220
|
+
# WHY build Symbol manually instead of using _make_symbol:
|
|
221
|
+
# _make_symbol expects a tree-sitter Node for start/end lines.
|
|
222
|
+
# collect_field_symbols_python returns explicit (name, line) pairs
|
|
223
|
+
# because the field may come from __init__ (a child node of the
|
|
224
|
+
# class body, not the class node itself). Building Symbol directly
|
|
225
|
+
# with the exact line avoids needing a dummy or sentinel node.
|
|
226
|
+
symbols.append(Symbol(
|
|
227
|
+
name=qualified_field,
|
|
228
|
+
kind="field",
|
|
229
|
+
file=file_str,
|
|
230
|
+
start_line=field_line,
|
|
231
|
+
end_line=field_line,
|
|
232
|
+
docstring=None,
|
|
233
|
+
signature=None,
|
|
234
|
+
decorators=[],
|
|
235
|
+
is_exported=None,
|
|
236
|
+
visibility=None,
|
|
237
|
+
qualified_name=qualified_field,
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
body = node.child_by_field_name("body")
|
|
241
|
+
if body:
|
|
242
|
+
for child in body.children:
|
|
243
|
+
_walk(child, name)
|
|
244
|
+
else:
|
|
245
|
+
for child in node.children:
|
|
246
|
+
_walk(child, class_name)
|
|
247
|
+
|
|
248
|
+
for child in root.children:
|
|
249
|
+
_walk(child, None)
|
|
250
|
+
|
|
251
|
+
return symbols
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _extract_edges_python(root: Node, filepath: Path) -> list[Edge]:
|
|
255
|
+
"""Extract import and call edges from a Python AST.
|
|
256
|
+
|
|
257
|
+
Import heuristic:
|
|
258
|
+
- import X → target = 'X' (dotted_name as-is)
|
|
259
|
+
- from X import Y → target = 'Y' for each name after 'import' keyword
|
|
260
|
+
|
|
261
|
+
Call heuristic (Tier B B4 enhanced):
|
|
262
|
+
- call node where function is a bare identifier (`foo()`) → target = identifier
|
|
263
|
+
- call node where function is an attribute (`mod.fn()`, `self.m()`, `a.b.c()`)
|
|
264
|
+
→ when SEAM_TYPE_INFERENCE=on and receiver type is known in scope, target =
|
|
265
|
+
'Type.method' (qualified); otherwise target = the rightmost identifier (bare).
|
|
266
|
+
- source = nearest enclosing function/method (skip if none)
|
|
267
|
+
|
|
268
|
+
B6: PascalCase bare calls (no receiver) → 'instantiates' edges.
|
|
269
|
+
Guard: stdlib/typing builtins excluded via _PY_BUILTIN_TYPES to avoid false positives.
|
|
270
|
+
"""
|
|
271
|
+
edges: list[Edge] = []
|
|
272
|
+
file_str = str(filepath)
|
|
273
|
+
file_stem = filepath.stem
|
|
274
|
+
|
|
275
|
+
emit_inheritance = config.SEAM_INHERITANCE_EDGES == "on"
|
|
276
|
+
type_inference_on = config.SEAM_TYPE_INFERENCE == "on"
|
|
277
|
+
composition_on = config.SEAM_COMPOSITION_EDGES == "on"
|
|
278
|
+
field_access_on = config.SEAM_FIELD_ACCESS_EDGES == "on"
|
|
279
|
+
param_edges_on = config.SEAM_PARAM_EDGES == "on"
|
|
280
|
+
|
|
281
|
+
def _emit_import_edges(node: Node) -> None:
|
|
282
|
+
if node.type == "import_statement":
|
|
283
|
+
for child in node.children:
|
|
284
|
+
if child.type in ("dotted_name", "aliased_import"):
|
|
285
|
+
target_node = child.child_by_field_name("name") or child
|
|
286
|
+
edges.append(Edge(
|
|
287
|
+
source=file_stem,
|
|
288
|
+
target=_text(target_node),
|
|
289
|
+
kind="import",
|
|
290
|
+
file=file_str,
|
|
291
|
+
line=node.start_point[0] + 1,
|
|
292
|
+
confidence="INFERRED",
|
|
293
|
+
receiver=None,
|
|
294
|
+
))
|
|
295
|
+
elif node.type == "import_from_statement":
|
|
296
|
+
found_import_kw = False
|
|
297
|
+
for child in node.children:
|
|
298
|
+
if child.type == "import":
|
|
299
|
+
found_import_kw = True
|
|
300
|
+
continue
|
|
301
|
+
if found_import_kw:
|
|
302
|
+
if child.type in ("dotted_name", "identifier"):
|
|
303
|
+
edges.append(Edge(
|
|
304
|
+
source=file_stem,
|
|
305
|
+
target=_text(child),
|
|
306
|
+
kind="import",
|
|
307
|
+
file=file_str,
|
|
308
|
+
line=node.start_point[0] + 1,
|
|
309
|
+
confidence="INFERRED",
|
|
310
|
+
receiver=None,
|
|
311
|
+
))
|
|
312
|
+
elif child.type == "aliased_import":
|
|
313
|
+
name_node = child.child_by_field_name("name")
|
|
314
|
+
if name_node:
|
|
315
|
+
edges.append(Edge(
|
|
316
|
+
source=file_stem,
|
|
317
|
+
target=_text(name_node),
|
|
318
|
+
kind="import",
|
|
319
|
+
file=file_str,
|
|
320
|
+
line=node.start_point[0] + 1,
|
|
321
|
+
confidence="INFERRED",
|
|
322
|
+
receiver=None,
|
|
323
|
+
))
|
|
324
|
+
|
|
325
|
+
def _emit_call_edge(
|
|
326
|
+
node: Node,
|
|
327
|
+
class_name: str | None,
|
|
328
|
+
var_types: dict[str, str],
|
|
329
|
+
) -> None:
|
|
330
|
+
"""Emit a call edge for a Python 'call' node using scope inference.
|
|
331
|
+
|
|
332
|
+
Two-stage decision (Tier B B4 + B6):
|
|
333
|
+
1. Classify the call shape (bare identifier vs attribute):
|
|
334
|
+
- `foo()` → bare call (receiver_text=None)
|
|
335
|
+
- `obj.method()` → attribute call (receiver_text='obj')
|
|
336
|
+
2. Emit the right edge kind:
|
|
337
|
+
- Bare PascalCase → 'instantiates' (B6); not 'call', because
|
|
338
|
+
seam_query needs to distinguish object-construction from
|
|
339
|
+
method calls, and callee_node is NOT stored at callee level.
|
|
340
|
+
- Attribute call → 'call' with target qualified to Type.method
|
|
341
|
+
when the receiver type is known; bare method name when unknown.
|
|
342
|
+
receiver_text is ALWAYS stored in the edge so the Tier A read
|
|
343
|
+
path (names.py) and future passes have the raw text even when
|
|
344
|
+
type resolution succeeded (prevents information loss).
|
|
345
|
+
"""
|
|
346
|
+
func_child = node.child_by_field_name("function")
|
|
347
|
+
callee_node: Node | None = None
|
|
348
|
+
receiver_text: str | None = None
|
|
349
|
+
|
|
350
|
+
if func_child and func_child.type == "identifier":
|
|
351
|
+
callee_node = func_child
|
|
352
|
+
elif func_child and func_child.type == "attribute":
|
|
353
|
+
# attribute node: object='self'/'obj'/… + attribute='method_name'
|
|
354
|
+
callee_node = func_child.child_by_field_name("attribute")
|
|
355
|
+
object_node = func_child.child_by_field_name("object")
|
|
356
|
+
if object_node is not None:
|
|
357
|
+
receiver_text = _text(object_node)
|
|
358
|
+
|
|
359
|
+
if callee_node is None or callee_node.type != "identifier":
|
|
360
|
+
return
|
|
361
|
+
source = _find_enclosing_function(node, "python")
|
|
362
|
+
if source is None:
|
|
363
|
+
# Top-level code with no enclosing named scope — drop the edge;
|
|
364
|
+
# source would be the file stem which conflates all module-level calls.
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
method_name = _text(callee_node)
|
|
368
|
+
target = method_name
|
|
369
|
+
|
|
370
|
+
# B6: PascalCase bare call (no receiver) → 'instantiates' edge.
|
|
371
|
+
# WHY 'instantiates' not 'call': a bare PascalCase name in Python is
|
|
372
|
+
# overwhelmingly a constructor call (Foo(), MyClass()), not a function named
|
|
373
|
+
# with an acronym. 'instantiates' allows seam_query to distinguish
|
|
374
|
+
# construction from regular calls. Early-return: no receiver to store.
|
|
375
|
+
# Guard: _PY_BUILTIN_TYPES excludes Exception, list, dict, etc. which would
|
|
376
|
+
# otherwise produce thousands of false instantiates edges to stdlib types.
|
|
377
|
+
if (
|
|
378
|
+
receiver_text is None
|
|
379
|
+
and method_name
|
|
380
|
+
and method_name[0].isupper()
|
|
381
|
+
and method_name not in _PY_BUILTIN_TYPES
|
|
382
|
+
):
|
|
383
|
+
edges.append(Edge(
|
|
384
|
+
source=source,
|
|
385
|
+
target=method_name,
|
|
386
|
+
kind="instantiates",
|
|
387
|
+
file=file_str,
|
|
388
|
+
line=node.start_point[0] + 1,
|
|
389
|
+
confidence="INFERRED",
|
|
390
|
+
receiver=None,
|
|
391
|
+
))
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
# B4: receiver-type inference for attribute calls.
|
|
395
|
+
# WHY only when resolved: the conservatism contract forbids emitting a wrong
|
|
396
|
+
# qualified edge. When resolve_receiver_type returns None (unknown/optional/
|
|
397
|
+
# generic/chained), we keep the bare method name — Tier A can still elevate
|
|
398
|
+
# unambiguous bare names at read time, and the raw receiver_text is stored.
|
|
399
|
+
if type_inference_on and receiver_text is not None:
|
|
400
|
+
resolved_type = resolve_receiver_type(
|
|
401
|
+
receiver_text, class_name, var_types, _PY_SELF_NAMES
|
|
402
|
+
)
|
|
403
|
+
if resolved_type is not None:
|
|
404
|
+
target = f"{resolved_type}.{method_name}"
|
|
405
|
+
|
|
406
|
+
# Always store receiver_text even when the target was already qualified.
|
|
407
|
+
# WHY: the raw receiver is useful for debugging mis-resolutions and for
|
|
408
|
+
# future inference passes that re-process edges without a full re-index.
|
|
409
|
+
edges.append(Edge(
|
|
410
|
+
source=source,
|
|
411
|
+
target=target,
|
|
412
|
+
kind="call",
|
|
413
|
+
file=file_str,
|
|
414
|
+
line=node.start_point[0] + 1,
|
|
415
|
+
confidence="INFERRED",
|
|
416
|
+
receiver=receiver_text,
|
|
417
|
+
))
|
|
418
|
+
|
|
419
|
+
def _walk_function_body(func_node: Node, class_name: str | None, class_fields: dict[str, str]) -> None:
|
|
420
|
+
# Start with a COPY of class_fields, not a reference. WHY: param/local bindings
|
|
421
|
+
# must not leak back into class_fields across methods (each function has an
|
|
422
|
+
# independent scope). class_fields is the order-independent Layer 1 pre-scan;
|
|
423
|
+
# var_types is the per-function Layer 2 scope that adds params and locals on top.
|
|
424
|
+
var_types: dict[str, str] = dict(class_fields)
|
|
425
|
+
record_py_param_types(func_node, var_types)
|
|
426
|
+
|
|
427
|
+
# 'uses' edges: this function references plain user types as parameters.
|
|
428
|
+
# Hooked here (not in the body walk) because the function node carries the
|
|
429
|
+
# full signature; source is qualified Class.method (or bare top-level fn).
|
|
430
|
+
if param_edges_on:
|
|
431
|
+
name_node = func_node.child_by_field_name("name")
|
|
432
|
+
if name_node is not None:
|
|
433
|
+
fn_name = _text(name_node)
|
|
434
|
+
source_fn = f"{class_name}.{fn_name}" if class_name else fn_name
|
|
435
|
+
for ptype, pline in collect_param_types_python(func_node):
|
|
436
|
+
edges.append(Edge(
|
|
437
|
+
source=source_fn,
|
|
438
|
+
target=ptype,
|
|
439
|
+
kind="uses",
|
|
440
|
+
file=file_str,
|
|
441
|
+
line=pline,
|
|
442
|
+
confidence="INFERRED",
|
|
443
|
+
receiver=None,
|
|
444
|
+
))
|
|
445
|
+
|
|
446
|
+
body = func_node.child_by_field_name("body")
|
|
447
|
+
if body is None:
|
|
448
|
+
return
|
|
449
|
+
for stmt in body.children:
|
|
450
|
+
record_py_local_types(stmt, var_types)
|
|
451
|
+
_walk_stmt(stmt, class_name, var_types, class_fields)
|
|
452
|
+
|
|
453
|
+
# A3: Emit reads/writes edges for field accesses in this function body.
|
|
454
|
+
# Done AFTER the main stmt walk so that var_types is fully populated
|
|
455
|
+
# (record_py_local_types runs incrementally during the stmt walk above).
|
|
456
|
+
# WHY separate pass: fully-populated var_types gives higher-quality receiver
|
|
457
|
+
# resolution than an interleaved walk.
|
|
458
|
+
if field_access_on:
|
|
459
|
+
# Build the qualified source name from the function's own name field.
|
|
460
|
+
# WHY not _find_enclosing_function: that walks UP the parent chain from a
|
|
461
|
+
# child node, which would land on the function itself — identical result
|
|
462
|
+
# but needs a child node as input. Computing directly from the name field
|
|
463
|
+
# is simpler and avoids passing an arbitrary child node.
|
|
464
|
+
name_node = func_node.child_by_field_name("name")
|
|
465
|
+
if name_node is not None:
|
|
466
|
+
fn_name = _text(name_node)
|
|
467
|
+
source_fn = f"{class_name}.{fn_name}" if class_name else fn_name
|
|
468
|
+
for _src, target_field, mode, fa_line in extract_field_accesses_python(
|
|
469
|
+
body, source_fn, class_name, var_types
|
|
470
|
+
):
|
|
471
|
+
edges.append(Edge(
|
|
472
|
+
source=source_fn,
|
|
473
|
+
target=target_field,
|
|
474
|
+
kind=mode,
|
|
475
|
+
file=file_str,
|
|
476
|
+
line=fa_line,
|
|
477
|
+
confidence="INFERRED",
|
|
478
|
+
receiver=None,
|
|
479
|
+
))
|
|
480
|
+
|
|
481
|
+
def _walk_stmt(
|
|
482
|
+
node: Node,
|
|
483
|
+
class_name: str | None,
|
|
484
|
+
var_types: dict[str, str],
|
|
485
|
+
class_fields: dict[str, str],
|
|
486
|
+
) -> None:
|
|
487
|
+
if node.type in ("import_statement", "import_from_statement"):
|
|
488
|
+
_emit_import_edges(node)
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
if node.type == "call":
|
|
492
|
+
_emit_call_edge(node, class_name, var_types)
|
|
493
|
+
|
|
494
|
+
if node.type in ("function_definition", "decorated_definition"):
|
|
495
|
+
inner_fn = node
|
|
496
|
+
if node.type == "decorated_definition":
|
|
497
|
+
inner_fn = node.child_by_field_name("definition") or node
|
|
498
|
+
if inner_fn.type == "function_definition":
|
|
499
|
+
_walk_function_body(inner_fn, class_name, class_fields)
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
if node.type == "class_definition":
|
|
503
|
+
_walk_class(node)
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
for child in node.children:
|
|
507
|
+
_walk_stmt(child, class_name, var_types, class_fields)
|
|
508
|
+
|
|
509
|
+
def _walk_class(class_node: Node) -> None:
|
|
510
|
+
cls_name = _node_name(class_node)
|
|
511
|
+
|
|
512
|
+
if emit_inheritance and cls_name:
|
|
513
|
+
bases = class_node.child_by_field_name("superclasses")
|
|
514
|
+
if bases is not None:
|
|
515
|
+
for base_child in bases.named_children:
|
|
516
|
+
base_target = _base_type_name(base_child)
|
|
517
|
+
if base_target:
|
|
518
|
+
edges.append(Edge(
|
|
519
|
+
source=cls_name,
|
|
520
|
+
target=base_target,
|
|
521
|
+
kind="extends",
|
|
522
|
+
file=file_str,
|
|
523
|
+
line=class_node.start_point[0] + 1,
|
|
524
|
+
confidence="INFERRED",
|
|
525
|
+
receiver=None,
|
|
526
|
+
))
|
|
527
|
+
|
|
528
|
+
# Pre-scan class body for field type bindings BEFORE walking methods.
|
|
529
|
+
# WHY: a method defined above a field declaration should still be able to
|
|
530
|
+
# resolve that field's type (e.g. DI patterns store injected objects as
|
|
531
|
+
# annotated class attributes). This is Layer 1 of the two-layer scope model.
|
|
532
|
+
# When SEAM_TYPE_INFERENCE=off the dict stays empty — inference is skipped
|
|
533
|
+
# entirely, producing the same bare-target edges as pre-Tier-B.
|
|
534
|
+
class_fields: dict[str, str] = {}
|
|
535
|
+
if type_inference_on and cls_name:
|
|
536
|
+
class_fields = scan_class_fields_python(class_node)
|
|
537
|
+
|
|
538
|
+
# Slice #77: emit composition (holds) edges.
|
|
539
|
+
# WHY here: this is the single natural place where we have both the class name
|
|
540
|
+
# (cls_name) and the full class AST node. The collector handles dedup internally
|
|
541
|
+
# so we don't risk double-emitting even if the same type appears as both a class
|
|
542
|
+
# field and an __init__ parameter.
|
|
543
|
+
if composition_on and cls_name:
|
|
544
|
+
for held_type, held_line in collect_composition_types_python(class_node):
|
|
545
|
+
edges.append(Edge(
|
|
546
|
+
source=cls_name,
|
|
547
|
+
target=held_type,
|
|
548
|
+
kind="holds",
|
|
549
|
+
file=file_str,
|
|
550
|
+
line=held_line,
|
|
551
|
+
confidence="INFERRED",
|
|
552
|
+
receiver=None,
|
|
553
|
+
))
|
|
554
|
+
|
|
555
|
+
body = class_node.child_by_field_name("body")
|
|
556
|
+
if body is None:
|
|
557
|
+
return
|
|
558
|
+
for child in body.children:
|
|
559
|
+
if child.type == "function_definition":
|
|
560
|
+
_walk_function_body(child, cls_name, class_fields)
|
|
561
|
+
elif child.type == "decorated_definition":
|
|
562
|
+
inner = child.child_by_field_name("definition")
|
|
563
|
+
if inner and inner.type == "function_definition":
|
|
564
|
+
_walk_function_body(inner, cls_name, class_fields)
|
|
565
|
+
elif inner and inner.type == "class_definition":
|
|
566
|
+
_walk_class(inner)
|
|
567
|
+
else:
|
|
568
|
+
_walk_stmt(child, cls_name, {}, class_fields)
|
|
569
|
+
elif child.type == "class_definition":
|
|
570
|
+
_walk_class(child)
|
|
571
|
+
else:
|
|
572
|
+
_walk_stmt(child, cls_name, class_fields, class_fields)
|
|
573
|
+
|
|
574
|
+
def _walk_toplevel(node: Node) -> None:
|
|
575
|
+
if node.type in ("import_statement", "import_from_statement"):
|
|
576
|
+
_emit_import_edges(node)
|
|
577
|
+
elif node.type == "class_definition":
|
|
578
|
+
_walk_class(node)
|
|
579
|
+
elif node.type == "function_definition":
|
|
580
|
+
_walk_function_body(node, None, {})
|
|
581
|
+
elif node.type == "decorated_definition":
|
|
582
|
+
inner = node.child_by_field_name("definition")
|
|
583
|
+
if inner and inner.type == "function_definition":
|
|
584
|
+
_walk_function_body(inner, None, {})
|
|
585
|
+
elif inner and inner.type == "class_definition":
|
|
586
|
+
_walk_class(inner)
|
|
587
|
+
else:
|
|
588
|
+
for child in node.children:
|
|
589
|
+
_walk_toplevel(child)
|
|
590
|
+
else:
|
|
591
|
+
for child in node.children:
|
|
592
|
+
_walk_toplevel(child)
|
|
593
|
+
|
|
594
|
+
for child in root.children:
|
|
595
|
+
_walk_toplevel(child)
|
|
596
|
+
|
|
597
|
+
return edges
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _extract_comments_python(root: Node, filepath: Path) -> list[Comment]:
|
|
601
|
+
"""Walk a Python AST and collect matched semantic comment nodes (WHY/HACK/NOTE/TODO/FIXME).
|
|
602
|
+
|
|
603
|
+
Python has a single 'comment' node type (lines starting with '#').
|
|
604
|
+
Strips the leading '#' prefix before marker matching.
|
|
605
|
+
"""
|
|
606
|
+
comments: list[Comment] = []
|
|
607
|
+
|
|
608
|
+
def _walk(node: Node) -> None:
|
|
609
|
+
if node.type == "comment":
|
|
610
|
+
raw = _text(node)
|
|
611
|
+
body = raw.lstrip("#").strip()
|
|
612
|
+
result = _match_marker(body)
|
|
613
|
+
if result is not None:
|
|
614
|
+
marker, text = result
|
|
615
|
+
comments.append(
|
|
616
|
+
Comment(
|
|
617
|
+
marker=marker,
|
|
618
|
+
text=text,
|
|
619
|
+
line=node.start_point[0] + 1,
|
|
620
|
+
)
|
|
621
|
+
)
|
|
622
|
+
for child in node.children:
|
|
623
|
+
_walk(child)
|
|
624
|
+
|
|
625
|
+
for child in root.children:
|
|
626
|
+
_walk(child)
|
|
627
|
+
|
|
628
|
+
return comments
|