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,335 @@
1
+ """Python entrypoint rules: flask, fastapi, django urls, click/typer, celery,
2
+ lambda handlers, and the language-core __main__ guard."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+
8
+ from entrygraph.detect.entrypoints.base import (
9
+ EntrypointRule,
10
+ first_string_arg,
11
+ identifier_args,
12
+ methods_kwarg,
13
+ register,
14
+ tainted_params,
15
+ )
16
+ from entrygraph.extract.ir import EntrypointHint, FileExtraction, RawSymbol
17
+ from entrygraph.kinds import EntrypointKind, SymbolKind
18
+
19
+ _FLASK_ROUTE = re.compile(r"^@(\w+)\.route\(")
20
+ _HTTP_VERB = re.compile(r"^@(\w+)\.(get|post|put|delete|patch|head|options)\(")
21
+ _CLICK_CMD = re.compile(r"^@(?:click\.(command|group)|(\w+)\.(command|group))\b")
22
+ _CELERY_TASK = re.compile(r"^@(?:shared_task\b|task\b|(\w+)\.task\b)")
23
+ _MIDDLEWARE_DECORATOR = re.compile(
24
+ r"^@(\w+)\.(before_request|after_request|before_first_request|errorhandler|middleware|teardown_request)\b"
25
+ )
26
+ _DJANGO_PATH_CALL = frozenset({"path", "re_path", "url"})
27
+ _LAMBDA_NAMES = frozenset({"lambda_handler", "handler"})
28
+
29
+
30
+ def _decorated(x: FileExtraction) -> list[tuple[RawSymbol, str]]:
31
+ return [(s, d) for s in x.symbols for d in s.decorators
32
+ if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)]
33
+
34
+
35
+ def _taint_meta(symbol: RawSymbol, kind: str) -> dict:
36
+ params = tainted_params(symbol.signature, kind)
37
+ return {"tainted_params": params} if params else {}
38
+
39
+
40
+ def _flask_routes(x: FileExtraction) -> list[EntrypointHint]:
41
+ hints = []
42
+ for symbol, decorator in _decorated(x):
43
+ route = None
44
+ methods: list[str] = []
45
+ if _FLASK_ROUTE.match(decorator):
46
+ route = first_string_arg(decorator)
47
+ methods = methods_kwarg(decorator) or ["GET"]
48
+ else:
49
+ verb = _HTTP_VERB.match(decorator)
50
+ if verb:
51
+ route = first_string_arg(decorator)
52
+ methods = [verb.group(2).upper()]
53
+ if route is not None:
54
+ hints.append(
55
+ EntrypointHint(
56
+ rule_id="python.flask.route",
57
+ kind=EntrypointKind.HTTP_ROUTE,
58
+ handler_qualified_name=symbol.qualified_name,
59
+ route=route,
60
+ http_methods=methods,
61
+ framework="flask",
62
+ metadata=_taint_meta(symbol, "http_route"),
63
+ )
64
+ )
65
+ return hints
66
+
67
+
68
+ def _flask_add_url_rule(x: FileExtraction) -> list[EntrypointHint]:
69
+ """Call-based route registration: app.add_url_rule("/x", view_func=handler)."""
70
+ hints = []
71
+ for ref in x.references:
72
+ if ref.kind != "call" or ref.callee_name != "add_url_rule" or not ref.arg_preview:
73
+ continue
74
+ route = first_string_arg("(" + ref.arg_preview.lstrip("("))
75
+ handler = None
76
+ for name in identifier_args(ref.arg_preview):
77
+ candidate = f"{x.module_path}.{name}"
78
+ if any(s.qualified_name == candidate for s in x.symbols):
79
+ handler = candidate
80
+ break
81
+ hints.append(
82
+ EntrypointHint(
83
+ rule_id="python.flask.add_url_rule",
84
+ kind=EntrypointKind.HTTP_ROUTE,
85
+ handler_qualified_name=handler,
86
+ route=route if route is not None else "",
87
+ http_methods=["*"],
88
+ framework="flask",
89
+ metadata={"registration": ref.arg_preview},
90
+ )
91
+ )
92
+ return hints
93
+
94
+
95
+ def _middleware(x: FileExtraction) -> list[EntrypointHint]:
96
+ """before_request / after_request / errorhandler / app.middleware wrappers."""
97
+ hints = []
98
+ for symbol, decorator in _decorated(x):
99
+ m = _MIDDLEWARE_DECORATOR.match(decorator)
100
+ if m:
101
+ framework = "fastapi" if m.group(2) == "middleware" else "flask"
102
+ hints.append(
103
+ EntrypointHint(
104
+ rule_id="python.web.middleware",
105
+ kind=EntrypointKind.MIDDLEWARE,
106
+ handler_qualified_name=symbol.qualified_name,
107
+ name=m.group(2),
108
+ framework=framework,
109
+ )
110
+ )
111
+ return hints
112
+
113
+
114
+ def _fastapi_routes(x: FileExtraction) -> list[EntrypointHint]:
115
+ hints = []
116
+ for symbol, decorator in _decorated(x):
117
+ verb = _HTTP_VERB.match(decorator)
118
+ if verb:
119
+ route = first_string_arg(decorator)
120
+ if route is not None:
121
+ hints.append(
122
+ EntrypointHint(
123
+ rule_id="python.fastapi.route",
124
+ kind=EntrypointKind.HTTP_ROUTE,
125
+ handler_qualified_name=symbol.qualified_name,
126
+ route=route,
127
+ http_methods=[verb.group(2).upper()],
128
+ framework="fastapi",
129
+ metadata=_taint_meta(symbol, "http_route"),
130
+ )
131
+ )
132
+ return hints
133
+
134
+
135
+ def _django_urls(x: FileExtraction) -> list[EntrypointHint]:
136
+ if not x.path.endswith("urls.py"):
137
+ return []
138
+ hints = []
139
+ for ref in x.references:
140
+ if ref.kind == "call" and ref.callee_name in _DJANGO_PATH_CALL and ref.arg_preview:
141
+ route = first_string_arg("(" + ref.arg_preview.lstrip("("))
142
+ hints.append(
143
+ EntrypointHint(
144
+ rule_id="python.django.urls",
145
+ kind=EntrypointKind.HTTP_ROUTE,
146
+ handler_qualified_name=None, # handler resolved as a normal call edge
147
+ route=route if route is not None else "",
148
+ http_methods=["*"],
149
+ framework="django",
150
+ metadata={"registration": ref.arg_preview},
151
+ )
152
+ )
153
+ return hints
154
+
155
+
156
+ def _click_commands(x: FileExtraction) -> list[EntrypointHint]:
157
+ hints = []
158
+ for symbol, decorator in _decorated(x):
159
+ if _CLICK_CMD.match(decorator):
160
+ hints.append(
161
+ EntrypointHint(
162
+ rule_id="python.click.command",
163
+ kind=EntrypointKind.CLI_COMMAND,
164
+ handler_qualified_name=symbol.qualified_name,
165
+ name=first_string_arg(decorator) or symbol.name,
166
+ framework="click",
167
+ )
168
+ )
169
+ return hints
170
+
171
+
172
+ def _celery_tasks(x: FileExtraction) -> list[EntrypointHint]:
173
+ hints = []
174
+ for symbol, decorator in _decorated(x):
175
+ if _CELERY_TASK.match(decorator):
176
+ hints.append(
177
+ EntrypointHint(
178
+ rule_id="python.celery.task",
179
+ kind=EntrypointKind.TASK,
180
+ handler_qualified_name=symbol.qualified_name,
181
+ name=symbol.name,
182
+ framework="celery",
183
+ )
184
+ )
185
+ return hints
186
+
187
+
188
+ def _lambda_handlers(x: FileExtraction) -> list[EntrypointHint]:
189
+ hints = []
190
+ for symbol in x.symbols:
191
+ if (
192
+ symbol.kind is SymbolKind.FUNCTION
193
+ and symbol.name in _LAMBDA_NAMES
194
+ and symbol.signature
195
+ and "event" in symbol.signature
196
+ and "context" in symbol.signature
197
+ ):
198
+ hints.append(
199
+ EntrypointHint(
200
+ rule_id="python.lambda.handler",
201
+ kind=EntrypointKind.LAMBDA_HANDLER,
202
+ handler_qualified_name=symbol.qualified_name,
203
+ name=symbol.name,
204
+ framework="aws-lambda",
205
+ metadata=_taint_meta(symbol, "lambda_handler"),
206
+ )
207
+ )
208
+ return hints
209
+
210
+
211
+ _VIEW_CONFIG = re.compile(r"^@view_config\b")
212
+ _DRAMATIQ_ACTOR = re.compile(r"^@(?:dramatiq\.actor|actor)\b")
213
+ _AIRFLOW_TASK = re.compile(r"^@(?:task|dag)\b")
214
+ _AIOHTTP_ADD = frozenset({"add_get", "add_post", "add_put", "add_delete", "add_route"})
215
+
216
+
217
+ def _sanic_bottle_routes(framework: str):
218
+ """@app.route / @app.get style routes, framework-labelled (sanic, bottle)."""
219
+
220
+ def matcher(x: FileExtraction) -> list[EntrypointHint]:
221
+ hints = []
222
+ for symbol, decorator in _decorated(x):
223
+ route = methods = None
224
+ if _FLASK_ROUTE.match(decorator):
225
+ route, methods = first_string_arg(decorator), methods_kwarg(decorator) or ["GET"]
226
+ else:
227
+ verb = _HTTP_VERB.match(decorator)
228
+ if verb:
229
+ route, methods = first_string_arg(decorator), [verb.group(2).upper()]
230
+ if route is not None:
231
+ hints.append(EntrypointHint(
232
+ rule_id=f"python.{framework}.route", kind=EntrypointKind.HTTP_ROUTE,
233
+ handler_qualified_name=symbol.qualified_name, route=route,
234
+ http_methods=methods, framework=framework,
235
+ metadata=_taint_meta(symbol, "http_route")))
236
+ return hints
237
+
238
+ return matcher
239
+
240
+
241
+ def _decorator_rule(pattern, rule_id, kind, framework):
242
+ """Simple decorator-matched entrypoint (pyramid view, dramatiq actor, airflow task)."""
243
+
244
+ def matcher(x: FileExtraction) -> list[EntrypointHint]:
245
+ hints = []
246
+ for symbol, decorator in _decorated(x):
247
+ if pattern.match(decorator):
248
+ hints.append(EntrypointHint(
249
+ rule_id=rule_id, kind=kind,
250
+ handler_qualified_name=symbol.qualified_name,
251
+ name=symbol.name, framework=framework,
252
+ route=first_string_arg(decorator) if kind is EntrypointKind.HTTP_ROUTE else None,
253
+ metadata=_taint_meta(symbol, kind.value)))
254
+ return hints
255
+
256
+ return matcher
257
+
258
+
259
+ def _aiohttp_routes(x: FileExtraction) -> list[EntrypointHint]:
260
+ """router.add_get('/x', handler) / @routes.get('/x') registrations."""
261
+ hints = []
262
+ for ref in x.references:
263
+ if ref.kind == "call" and ref.callee_name in _AIOHTTP_ADD and ref.arg_preview:
264
+ route = first_string_arg("(" + ref.arg_preview.lstrip("("))
265
+ hints.append(EntrypointHint(
266
+ rule_id="python.aiohttp.route", kind=EntrypointKind.HTTP_ROUTE,
267
+ handler_qualified_name=None, route=route or "",
268
+ http_methods=[ref.callee_name.replace("add_", "").upper()], framework="aiohttp",
269
+ metadata={"registration": ref.arg_preview}))
270
+ for symbol, decorator in _decorated(x):
271
+ verb = _HTTP_VERB.match(decorator.replace("@routes.", "@r."))
272
+ if decorator.startswith("@routes.") and verb:
273
+ route = first_string_arg(decorator)
274
+ if route is not None:
275
+ hints.append(EntrypointHint(
276
+ rule_id="python.aiohttp.route", kind=EntrypointKind.HTTP_ROUTE,
277
+ handler_qualified_name=symbol.qualified_name, route=route,
278
+ http_methods=[verb.group(2).upper()], framework="aiohttp",
279
+ metadata=_taint_meta(symbol, "http_route")))
280
+ return hints
281
+
282
+
283
+ def _main_guard(x: FileExtraction) -> list[EntrypointHint]:
284
+ if ("main_guard", x.module_path) not in x.framework_signals:
285
+ return []
286
+ return [
287
+ EntrypointHint(
288
+ rule_id="python.core.main",
289
+ kind=EntrypointKind.MAIN,
290
+ handler_qualified_name=None, # the module itself
291
+ name=x.module_path,
292
+ )
293
+ ]
294
+
295
+
296
+ register(EntrypointRule("python.flask.route", "python", "flask",
297
+ EntrypointKind.HTTP_ROUTE, _flask_routes))
298
+ register(EntrypointRule("python.flask.add_url_rule", "python", "flask",
299
+ EntrypointKind.HTTP_ROUTE, _flask_add_url_rule))
300
+ register(EntrypointRule("python.flask.middleware", "python", "flask",
301
+ EntrypointKind.MIDDLEWARE, _middleware))
302
+ register(EntrypointRule("python.fastapi.middleware", "python", "fastapi",
303
+ EntrypointKind.MIDDLEWARE, _middleware))
304
+ register(EntrypointRule("python.fastapi.route", "python", "fastapi",
305
+ EntrypointKind.HTTP_ROUTE, _fastapi_routes))
306
+ register(EntrypointRule("python.django.urls", "python", "django",
307
+ EntrypointKind.HTTP_ROUTE, _django_urls))
308
+ register(EntrypointRule("python.click.command", "python", "click",
309
+ EntrypointKind.CLI_COMMAND, _click_commands))
310
+ register(EntrypointRule("python.typer.command", "python", "typer",
311
+ EntrypointKind.CLI_COMMAND, _click_commands))
312
+ register(EntrypointRule("python.celery.task", "python", "celery",
313
+ EntrypointKind.TASK, _celery_tasks))
314
+ register(EntrypointRule("python.lambda.handler", "python", "aws-lambda",
315
+ EntrypointKind.LAMBDA_HANDLER, _lambda_handlers))
316
+ register(EntrypointRule("python.sanic.route", "python", "sanic",
317
+ EntrypointKind.HTTP_ROUTE, _sanic_bottle_routes("sanic")))
318
+ register(EntrypointRule("python.bottle.route", "python", "bottle",
319
+ EntrypointKind.HTTP_ROUTE, _sanic_bottle_routes("bottle")))
320
+ register(EntrypointRule("python.aiohttp.route", "python", "aiohttp",
321
+ EntrypointKind.HTTP_ROUTE, _aiohttp_routes))
322
+ register(EntrypointRule("python.pyramid.view", "python", "pyramid",
323
+ EntrypointKind.HTTP_ROUTE,
324
+ _decorator_rule(_VIEW_CONFIG, "python.pyramid.view",
325
+ EntrypointKind.HTTP_ROUTE, "pyramid")))
326
+ register(EntrypointRule("python.dramatiq.actor", "python", "dramatiq",
327
+ EntrypointKind.TASK,
328
+ _decorator_rule(_DRAMATIQ_ACTOR, "python.dramatiq.actor",
329
+ EntrypointKind.TASK, "dramatiq")))
330
+ register(EntrypointRule("python.airflow.task", "python", "airflow",
331
+ EntrypointKind.TASK,
332
+ _decorator_rule(_AIRFLOW_TASK, "python.airflow.task",
333
+ EntrypointKind.TASK, "airflow")))
334
+ register(EntrypointRule("python.core.main", "python", None,
335
+ EntrypointKind.MAIN, _main_guard))
@@ -0,0 +1,147 @@
1
+ """Ruby entrypoint rules: Sinatra routes, Rails route DSL, and Rake tasks.
2
+
3
+ Ruby route DSLs are bare top-level method calls (``get '/x' do ... end``), not
4
+ decorators, so these rules match against extracted call references rather than
5
+ symbol decorators. Sinatra handlers are inline blocks with no named symbol, so
6
+ the handler qualified name is the enclosing method (usually None at file top
7
+ level) — the block body's calls still resolve as ordinary edges.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from entrygraph.detect.entrypoints.base import (
13
+ EntrypointRule,
14
+ first_string_arg,
15
+ register,
16
+ )
17
+ from entrygraph.extract.ir import EntrypointHint, FileExtraction
18
+ from entrygraph.kinds import EntrypointKind
19
+
20
+ _SINATRA_VERBS = frozenset({"get", "post", "put", "delete", "patch"})
21
+ _RAILS_VERBS = frozenset({"get", "post", "put", "patch", "delete", "resources", "resource", "root"})
22
+
23
+
24
+ def _sinatra_routes(x: FileExtraction) -> list[EntrypointHint]:
25
+ hints = []
26
+ for ref in x.references:
27
+ if (
28
+ ref.kind == "call"
29
+ and ref.receiver_text is None
30
+ and ref.callee_name in _SINATRA_VERBS
31
+ and ref.arg_preview
32
+ ):
33
+ route = first_string_arg("(" + ref.arg_preview.lstrip("("))
34
+ if route is not None and route.startswith("/"):
35
+ hints.append(
36
+ EntrypointHint(
37
+ rule_id="ruby.sinatra.route",
38
+ kind=EntrypointKind.HTTP_ROUTE,
39
+ handler_qualified_name=ref.caller_qualified_name,
40
+ route=route,
41
+ http_methods=[ref.callee_name.upper()],
42
+ framework="sinatra",
43
+ )
44
+ )
45
+ return hints
46
+
47
+
48
+ def _rails_routes(x: FileExtraction) -> list[EntrypointHint]:
49
+ if not x.path.endswith("config/routes.rb"):
50
+ return []
51
+ hints = []
52
+ for ref in x.references:
53
+ if ref.kind != "call" or ref.receiver_text is not None:
54
+ continue
55
+ if ref.callee_name not in _RAILS_VERBS:
56
+ continue
57
+ route = None
58
+ if ref.arg_preview:
59
+ route = first_string_arg("(" + ref.arg_preview.lstrip("("))
60
+ hints.append(
61
+ EntrypointHint(
62
+ rule_id="ruby.rails.routes",
63
+ kind=EntrypointKind.HTTP_ROUTE,
64
+ handler_qualified_name=None, # handler resolved as a normal call edge
65
+ route=route if route is not None else "",
66
+ http_methods=["*"] if ref.callee_name in ("resources", "resource", "root")
67
+ else [ref.callee_name.upper()],
68
+ framework="rails",
69
+ metadata={"registration": ref.arg_preview or ref.callee_name},
70
+ )
71
+ )
72
+ return hints
73
+
74
+
75
+ def _rake_tasks(x: FileExtraction) -> list[EntrypointHint]:
76
+ hints = []
77
+ for ref in x.references:
78
+ if ref.kind == "call" and ref.receiver_text is None and ref.callee_name == "task":
79
+ name = None
80
+ if ref.arg_preview:
81
+ name = first_string_arg("(" + ref.arg_preview.lstrip("("))
82
+ if name is None:
83
+ # symbol arg like `:build` — strip the leading colon
84
+ stripped = ref.arg_preview.strip("()").lstrip(":").split(" ", 1)[0]
85
+ stripped = stripped.split("=", 1)[0].strip().rstrip(",")
86
+ name = stripped or None
87
+ hints.append(
88
+ EntrypointHint(
89
+ rule_id="ruby.rake.task",
90
+ kind=EntrypointKind.CLI_COMMAND,
91
+ handler_qualified_name=ref.caller_qualified_name,
92
+ name=name or "task",
93
+ framework="rake",
94
+ )
95
+ )
96
+ return hints
97
+
98
+
99
+ def _grape_routes(x: FileExtraction) -> list[EntrypointHint]:
100
+ """Grape API classes: class-body `get '/x'` / `post '/y'` declarations."""
101
+ hints = []
102
+ for ref in x.references:
103
+ if (
104
+ ref.kind == "call"
105
+ and ref.receiver_text is None
106
+ and ref.callee_name in _SINATRA_VERBS
107
+ and ref.arg_preview
108
+ ):
109
+ route = first_string_arg("(" + ref.arg_preview.lstrip("("))
110
+ if route is not None:
111
+ hints.append(EntrypointHint(
112
+ rule_id="ruby.grape.route", kind=EntrypointKind.HTTP_ROUTE,
113
+ handler_qualified_name=ref.caller_qualified_name,
114
+ route=route, http_methods=[ref.callee_name.upper()], framework="grape"))
115
+ return hints
116
+
117
+
118
+ def _sidekiq_workers(x: FileExtraction) -> list[EntrypointHint]:
119
+ """Classes including Sidekiq::Worker/Job expose `perform` as a task handler."""
120
+ worker_modules = {
121
+ ref.caller_qualified_name
122
+ for ref in x.references
123
+ if ref.callee_name == "include" and ref.arg_preview
124
+ and "Sidekiq" in ref.arg_preview
125
+ }
126
+ hints = []
127
+ for symbol in x.symbols:
128
+ if symbol.name == "perform" and (
129
+ symbol.parent_qualified_name in worker_modules or not worker_modules
130
+ ) and worker_modules:
131
+ hints.append(EntrypointHint(
132
+ rule_id="ruby.sidekiq.worker", kind=EntrypointKind.TASK,
133
+ handler_qualified_name=symbol.qualified_name,
134
+ name=symbol.parent_qualified_name or symbol.name, framework="sidekiq"))
135
+ return hints
136
+
137
+
138
+ register(EntrypointRule("ruby.sinatra.route", "ruby", "sinatra",
139
+ EntrypointKind.HTTP_ROUTE, _sinatra_routes))
140
+ register(EntrypointRule("ruby.grape.route", "ruby", "grape",
141
+ EntrypointKind.HTTP_ROUTE, _grape_routes))
142
+ register(EntrypointRule("ruby.sidekiq.worker", "ruby", "sidekiq",
143
+ EntrypointKind.TASK, _sidekiq_workers))
144
+ register(EntrypointRule("ruby.rails.routes", "ruby", "rails",
145
+ EntrypointKind.HTTP_ROUTE, _rails_routes))
146
+ register(EntrypointRule("ruby.rake.task", "ruby", "rake",
147
+ EntrypointKind.CLI_COMMAND, _rake_tasks))
@@ -0,0 +1,153 @@
1
+ """Rust entrypoint rules: the crate ``main`` (and ``#[tokio::main]`` /
2
+ ``#[actix_web::main]`` async mains), axum router routes, actix-web/rocket route
3
+ attribute macros, and clap ``#[derive(Parser)]`` CLI commands.
4
+
5
+ Rules are IR-driven: axum routes read ``route(...)`` call references (the same
6
+ low-fidelity tradeoff as the Express rule — the handler lives inside the second
7
+ argument, so ``handler_qualified_name`` is left None and the raw registration is
8
+ stashed in ``metadata``). actix/rocket/clap rules read attribute macros captured
9
+ on ``RawSymbol.decorators`` as raw source text (``#[get("/x")]``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from entrygraph.detect.entrypoints.base import (
17
+ EntrypointRule,
18
+ first_string_arg,
19
+ register,
20
+ )
21
+ from entrygraph.extract.ir import EntrypointHint, FileExtraction, RawSymbol
22
+ from entrygraph.kinds import EntrypointKind, SymbolKind
23
+
24
+ # #[get("/x")] / #[post(..)] / ... on a handler function -> HTTP verb.
25
+ _ROUTE_ATTR = re.compile(r"^#\[\s*(get|post|put|delete|patch|head|route)\s*\(")
26
+ _VERB = {
27
+ "get": "GET", "post": "POST", "put": "PUT", "delete": "DELETE",
28
+ "patch": "PATCH", "head": "HEAD",
29
+ }
30
+ # #[derive(.. Parser ..)] anywhere in the derive list.
31
+ _DERIVE_PARSER = re.compile(r"^#\[\s*derive\s*\(.*\bParser\b")
32
+ # #[tokio::main] / #[actix_web::main] async entrypoints.
33
+ _ASYNC_MAIN = re.compile(r"^#\[\s*(?:tokio|actix_web|async_std)::main\b")
34
+
35
+
36
+ def _rust_main(x: FileExtraction) -> list[EntrypointHint]:
37
+ hints = []
38
+ for symbol in x.symbols:
39
+ if symbol.kind is not SymbolKind.FUNCTION:
40
+ continue
41
+ is_crate_main = symbol.name == "main" and symbol.parent_qualified_name is None
42
+ is_async_main = any(_ASYNC_MAIN.match(d) for d in symbol.decorators)
43
+ if is_crate_main or is_async_main:
44
+ hints.append(
45
+ EntrypointHint(
46
+ rule_id="rust.core.main",
47
+ kind=EntrypointKind.MAIN,
48
+ handler_qualified_name=symbol.qualified_name,
49
+ name=symbol.qualified_name,
50
+ span=symbol.span,
51
+ framework=None,
52
+ )
53
+ )
54
+ return hints
55
+
56
+
57
+ def _axum_routes(x: FileExtraction) -> list[EntrypointHint]:
58
+ """`Router::new().route("/x", post(handler))` — the handler is buried in the
59
+ second argument, so we regex it out of the preview but can't statically bind
60
+ it (the Express-rule fidelity tradeoff)."""
61
+ hints = []
62
+ for ref in x.references:
63
+ if ref.kind != "call" or ref.callee_name != "route" or not ref.arg_preview:
64
+ continue
65
+ route = first_string_arg("(" + ref.arg_preview.lstrip("("))
66
+ if route is None or not route.startswith("/"):
67
+ continue
68
+ method, handler = _axum_method_and_handler(ref.arg_preview)
69
+ hints.append(
70
+ EntrypointHint(
71
+ rule_id="rust.axum.route",
72
+ kind=EntrypointKind.HTTP_ROUTE,
73
+ handler_qualified_name=None,
74
+ route=route,
75
+ http_methods=[method],
76
+ framework="axum",
77
+ metadata={"handler_text": handler, "registration": ref.arg_preview},
78
+ )
79
+ )
80
+ return hints
81
+
82
+
83
+ _AXUM_HANDLER = re.compile(
84
+ r"\b(get|post|put|delete|patch|head|options)\s*\(\s*([A-Za-z_]\w*)"
85
+ )
86
+
87
+
88
+ def _axum_method_and_handler(preview: str) -> tuple[str, str | None]:
89
+ match = _AXUM_HANDLER.search(preview)
90
+ if match:
91
+ return match.group(1).upper(), match.group(2)
92
+ return "*", None
93
+
94
+
95
+ def _route_attr_rule(framework: str):
96
+ """actix-web and rocket both register routes with `#[get("/x")]`-style
97
+ attribute macros on the handler function; one matcher, framework by param."""
98
+
99
+ def matcher(x: FileExtraction) -> list[EntrypointHint]:
100
+ hints = []
101
+ for symbol in x.symbols:
102
+ if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
103
+ continue
104
+ for decorator in symbol.decorators:
105
+ m = _ROUTE_ATTR.match(decorator)
106
+ if not m:
107
+ continue
108
+ verb = _VERB.get(m.group(1), "*")
109
+ hints.append(
110
+ EntrypointHint(
111
+ rule_id=f"rust.{framework}.route",
112
+ kind=EntrypointKind.HTTP_ROUTE,
113
+ handler_qualified_name=symbol.qualified_name,
114
+ route=first_string_arg(decorator) or "",
115
+ http_methods=[verb],
116
+ framework=framework,
117
+ span=symbol.span,
118
+ )
119
+ )
120
+ return hints
121
+
122
+ return matcher
123
+
124
+
125
+ def _clap_commands(x: FileExtraction) -> list[EntrypointHint]:
126
+ hints = []
127
+ for symbol in x.symbols:
128
+ if symbol.kind not in (SymbolKind.STRUCT, SymbolKind.CLASS):
129
+ continue
130
+ if any(_DERIVE_PARSER.match(d) for d in symbol.decorators):
131
+ hints.append(
132
+ EntrypointHint(
133
+ rule_id="rust.clap.command",
134
+ kind=EntrypointKind.CLI_COMMAND,
135
+ handler_qualified_name=symbol.qualified_name,
136
+ name=symbol.name,
137
+ framework="clap",
138
+ span=symbol.span,
139
+ )
140
+ )
141
+ return hints
142
+
143
+
144
+ register(EntrypointRule("rust.core.main", "rust", None,
145
+ EntrypointKind.MAIN, _rust_main))
146
+ register(EntrypointRule("rust.axum.route", "rust", "axum",
147
+ EntrypointKind.HTTP_ROUTE, _axum_routes))
148
+ register(EntrypointRule("rust.actix.route", "rust", "actix-web",
149
+ EntrypointKind.HTTP_ROUTE, _route_attr_rule("actix-web")))
150
+ register(EntrypointRule("rust.rocket.route", "rust", "rocket",
151
+ EntrypointKind.HTTP_ROUTE, _route_attr_rule("rocket")))
152
+ register(EntrypointRule("rust.clap.command", "rust", "clap",
153
+ EntrypointKind.CLI_COMMAND, _clap_commands))