entrygraph 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.
- entrygraph/__init__.py +87 -0
- entrygraph/__main__.py +8 -0
- entrygraph/_version.py +24 -0
- entrygraph/api.py +549 -0
- entrygraph/cli/__init__.py +0 -0
- entrygraph/cli/main.py +387 -0
- entrygraph/cli/render.py +136 -0
- entrygraph/data/sinks/csharp.toml +106 -0
- entrygraph/data/sinks/go.toml +87 -0
- entrygraph/data/sinks/java.toml +92 -0
- entrygraph/data/sinks/javascript.toml +112 -0
- entrygraph/data/sinks/lib_javascript.toml +34 -0
- entrygraph/data/sinks/lib_python.toml +39 -0
- entrygraph/data/sinks/php.toml +125 -0
- entrygraph/data/sinks/python.toml +160 -0
- entrygraph/data/sinks/ruby.toml +102 -0
- entrygraph/data/sinks/rust.toml +68 -0
- entrygraph/db/__init__.py +0 -0
- entrygraph/db/engine.py +34 -0
- entrygraph/db/meta.py +70 -0
- entrygraph/db/models.py +176 -0
- entrygraph/db/queries.py +155 -0
- entrygraph/detect/__init__.py +0 -0
- entrygraph/detect/entrypoints/__init__.py +22 -0
- entrygraph/detect/entrypoints/base.py +124 -0
- entrygraph/detect/entrypoints/configs.py +139 -0
- entrygraph/detect/entrypoints/csharp.py +156 -0
- entrygraph/detect/entrypoints/golang.py +158 -0
- entrygraph/detect/entrypoints/java.py +187 -0
- entrygraph/detect/entrypoints/javascript.py +211 -0
- entrygraph/detect/entrypoints/php.py +133 -0
- entrygraph/detect/entrypoints/python.py +335 -0
- entrygraph/detect/entrypoints/ruby.py +147 -0
- entrygraph/detect/entrypoints/rust.py +153 -0
- entrygraph/detect/frameworks.py +369 -0
- entrygraph/detect/manifests.py +234 -0
- entrygraph/detect/taint.py +224 -0
- entrygraph/errors.py +27 -0
- entrygraph/extract/__init__.py +0 -0
- entrygraph/extract/base.py +51 -0
- entrygraph/extract/csharp.py +502 -0
- entrygraph/extract/golang.py +342 -0
- entrygraph/extract/ir.py +105 -0
- entrygraph/extract/java.py +329 -0
- entrygraph/extract/javascript.py +400 -0
- entrygraph/extract/php.py +426 -0
- entrygraph/extract/python.py +390 -0
- entrygraph/extract/registry.py +43 -0
- entrygraph/extract/ruby.py +321 -0
- entrygraph/extract/rust.py +482 -0
- entrygraph/fs/__init__.py +0 -0
- entrygraph/fs/hashing.py +78 -0
- entrygraph/fs/lang.py +134 -0
- entrygraph/fs/walker.py +167 -0
- entrygraph/graph/__init__.py +0 -0
- entrygraph/graph/adjacency.py +146 -0
- entrygraph/graph/cte.py +123 -0
- entrygraph/graph/scoring.py +101 -0
- entrygraph/kinds.py +51 -0
- entrygraph/parsing/__init__.py +0 -0
- entrygraph/parsing/parsers.py +49 -0
- entrygraph/parsing/queries.py +39 -0
- entrygraph/pipeline/__init__.py +0 -0
- entrygraph/pipeline/scanner.py +506 -0
- entrygraph/pipeline/worker.py +49 -0
- entrygraph/pipeline/writer.py +41 -0
- entrygraph/py.typed +0 -0
- entrygraph/queries/csharp/calls.scm +4 -0
- entrygraph/queries/csharp/definitions.scm +29 -0
- entrygraph/queries/csharp/imports.scm +4 -0
- entrygraph/queries/go/calls.scm +2 -0
- entrygraph/queries/go/definitions.scm +24 -0
- entrygraph/queries/go/imports.scm +1 -0
- entrygraph/queries/java/calls.scm +2 -0
- entrygraph/queries/java/definitions.scm +14 -0
- entrygraph/queries/java/imports.scm +2 -0
- entrygraph/queries/javascript/calls.scm +4 -0
- entrygraph/queries/javascript/definitions.scm +4 -0
- entrygraph/queries/javascript/imports.scm +6 -0
- entrygraph/queries/php/calls.scm +8 -0
- entrygraph/queries/php/definitions.scm +24 -0
- entrygraph/queries/php/imports.scm +1 -0
- entrygraph/queries/python/calls.scm +2 -0
- entrygraph/queries/python/definitions.scm +11 -0
- entrygraph/queries/python/imports.scm +2 -0
- entrygraph/queries/ruby/calls.scm +4 -0
- entrygraph/queries/ruby/definitions.scm +20 -0
- entrygraph/queries/ruby/imports.scm +7 -0
- entrygraph/queries/rust/calls.scm +5 -0
- entrygraph/queries/rust/definitions.scm +26 -0
- entrygraph/queries/rust/imports.scm +4 -0
- entrygraph/resolve/__init__.py +0 -0
- entrygraph/resolve/externals.py +61 -0
- entrygraph/resolve/hierarchy.py +152 -0
- entrygraph/resolve/resolver.py +275 -0
- entrygraph/resolve/symbol_table.py +48 -0
- entrygraph/results.py +138 -0
- entrygraph-0.1.0.dist-info/METADATA +204 -0
- entrygraph-0.1.0.dist-info/RECORD +102 -0
- entrygraph-0.1.0.dist-info/WHEEL +4 -0
- entrygraph-0.1.0.dist-info/entry_points.txt +2 -0
- entrygraph-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Ruby extractor: .scm queries harvest nodes, this shaper builds the IR.
|
|
2
|
+
|
|
3
|
+
Ruby resolution is intentionally loose. Files map to dotted module paths by
|
|
4
|
+
convention (path minus ``.rb``, common roots stripped), classes and modules
|
|
5
|
+
nest via ``constant`` names, and ``require`` calls are the closest thing to
|
|
6
|
+
imports. Because Ruby has no static type information, most cross-object method
|
|
7
|
+
calls resolve FUZZY (unique project name) or stay UNRESOLVED as ``rb:*.name``.
|
|
8
|
+
That is honest: the graph reports what it can prove and no more.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
14
|
+
|
|
15
|
+
from entrygraph.extract.base import FileContext, node_text, span_of, truncate
|
|
16
|
+
from entrygraph.extract.ir import FileExtraction, RawImport, RawReference, RawSymbol
|
|
17
|
+
from entrygraph.kinds import SymbolKind
|
|
18
|
+
from entrygraph.parsing.queries import captures, load_query
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
|
+
from tree_sitter import Node, Tree
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
_SRC_ROOTS = ("app", "lib", "src")
|
|
26
|
+
# scope-defining nodes whose names build a qualified-name chain
|
|
27
|
+
_SCOPE_TYPES = frozenset({"class", "module", "method", "singleton_method"})
|
|
28
|
+
_METHOD_SCOPES = frozenset({"method", "singleton_method"})
|
|
29
|
+
_REQUIRE_METHODS = frozenset({"require", "require_relative", "load"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RubyExtractor:
|
|
33
|
+
language_ids: ClassVar[tuple[str, ...]] = ("ruby",)
|
|
34
|
+
|
|
35
|
+
def module_path_for(self, repo_relative_path: str) -> tuple[str, bool]:
|
|
36
|
+
parts = repo_relative_path.split("/")
|
|
37
|
+
if parts and parts[0] in _SRC_ROOTS and len(parts) > 1:
|
|
38
|
+
parts = parts[1:]
|
|
39
|
+
parts[-1] = parts[-1].removesuffix(".rb")
|
|
40
|
+
return ".".join(p for p in parts if p) or "_root", False
|
|
41
|
+
|
|
42
|
+
def extract(self, tree: "Tree", ctx: FileContext) -> FileExtraction:
|
|
43
|
+
root = tree.root_node
|
|
44
|
+
out = FileExtraction(
|
|
45
|
+
path=ctx.path,
|
|
46
|
+
language=ctx.language,
|
|
47
|
+
module_path=ctx.module_path,
|
|
48
|
+
parse_ok=not root.has_error,
|
|
49
|
+
error_count=1 if root.has_error else 0,
|
|
50
|
+
)
|
|
51
|
+
self._extract_definitions(root, ctx, out)
|
|
52
|
+
self._extract_imports(root, ctx, out)
|
|
53
|
+
self._extract_calls(root, ctx, out)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
# ---------------- definitions ----------------
|
|
57
|
+
|
|
58
|
+
def _extract_definitions(self, root: "Node", ctx: FileContext, out: FileExtraction) -> None:
|
|
59
|
+
caps = captures(load_query("ruby", "definitions"), root)
|
|
60
|
+
|
|
61
|
+
for node in caps.get("def.module", []):
|
|
62
|
+
name_node = node.child_by_field_name("name")
|
|
63
|
+
if name_node is None:
|
|
64
|
+
continue
|
|
65
|
+
name = node_text(name_node)
|
|
66
|
+
qname, parent_q = self._qualify(node, name, ctx)
|
|
67
|
+
out.symbols.append(
|
|
68
|
+
RawSymbol(
|
|
69
|
+
kind=SymbolKind.MODULE,
|
|
70
|
+
name=name,
|
|
71
|
+
qualified_name=qname,
|
|
72
|
+
span=span_of(node),
|
|
73
|
+
parent_qualified_name=parent_q,
|
|
74
|
+
signature=self._signature(node),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for node in caps.get("def.class", []):
|
|
79
|
+
name_node = node.child_by_field_name("name")
|
|
80
|
+
if name_node is None:
|
|
81
|
+
continue
|
|
82
|
+
name = node_text(name_node)
|
|
83
|
+
qname, parent_q = self._qualify(node, name, ctx)
|
|
84
|
+
bases = self._class_bases(node)
|
|
85
|
+
out.symbols.append(
|
|
86
|
+
RawSymbol(
|
|
87
|
+
kind=SymbolKind.CLASS,
|
|
88
|
+
name=name,
|
|
89
|
+
qualified_name=qname,
|
|
90
|
+
span=span_of(node),
|
|
91
|
+
parent_qualified_name=parent_q,
|
|
92
|
+
signature=self._signature(node),
|
|
93
|
+
bases=bases,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
for base in bases:
|
|
97
|
+
out.references.append(
|
|
98
|
+
RawReference(
|
|
99
|
+
kind="inherit",
|
|
100
|
+
callee_text=base,
|
|
101
|
+
callee_name=base.rsplit("::", 1)[-1].rsplit(".", 1)[-1],
|
|
102
|
+
receiver_text=None,
|
|
103
|
+
span=span_of(node),
|
|
104
|
+
caller_qualified_name=qname,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
for capture in ("def.method", "def.singleton_method"):
|
|
109
|
+
for node in caps.get(capture, []):
|
|
110
|
+
name_node = node.child_by_field_name("name")
|
|
111
|
+
if name_node is None:
|
|
112
|
+
continue
|
|
113
|
+
name = node_text(name_node)
|
|
114
|
+
qname, parent_q = self._qualify(node, name, ctx)
|
|
115
|
+
modifiers = ["self"] if node.type == "singleton_method" else []
|
|
116
|
+
out.symbols.append(
|
|
117
|
+
RawSymbol(
|
|
118
|
+
kind=SymbolKind.METHOD,
|
|
119
|
+
name=name,
|
|
120
|
+
qualified_name=qname,
|
|
121
|
+
span=span_of(node),
|
|
122
|
+
parent_qualified_name=parent_q,
|
|
123
|
+
signature=self._signature(node),
|
|
124
|
+
modifiers=modifiers,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
for node in caps.get("def.assign.constant", []):
|
|
129
|
+
if self._nearest_scope_type(node) in _METHOD_SCOPES:
|
|
130
|
+
continue # local constant inside a method — skip
|
|
131
|
+
left = node.child_by_field_name("left")
|
|
132
|
+
if left is None:
|
|
133
|
+
continue
|
|
134
|
+
name = node_text(left)
|
|
135
|
+
qname, parent_q = self._qualify(node, name, ctx)
|
|
136
|
+
out.symbols.append(
|
|
137
|
+
RawSymbol(
|
|
138
|
+
kind=SymbolKind.CONSTANT,
|
|
139
|
+
name=name,
|
|
140
|
+
qualified_name=qname,
|
|
141
|
+
span=span_of(node),
|
|
142
|
+
parent_qualified_name=parent_q,
|
|
143
|
+
signature=truncate(node_text(node)),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
for node in caps.get("def.assign.variable", []):
|
|
148
|
+
if self._nearest_scope_type(node) is not None:
|
|
149
|
+
continue # only top-level (global-ish) assignments become symbols
|
|
150
|
+
left = node.child_by_field_name("left")
|
|
151
|
+
if left is None:
|
|
152
|
+
continue
|
|
153
|
+
name = node_text(left)
|
|
154
|
+
qname, parent_q = self._qualify(node, name, ctx)
|
|
155
|
+
out.symbols.append(
|
|
156
|
+
RawSymbol(
|
|
157
|
+
kind=SymbolKind.VARIABLE,
|
|
158
|
+
name=name,
|
|
159
|
+
qualified_name=qname,
|
|
160
|
+
span=span_of(node),
|
|
161
|
+
parent_qualified_name=parent_q,
|
|
162
|
+
signature=truncate(node_text(node)),
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# ---------------- imports ----------------
|
|
167
|
+
|
|
168
|
+
def _extract_imports(self, root: "Node", ctx: FileContext, out: FileExtraction) -> None:
|
|
169
|
+
caps = captures(load_query("ruby", "imports"), root)
|
|
170
|
+
for node in caps.get("import.call", []):
|
|
171
|
+
method_node = node.child_by_field_name("method")
|
|
172
|
+
if method_node is None or node_text(method_node) not in _REQUIRE_METHODS:
|
|
173
|
+
continue
|
|
174
|
+
args = node.child_by_field_name("arguments")
|
|
175
|
+
if args is None:
|
|
176
|
+
continue
|
|
177
|
+
spec = self._first_string_arg(args)
|
|
178
|
+
if spec is None:
|
|
179
|
+
continue
|
|
180
|
+
is_relative = node_text(method_node) == "require_relative" or spec.startswith(".")
|
|
181
|
+
if is_relative:
|
|
182
|
+
# Pre-expand relative requires to a project-style dotted module so
|
|
183
|
+
# the IMPORTS edge points at the required file. Ruby creates no
|
|
184
|
+
# local name binding for a require, so we deliberately use the
|
|
185
|
+
# full dotted path as the alias — a single-segment basename would
|
|
186
|
+
# otherwise hijack same-named local-variable method calls in the
|
|
187
|
+
# resolver's import map and defeat fuzzy resolution.
|
|
188
|
+
module = self._expand_relative_require(spec, ctx)
|
|
189
|
+
alias = module
|
|
190
|
+
else:
|
|
191
|
+
module = spec
|
|
192
|
+
alias = os.path.basename(spec).removesuffix(".rb") or spec
|
|
193
|
+
out.imports.append(
|
|
194
|
+
RawImport(
|
|
195
|
+
module=module,
|
|
196
|
+
imported_name=None,
|
|
197
|
+
alias=alias,
|
|
198
|
+
span=span_of(node),
|
|
199
|
+
is_relative=False, # already expanded; keep resolver logic simple
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
# Ruby resolution is fuzzy; still surface require targets as framework
|
|
203
|
+
# signals so specs like sinatra fire on `require 'sinatra'`.
|
|
204
|
+
for imp in out.imports:
|
|
205
|
+
top = imp.module.strip("./").replace("/", ".").split(".")[0]
|
|
206
|
+
if top:
|
|
207
|
+
out.framework_signals.append(("import", top))
|
|
208
|
+
|
|
209
|
+
# ---------------- calls ----------------
|
|
210
|
+
|
|
211
|
+
def _extract_calls(self, root: "Node", ctx: FileContext, out: FileExtraction) -> None:
|
|
212
|
+
caps = captures(load_query("ruby", "calls"), root)
|
|
213
|
+
for node in caps.get("call", []):
|
|
214
|
+
method_node = node.child_by_field_name("method")
|
|
215
|
+
if method_node is None:
|
|
216
|
+
continue
|
|
217
|
+
callee_name = node_text(method_node)
|
|
218
|
+
receiver_node = node.child_by_field_name("receiver")
|
|
219
|
+
if receiver_node is not None:
|
|
220
|
+
receiver = node_text(receiver_node)
|
|
221
|
+
callee_text = f"{receiver}.{callee_name}"
|
|
222
|
+
elif self._caller(node, ctx) is not None:
|
|
223
|
+
# Bare call inside a method is an implicit-self send in Ruby;
|
|
224
|
+
# model it as a self-receiver so the resolver can bind it to a
|
|
225
|
+
# sibling method on the enclosing class (fuzzy otherwise).
|
|
226
|
+
receiver, callee_text = "self", callee_name
|
|
227
|
+
else:
|
|
228
|
+
# Bare call at module/top level (e.g. `get '/x'`, `require`).
|
|
229
|
+
receiver, callee_text = None, callee_name
|
|
230
|
+
args = node.child_by_field_name("arguments")
|
|
231
|
+
out.references.append(
|
|
232
|
+
RawReference(
|
|
233
|
+
kind="call",
|
|
234
|
+
callee_text=callee_text,
|
|
235
|
+
callee_name=callee_name,
|
|
236
|
+
receiver_text=receiver,
|
|
237
|
+
span=span_of(node),
|
|
238
|
+
caller_qualified_name=self._caller(node, ctx),
|
|
239
|
+
arg_count=len(args.named_children) if args is not None else 0,
|
|
240
|
+
arg_preview=truncate(node_text(args)) if args is not None else None,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# ---------------- shared walking helpers ----------------
|
|
245
|
+
|
|
246
|
+
def _scope_chain(self, node: "Node") -> list[str]:
|
|
247
|
+
parts: list[str] = []
|
|
248
|
+
current = node.parent
|
|
249
|
+
while current is not None:
|
|
250
|
+
if current.type in _SCOPE_TYPES:
|
|
251
|
+
name = current.child_by_field_name("name")
|
|
252
|
+
if name is not None:
|
|
253
|
+
parts.append(node_text(name))
|
|
254
|
+
current = current.parent
|
|
255
|
+
parts.reverse()
|
|
256
|
+
return parts
|
|
257
|
+
|
|
258
|
+
def _nearest_scope_type(self, node: "Node") -> str | None:
|
|
259
|
+
current = node.parent
|
|
260
|
+
while current is not None:
|
|
261
|
+
if current.type in _SCOPE_TYPES:
|
|
262
|
+
return current.type
|
|
263
|
+
current = current.parent
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
def _qualify(self, node: "Node", name: str, ctx: FileContext) -> tuple[str, str | None]:
|
|
267
|
+
chain = self._scope_chain(node)
|
|
268
|
+
parent_q = ".".join([ctx.module_path, *chain]) if chain else None
|
|
269
|
+
qname = ".".join([ctx.module_path, *chain, name])
|
|
270
|
+
return qname, parent_q
|
|
271
|
+
|
|
272
|
+
def _caller(self, node: "Node", ctx: FileContext) -> str | None:
|
|
273
|
+
"""FQN of the enclosing method, or None for module/top level."""
|
|
274
|
+
current = node.parent
|
|
275
|
+
while current is not None:
|
|
276
|
+
if current.type in _METHOD_SCOPES:
|
|
277
|
+
name = current.child_by_field_name("name")
|
|
278
|
+
if name is not None:
|
|
279
|
+
return self._qualify(current, node_text(name), ctx)[0]
|
|
280
|
+
current = current.parent
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def _signature(self, node: "Node") -> str:
|
|
284
|
+
first_line = node_text(node).split("\n", 1)[0].rstrip()
|
|
285
|
+
return truncate(first_line, 120)
|
|
286
|
+
|
|
287
|
+
def _class_bases(self, node: "Node") -> list[str]:
|
|
288
|
+
supers = node.child_by_field_name("superclass")
|
|
289
|
+
if supers is None:
|
|
290
|
+
return []
|
|
291
|
+
# superclass node is "< Base"; its named child is the actual type expr
|
|
292
|
+
for child in supers.named_children:
|
|
293
|
+
text = node_text(child).strip()
|
|
294
|
+
if text:
|
|
295
|
+
return [text]
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
def _expand_relative_require(self, spec: str, ctx: FileContext) -> str:
|
|
299
|
+
"""`require_relative './services/runner'` from module `app` -> the
|
|
300
|
+
project dotted module `services.runner`, mirroring module_path_for."""
|
|
301
|
+
parts = ctx.module_path.split(".")[:-1] # directory of the current file
|
|
302
|
+
for segment in spec.split("/"):
|
|
303
|
+
if segment in ("", "."):
|
|
304
|
+
continue
|
|
305
|
+
if segment == "..":
|
|
306
|
+
parts = parts[:-1]
|
|
307
|
+
else:
|
|
308
|
+
parts.append(segment.removesuffix(".rb"))
|
|
309
|
+
return ".".join(p for p in parts if p) or "_root"
|
|
310
|
+
|
|
311
|
+
def _first_string_arg(self, args: "Node") -> str | None:
|
|
312
|
+
for child in args.named_children:
|
|
313
|
+
if child.type == "string":
|
|
314
|
+
content = "".join(
|
|
315
|
+
node_text(c) for c in child.named_children if c.type == "string_content"
|
|
316
|
+
)
|
|
317
|
+
if content:
|
|
318
|
+
return content
|
|
319
|
+
# empty or interpolated: fall back to stripped literal text
|
|
320
|
+
return node_text(child).strip("'\"")
|
|
321
|
+
return None
|