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.
- loom/__init__.py +32 -0
- loom/analysis/__init__.py +0 -0
- loom/analysis/code/__init__.py +1 -0
- loom/analysis/code/calls.py +232 -0
- loom/analysis/code/calls_java.py +132 -0
- loom/analysis/code/calls_ts.py +136 -0
- loom/analysis/code/communities.py +261 -0
- loom/analysis/code/coupling.py +205 -0
- loom/analysis/code/extractor.py +78 -0
- loom/analysis/code/noise_filter.py +251 -0
- loom/analysis/code/parser.py +58 -0
- loom/cli.py +1157 -0
- loom/config.py +86 -0
- loom/core/__init__.py +13 -0
- loom/core/content_hash.py +20 -0
- loom/core/edge.py +88 -0
- loom/core/falkor/__init__.py +3 -0
- loom/core/falkor/cypher.py +92 -0
- loom/core/falkor/edge_type_adapter.py +91 -0
- loom/core/falkor/gateway.py +107 -0
- loom/core/falkor/mappers.py +119 -0
- loom/core/falkor/repositories.py +306 -0
- loom/core/falkor/schema.py +87 -0
- loom/core/graph.py +145 -0
- loom/core/node.py +98 -0
- loom/core/protocols.py +32 -0
- loom/devtools.py +123 -0
- loom/drift/__init__.py +0 -0
- loom/drift/detector.py +160 -0
- loom/embed/__init__.py +0 -0
- loom/embed/embedder.py +150 -0
- loom/ingest/__init__.py +0 -0
- loom/ingest/code/__init__.py +1 -0
- loom/ingest/code/languages/__init__.py +1 -0
- loom/ingest/code/languages/_ts_utils.py +27 -0
- loom/ingest/code/languages/constants.py +224 -0
- loom/ingest/code/languages/go_lang.py +185 -0
- loom/ingest/code/languages/java.py +344 -0
- loom/ingest/code/languages/javascript.py +242 -0
- loom/ingest/code/languages/markup.py +601 -0
- loom/ingest/code/languages/python.py +399 -0
- loom/ingest/code/languages/ruby.py +214 -0
- loom/ingest/code/languages/rust.py +178 -0
- loom/ingest/code/languages/typescript.py +442 -0
- loom/ingest/code/registry.py +290 -0
- loom/ingest/code/walker.py +104 -0
- loom/ingest/differ.py +53 -0
- loom/ingest/docs/__init__.py +1 -0
- loom/ingest/docs/base.py +121 -0
- loom/ingest/docs/markdown.py +87 -0
- loom/ingest/docs/pdf.py +41 -0
- loom/ingest/git.py +102 -0
- loom/ingest/helpers.py +36 -0
- loom/ingest/incremental.py +606 -0
- loom/ingest/integrations/__init__.py +1 -0
- loom/ingest/integrations/jira.py +224 -0
- loom/ingest/pipeline.py +677 -0
- loom/ingest/result.py +56 -0
- loom/ingest/utils.py +158 -0
- loom/linker/__init__.py +0 -0
- loom/linker/_text_utils.py +15 -0
- loom/linker/embed_match.py +100 -0
- loom/linker/linker.py +92 -0
- loom/linker/llm_match.py +104 -0
- loom/linker/name_match.py +60 -0
- loom/linker/prompts.py +20 -0
- loom/linker/reranker.py +84 -0
- loom/llm/__init__.py +0 -0
- loom/llm/client.py +35 -0
- loom/mcp/__init__.py +1 -0
- loom/mcp/server.py +301 -0
- loom/py.typed +0 -0
- loom/query/__init__.py +1 -0
- loom/query/blast_radius.py +109 -0
- loom/query/node_lookup.py +66 -0
- loom/query/traceability.py +176 -0
- loom/search/__init__.py +1 -0
- loom/search/searcher.py +156 -0
- loom/watch/__init__.py +1 -0
- loom/watch/watcher.py +105 -0
- loom_tool-0.1.0.dist-info/METADATA +371 -0
- loom_tool-0.1.0.dist-info/RECORD +85 -0
- loom_tool-0.1.0.dist-info/WHEEL +4 -0
- loom_tool-0.1.0.dist-info/entry_points.txt +4 -0
- 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
|