loom-tool 0.1.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 (85) hide show
  1. loom/__init__.py +32 -0
  2. loom/analysis/__init__.py +0 -0
  3. loom/analysis/code/__init__.py +1 -0
  4. loom/analysis/code/calls.py +232 -0
  5. loom/analysis/code/calls_java.py +132 -0
  6. loom/analysis/code/calls_ts.py +136 -0
  7. loom/analysis/code/communities.py +261 -0
  8. loom/analysis/code/coupling.py +205 -0
  9. loom/analysis/code/extractor.py +78 -0
  10. loom/analysis/code/noise_filter.py +251 -0
  11. loom/analysis/code/parser.py +58 -0
  12. loom/cli.py +1157 -0
  13. loom/config.py +86 -0
  14. loom/core/__init__.py +13 -0
  15. loom/core/content_hash.py +20 -0
  16. loom/core/edge.py +88 -0
  17. loom/core/falkor/__init__.py +3 -0
  18. loom/core/falkor/cypher.py +92 -0
  19. loom/core/falkor/edge_type_adapter.py +91 -0
  20. loom/core/falkor/gateway.py +107 -0
  21. loom/core/falkor/mappers.py +119 -0
  22. loom/core/falkor/repositories.py +306 -0
  23. loom/core/falkor/schema.py +87 -0
  24. loom/core/graph.py +145 -0
  25. loom/core/node.py +98 -0
  26. loom/core/protocols.py +32 -0
  27. loom/devtools.py +123 -0
  28. loom/drift/__init__.py +0 -0
  29. loom/drift/detector.py +160 -0
  30. loom/embed/__init__.py +0 -0
  31. loom/embed/embedder.py +150 -0
  32. loom/ingest/__init__.py +0 -0
  33. loom/ingest/code/__init__.py +1 -0
  34. loom/ingest/code/languages/__init__.py +1 -0
  35. loom/ingest/code/languages/_ts_utils.py +27 -0
  36. loom/ingest/code/languages/constants.py +224 -0
  37. loom/ingest/code/languages/go_lang.py +185 -0
  38. loom/ingest/code/languages/java.py +344 -0
  39. loom/ingest/code/languages/javascript.py +242 -0
  40. loom/ingest/code/languages/markup.py +601 -0
  41. loom/ingest/code/languages/python.py +399 -0
  42. loom/ingest/code/languages/ruby.py +214 -0
  43. loom/ingest/code/languages/rust.py +178 -0
  44. loom/ingest/code/languages/typescript.py +442 -0
  45. loom/ingest/code/registry.py +290 -0
  46. loom/ingest/code/walker.py +104 -0
  47. loom/ingest/differ.py +53 -0
  48. loom/ingest/docs/__init__.py +1 -0
  49. loom/ingest/docs/base.py +121 -0
  50. loom/ingest/docs/markdown.py +87 -0
  51. loom/ingest/docs/pdf.py +41 -0
  52. loom/ingest/git.py +102 -0
  53. loom/ingest/helpers.py +36 -0
  54. loom/ingest/incremental.py +606 -0
  55. loom/ingest/integrations/__init__.py +1 -0
  56. loom/ingest/integrations/jira.py +224 -0
  57. loom/ingest/pipeline.py +677 -0
  58. loom/ingest/result.py +56 -0
  59. loom/ingest/utils.py +158 -0
  60. loom/linker/__init__.py +0 -0
  61. loom/linker/_text_utils.py +15 -0
  62. loom/linker/embed_match.py +100 -0
  63. loom/linker/linker.py +92 -0
  64. loom/linker/llm_match.py +104 -0
  65. loom/linker/name_match.py +60 -0
  66. loom/linker/prompts.py +20 -0
  67. loom/linker/reranker.py +84 -0
  68. loom/llm/__init__.py +0 -0
  69. loom/llm/client.py +35 -0
  70. loom/mcp/__init__.py +1 -0
  71. loom/mcp/server.py +301 -0
  72. loom/py.typed +0 -0
  73. loom/query/__init__.py +1 -0
  74. loom/query/blast_radius.py +109 -0
  75. loom/query/node_lookup.py +66 -0
  76. loom/query/traceability.py +176 -0
  77. loom/search/__init__.py +1 -0
  78. loom/search/searcher.py +156 -0
  79. loom/watch/__init__.py +1 -0
  80. loom/watch/watcher.py +105 -0
  81. loom_tool-0.1.0.dist-info/METADATA +371 -0
  82. loom_tool-0.1.0.dist-info/RECORD +85 -0
  83. loom_tool-0.1.0.dist-info/WHEEL +4 -0
  84. loom_tool-0.1.0.dist-info/entry_points.txt +4 -0
  85. loom_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
