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.
Files changed (102) hide show
  1. entrygraph/__init__.py +87 -0
  2. entrygraph/__main__.py +8 -0
  3. entrygraph/_version.py +24 -0
  4. entrygraph/api.py +549 -0
  5. entrygraph/cli/__init__.py +0 -0
  6. entrygraph/cli/main.py +387 -0
  7. entrygraph/cli/render.py +136 -0
  8. entrygraph/data/sinks/csharp.toml +106 -0
  9. entrygraph/data/sinks/go.toml +87 -0
  10. entrygraph/data/sinks/java.toml +92 -0
  11. entrygraph/data/sinks/javascript.toml +112 -0
  12. entrygraph/data/sinks/lib_javascript.toml +34 -0
  13. entrygraph/data/sinks/lib_python.toml +39 -0
  14. entrygraph/data/sinks/php.toml +125 -0
  15. entrygraph/data/sinks/python.toml +160 -0
  16. entrygraph/data/sinks/ruby.toml +102 -0
  17. entrygraph/data/sinks/rust.toml +68 -0
  18. entrygraph/db/__init__.py +0 -0
  19. entrygraph/db/engine.py +34 -0
  20. entrygraph/db/meta.py +70 -0
  21. entrygraph/db/models.py +176 -0
  22. entrygraph/db/queries.py +155 -0
  23. entrygraph/detect/__init__.py +0 -0
  24. entrygraph/detect/entrypoints/__init__.py +22 -0
  25. entrygraph/detect/entrypoints/base.py +124 -0
  26. entrygraph/detect/entrypoints/configs.py +139 -0
  27. entrygraph/detect/entrypoints/csharp.py +156 -0
  28. entrygraph/detect/entrypoints/golang.py +158 -0
  29. entrygraph/detect/entrypoints/java.py +187 -0
  30. entrygraph/detect/entrypoints/javascript.py +211 -0
  31. entrygraph/detect/entrypoints/php.py +133 -0
  32. entrygraph/detect/entrypoints/python.py +335 -0
  33. entrygraph/detect/entrypoints/ruby.py +147 -0
  34. entrygraph/detect/entrypoints/rust.py +153 -0
  35. entrygraph/detect/frameworks.py +369 -0
  36. entrygraph/detect/manifests.py +234 -0
  37. entrygraph/detect/taint.py +224 -0
  38. entrygraph/errors.py +27 -0
  39. entrygraph/extract/__init__.py +0 -0
  40. entrygraph/extract/base.py +51 -0
  41. entrygraph/extract/csharp.py +502 -0
  42. entrygraph/extract/golang.py +342 -0
  43. entrygraph/extract/ir.py +105 -0
  44. entrygraph/extract/java.py +329 -0
  45. entrygraph/extract/javascript.py +400 -0
  46. entrygraph/extract/php.py +426 -0
  47. entrygraph/extract/python.py +390 -0
  48. entrygraph/extract/registry.py +43 -0
  49. entrygraph/extract/ruby.py +321 -0
  50. entrygraph/extract/rust.py +482 -0
  51. entrygraph/fs/__init__.py +0 -0
  52. entrygraph/fs/hashing.py +78 -0
  53. entrygraph/fs/lang.py +134 -0
  54. entrygraph/fs/walker.py +167 -0
  55. entrygraph/graph/__init__.py +0 -0
  56. entrygraph/graph/adjacency.py +146 -0
  57. entrygraph/graph/cte.py +123 -0
  58. entrygraph/graph/scoring.py +101 -0
  59. entrygraph/kinds.py +51 -0
  60. entrygraph/parsing/__init__.py +0 -0
  61. entrygraph/parsing/parsers.py +49 -0
  62. entrygraph/parsing/queries.py +39 -0
  63. entrygraph/pipeline/__init__.py +0 -0
  64. entrygraph/pipeline/scanner.py +506 -0
  65. entrygraph/pipeline/worker.py +49 -0
  66. entrygraph/pipeline/writer.py +41 -0
  67. entrygraph/py.typed +0 -0
  68. entrygraph/queries/csharp/calls.scm +4 -0
  69. entrygraph/queries/csharp/definitions.scm +29 -0
  70. entrygraph/queries/csharp/imports.scm +4 -0
  71. entrygraph/queries/go/calls.scm +2 -0
  72. entrygraph/queries/go/definitions.scm +24 -0
  73. entrygraph/queries/go/imports.scm +1 -0
  74. entrygraph/queries/java/calls.scm +2 -0
  75. entrygraph/queries/java/definitions.scm +14 -0
  76. entrygraph/queries/java/imports.scm +2 -0
  77. entrygraph/queries/javascript/calls.scm +4 -0
  78. entrygraph/queries/javascript/definitions.scm +4 -0
  79. entrygraph/queries/javascript/imports.scm +6 -0
  80. entrygraph/queries/php/calls.scm +8 -0
  81. entrygraph/queries/php/definitions.scm +24 -0
  82. entrygraph/queries/php/imports.scm +1 -0
  83. entrygraph/queries/python/calls.scm +2 -0
  84. entrygraph/queries/python/definitions.scm +11 -0
  85. entrygraph/queries/python/imports.scm +2 -0
  86. entrygraph/queries/ruby/calls.scm +4 -0
  87. entrygraph/queries/ruby/definitions.scm +20 -0
  88. entrygraph/queries/ruby/imports.scm +7 -0
  89. entrygraph/queries/rust/calls.scm +5 -0
  90. entrygraph/queries/rust/definitions.scm +26 -0
  91. entrygraph/queries/rust/imports.scm +4 -0
  92. entrygraph/resolve/__init__.py +0 -0
  93. entrygraph/resolve/externals.py +61 -0
  94. entrygraph/resolve/hierarchy.py +152 -0
  95. entrygraph/resolve/resolver.py +275 -0
  96. entrygraph/resolve/symbol_table.py +48 -0
  97. entrygraph/results.py +138 -0
  98. entrygraph-0.1.0.dist-info/METADATA +204 -0
  99. entrygraph-0.1.0.dist-info/RECORD +102 -0
  100. entrygraph-0.1.0.dist-info/WHEEL +4 -0
  101. entrygraph-0.1.0.dist-info/entry_points.txt +2 -0
  102. 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