loom/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from importlib.metadata import PackageNotFoundError, version
5
+
6
+ try:
7
+ __version__ = version("loom")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0.dev0"
10
+
11
+
12
+ def _install_fast_event_loop() -> None:
13
+ if sys.platform == "win32":
14
+ if sys.version_info >= (3, 12):
15
+ return
16
+ try:
17
+ import winloop # type: ignore
18
+
19
+ winloop.install()
20
+ except ImportError:
21
+ pass
22
+ return
23
+
24
+ try:
25
+ import uvloop # type: ignore
26
+
27
+ uvloop.install()
28
+ except ImportError:
29
+ pass
30
+
31
+
32
+ _install_fast_event_loop()
File without changes
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from tree_sitter import Language, Parser
7
+ from tree_sitter import Node as TSNode
8
+ from tree_sitter_python import language as python_language
9
+
10
+ from loom.analysis.code.noise_filter import should_ignore_call
11
+ from loom.core import Edge, EdgeOrigin, EdgeType, Node, NodeKind
12
+ from loom.ingest.code.languages.constants import (
13
+ TS_PY_ATTRIBUTE,
14
+ TS_PY_CALL,
15
+ TS_PY_FUNCTION_DEF,
16
+ TS_PY_IDENTIFIER,
17
+ )
18
+
19
+ _PY_LANGUAGE = Language(python_language())
20
+
21
+
22
+ def _node_text(src: bytes, n: TSNode) -> str:
23
+ return src[n.start_byte : n.end_byte].decode("utf-8", errors="replace")
24
+
25
+
26
+ def _extract_call_name(src: bytes, func_node: TSNode) -> tuple[str | None, float]:
27
+ """Extract the function name from a call's function node.
28
+
29
+ Returns (name, confidence):
30
+ - Direct call foo() -> ("foo", 1.0)
31
+ - Method call obj.foo() -> ("foo", 0.8)
32
+ - Chained call a.b.c() -> ("c", 0.8)
33
+ - Dynamic/computed -> (None, 0.5)
34
+ """
35
+ if func_node.type == TS_PY_IDENTIFIER:
36
+ name = _node_text(src, func_node)
37
+ return name, 1.0
38
+
39
+ if func_node.type == TS_PY_ATTRIBUTE:
40
+ attr_node = func_node.child_by_field_name("attribute")
41
+ if attr_node:
42
+ name = _node_text(src, attr_node)
43
+ return name, 0.8
44
+ return None, 0.5
45
+
46
+ return None, 0.5
47
+
48
+
49
+ def _find_calls_in_node(
50
+ src: bytes, n: TSNode, calls: list[tuple[str, float, bool]]
51
+ ) -> None:
52
+ """Recursively find all call nodes and extract their names."""
53
+ if n.type == TS_PY_CALL:
54
+ func_node = n.child_by_field_name("function")
55
+ if func_node:
56
+ name, confidence = _extract_call_name(src, func_node)
57
+ is_method_call = func_node.type == TS_PY_ATTRIBUTE
58
+ if name and not should_ignore_call(name):
59
+ calls.append((name, confidence, is_method_call))
60
+
61
+ for child in n.children:
62
+ _find_calls_in_node(src, child, calls)
63
+
64
+
65
+ def _find_function_body(
66
+ src: bytes, subtree: TSNode, func_name: str, start_line: int
67
+ ) -> TSNode | None:
68
+ """Find the function definition node matching the given name and line."""
69
+
70
+ def _search(node: TSNode) -> TSNode | None:
71
+ if node.type == TS_PY_FUNCTION_DEF:
72
+ name_node = node.child_by_field_name("name")
73
+ if name_node:
74
+ name = _node_text(src, name_node)
75
+ if name == func_name and node.start_point[0] + 1 == start_line:
76
+ body = node.child_by_field_name("body")
77
+ return body if body else node
78
+
79
+ for child in node.children:
80
+ result = _search(child)
81
+ if result:
82
+ return result
83
+ return None
84
+
85
+ return _search(subtree)
86
+
87
+
88
+ def trace_calls(
89
+ function_node: Node,
90
+ subtree: TSNode,
91
+ all_symbols: dict[str, list[Node]],
92
+ *,
93
+ src: bytes | None = None,
94
+ ) -> list[Edge]:
95
+ """Extract CALLS edges from a function's body.
96
+
97
+ Args:
98
+ function_node: The Node representing the function being analyzed
99
+ subtree: The tree-sitter root node (or function body node)
100
+ all_symbols: Dict mapping symbol names to candidate Nodes for resolution
101
+ src: Source bytes (will be read from function_node.path if not provided)
102
+
103
+ Returns:
104
+ List of Edge objects with kind=CALLS, including confidence scores
105
+ """
106
+ if src is None:
107
+ src = Path(function_node.path).read_bytes()
108
+
109
+ calls: list[tuple[str, float, bool]] = []
110
+ _find_calls_in_node(src, subtree, calls)
111
+
112
+ edges: list[Edge] = []
113
+ for callee_name, confidence, is_method_call in calls:
114
+ candidates = all_symbols.get(callee_name, [])
115
+
116
+ if not candidates:
117
+ edges.append(
118
+ Edge(
119
+ from_id=function_node.id,
120
+ to_id=f"unresolved:{callee_name}",
121
+ kind=EdgeType.CALLS,
122
+ origin=EdgeOrigin.COMPUTED,
123
+ confidence=confidence,
124
+ metadata={"unresolved": True},
125
+ )
126
+ )
127
+ continue
128
+
129
+ if len(candidates) == 1:
130
+ resolved = candidates
131
+ else:
132
+ # Multiple candidates: prefer same-file first, then disambiguate by
133
+ # kind. For method calls (obj.foo()), prefer METHOD candidates;
134
+ # for direct calls (foo()), prefer FUNCTION candidates.
135
+ same_file = [c for c in candidates if c.path == function_node.path]
136
+ pool = same_file or candidates
137
+
138
+ preferred = [
139
+ c
140
+ for c in pool
141
+ if c.kind == (NodeKind.METHOD if is_method_call else NodeKind.FUNCTION)
142
+ ]
143
+
144
+ if len(preferred) == 1:
145
+ resolved = preferred
146
+ elif len(pool) == 1:
147
+ resolved = pool
148
+ else:
149
+ # Still ambiguous: emit one edge per candidate so no callers
150
+ # are silently lost. Each edge carries ambiguous=True so
151
+ # consumers can filter by confidence if needed.
152
+ resolved = pool
153
+
154
+ for callee_node in resolved:
155
+ metadata: dict[str, Any] = {}
156
+ if len(resolved) > 1:
157
+ metadata["ambiguous"] = True
158
+ edges.append(
159
+ Edge(
160
+ from_id=function_node.id,
161
+ to_id=callee_node.id,
162
+ kind=EdgeType.CALLS,
163
+ origin=EdgeOrigin.COMPUTED,
164
+ confidence=confidence,
165
+ metadata=metadata,
166
+ )
167
+ )
168
+
169
+ return edges
170
+
171
+
172
+ def _build_symbol_map(nodes: list[Node]) -> dict[str, list[Node]]:
173
+ """Build a name → [Node] map for all function/method nodes."""
174
+ symbol_map: dict[str, list[Node]] = {}
175
+ for n in nodes:
176
+ if n.kind in {NodeKind.FUNCTION, NodeKind.METHOD}:
177
+ symbol_map.setdefault(n.name, []).append(n)
178
+ return symbol_map
179
+
180
+
181
+ def trace_calls_for_file(path: str, nodes: list[Node]) -> list[Edge]:
182
+ """Trace all CALLS edges for functions in a file.
183
+
184
+ Uses only file-local symbols for resolution. For better cross-file
185
+ accuracy use trace_calls_for_file_with_global_symbols().
186
+ """
187
+ return trace_calls_for_file_with_global_symbols(path, nodes, global_symbol_map=None)
188
+
189
+
190
+ def trace_calls_for_file_with_global_symbols(
191
+ path: str,
192
+ nodes: list[Node],
193
+ *,
194
+ global_symbol_map: dict[str, list[Node]] | None,
195
+ ) -> list[Edge]:
196
+ """Trace CALLS edges for functions in a file using a cross-file symbol map.
197
+
198
+ Args:
199
+ path: Path to the Python file.
200
+ nodes: Nodes extracted from *this* file.
201
+ global_symbol_map: Pre-built name→[Node] map covering the entire repo.
202
+ When provided, cross-file calls resolve to real node IDs instead of
203
+ becoming ``unresolved:<name>``. If None, falls back to file-local
204
+ symbols only.
205
+
206
+ Returns:
207
+ List of CALLS edges for all functions in the file.
208
+ """
209
+ p = Path(path)
210
+ src = p.read_bytes()
211
+
212
+ parser = Parser(_PY_LANGUAGE)
213
+ tree = parser.parse(src)
214
+
215
+ if global_symbol_map is not None:
216
+ symbol_map = global_symbol_map
217
+ else:
218
+ symbol_map = _build_symbol_map(nodes)
219
+
220
+ all_edges: list[Edge] = []
221
+ for node in nodes:
222
+ if node.kind.value in {"function", "method"}:
223
+ if node.start_line is None:
224
+ continue
225
+ func_body = _find_function_body(
226
+ src, tree.root_node, node.name, node.start_line
227
+ )
228
+ if func_body:
229
+ edges = trace_calls(node, func_body, symbol_map, src=src)
230
+ all_edges.extend(edges)
231
+
232
+ return all_edges
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from tree_sitter import Language, Parser
6
+ from tree_sitter import Node as TSNode
7
+ from tree_sitter_java import language as java_language
8
+
9
+ from loom.analysis.code.noise_filter import should_ignore_call
10
+ from loom.core import Edge, EdgeOrigin, EdgeType, Node, NodeKind
11
+
12
+ _JAVA_LANGUAGE = Language(java_language())
13
+
14
+ _JAVA_METHOD_INVOCATION = "method_invocation"
15
+ _JAVA_OBJECT_CREATION = "object_creation_expression"
16
+ _JAVA_METHOD_DECL = "method_declaration"
17
+ _JAVA_CTOR_DECL = "constructor_declaration"
18
+
19
+
20
+ def _node_text(src: bytes, n: TSNode) -> str:
21
+ return src[n.start_byte : n.end_byte].decode("utf-8", errors="replace")
22
+
23
+
24
+ def _extract_method_call_name(src: bytes, n: TSNode) -> str | None:
25
+ name_node = n.child_by_field_name("name")
26
+ if name_node is None:
27
+ return None
28
+ return _node_text(src, name_node)
29
+
30
+
31
+ def _extract_constructor_call_name(src: bytes, n: TSNode) -> str | None:
32
+ type_node = n.child_by_field_name("type")
33
+ if type_node is None:
34
+ return None
35
+
36
+ # type can be scoped_type_identifier, type_identifier, generic_type, etc.
37
+ # We take the last identifier-looking token.
38
+ text = _node_text(src, type_node)
39
+ text = text.split("<", 1)[0]
40
+ text = text.split(".")[-1]
41
+ text = text.strip()
42
+ return text or None
43
+
44
+
45
+ def _enclosing_named_method(src: bytes, n: TSNode) -> tuple[str | None, int] | None:
46
+ cur: TSNode | None = n
47
+ while cur is not None:
48
+ if cur.type in {_JAVA_METHOD_DECL, _JAVA_CTOR_DECL}:
49
+ name_node = cur.child_by_field_name("name")
50
+ if name_node is None:
51
+ return None
52
+ name = _node_text(src, name_node)
53
+ start_line = cur.start_point[0] + 1
54
+ return name, start_line
55
+ cur = cur.parent
56
+ return None
57
+
58
+
59
+ def _find_calls(src: bytes, root: TSNode) -> list[tuple[TSNode, str, float]]:
60
+ out: list[tuple[TSNode, str, float]] = []
61
+
62
+ def _walk(n: TSNode) -> None:
63
+ if n.type == _JAVA_METHOD_INVOCATION:
64
+ name = _extract_method_call_name(src, n)
65
+ if name and not should_ignore_call(name, language="java"):
66
+ out.append((n, name, 1.0))
67
+ elif n.type == _JAVA_OBJECT_CREATION:
68
+ name = _extract_constructor_call_name(src, n)
69
+ if name and not should_ignore_call(name, language="java"):
70
+ out.append((n, name, 0.8))
71
+
72
+ for ch in n.children:
73
+ _walk(ch)
74
+
75
+ _walk(root)
76
+ return out
77
+
78
+
79
+ def trace_calls_for_java_file(path: str, nodes: list[Node]) -> list[Edge]:
80
+ p = Path(path)
81
+ src = p.read_bytes()
82
+
83
+ parser = Parser(_JAVA_LANGUAGE)
84
+ tree = parser.parse(src)
85
+
86
+ symbol_map: dict[str, list[Node]] = {}
87
+ for n in nodes:
88
+ if n.kind in {NodeKind.FUNCTION, NodeKind.METHOD, NodeKind.CLASS}:
89
+ symbol_map.setdefault(n.name, []).append(n)
90
+
91
+ calls = _find_calls(src, tree.root_node)
92
+
93
+ edges: list[Edge] = []
94
+ for call_node, callee_name, confidence in calls:
95
+ enclosing = _enclosing_named_method(src, call_node)
96
+ if enclosing is None:
97
+ continue
98
+ caller_name, caller_start_line = enclosing
99
+ if not caller_name:
100
+ continue
101
+
102
+ caller_path = Path(path)
103
+ caller_candidates = [
104
+ c
105
+ for c in symbol_map.get(caller_name, [])
106
+ if Path(c.path) == caller_path and c.start_line == caller_start_line
107
+ ]
108
+ if len(caller_candidates) != 1:
109
+ continue
110
+ caller = caller_candidates[0]
111
+
112
+ candidates = symbol_map.get(callee_name, [])
113
+ callee_node: Node | None = None
114
+ if len(candidates) == 1:
115
+ callee_node = candidates[0]
116
+
117
+ metadata: dict[str, object] = {}
118
+ if callee_node is None:
119
+ metadata["unresolved"] = True
120
+
121
+ edges.append(
122
+ Edge(
123
+ from_id=caller.id,
124
+ to_id=callee_node.id if callee_node else f"unresolved:{callee_name}",
125
+ kind=EdgeType.CALLS,
126
+ origin=EdgeOrigin.COMPUTED,
127
+ confidence=confidence,
128
+ metadata=metadata,
129
+ )
130
+ )
131
+
132
+ return edges
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from tree_sitter import Language, Parser
6
+ from tree_sitter import Node as TSNode
7
+ from tree_sitter_typescript import language_tsx, language_typescript
8
+
9
+ from loom.analysis.code.noise_filter import should_ignore_call
10
+ from loom.core import Edge, EdgeOrigin, EdgeType, Node, NodeKind
11
+
12
+ _TS_LANGUAGE = Language(language_typescript())
13
+ _TSX_LANGUAGE = Language(language_tsx())
14
+
15
+ _TS_CALL = "call_expression"
16
+ _TS_IDENTIFIER = "identifier"
17
+ _TS_MEMBER_EXPRESSION = "member_expression"
18
+ _TS_FUNCTION_DECL = "function_declaration"
19
+ _TS_METHOD_DEF = "method_definition"
20
+ _TS_ARROW_FUNCTION = "arrow_function"
21
+ _TS_FUNCTION_EXPR = "function"
22
+
23
+
24
+ def _node_text(src: bytes, n: TSNode) -> str:
25
+ return src[n.start_byte : n.end_byte].decode("utf-8", errors="replace")
26
+
27
+
28
+ def _extract_call_name(src: bytes, func_node: TSNode) -> tuple[str | None, float]:
29
+ if func_node.type == _TS_IDENTIFIER:
30
+ return _node_text(src, func_node), 1.0
31
+
32
+ if func_node.type == _TS_MEMBER_EXPRESSION:
33
+ prop = func_node.child_by_field_name("property")
34
+ if prop is not None and prop.type == _TS_IDENTIFIER:
35
+ return _node_text(src, prop), 0.8
36
+
37
+ return None, 0.5
38
+
39
+
40
+ def _enclosing_named_function(src: bytes, n: TSNode) -> tuple[str | None, int] | None:
41
+ cur: TSNode | None = n
42
+ while cur is not None:
43
+ if cur.type in {_TS_FUNCTION_DECL, _TS_METHOD_DEF}:
44
+ name_node = cur.child_by_field_name("name")
45
+ if name_node is None:
46
+ return None
47
+ name = _node_text(src, name_node)
48
+ start_line = cur.start_point[0] + 1
49
+ return name, start_line
50
+
51
+ if cur.type in {_TS_ARROW_FUNCTION, _TS_FUNCTION_EXPR}:
52
+ # Best-effort: if assigned to a variable/property, use that identifier.
53
+ parent = cur.parent
54
+ if parent is not None:
55
+ for field in ("name", "left"):
56
+ maybe = parent.child_by_field_name(field)
57
+ if maybe is not None and maybe.type == _TS_IDENTIFIER:
58
+ start_line = cur.start_point[0] + 1
59
+ return _node_text(src, maybe), start_line
60
+
61
+ cur = cur.parent
62
+ return None
63
+
64
+
65
+ def _find_calls(src: bytes, root: TSNode) -> list[tuple[TSNode, str, float]]:
66
+ out: list[tuple[TSNode, str, float]] = []
67
+
68
+ def _walk(n: TSNode) -> None:
69
+ if n.type == _TS_CALL:
70
+ fn = n.child_by_field_name("function")
71
+ if fn is not None:
72
+ name, conf = _extract_call_name(src, fn)
73
+ if name and not should_ignore_call(name, language="typescript"):
74
+ out.append((n, name, conf))
75
+ for ch in n.children:
76
+ _walk(ch)
77
+
78
+ _walk(root)
79
+ return out
80
+
81
+
82
+ def trace_calls_for_ts_file(path: str, nodes: list[Node]) -> list[Edge]:
83
+ p = Path(path)
84
+ src = p.read_bytes()
85
+
86
+ lang = _TSX_LANGUAGE if p.suffix.lower() == ".tsx" else _TS_LANGUAGE
87
+ parser = Parser(lang)
88
+ tree = parser.parse(src)
89
+
90
+ symbol_map: dict[str, list[Node]] = {}
91
+ for n in nodes:
92
+ if n.kind in {NodeKind.FUNCTION, NodeKind.METHOD}:
93
+ symbol_map.setdefault(n.name, []).append(n)
94
+
95
+ calls = _find_calls(src, tree.root_node)
96
+
97
+ edges: list[Edge] = []
98
+ for call_node, callee_name, confidence in calls:
99
+ enclosing = _enclosing_named_function(src, call_node)
100
+ if enclosing is None:
101
+ continue
102
+ caller_name, caller_start_line = enclosing
103
+ if not caller_name:
104
+ continue
105
+
106
+ caller_path = Path(path)
107
+ caller_candidates = [
108
+ c
109
+ for c in symbol_map.get(caller_name, [])
110
+ if Path(c.path) == caller_path and c.start_line == caller_start_line
111
+ ]
112
+ if len(caller_candidates) != 1:
113
+ continue
114
+ caller = caller_candidates[0]
115
+
116
+ candidates = symbol_map.get(callee_name, [])
117
+ callee_node: Node | None = None
118
+ if len(candidates) == 1:
119
+ callee_node = candidates[0]
120
+
121
+ metadata: dict[str, object] = {}
122
+ if callee_node is None:
123
+ metadata["unresolved"] = True
124
+
125
+ edges.append(
126
+ Edge(
127
+ from_id=caller.id,
128
+ to_id=callee_node.id if callee_node else f"unresolved:{callee_name}",
129
+ kind=EdgeType.CALLS,
130
+ origin=EdgeOrigin.COMPUTED,
131
+ confidence=confidence,
132
+ metadata=metadata,
133
+ )
134
+ )
135
+
136
+ return edges