apisec-code-bolt 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 (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,2546 @@
1
+ """
2
+ ASP.NET Core framework plugin.
3
+
4
+ Extracts HTTP routes, auth schemes, dependencies, JWT config, and
5
+ middleware from ASP.NET Core applications using attribute-based detection.
6
+
7
+ Supports
8
+ --------
9
+ - [ApiController] / [Controller] with [Route("prefix")]
10
+ - [HttpGet/Post/Put/Delete/Patch/Head/Options] route attributes
11
+ - [Route] on methods as an additional path contributor
12
+ - Multi-value path templates: "/api/{version:apiVersion}/users/{id:int}"
13
+ - [FromRoute], [FromQuery], [FromBody], [FromHeader], [FromForm] parameters
14
+ - Bean Validation equivalents: [Required], [Range], [MinLength], [MaxLength],
15
+ [StringLength], [RegularExpression], [EmailAddress]
16
+ - [Authorize] / [AllowAnonymous] per-controller and per-action
17
+ - [Authorize(Roles = "Admin,User")] role extraction
18
+ - [Authorize(Policy = "...")] policy extraction
19
+ - services.AddAuthentication() / AddJwtBearer() / AddOAuth() in Startup/Program
20
+ - Middleware: IMiddleware, IActionFilter, app.UseMiddleware<T>(), app.Use()
21
+ - Minimal API (MapGet / MapPost …) on WebApplication
22
+ - [ProducesResponseType] / [Produces] / [Consumes] content negotiation
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import logging
29
+ import re
30
+ from pathlib import Path
31
+ from typing import TYPE_CHECKING, Any, ClassVar
32
+
33
+ from ...core.types import (
34
+ AuthDependencyType,
35
+ AuthSchemeType,
36
+ CodeLocation,
37
+ Confidence,
38
+ Framework,
39
+ HttpMethod,
40
+ Language,
41
+ ParameterLocation,
42
+ QualifiedName,
43
+ )
44
+ from ...parsing.base import (
45
+ ParsedCallSite,
46
+ ParsedClass,
47
+ ParsedDecorator,
48
+ ParsedFile,
49
+ ParsedFunction,
50
+ )
51
+ from ...parsing.services import AnalysisContext
52
+ from ..base import (
53
+ BaseFrameworkPlugin,
54
+ ExtractedAuthDependency,
55
+ ExtractedAuthScheme,
56
+ ExtractedBody,
57
+ ExtractedDependency,
58
+ ExtractedJwtConfig,
59
+ ExtractedMiddleware,
60
+ ExtractedParameter,
61
+ ExtractedResponse,
62
+ ExtractedRoute,
63
+ FrameworkPluginRegistry,
64
+ )
65
+ from .jwt_config_extractor import DotNetJwtConfigExtractor
66
+
67
+ if TYPE_CHECKING:
68
+ pass
69
+
70
+ logger = logging.getLogger(__name__)
71
+
72
+ # =============================================================================
73
+ # Constants
74
+ # =============================================================================
75
+
76
+ # Attribute name → HTTP method
77
+ _HTTP_METHOD_ATTRS: dict[str, HttpMethod] = {
78
+ "HttpGet": HttpMethod.GET,
79
+ "HttpPost": HttpMethod.POST,
80
+ "HttpPut": HttpMethod.PUT,
81
+ "HttpDelete": HttpMethod.DELETE,
82
+ "HttpPatch": HttpMethod.PATCH,
83
+ "HttpHead": HttpMethod.HEAD,
84
+ "HttpOptions": HttpMethod.OPTIONS,
85
+ }
86
+
87
+ # Minimal API WebApplication extension methods → HTTP method
88
+ _MINIMAL_API_METHODS: dict[str, HttpMethod] = {
89
+ "MapGet": HttpMethod.GET,
90
+ "MapPost": HttpMethod.POST,
91
+ "MapPut": HttpMethod.PUT,
92
+ "MapDelete": HttpMethod.DELETE,
93
+ "MapPatch": HttpMethod.PATCH,
94
+ }
95
+
96
+ # IEndpointConventionBuilder extension methods that can be chained on Map* calls.
97
+ # Used to recognise that a call site is chained on a route registration.
98
+ _MINIMAL_API_CHAINERS: frozenset[str] = frozenset(
99
+ {
100
+ "RequireAuthorization",
101
+ "AllowAnonymous",
102
+ "RequireRateLimiting",
103
+ "RequireCors",
104
+ "RequirePermission",
105
+ "RequireScope",
106
+ "WithName",
107
+ "WithTags",
108
+ "WithGroupName",
109
+ "WithOpenApi",
110
+ "WithMetadata",
111
+ "WithApiVersionSet",
112
+ "Produces",
113
+ "ProducesProblem",
114
+ "WithSummary",
115
+ "WithDescription",
116
+ "ExcludeFromDescription",
117
+ "DisableRateLimiting",
118
+ "WithIdempotency",
119
+ }
120
+ )
121
+
122
+ # Subset that carry explicit auth intent and should generate a dep_ref.
123
+ _MINIMAL_API_AUTH_CHAINERS: frozenset[str] = frozenset(
124
+ {
125
+ "RequireAuthorization",
126
+ "AllowAnonymous",
127
+ "RequirePermission",
128
+ "RequireScope",
129
+ }
130
+ )
131
+
132
+ _CONTROLLER_ATTRS: frozenset[str] = frozenset({"ApiController", "Controller"})
133
+
134
+ # IEndpointGroup base interface name used in the CleanArchitecture / group-builder pattern.
135
+ _ENDPOINT_GROUP_BASES: frozenset[str] = frozenset({"IEndpointGroup"})
136
+
137
+ # Standard ASP.NET Core Identity API endpoints registered by MapIdentityApi<T>().
138
+ # Tuple: (HttpMethod, path-suffix, requires_auth)
139
+ _IDENTITY_API_ROUTES: list[tuple[HttpMethod, str, bool]] = [
140
+ (HttpMethod.POST, "register", False),
141
+ (HttpMethod.POST, "login", False),
142
+ (HttpMethod.POST, "refresh", False),
143
+ (HttpMethod.GET, "confirmEmail", False),
144
+ (HttpMethod.POST, "resendConfirmationEmail", False),
145
+ (HttpMethod.POST, "forgotPassword", False),
146
+ (HttpMethod.POST, "resetPassword", False),
147
+ (HttpMethod.POST, "manage/2fa", True),
148
+ (HttpMethod.GET, "manage/info", True),
149
+ (HttpMethod.POST, "manage/info", True),
150
+ ]
151
+
152
+ # Base classes that mark a class as a controller/endpoint (not a DTO)
153
+ _CONTROLLER_BASE_CLASSES: frozenset[str] = frozenset(
154
+ {
155
+ "ControllerBase",
156
+ "Controller",
157
+ "ApiController",
158
+ "EndpointBaseAsync",
159
+ "EndpointBase", # Ardalis ApiEndpoints
160
+ "PageModel", # Razor Pages
161
+ }
162
+ )
163
+
164
+ _SECURITY_ATTRS: frozenset[str] = frozenset(
165
+ {
166
+ "Authorize",
167
+ "AllowAnonymous",
168
+ "RequireScope",
169
+ }
170
+ )
171
+
172
+ # Method names inherited from System.Object — never conventional MVC actions.
173
+ _OBJECT_METHOD_NAMES: frozenset[str] = frozenset(
174
+ {
175
+ "Equals",
176
+ "GetHashCode",
177
+ "ToString",
178
+ "Finalize",
179
+ }
180
+ )
181
+
182
+ # Attributes that mark a controller method as NOT routable to HTTP.
183
+ # - `[NonAction]` — explicit "don't route" marker (both MVC + Web API)
184
+ # - `[ChildActionOnly]` — System.Web.Mvc; method renders a partial view via
185
+ # Html.Action(...) and is invoked from a parent view,
186
+ # never from a direct HTTP request.
187
+ _NON_ROUTABLE_ATTRS: frozenset[str] = frozenset(
188
+ {
189
+ "NonAction",
190
+ "ChildActionOnly",
191
+ }
192
+ )
193
+
194
+ # camelCase / PascalCase → kebab-case for the slugify route-parameter transformer.
195
+ # Matches the canonical Microsoft example: Regex.Replace(value, "([a-z])([A-Z])", "$1-$2").ToLower().
196
+ _SLUGIFY_RE = re.compile(r"([a-z0-9])([A-Z])")
197
+
198
+
199
+ def _slugify(name: str) -> str:
200
+ return _SLUGIFY_RE.sub(r"\1-\2", name).lower()
201
+
202
+
203
+ def _file_registers_slugify(parsed_file: ParsedFile) -> bool:
204
+ """
205
+ Return True if `parsed_file` contains a `MapControllerRoute` call whose
206
+ route template uses the `:slugify=` constraint — the canonical signal
207
+ that the project has registered `SlugifyParameterTransformer`.
208
+ """
209
+ for call in parsed_file.call_sites:
210
+ if not call.callee_name.endswith("MapControllerRoute"):
211
+ continue
212
+ for arg in call.arguments:
213
+ value = arg.literal_value
214
+ if value and isinstance(value, str) and ":slugify=" in value:
215
+ return True
216
+ return False
217
+
218
+
219
+ # Match `controller = "Foo"` and `action = "Bar"` inside `new { ... }` anonymous
220
+ # initializers passed to routes.MapRoute / MapLocalizedRoute defaults.
221
+ _MAPROUTE_CONTROLLER_RE = re.compile(
222
+ r'\bcontroller\s*=\s*"([A-Za-z_][\w]*)"',
223
+ re.IGNORECASE,
224
+ )
225
+ _MAPROUTE_ACTION_RE = re.compile(
226
+ r'\baction\s*=\s*"([A-Za-z_][\w]*)"',
227
+ re.IGNORECASE,
228
+ )
229
+
230
+
231
+ def _scan_maproute_registrations(
232
+ parsed_file: ParsedFile,
233
+ ) -> dict[tuple[str, str], str]:
234
+ """
235
+ Find every `routes.MapRoute(name, url, defaults, ...)` or
236
+ `routes.MapLocalizedRoute(name, url, defaults, ...)` call and return
237
+ `{(controller, action): url_pattern}` extracted from the literal URL
238
+ + the `new { controller = "X", action = "Y" }` defaults expression.
239
+
240
+ Routes whose defaults can't be parsed are skipped silently.
241
+ """
242
+ overrides: dict[tuple[str, str], str] = {}
243
+ for call in parsed_file.call_sites:
244
+ leaf = call.callee_name.rsplit(".", 1)[-1]
245
+ if leaf not in ("MapRoute", "MapLocalizedRoute"):
246
+ continue
247
+ if len(call.arguments) < 3:
248
+ continue
249
+ url_arg = call.arguments[1]
250
+ defaults_arg = call.arguments[2]
251
+ if not isinstance(url_arg.literal_value, str):
252
+ continue
253
+ defaults_expr = defaults_arg.expression_text or ""
254
+ if "new" not in defaults_expr:
255
+ continue
256
+ ctl_match = _MAPROUTE_CONTROLLER_RE.search(defaults_expr)
257
+ act_match = _MAPROUTE_ACTION_RE.search(defaults_expr)
258
+ if not ctl_match or not act_match:
259
+ continue
260
+ url = url_arg.literal_value
261
+ if not url.startswith("/"):
262
+ url = "/" + url
263
+ # Trailing slash kept as-is; downstream normalization decides.
264
+ overrides[(ctl_match.group(1), act_match.group(1))] = url
265
+ return overrides
266
+
267
+
268
+ # Auth-scheme detection from .AddXxx() call sites in Program.cs / Startup.cs.
269
+ # Match order matters: more-specific patterns must come before less-specific.
270
+ # Pattern is a lowercase substring of the callee name.
271
+ _AUTH_SCHEME_CALL_PATTERNS: tuple[tuple[str, AuthSchemeType, str, Confidence], ...] = (
272
+ ("addjwtbearer", AuthSchemeType.JWT_BEARER, "jwt_bearer", Confidence.HIGH),
273
+ ("addoauth2", AuthSchemeType.OAUTH2_AUTHORIZATION_CODE, "oauth2", Confidence.MEDIUM),
274
+ ("addoauth", AuthSchemeType.OAUTH2_AUTHORIZATION_CODE, "oauth2", Confidence.MEDIUM),
275
+ ("addopenidconnect", AuthSchemeType.OAUTH2_AUTHORIZATION_CODE, "oidc", Confidence.HIGH),
276
+ # AspNet Core Identity (AddIdentity / AddDefaultIdentity) is cookie-driven.
277
+ # Match these *before* the generic AddCookie pattern below so the name
278
+ # surfaces as "identity" rather than "cookie".
279
+ ("adddefaultidentity", AuthSchemeType.SESSION_COOKIE, "identity", Confidence.HIGH),
280
+ ("addidentitycore", AuthSchemeType.SESSION_COOKIE, "identity", Confidence.HIGH),
281
+ ("addidentity", AuthSchemeType.SESSION_COOKIE, "identity", Confidence.HIGH),
282
+ ("addcookie", AuthSchemeType.SESSION_COOKIE, "cookie", Confidence.HIGH),
283
+ ("addapikeysupport", AuthSchemeType.API_KEY_HEADER, "api_key", Confidence.MEDIUM),
284
+ ("addapikey", AuthSchemeType.API_KEY_HEADER, "api_key", Confidence.MEDIUM),
285
+ ("addnegotiate", AuthSchemeType.CUSTOM, "negotiate", Confidence.HIGH),
286
+ ("addcertificate", AuthSchemeType.CUSTOM, "client_cert", Confidence.HIGH),
287
+ ("addbasic", AuthSchemeType.HTTP_BASIC, "basic", Confidence.MEDIUM),
288
+ )
289
+
290
+ # Return types that mark a method as an MVC action when no [HttpVerb] attribute
291
+ # is present. The C# parser doesn't expose method visibility, so this filter
292
+ # substitutes for "is public": real actions return one of these; private
293
+ # helpers on the same controller usually return view-model DTOs.
294
+ _ACTION_RETURN_TYPE_NAMES: frozenset[str] = frozenset(
295
+ {
296
+ "IActionResult",
297
+ "ActionResult",
298
+ "IResult",
299
+ "ViewResult",
300
+ "JsonResult",
301
+ "ContentResult",
302
+ "FileResult",
303
+ "RedirectResult",
304
+ "RedirectToActionResult",
305
+ "RedirectToRouteResult",
306
+ "RedirectToPageResult",
307
+ "LocalRedirectResult",
308
+ "OkResult",
309
+ "OkObjectResult",
310
+ "NotFoundResult",
311
+ "NotFoundObjectResult",
312
+ "BadRequestResult",
313
+ "BadRequestObjectResult",
314
+ "UnauthorizedResult",
315
+ "StatusCodeResult",
316
+ "ObjectResult",
317
+ "EmptyResult",
318
+ "NoContentResult",
319
+ "PartialViewResult",
320
+ "ChallengeResult",
321
+ "ForbidResult",
322
+ "SignOutResult",
323
+ "CreatedResult",
324
+ "CreatedAtActionResult",
325
+ "CreatedAtRouteResult",
326
+ "AcceptedResult",
327
+ "AcceptedAtActionResult",
328
+ "AcceptedAtRouteResult",
329
+ "ConflictResult",
330
+ "ConflictObjectResult",
331
+ "UnprocessableEntityResult",
332
+ "UnprocessableEntityObjectResult",
333
+ # Web API 2 (System.Web.Http.ApiController) result types
334
+ "IHttpActionResult",
335
+ "HttpResponseMessage",
336
+ }
337
+ )
338
+
339
+ # Bases that mark a class as a Web API 2 ApiController (System.Web.Http).
340
+ _WEB_API_CONTROLLER_BASES: frozenset[str] = frozenset({"ApiController"})
341
+
342
+ # C# primitive / built-in scalar type names used as route-bindable parameters.
343
+ # A method parameter of one of these types in a Web API 2 conventional route
344
+ # becomes the `{id}` segment; complex types are bound from the body instead.
345
+ _PRIMITIVE_PARAM_TYPES: frozenset[str] = frozenset(
346
+ {
347
+ "bool",
348
+ "Boolean",
349
+ "byte",
350
+ "Byte",
351
+ "sbyte",
352
+ "SByte",
353
+ "char",
354
+ "Char",
355
+ "short",
356
+ "Int16",
357
+ "ushort",
358
+ "UInt16",
359
+ "int",
360
+ "Int32",
361
+ "uint",
362
+ "UInt32",
363
+ "long",
364
+ "Int64",
365
+ "ulong",
366
+ "UInt64",
367
+ "float",
368
+ "Single",
369
+ "double",
370
+ "Double",
371
+ "decimal",
372
+ "Decimal",
373
+ "string",
374
+ "String",
375
+ "Guid",
376
+ "DateTime",
377
+ "DateTimeOffset",
378
+ "DateOnly",
379
+ "TimeOnly",
380
+ }
381
+ )
382
+
383
+ # Web API 2 method-name-prefix → HTTP verb convention. Action methods on
384
+ # an ApiController whose name starts with one of these prefixes (and that
385
+ # carry no explicit [HttpVerb] attribute) bind to the corresponding verb.
386
+ # Order matters here: longer prefixes must come first so that e.g.
387
+ # `Options` doesn't accidentally match an OptionsForX method as just `O`.
388
+ _WEBAPI_VERB_PREFIXES: tuple[tuple[str, HttpMethod], ...] = (
389
+ ("Options", HttpMethod.OPTIONS),
390
+ ("Delete", HttpMethod.DELETE),
391
+ ("Patch", HttpMethod.PATCH),
392
+ ("Post", HttpMethod.POST),
393
+ ("Head", HttpMethod.HEAD),
394
+ ("Put", HttpMethod.PUT),
395
+ ("Get", HttpMethod.GET),
396
+ )
397
+
398
+ _ASPNET_IMPORTS: frozenset[str] = frozenset(
399
+ {
400
+ "Microsoft.AspNetCore.Mvc",
401
+ "Microsoft.AspNetCore.Http",
402
+ "Microsoft.AspNetCore.Authorization",
403
+ "Microsoft.AspNetCore.Routing",
404
+ "Microsoft.AspNetCore.Builder",
405
+ "Microsoft.Extensions.DependencyInjection",
406
+ }
407
+ )
408
+
409
+ # Parameter binding attributes → ParameterLocation
410
+ _PARAM_LOCATION: dict[str, ParameterLocation] = {
411
+ "FromRoute": ParameterLocation.PATH,
412
+ "FromQuery": ParameterLocation.QUERY,
413
+ "FromHeader": ParameterLocation.HEADER,
414
+ "FromForm": ParameterLocation.QUERY, # surface as query; body flagged separately
415
+ }
416
+
417
+ # Bean-validation-equivalent constraints we surface
418
+ _CONSTRAINT_ATTRS: frozenset[str] = frozenset(
419
+ {
420
+ "Required",
421
+ "Range",
422
+ "MinLength",
423
+ "MaxLength",
424
+ "StringLength",
425
+ "RegularExpression",
426
+ "EmailAddress",
427
+ "Url",
428
+ "Phone",
429
+ "CreditCard",
430
+ }
431
+ )
432
+
433
+ _AUTH_KEYWORDS: frozenset[str] = frozenset(
434
+ {
435
+ "auth",
436
+ "authentication",
437
+ "authorization",
438
+ "security",
439
+ "jwt",
440
+ "token",
441
+ "user",
442
+ "identity",
443
+ "principal",
444
+ "credential",
445
+ "login",
446
+ "filter",
447
+ "middleware",
448
+ }
449
+ )
450
+
451
+ # HTTP status code map (covers common ASP.NET / RFC names)
452
+ _STATUS_NAMES: dict[str, int] = {
453
+ "Ok": 200,
454
+ "Created": 201,
455
+ "NoContent": 204,
456
+ "BadRequest": 400,
457
+ "Unauthorized": 401,
458
+ "Forbidden": 403,
459
+ "NotFound": 404,
460
+ "Conflict": 409,
461
+ "UnprocessableEntity": 422,
462
+ "InternalServerError": 500,
463
+ # Numeric strings come through as-is
464
+ }
465
+
466
+
467
+ # =============================================================================
468
+ # Helpers
469
+ # =============================================================================
470
+
471
+
472
+ def _str_to_qname(s: str) -> QualifiedName:
473
+ """Convert a dotted string like 'app.MapGet' to a QualifiedName."""
474
+ if "." in s:
475
+ module, _, name = s.rpartition(".")
476
+ return QualifiedName(module=module, name=name)
477
+ return QualifiedName(module="", name=s)
478
+
479
+
480
+ # =============================================================================
481
+ # AspNetCorePlugin
482
+ # =============================================================================
483
+
484
+
485
+ class AspNetCorePlugin(BaseFrameworkPlugin):
486
+ """
487
+ Framework plugin for ASP.NET Core applications.
488
+
489
+ Extracts routes, auth, dependencies, and middleware from
490
+ ASP.NET Core attribute-based and minimal-API patterns.
491
+ """
492
+
493
+ FRAMEWORK: ClassVar[Framework] = Framework.ASPNET_CORE
494
+ LANGUAGE: ClassVar[Language] = Language.CSHARP
495
+ DETECTION_IMPORTS: ClassVar[frozenset[str]] = _ASPNET_IMPORTS
496
+
497
+ def __init__(self) -> None:
498
+ # Project-wide signals derived from cross-file scanning, cached by
499
+ # AnalysisContext identity so the work is done once per analysis run.
500
+ self._slugify_active_cache: dict[int, bool] = {}
501
+ self._maproute_overrides_cache: dict[int, dict[tuple[str, str], str]] = {}
502
+
503
+ # -------------------------------------------------------------------------
504
+ # Detection
505
+ # -------------------------------------------------------------------------
506
+
507
+ def detect(self, parsed_file: ParsedFile) -> bool:
508
+ has_system_web = any(
509
+ (imp.module or "").startswith("System.Web.Mvc")
510
+ or (imp.module or "").startswith("System.Web.Http")
511
+ for imp in parsed_file.imports
512
+ )
513
+ if has_system_web:
514
+ return False # legacy file — handled by LegacyAspNetPlugin
515
+ for imp in parsed_file.imports:
516
+ module = imp.module or ""
517
+ if module.startswith("Microsoft.AspNetCore") or module.startswith(
518
+ "Microsoft.Extensions.DependencyInjection"
519
+ ):
520
+ return True
521
+ # Also detect via class attributes (in case using statements were missed)
522
+ for cls in parsed_file.classes:
523
+ if any(d.name in _CONTROLLER_ATTRS for d in cls.decorators):
524
+ return True
525
+ # Also detect via base class (global usings / no explicit imports)
526
+ for cls in parsed_file.classes:
527
+ for base in cls.base_classes:
528
+ if base.rsplit(".", 1)[-1] in _CONTROLLER_BASE_CLASSES:
529
+ return True
530
+ return False
531
+
532
+ # -------------------------------------------------------------------------
533
+ # Route extraction
534
+ # -------------------------------------------------------------------------
535
+
536
+ def extract_routes(
537
+ self,
538
+ parsed_file: ParsedFile,
539
+ context: AnalysisContext | None = None,
540
+ ) -> list[ExtractedRoute]:
541
+ routes: list[ExtractedRoute] = []
542
+ slugify_active = self._project_uses_slugify(context, parsed_file)
543
+ maproute_overrides = self._project_maproute_overrides(context, parsed_file)
544
+
545
+ for cls in parsed_file.classes:
546
+ if not self._is_controller(cls):
547
+ continue
548
+ # Razor Pages PageModel subclasses are handled by the dedicated loop below.
549
+ if self._is_razor_page(cls):
550
+ continue
551
+
552
+ class_prefix = self._get_class_prefix(cls, parsed_file, slugify=slugify_active)
553
+
554
+ is_conventional = not any(
555
+ d.name == "Route" for d in cls.decorators
556
+ ) and self._is_controller(cls)
557
+
558
+ for method in cls.methods:
559
+ routes.extend(
560
+ self._extract_routes_from_method(
561
+ method,
562
+ class_prefix,
563
+ cls,
564
+ parsed_file,
565
+ context,
566
+ is_conventional_mvc=is_conventional,
567
+ slugify=slugify_active,
568
+ maproute_overrides=maproute_overrides,
569
+ )
570
+ )
571
+
572
+ # Razor Pages: PageModel subclasses with OnGet*/OnPost* handler methods
573
+ routes.extend(self._extract_razor_page_routes(parsed_file, context))
574
+
575
+ # Minimal API: top-level call sites (app.MapGet("/path", handler))
576
+ routes.extend(self._extract_minimal_api_routes(parsed_file, context))
577
+
578
+ # IEndpointGroup pattern (CleanArchitecture style): groupBuilder.Map*(handler, pattern)
579
+ routes.extend(self._extract_endpoint_group_routes(parsed_file, context))
580
+
581
+ return routes
582
+
583
+ def _is_controller(self, cls: ParsedClass) -> bool:
584
+ if any(d.name in _CONTROLLER_ATTRS for d in cls.decorators):
585
+ return True
586
+ # Ardalis ApiEndpoints / ControllerBase subclasses without [ApiController]
587
+ for base in cls.base_classes:
588
+ simple = base.rsplit(".", 1)[-1]
589
+ if simple in _CONTROLLER_BASE_CLASSES or base in _CONTROLLER_BASE_CLASSES:
590
+ return True
591
+ # Convention-based detection: a class named `XxxController` with at
592
+ # least one base class and one action-shaped method is a controller.
593
+ # This is how ASP.NET's runtime discovers controllers when intermediate
594
+ # base classes (`BasePublicController : Controller`) hide the well-
595
+ # known base from a single-file view of the code.
596
+ if (
597
+ cls.name.endswith("Controller")
598
+ and cls.name != "Controller"
599
+ and cls.base_classes
600
+ and any(self._is_action_return_type(m.return_type) for m in cls.methods)
601
+ ):
602
+ return True
603
+ return False
604
+
605
+ def _build_iendpoint_class_map(self, parsed_file: ParsedFile) -> dict[str, str]:
606
+ """
607
+ For every class implementing `IEndpoint<...>` (the MinimalApi.Endpoint
608
+ library pattern), return `{ClassName: HandlerMethodName}`.
609
+
610
+ Convention: the handler is the public method named ``HandleAsync``;
611
+ if no such method exists we fall back to the class name itself so
612
+ the caller still gets a class-level attribution.
613
+ """
614
+ mapping: dict[str, str] = {}
615
+ for cls in parsed_file.classes:
616
+ # The parser strips generic arguments from base_classes, so we
617
+ # match on the simple name `IEndpoint` regardless of arity.
618
+ if not any(base.rsplit(".", 1)[-1] == "IEndpoint" for base in cls.base_classes):
619
+ continue
620
+ handler_method = next(
621
+ (m.name for m in cls.methods if m.name == "HandleAsync"),
622
+ None,
623
+ )
624
+ mapping[cls.name] = f"{cls.name}.{handler_method}" if handler_method else cls.name
625
+ return mapping
626
+
627
+ def _project_maproute_overrides(
628
+ self,
629
+ context: AnalysisContext | None,
630
+ parsed_file: ParsedFile,
631
+ ) -> dict[tuple[str, str], str]:
632
+ """
633
+ Build `{(Controller, Action): explicit_url}` from every
634
+ `routes.MapRoute(...)` / `MapLocalizedRoute(...)` call across the
635
+ project. These central registrations override the conventional
636
+ `/{Controller}/{Action}` path — e.g. `ShoppingCartController.Cart`
637
+ becomes `/cart/`, not `/ShoppingCart/Cart`.
638
+ """
639
+ if context is not None:
640
+ key = id(context)
641
+ cached = self._maproute_overrides_cache.get(key)
642
+ if cached is not None:
643
+ return cached
644
+ merged: dict[tuple[str, str], str] = {}
645
+ for pf in context.all_parsed_files or []:
646
+ merged.update(_scan_maproute_registrations(pf))
647
+ self._maproute_overrides_cache[key] = merged
648
+ return merged
649
+ return _scan_maproute_registrations(parsed_file)
650
+
651
+ def _project_uses_slugify(
652
+ self,
653
+ context: AnalysisContext | None,
654
+ parsed_file: ParsedFile,
655
+ ) -> bool:
656
+ """
657
+ Detect whether the project registers `SlugifyParameterTransformer` /
658
+ a `:slugify=` constraint on the default MVC route. The signal lives
659
+ in Program.cs (route registration) but applies to controllers in
660
+ other files — so we scan the AnalysisContext's full file list, with
661
+ a fallback to the current file when no context is available.
662
+ """
663
+ if context is not None:
664
+ key = id(context)
665
+ cached = self._slugify_active_cache.get(key)
666
+ if cached is not None:
667
+ return cached
668
+ active = any(_file_registers_slugify(pf) for pf in (context.all_parsed_files or []))
669
+ self._slugify_active_cache[key] = active
670
+ return active
671
+ # No context (unit-test invocation): only the current file is visible.
672
+ return _file_registers_slugify(parsed_file)
673
+
674
+ def _iendpoint_handler_for_call(
675
+ self,
676
+ call: ParsedCallSite,
677
+ iendpoint_classes: dict[str, str],
678
+ ) -> str | None:
679
+ """If `call` lives inside an `AddRoute` method on an IEndpoint class,
680
+ return the dotted handler attribution. Otherwise None."""
681
+ caller = call.caller_function
682
+ if caller is None or caller.name != "AddRoute":
683
+ return None
684
+ # caller.module is the dotted class path; we only care about the leaf.
685
+ enclosing_class = caller.module.rsplit(".", 1)[-1] if caller.module else ""
686
+ return iendpoint_classes.get(enclosing_class)
687
+
688
+ def _is_conventional_action(
689
+ self,
690
+ method: ParsedFunction,
691
+ cls: ParsedClass | None = None,
692
+ ) -> bool:
693
+ """
694
+ Whether a method on a conventional-routing controller should be treated
695
+ as an action (i.e. emitted as a GET route in the absence of an explicit
696
+ [HttpVerb] attribute).
697
+
698
+ MVC: the C# parser doesn't expose visibility, so visibility is
699
+ approximated by return-type filtering — real actions return one of
700
+ the well-known IActionResult-family types, view-model-returning
701
+ helpers fail the check.
702
+
703
+ Web API 2 (`ApiController` subclass): the convention is different.
704
+ Every public action returns *something* — the framework serializes
705
+ whatever you give it (IEnumerable<T>, POCO, IHttpActionResult, …).
706
+ Visibility-by-return-type doesn't work, so we accept any non-void
707
+ return type on an ApiController subclass.
708
+ """
709
+ if method.binding != "instance" or method.is_abstract:
710
+ return False
711
+ if method.name in _OBJECT_METHOD_NAMES:
712
+ return False
713
+ if any(d.name in _NON_ROUTABLE_ATTRS for d in method.decorators):
714
+ return False
715
+ # Web API 2: any non-void, non-junk return is an action — but
716
+ # only when the method is `public` AND bound to an HTTP verb.
717
+ if cls is not None and self._is_web_api_controller(cls):
718
+ ret = (method.return_type or "").strip()
719
+ if not ret or ret == "void":
720
+ return False
721
+ # Parser sometimes captures the leading `//` comment as the return
722
+ # type when there's a comment immediately above the method. Reject
723
+ # those so we don't accept comment-as-return-type as valid.
724
+ if ret.startswith("//") or ret.startswith("/*"):
725
+ return False
726
+ # Web API 2 only routes `public` methods. ParsedFunction
727
+ # doesn't track access modifiers; we read the source line
728
+ # for the method declaration and look for `public`. In C#,
729
+ # methods in a class default to `private` when no modifier
730
+ # is present, so absence-of-`public` is non-routable.
731
+ if not _method_is_public_csharp(method):
732
+ return False
733
+ # Web API 2 action selection requires the method bind to an
734
+ # HTTP verb — either via an explicit attribute
735
+ # (`[HttpGet]`/`[HttpPost]`/...) or via a method-name prefix
736
+ # (`Get*`/`Post*`/...). Methods without either are
737
+ # unreachable through Web API 2 conventional routing.
738
+ has_verb_attr = any(d.name in _HTTP_METHOD_ATTRS for d in method.decorators)
739
+ if has_verb_attr:
740
+ return True
741
+ has_verb_prefix = any(
742
+ method.name.startswith(prefix) for prefix, _ in _WEBAPI_VERB_PREFIXES
743
+ )
744
+ return has_verb_prefix
745
+ return self._is_action_return_type(method.return_type)
746
+
747
+ def _is_web_api_controller(self, cls: ParsedClass) -> bool:
748
+ """True if the class directly inherits from a Web API 2 base."""
749
+ for base in cls.base_classes:
750
+ simple = base.rsplit(".", 1)[-1]
751
+ if simple in _WEB_API_CONTROLLER_BASES:
752
+ return True
753
+ return False
754
+
755
+ def _webapi_verb_for_method(self, method_name: str) -> HttpMethod:
756
+ """Resolve a Web API 2 method's HTTP verb from its name prefix.
757
+
758
+ `Get*` → GET, `Post*` → POST, `Put*` → PUT, `Delete*` → DELETE,
759
+ `Patch*` → PATCH, `Head*` → HEAD, `Options*` → OPTIONS.
760
+ Unrecognised prefixes default to GET — matching Web API 2's
761
+ fallback for unmatched methods on conventional routing.
762
+ """
763
+ for prefix, verb in _WEBAPI_VERB_PREFIXES:
764
+ if method_name.startswith(prefix):
765
+ return verb
766
+ return HttpMethod.GET
767
+
768
+ def _webapi_path_suffix(self, method: ParsedFunction) -> str:
769
+ """
770
+ Return the per-method path segment for a Web API 2 conventional route.
771
+
772
+ The default Web API 2 route template is `api/{controller}/{id?}` —
773
+ the `{id}` route token binds to a method parameter literally named
774
+ `id`. Primitive parameters with OTHER names (`start`, `notify`,
775
+ `productId`, etc.) bind from the QUERY STRING and don't contribute
776
+ a path segment — so the URL is just `/api/{controller}`.
777
+
778
+ Returns `"{id}"` when a primitive `id` parameter is present;
779
+ otherwise empty string.
780
+
781
+ (Earlier behaviour appended `{<param>}` for the first primitive
782
+ regardless of name, producing URLs like `/api/Contacts/{start}`
783
+ for `GetContacts(int start, ...)` — a route Web API 2's runtime
784
+ wouldn't actually dispatch.)
785
+ """
786
+ for param in method.parameters:
787
+ if param.name != "id":
788
+ continue
789
+ type_name = (param.type_annotation or "").strip()
790
+ if not type_name:
791
+ continue
792
+ base = type_name.split("<")[0].strip().rstrip("?")
793
+ simple = base.rsplit(".", 1)[-1]
794
+ if simple in _PRIMITIVE_PARAM_TYPES:
795
+ return "{id}"
796
+ return ""
797
+
798
+ def _is_action_return_type(self, return_type: str | None) -> bool:
799
+ """Return True for IActionResult-family return types (Task<…> aware)."""
800
+ if not return_type:
801
+ return False
802
+ ret = return_type.strip()
803
+ # Bare Task / ValueTask with no generic = async void action.
804
+ if ret in ("Task", "ValueTask"):
805
+ return True
806
+ for wrapper in ("Task<", "ValueTask<"):
807
+ if ret.startswith(wrapper) and ret.endswith(">"):
808
+ return self._is_action_return_type(ret[len(wrapper) : -1].strip())
809
+ base = ret.split("<")[0].strip().rsplit(".", 1)[-1]
810
+ return base in _ACTION_RETURN_TYPE_NAMES
811
+
812
+ def _get_class_prefix(
813
+ self,
814
+ cls: ParsedClass,
815
+ parsed_file: ParsedFile,
816
+ slugify: bool = False,
817
+ ) -> str:
818
+ """
819
+ Resolve [Route("prefix")] on the controller class.
820
+
821
+ Handles the [controller] token — replaced by the class name without
822
+ the "Controller" suffix. When `slugify=True` (project registers
823
+ SlugifyParameterTransformer), the token expansion and conventional
824
+ fallback are kebab-cased: ``UsersController`` → ``/users``,
825
+ ``MyAdminController`` → ``/my-admin``.
826
+ """
827
+ controller_name = cls.name
828
+ if controller_name.endswith("Controller"):
829
+ controller_name = controller_name[: -len("Controller")]
830
+ token_value = _slugify(controller_name) if slugify else controller_name
831
+
832
+ # `[RoutePrefix("...")]` (Web API 2 attribute routing) takes priority
833
+ # over `[Route]` — it's a hard prefix that combines with method-level
834
+ # `[Route]` to form the full URL. A `[Route]` *also* on the class is
835
+ # rare in this scenario; if present, the RoutePrefix wins.
836
+ for dec in cls.decorators:
837
+ if dec.name == "RoutePrefix":
838
+ path = self._dec_path(dec)
839
+ if path:
840
+ path = path.replace("[controller]", token_value)
841
+ path = path.replace("[Controller]", token_value)
842
+ return ("/" + path.strip("/")).rstrip("/")
843
+
844
+ for dec in cls.decorators:
845
+ if dec.name == "Route":
846
+ path = self._dec_path(dec)
847
+ if path:
848
+ path = path.replace("[controller]", token_value)
849
+ path = path.replace("[Controller]", token_value)
850
+ return path.rstrip("/")
851
+ # Conventional routing: no [Route] but the class is recognized as a
852
+ # controller. Web API 2 ApiController subclasses get the framework's
853
+ # `api/{controller}` default; MVC Controllers get `/{controller}`.
854
+ if self._is_web_api_controller(cls):
855
+ return f"/api/{token_value}"
856
+ if self._is_controller(cls):
857
+ return f"/{token_value}"
858
+ return ""
859
+
860
+ def _extract_routes_from_method(
861
+ self,
862
+ method: ParsedFunction,
863
+ class_prefix: str,
864
+ cls: ParsedClass,
865
+ parsed_file: ParsedFile,
866
+ context: AnalysisContext | None,
867
+ is_conventional_mvc: bool = False,
868
+ slugify: bool = False,
869
+ maproute_overrides: dict[tuple[str, str], str] | None = None,
870
+ ) -> list[ExtractedRoute]:
871
+ routes: list[ExtractedRoute] = []
872
+
873
+ # [ChildActionOnly] / [NonAction] mark the method as non-HTTP — used
874
+ # for partial-view rendering (Html.Action) and helper methods. ASP.NET
875
+ # MVC's runtime skips routing entirely for these, and so should we —
876
+ # otherwise the conventional-routing path emits hundreds of false
877
+ # positives (nopCommerce 3.90 has 204 [ChildActionOnly] methods).
878
+ if any(d.name in _NON_ROUTABLE_ATTRS for d in method.decorators):
879
+ return routes
880
+
881
+ # Collect HTTP-method attributes and optional [Route] on this method
882
+ method_paths: list[str] = []
883
+ http_methods: list[HttpMethod] = []
884
+ produces: str | None = None
885
+ consumes: str | None = None
886
+ response_status = 200
887
+ explicit_method_path = False # True when an [HttpVerb("path")] carried an explicit path
888
+
889
+ for dec in method.decorators:
890
+ hm = _HTTP_METHOD_ATTRS.get(dec.name)
891
+ if hm is not None:
892
+ http_methods.append(hm)
893
+ # Only record a path from [HttpMethod] when it carries an explicit
894
+ # path argument (e.g. [HttpGet("{id}")]). When there is no argument
895
+ # (e.g. bare [HttpPost]) we leave method_paths unchanged so that
896
+ # any earlier [Route("…")] path is used instead of generating a
897
+ # spurious duplicate route with an empty segment.
898
+ local = self._dec_path(dec)
899
+ if local is not None and local not in method_paths:
900
+ method_paths.append(local)
901
+ explicit_method_path = True
902
+
903
+ elif dec.name == "Route":
904
+ # [Route] on a method contributes a path but not an HTTP method.
905
+ # Counts as an explicit method path — when the class has no
906
+ # routing attribute, this is the FULL URL (no conventional
907
+ # `/api/{controller}` prefix prepended).
908
+ local = self._dec_path(dec) or ""
909
+ if local not in method_paths:
910
+ method_paths.append(local)
911
+ explicit_method_path = True
912
+
913
+ elif dec.name == "Produces":
914
+ produces = dec.positional_args[0] if dec.positional_args else None
915
+ elif dec.name == "Consumes":
916
+ consumes = dec.positional_args[0] if dec.positional_args else None
917
+ elif dec.name == "ProducesResponseType":
918
+ response_status = self._extract_response_status(dec)
919
+
920
+ if not http_methods:
921
+ # Methods without an explicit [HttpVerb] attribute can still bind
922
+ # to a verb in two cases:
923
+ #
924
+ # 1. Web API 2 (ApiController): the method-name prefix selects the
925
+ # verb (Get*, Post*, Put*, …). This is unconditional in Web
926
+ # API 2 — it applies *whether or not* the class carries
927
+ # [Route] / [RoutePrefix]. LPL-style controllers commonly
928
+ # decorate the class with [RoutePrefix("api/v1/orders")] and
929
+ # leave methods without [HttpVerb]; the runtime still binds
930
+ # by name.
931
+ #
932
+ # 2. MVC (Controller) with conventional routing (no class-level
933
+ # [Route]): the method is an action that defaults to GET.
934
+ if self._is_web_api_controller(cls) and self._is_conventional_action(method, cls):
935
+ http_methods = [self._webapi_verb_for_method(method.name)]
936
+ elif is_conventional_mvc and self._is_conventional_action(method, cls):
937
+ http_methods = [HttpMethod.GET]
938
+ else:
939
+ return routes
940
+
941
+ if not method_paths:
942
+ if is_conventional_mvc:
943
+ if self._is_web_api_controller(cls):
944
+ # Web API 2 default route template is `api/{controller}/{id?}`
945
+ # — there's no {action} segment. Method name selected the
946
+ # verb already (PR #18). Append `{id}` when the method
947
+ # takes an id-like parameter; otherwise the path is the
948
+ # bare class prefix.
949
+ method_paths = [self._webapi_path_suffix(method)]
950
+ else:
951
+ # Conventional MVC: use method name as action;
952
+ # Index() → controller root. Slug-case when registered.
953
+ action = (
954
+ ""
955
+ if method.name == "Index"
956
+ else (_slugify(method.name) if slugify else method.name)
957
+ )
958
+ method_paths = [action]
959
+ else:
960
+ method_paths = [""]
961
+
962
+ auth_refs = self._method_auth_refs(cls, method)
963
+
964
+ # Resolve the controller name once for MapRoute-override lookup.
965
+ controller_short_name = cls.name
966
+ if controller_short_name.endswith("Controller"):
967
+ controller_short_name = controller_short_name[: -len("Controller")]
968
+
969
+ # When the class has no routing attribute at all (no `[Route]`,
970
+ # no `[RoutePrefix]`), a method's explicit `[Route]` or
971
+ # `[HttpVerb("path")]` IS the full URL — the conventional
972
+ # `/api/{controller}` fallback prefix doesn't apply. When the
973
+ # class HAS `[RoutePrefix]` (or class-level `[Route]`), the
974
+ # method route is appended to that prefix.
975
+ class_has_routing_attr = any(d.name in ("Route", "RoutePrefix") for d in cls.decorators)
976
+
977
+ for http_method in http_methods:
978
+ for local_path in method_paths:
979
+ # Ardalis/single-method endpoint pattern: class has no
980
+ # routing attribute (is_conventional_mvc=True AND no
981
+ # `[RoutePrefix]`) and the method carries its own explicit
982
+ # route path. The method path IS the full route — no
983
+ # controller-name prefix is applied.
984
+ if (
985
+ is_conventional_mvc
986
+ and explicit_method_path
987
+ and local_path
988
+ and not class_has_routing_attr
989
+ ):
990
+ full_path = local_path if local_path.startswith("/") else "/" + local_path
991
+ else:
992
+ full_path = self._join_paths(class_prefix, local_path)
993
+ # Resolve [action] / [Action] token inserted by [Route("[controller]/[action]")]
994
+ action_token = _slugify(method.name) if slugify else method.name
995
+ full_path = full_path.replace("[action]", action_token).replace(
996
+ "[Action]", action_token
997
+ )
998
+
999
+ # Central registrations (routes.MapRoute / MapLocalizedRoute)
1000
+ # override the conventional `/{Controller}/{Action}` path with
1001
+ # whatever explicit URL was registered for this (controller,
1002
+ # action) pair. Only consult for conventional routing where
1003
+ # no explicit method path was set — attribute-routed methods
1004
+ # already carry the truth in `local_path`.
1005
+ if is_conventional_mvc and not explicit_method_path and maproute_overrides:
1006
+ override = maproute_overrides.get((controller_short_name, method.name))
1007
+ if override is not None:
1008
+ full_path = override
1009
+
1010
+ full_path = self._normalize_path(full_path)
1011
+
1012
+ path_params, query_params, header_params, cookie_params, body = (
1013
+ self._extract_params(method, full_path, context, parsed_file)
1014
+ )
1015
+
1016
+ if consumes and body is not None:
1017
+ body = ExtractedBody(
1018
+ content_type=consumes,
1019
+ model_name=body.model_name,
1020
+ model_fields=body.model_fields,
1021
+ required=body.required,
1022
+ )
1023
+
1024
+ response = ExtractedResponse(
1025
+ model_name=method.return_type,
1026
+ status_code=response_status,
1027
+ content_type=produces,
1028
+ )
1029
+
1030
+ routes.append(
1031
+ ExtractedRoute(
1032
+ method=http_method,
1033
+ path=full_path,
1034
+ handler_function=method.qualified_name,
1035
+ handler_location=method.location,
1036
+ path_params=path_params,
1037
+ query_params=query_params,
1038
+ header_params=header_params,
1039
+ cookie_params=cookie_params,
1040
+ body=body,
1041
+ response=response,
1042
+ tags=[cls.name],
1043
+ dependency_refs=auth_refs,
1044
+ confidence=Confidence.HIGH,
1045
+ kind="http",
1046
+ )
1047
+ )
1048
+
1049
+ return routes
1050
+
1051
+ # -------------------------------------------------------------------------
1052
+ # Razor Pages support
1053
+ # -------------------------------------------------------------------------
1054
+
1055
+ # Handler name prefix → HTTP method (case-sensitive; Async suffix stripped first)
1056
+ _RAZOR_HANDLER_VERBS: ClassVar[dict[str, HttpMethod]] = {
1057
+ "OnGet": HttpMethod.GET,
1058
+ "OnPost": HttpMethod.POST,
1059
+ "OnPut": HttpMethod.PUT,
1060
+ "OnDelete": HttpMethod.DELETE,
1061
+ "OnPatch": HttpMethod.PATCH,
1062
+ "OnHead": HttpMethod.HEAD,
1063
+ }
1064
+
1065
+ def _is_razor_page(self, cls: ParsedClass) -> bool:
1066
+ return any(base.rsplit(".", 1)[-1] == "PageModel" for base in cls.base_classes)
1067
+
1068
+ def _derive_razor_page_path(self, file_path: str, cls: ParsedClass) -> str:
1069
+ """
1070
+ Derive the Razor Pages URL from the source file path.
1071
+
1072
+ Conventions:
1073
+ - ``{root}/Pages/{Rel}.cshtml.cs`` → ``/{Rel}``
1074
+ - ``{root}/Areas/{Name}/Pages/{Rel}.cshtml.cs`` → ``/{Name}/{Rel}``
1075
+ - ``Index`` as the final segment is stripped (maps to parent dir).
1076
+ - ``[Route]`` on the class overrides the derived path entirely.
1077
+
1078
+ Falls back to ``/{ClassName}`` when the Pages directory is not found.
1079
+ """
1080
+ # Explicit [Route] on the class wins
1081
+ for dec in cls.decorators:
1082
+ if dec.name == "Route":
1083
+ raw = self._dec_path(dec)
1084
+ if raw:
1085
+ return raw if raw.startswith("/") else "/" + raw
1086
+
1087
+ # Normalise separators so we can search consistently
1088
+ norm_path = file_path.replace("\\", "/")
1089
+ # Strip .cshtml.cs or .cshtml extension
1090
+ for ext in (".cshtml.cs", ".cshtml"):
1091
+ if norm_path.endswith(ext):
1092
+ norm_path = norm_path[: -len(ext)]
1093
+ break
1094
+
1095
+ # Try Areas/{AreaName}/Pages/{Rel}
1096
+ area_marker = "/Areas/"
1097
+ pages_marker = "/Pages/"
1098
+ area_idx = norm_path.find(area_marker)
1099
+ if area_idx != -1:
1100
+ rest = norm_path[area_idx + len(area_marker) :]
1101
+ # rest = "{AreaName}/Pages/{Rel}"
1102
+ pages_idx = rest.find(pages_marker)
1103
+ if pages_idx != -1:
1104
+ area_name = rest[:pages_idx]
1105
+ rel = rest[pages_idx + len(pages_marker) :]
1106
+ path = f"/{area_name}/{rel}"
1107
+ return self._razor_strip_index(self._normalize_path(path))
1108
+
1109
+ # Regular Pages/{Rel}
1110
+ pages_idx = norm_path.find(pages_marker)
1111
+ if pages_idx != -1:
1112
+ rel = norm_path[pages_idx + len(pages_marker) :]
1113
+ path = "/" + rel
1114
+ return self._razor_strip_index(self._normalize_path(path))
1115
+
1116
+ # Fallback: use class name, strip "Model" suffix
1117
+ name = cls.name
1118
+ if name.endswith("Model"):
1119
+ name = name[: -len("Model")]
1120
+ return "/" + name
1121
+
1122
+ @staticmethod
1123
+ def _razor_strip_index(path: str) -> str:
1124
+ """Strip a trailing ``/Index`` segment (Razor Pages convention)."""
1125
+ if path.endswith("/Index"):
1126
+ stripped = path[: -len("/Index")]
1127
+ return stripped or "/"
1128
+ return path
1129
+
1130
+ def _extract_razor_page_routes(
1131
+ self,
1132
+ parsed_file: ParsedFile,
1133
+ context: AnalysisContext | None = None,
1134
+ ) -> list[ExtractedRoute]:
1135
+ """
1136
+ Extract routes from Razor Pages PageModel classes.
1137
+
1138
+ Handler naming convention: ``On{Verb}[HandlerName][Async]``
1139
+
1140
+ - ``OnGet`` → GET (no named handler)
1141
+ - ``OnPost`` → POST (no named handler)
1142
+ - ``OnPostUpdate`` → POST with query param ``handler=Update``
1143
+ - ``OnGetAsync`` → GET (Async suffix stripped before matching)
1144
+ """
1145
+ routes: list[ExtractedRoute] = []
1146
+ file_path = str(parsed_file.path)
1147
+
1148
+ for cls in parsed_file.classes:
1149
+ if not self._is_razor_page(cls):
1150
+ continue
1151
+
1152
+ page_path = self._derive_razor_page_path(file_path, cls)
1153
+
1154
+ # Class-level auth applies to all handlers unless the method overrides
1155
+ class_allow_anon = any(d.name == "AllowAnonymous" for d in cls.decorators)
1156
+ class_authorize = any(d.name == "Authorize" for d in cls.decorators)
1157
+
1158
+ for method in cls.methods:
1159
+ name = method.name
1160
+ # Strip Async suffix before pattern matching
1161
+ if name.endswith("Async"):
1162
+ name = name[: -len("Async")]
1163
+
1164
+ http_method: HttpMethod | None = None
1165
+ handler_name: str | None = None
1166
+
1167
+ for verb_prefix, hm in self._RAZOR_HANDLER_VERBS.items():
1168
+ if name == verb_prefix:
1169
+ http_method = hm
1170
+ break
1171
+ if name.startswith(verb_prefix) and len(name) > len(verb_prefix):
1172
+ http_method = hm
1173
+ handler_name = name[len(verb_prefix) :]
1174
+ break
1175
+
1176
+ if http_method is None:
1177
+ continue
1178
+
1179
+ # Named handler → append ?handler=Name as a query param
1180
+ query_params: list[ExtractedParameter] = []
1181
+ if handler_name:
1182
+ query_params.append(
1183
+ ExtractedParameter(
1184
+ name="handler",
1185
+ location=ParameterLocation.QUERY,
1186
+ type_annotation="string",
1187
+ required=True,
1188
+ default_value=handler_name,
1189
+ )
1190
+ )
1191
+
1192
+ # Auth: method-level overrides class-level
1193
+ method_allow_anon = any(d.name == "AllowAnonymous" for d in method.decorators)
1194
+ method_authorize = any(d.name == "Authorize" for d in method.decorators)
1195
+
1196
+ if method_allow_anon or class_allow_anon:
1197
+ auth_refs: list[str] = []
1198
+ elif method_authorize:
1199
+ auth_refs = [f"{cls.name}.{method.name}"]
1200
+ elif class_authorize:
1201
+ auth_refs = [cls.name]
1202
+ else:
1203
+ auth_refs = []
1204
+
1205
+ # Path params from [BindProperty(SupportsGet=true)] or route template
1206
+ path_params = self._extract_path_params_from_template(page_path)
1207
+
1208
+ routes.append(
1209
+ ExtractedRoute(
1210
+ method=http_method,
1211
+ path=page_path,
1212
+ handler_function=_str_to_qname(f"{cls.name}.{method.name}"),
1213
+ handler_location=method.location,
1214
+ path_params=path_params,
1215
+ query_params=query_params,
1216
+ header_params=[],
1217
+ cookie_params=[],
1218
+ body=None,
1219
+ response=ExtractedResponse(
1220
+ status_code=200,
1221
+ model_name=method.return_type,
1222
+ ),
1223
+ tags=["razor-pages"],
1224
+ dependency_refs=auth_refs,
1225
+ confidence=Confidence.HIGH,
1226
+ kind="razor-page",
1227
+ )
1228
+ )
1229
+
1230
+ return routes
1231
+
1232
+ def _extract_minimal_api_routes(
1233
+ self,
1234
+ parsed_file: ParsedFile,
1235
+ context: AnalysisContext | None = None,
1236
+ ) -> list[ExtractedRoute]:
1237
+ """
1238
+ Detect minimal-API call sites: app.MapGet("/users/{id}", handler)
1239
+
1240
+ Extracts:
1241
+ - Actual path from first string-literal argument (HIGH confidence)
1242
+ or falls back to "/[minimal-api]" when the path is dynamic (MEDIUM)
1243
+ - Path parameters derived from the path template
1244
+ - Handler function name from the second argument (method reference)
1245
+ or "<lambda>" for inline lambdas. When the call lives inside
1246
+ `AddRoute(IEndpointRouteBuilder)` on a class implementing
1247
+ `IEndpoint<...>` (the `MinimalApi.Endpoint` library pattern), the
1248
+ handler is attributed to `{ClassName}.HandleAsync` instead.
1249
+ - Auth chaining: .RequireAuthorization() / .AllowAnonymous() detected
1250
+ via receiver_expression line-number correlation set by the parser
1251
+ """
1252
+ routes: list[ExtractedRoute] = []
1253
+
1254
+ # Gap 3: Build a name → ParsedFunction lookup for method-reference handlers
1255
+ _method_lookup: dict[str, ParsedFunction] = {}
1256
+ for _func in parsed_file.functions:
1257
+ _method_lookup[_func.name] = _func
1258
+ for _cls in parsed_file.classes:
1259
+ for _m in _cls.methods:
1260
+ _method_lookup[_m.name] = _m
1261
+
1262
+ # MinimalApi.Endpoint pattern: class → handler-method-name override.
1263
+ # Built once per file because most projects with this pattern have
1264
+ # one endpoint class per file.
1265
+ _iendpoint_classes: dict[str, str] = self._build_iendpoint_class_map(parsed_file)
1266
+
1267
+ # Separate Map* calls from auth/metadata chainers
1268
+ map_calls: list = []
1269
+ map_methods_calls: list = []
1270
+ # line → list of chainer method names that reference that line
1271
+ auth_chainers: dict[int, list[str]] = {}
1272
+
1273
+ for call in parsed_file.call_sites:
1274
+ method_name = call.callee_name.split(".")[-1]
1275
+ if method_name in _MINIMAL_API_METHODS:
1276
+ # Skip groupBuilder.Map* — handled by _extract_endpoint_group_routes
1277
+ if call.receiver_expression == "groupBuilder":
1278
+ continue
1279
+ map_calls.append(call)
1280
+ elif method_name == "MapMethods":
1281
+ # Skip groupBuilder.MapMethods
1282
+ if call.receiver_expression == "groupBuilder":
1283
+ continue
1284
+ map_methods_calls.append(call)
1285
+ elif (
1286
+ method_name in _MINIMAL_API_CHAINERS
1287
+ and call.receiver_expression
1288
+ and call.receiver_expression.startswith("line:")
1289
+ ):
1290
+ try:
1291
+ target_line = int(call.receiver_expression[5:])
1292
+ auth_chainers.setdefault(target_line, []).append(method_name)
1293
+ except ValueError:
1294
+ pass
1295
+
1296
+ # ── Two-pass MapGroup inline prefix tracker ───────────────────────────
1297
+ # First pass: collect MapGroup calls → map receiver_expression + "_group"
1298
+ # to the resolved prefix string.
1299
+ # This handles: var api = app.MapGroup("/api"); api.MapGet("/users", ...)
1300
+ _map_group_prefixes: dict[str, str] = {}
1301
+ for call in parsed_file.call_sites:
1302
+ if call.callee_name.split(".")[-1] != "MapGroup":
1303
+ continue
1304
+ if not call.arguments or not call.arguments[0].is_literal:
1305
+ continue
1306
+ raw = str(call.arguments[0].literal_value or "")
1307
+ grp_prefix = "/" + raw.strip("/") if raw else ""
1308
+ # Map the receiver (e.g. "api", "app") to the group prefix
1309
+ recv = call.receiver_expression or ""
1310
+ if recv:
1311
+ _map_group_prefixes[recv + "_group"] = grp_prefix
1312
+
1313
+ for call in map_calls:
1314
+ method_name = call.callee_name.split(".")[-1]
1315
+ hm = _MINIMAL_API_METHODS[method_name]
1316
+
1317
+ # ── Path ────────────────────────────────────────────────────────
1318
+ path = "/[minimal-api]"
1319
+ if (
1320
+ call.arguments
1321
+ and call.arguments[0].is_literal
1322
+ and call.arguments[0].literal_type == "str"
1323
+ and call.arguments[0].literal_value
1324
+ ):
1325
+ path = call.arguments[0].literal_value
1326
+
1327
+ # Strip route constraints: {id:int} → {id}
1328
+ path = self._normalize_path(path)
1329
+ # Normalise: all paths should start with /
1330
+ if path and path != "/[minimal-api]" and not path.startswith("/"):
1331
+ path = "/" + path
1332
+
1333
+ # Apply inline MapGroup prefix (same-file pattern)
1334
+ recv = call.receiver_expression or ""
1335
+ _inline_prefix = _map_group_prefixes.get(recv + "_group", "")
1336
+ if _inline_prefix:
1337
+ import re as _re
1338
+
1339
+ path = _inline_prefix.rstrip("/") + "/" + path.lstrip("/")
1340
+ path = _re.sub(r"/+", "/", path)
1341
+
1342
+ # ── Handler name ─────────────────────────────────────────────────
1343
+ handler_name = call.callee_name
1344
+ lambda_arg = None
1345
+ handler_method: ParsedFunction | None = None
1346
+ if len(call.arguments) > 1:
1347
+ arg1 = call.arguments[1]
1348
+ if arg1.is_variable and arg1.variable_name:
1349
+ handler_name = arg1.variable_name
1350
+ handler_method = _method_lookup.get(handler_name)
1351
+ elif arg1.is_expression and arg1.expression_text == "<lambda>":
1352
+ handler_name = f"{call.callee_name}/<lambda>"
1353
+ lambda_arg = arg1
1354
+
1355
+ # IEndpoint<T> override: the call is inside an `AddRoute` method on
1356
+ # a class implementing IEndpoint — attribute to {Class}.HandleAsync
1357
+ # (or the resolved handler-method name) rather than the lambda.
1358
+ iendpoint_handler = self._iendpoint_handler_for_call(call, _iendpoint_classes)
1359
+ if iendpoint_handler is not None:
1360
+ handler_name = iendpoint_handler
1361
+ # If the IEndpoint class exposes the resolved method, route
1362
+ # param extraction can use it instead of the AddRoute lambda.
1363
+ handler_method = (
1364
+ _method_lookup.get(iendpoint_handler.rsplit(".", 1)[-1]) or handler_method
1365
+ )
1366
+
1367
+ # ── Path parameters ──────────────────────────────────────────────
1368
+ path_params = self._extract_path_params_from_template(path)
1369
+
1370
+ # ── Body / query from lambda typed params or method reference ──────
1371
+ query_params: list[ExtractedParameter] = []
1372
+ header_params: list[ExtractedParameter] = []
1373
+ body: ExtractedBody | None = None
1374
+ if lambda_arg is not None and lambda_arg.lambda_parameter_types:
1375
+ path_tpl_names = {m.group(1) for m in re.finditer(r"\{([^}:]+)(?::[^}]+)?\}", path)}
1376
+ # path_params already seeded from template; don't duplicate
1377
+ path_param_names = {p.name for p in path_params}
1378
+ body, query_params = self._classify_lambda_params(
1379
+ lambda_arg.lambda_parameter_types,
1380
+ path_tpl_names,
1381
+ path_param_names,
1382
+ context,
1383
+ )
1384
+ elif handler_method is not None:
1385
+ path_params, query_params, header_params, _cookie_params, body = (
1386
+ self._extract_params(handler_method, path, context, parsed_file)
1387
+ )
1388
+
1389
+ # ── Auth chaining (.RequireAuthorization etc.) ────────────────────
1390
+ call_line = call.location.line if call.location else -1
1391
+ chained = auth_chainers.get(call_line, [])
1392
+ # Only AUTH_CHAINERS (RequireAuthorization, AllowAnonymous, RequirePermission …)
1393
+ # trigger an auth dep_ref. Use the chainer name so the auth-keyword matcher in
1394
+ # _populate_route_auth_mapping can recognize it (e.g. "permission" in "RequirePermission").
1395
+ auth_chainer = next((c for c in chained if c in _MINIMAL_API_AUTH_CHAINERS), None)
1396
+ auth_refs = [f"{auth_chainer}@{call_line}"] if auth_chainer else []
1397
+ # [Authorize] on the lambda handler → add an explicit auth dep_ref
1398
+ # that the auth-keyword matcher in _populate_route_auth_mapping picks up
1399
+ lambda_auth_attrs = lambda_arg.lambda_attribute_names if lambda_arg else []
1400
+ if "Authorize" in lambda_auth_attrs and "AllowAnonymous" not in lambda_auth_attrs:
1401
+ auth_refs.append(f"Authorize@{call_line}")
1402
+
1403
+ # ── Response status from chained .Produces<T>(statusCode) ─────────
1404
+ response_status = 200
1405
+ produces_calls = [
1406
+ c
1407
+ for c in parsed_file.call_sites
1408
+ if c.callee_name.split(".")[-1] == "Produces"
1409
+ and c.receiver_expression
1410
+ and c.receiver_expression.startswith("line:")
1411
+ and c.receiver_expression[5:].lstrip("-").isdigit()
1412
+ and int(c.receiver_expression[5:]) == call_line
1413
+ ]
1414
+ for pc in produces_calls[:1]:
1415
+ # Produces<T>() → 200; Produces<T>(statusCode) → statusCode
1416
+ int_args = [a for a in pc.arguments if a.is_literal and a.literal_type == "int"]
1417
+ if int_args:
1418
+ response_status = int(int_args[0].literal_value)
1419
+
1420
+ # ── Confidence ───────────────────────────────────────────────────
1421
+ confidence = Confidence.HIGH if path != "/[minimal-api]" else Confidence.MEDIUM
1422
+
1423
+ routes.append(
1424
+ ExtractedRoute(
1425
+ method=hm,
1426
+ path=path,
1427
+ handler_function=_str_to_qname(handler_name),
1428
+ handler_location=call.location,
1429
+ path_params=path_params,
1430
+ query_params=query_params,
1431
+ header_params=header_params,
1432
+ cookie_params=[],
1433
+ body=body,
1434
+ response=ExtractedResponse(status_code=response_status),
1435
+ tags=["minimal-api"],
1436
+ dependency_refs=auth_refs,
1437
+ confidence=confidence,
1438
+ kind="http",
1439
+ )
1440
+ )
1441
+
1442
+ # Gap 1: MapMethods call sites
1443
+ for call in map_methods_calls:
1444
+ # arguments[0] = path, arguments[1] = http methods array, arguments[2] = handler
1445
+ if not call.arguments or not call.arguments[0].is_literal:
1446
+ continue
1447
+ path = str(call.arguments[0].literal_value or "")
1448
+ path = self._normalize_path(path)
1449
+ if path and not path.startswith("/"):
1450
+ path = "/" + path
1451
+
1452
+ # Parse HTTP methods from arguments[1]
1453
+ http_methods_list: list[HttpMethod] = []
1454
+ if len(call.arguments) > 1:
1455
+ methods_text = call.arguments[1].expression_text or ""
1456
+ for match in re.finditer(r'HttpMethods\.(\w+)|"(\w+)"', methods_text):
1457
+ raw = match.group(1) or match.group(2)
1458
+ with contextlib.suppress(KeyError):
1459
+ http_methods_list.append(HttpMethod[raw.upper()])
1460
+ if not http_methods_list:
1461
+ http_methods_list = [HttpMethod.GET]
1462
+
1463
+ # Extract handler name / method reference from arguments[2]
1464
+ handler_name = call.callee_name
1465
+ lambda_arg = None
1466
+ handler_method = None
1467
+ if len(call.arguments) > 2:
1468
+ arg2 = call.arguments[2]
1469
+ if arg2.is_variable and arg2.variable_name:
1470
+ handler_name = arg2.variable_name
1471
+ handler_method = _method_lookup.get(handler_name)
1472
+ elif arg2.is_expression and arg2.expression_text == "<lambda>":
1473
+ handler_name = f"{call.callee_name}/<lambda>"
1474
+ lambda_arg = arg2
1475
+
1476
+ path_params = self._extract_path_params_from_template(path)
1477
+ query_params = []
1478
+ header_params = []
1479
+ body = None
1480
+ if lambda_arg is not None and lambda_arg.lambda_parameter_types:
1481
+ path_tpl_names = {m.group(1) for m in re.finditer(r"\{([^}:]+)(?::[^}]+)?\}", path)}
1482
+ path_param_names = {p.name for p in path_params}
1483
+ body, query_params = self._classify_lambda_params(
1484
+ lambda_arg.lambda_parameter_types,
1485
+ path_tpl_names,
1486
+ path_param_names,
1487
+ context,
1488
+ )
1489
+ elif handler_method is not None:
1490
+ path_params, query_params, header_params, _cookie_params, body = (
1491
+ self._extract_params(handler_method, path, context, parsed_file)
1492
+ )
1493
+
1494
+ call_line = call.location.line if call.location else -1
1495
+ chained = auth_chainers.get(call_line, [])
1496
+ auth_chainer = next((c for c in chained if c in _MINIMAL_API_AUTH_CHAINERS), None)
1497
+ auth_refs = [f"{auth_chainer}@{call_line}"] if auth_chainer else []
1498
+ # C3: [Authorize] on the MapMethods lambda handler → explicit auth dep_ref
1499
+ lambda_auth_attrs = lambda_arg.lambda_attribute_names if lambda_arg else []
1500
+ if "Authorize" in lambda_auth_attrs and "AllowAnonymous" not in lambda_auth_attrs:
1501
+ auth_refs.append(f"Authorize@{call_line}")
1502
+
1503
+ for hm in http_methods_list:
1504
+ routes.append(
1505
+ ExtractedRoute(
1506
+ method=hm,
1507
+ path=path,
1508
+ handler_function=_str_to_qname(handler_name),
1509
+ handler_location=call.location,
1510
+ path_params=path_params,
1511
+ query_params=query_params,
1512
+ header_params=header_params,
1513
+ cookie_params=[],
1514
+ body=body,
1515
+ response=ExtractedResponse(status_code=200),
1516
+ tags=["minimal-api"],
1517
+ dependency_refs=auth_refs,
1518
+ confidence=Confidence.HIGH,
1519
+ kind="http",
1520
+ )
1521
+ )
1522
+
1523
+ # Gap 4: MapHealthChecks call sites
1524
+ for call in parsed_file.call_sites:
1525
+ if call.callee_name.split(".")[-1] != "MapHealthChecks":
1526
+ continue
1527
+ if not call.arguments or not call.arguments[0].is_literal:
1528
+ continue
1529
+ hc_path = str(call.arguments[0].literal_value or "")
1530
+ if not hc_path.startswith("/"):
1531
+ hc_path = "/" + hc_path
1532
+ routes.append(
1533
+ ExtractedRoute(
1534
+ method=HttpMethod.GET,
1535
+ path=hc_path,
1536
+ handler_function=_str_to_qname("MapHealthChecks"),
1537
+ handler_location=call.location,
1538
+ path_params=[],
1539
+ query_params=[],
1540
+ header_params=[],
1541
+ cookie_params=[],
1542
+ body=None,
1543
+ response=ExtractedResponse(status_code=200),
1544
+ tags=["health"],
1545
+ dependency_refs=[],
1546
+ confidence=Confidence.HIGH,
1547
+ kind="health",
1548
+ )
1549
+ )
1550
+
1551
+ return routes
1552
+
1553
+ # -------------------------------------------------------------------------
1554
+ # IEndpointGroup pattern (CleanArchitecture / RouteGroupBuilder style)
1555
+ # -------------------------------------------------------------------------
1556
+
1557
+ def _extract_endpoint_group_routes(
1558
+ self,
1559
+ parsed_file: ParsedFile,
1560
+ context: AnalysisContext | None = None,
1561
+ ) -> list[ExtractedRoute]:
1562
+ """Extract routes from the IEndpointGroup pattern.
1563
+
1564
+ In this pattern a class implements IEndpointGroup and registers routes
1565
+ inside a static ``Map(RouteGroupBuilder groupBuilder)`` method:
1566
+
1567
+ public class TodoItems : IEndpointGroup {
1568
+ public static void Map(RouteGroupBuilder groupBuilder) {
1569
+ groupBuilder.RequireAuthorization(); // ← group auth
1570
+ groupBuilder.MapPost(CreateTodoItem);
1571
+ groupBuilder.MapPut(UpdateTodoItem, "{id}");
1572
+ }
1573
+ public static async Task<...> CreateTodoItem(ISender s, CreateTodoItemCommand cmd) { ... }
1574
+ }
1575
+
1576
+ Route prefix defaults to ``/api/{ClassName}``; group-level
1577
+ ``RequireAuthorization()`` is inherited by all routes in the group;
1578
+ handler parameters are resolved from the named static methods in the
1579
+ same class. ``MapIdentityApi<T>()`` emits the standard Identity
1580
+ endpoint set.
1581
+ """
1582
+ routes: list[ExtractedRoute] = []
1583
+
1584
+ for cls in parsed_file.classes:
1585
+ if not any(b.rsplit(".", 1)[-1] in _ENDPOINT_GROUP_BASES for b in cls.base_classes):
1586
+ continue
1587
+
1588
+ # Route prefix: check for an explicit Prefix field/property, else /api/{ClassName}
1589
+ prefix_field = next(
1590
+ (f for f in cls.fields if "prefix" in f.name.lower() and f.default_value),
1591
+ None,
1592
+ )
1593
+ if prefix_field:
1594
+ raw_prefix = prefix_field.default_value or ""
1595
+ if (
1596
+ len(raw_prefix) >= 2
1597
+ and raw_prefix[0] == raw_prefix[-1]
1598
+ and raw_prefix[0] in ('"', "'")
1599
+ ):
1600
+ raw_prefix = raw_prefix[1:-1]
1601
+ group_prefix = raw_prefix if raw_prefix.startswith("/") else "/" + raw_prefix
1602
+ else:
1603
+ group_prefix = f"/api/{cls.name}"
1604
+
1605
+ # Handler method lookup: name → ParsedFunction
1606
+ method_map: dict[str, ParsedFunction] = {m.name: m for m in cls.methods}
1607
+
1608
+ # Scan all call sites for group-builder calls and chainers
1609
+ map_calls = []
1610
+ auth_chainers: dict[int, list[str]] = {}
1611
+ has_identity_api = False
1612
+
1613
+ for call in parsed_file.call_sites:
1614
+ method_name = call.callee_name.split(".")[-1]
1615
+ recv = call.receiver_expression or ""
1616
+
1617
+ if method_name == "MapIdentityApi" and recv == "groupBuilder":
1618
+ has_identity_api = True
1619
+ elif method_name in _MINIMAL_API_METHODS and recv == "groupBuilder":
1620
+ map_calls.append(call)
1621
+ elif method_name in _MINIMAL_API_CHAINERS and recv.startswith("line:"):
1622
+ with contextlib.suppress(ValueError):
1623
+ auth_chainers.setdefault(int(recv[5:]), []).append(method_name)
1624
+
1625
+ # Group-level auth: RequireAuthorization() called directly on groupBuilder
1626
+ group_requires_auth = any(
1627
+ call.callee_name.split(".")[-1] == "RequireAuthorization"
1628
+ and (call.receiver_expression or "") == "groupBuilder"
1629
+ for call in parsed_file.call_sites
1630
+ )
1631
+
1632
+ if has_identity_api:
1633
+ routes.extend(self._identity_api_routes(group_prefix))
1634
+
1635
+ for call in map_calls:
1636
+ method_name = call.callee_name.split(".")[-1]
1637
+ hm = _MINIMAL_API_METHODS[method_name]
1638
+ call_line = call.location.line if call.location else -1
1639
+
1640
+ # Extract route suffix (first string literal arg) and handler name
1641
+ route_suffix = ""
1642
+ handler_name: str | None = None
1643
+ for arg in call.arguments:
1644
+ if arg.is_literal and arg.literal_type == "str" and arg.literal_value:
1645
+ if not route_suffix:
1646
+ route_suffix = arg.literal_value.strip("/")
1647
+ elif arg.is_variable and arg.variable_name and handler_name is None:
1648
+ handler_name = arg.variable_name
1649
+
1650
+ path = f"{group_prefix}/{route_suffix}" if route_suffix else group_prefix
1651
+ path = self._normalize_path(path)
1652
+
1653
+ path_params = self._extract_path_params_from_template(path)
1654
+
1655
+ # Extract body/query from handler method signature
1656
+ body: ExtractedBody | None = None
1657
+ query_params: list[ExtractedParameter] = []
1658
+ if handler_name and handler_name in method_map:
1659
+ handler_method = method_map[handler_name]
1660
+ lambda_params = [
1661
+ (p.type_annotation or "object", p.name)
1662
+ for p in handler_method.parameters
1663
+ if p.type_annotation
1664
+ ]
1665
+ if lambda_params:
1666
+ path_tpl_names = {
1667
+ m.group(1) for m in re.finditer(r"\{([^}:]+)(?::[^}]+)?\}", path)
1668
+ }
1669
+ path_param_names = {p.name for p in path_params}
1670
+ body, query_params = self._classify_lambda_params(
1671
+ lambda_params,
1672
+ path_tpl_names,
1673
+ path_param_names,
1674
+ context,
1675
+ )
1676
+
1677
+ # Auth: group-level OR per-route chaining
1678
+ chained = auth_chainers.get(call_line, [])
1679
+ has_route_auth = any(c in _MINIMAL_API_AUTH_CHAINERS for c in chained)
1680
+ auth_refs: list[str] = []
1681
+ if group_requires_auth or has_route_auth:
1682
+ auth_refs = [f"Authorize@{call_line}"]
1683
+
1684
+ routes.append(
1685
+ ExtractedRoute(
1686
+ method=hm,
1687
+ path=path,
1688
+ handler_function=_str_to_qname(handler_name or method_name),
1689
+ handler_location=call.location,
1690
+ path_params=path_params,
1691
+ query_params=query_params,
1692
+ header_params=[],
1693
+ cookie_params=[],
1694
+ body=body,
1695
+ response=ExtractedResponse(status_code=200),
1696
+ tags=["endpoint-group"],
1697
+ dependency_refs=auth_refs,
1698
+ confidence=Confidence.HIGH,
1699
+ kind="http",
1700
+ )
1701
+ )
1702
+
1703
+ return routes
1704
+
1705
+ def _identity_api_routes(self, group_prefix: str) -> list[ExtractedRoute]:
1706
+ """Synthesise the well-known ASP.NET Core Identity API endpoints."""
1707
+ routes = []
1708
+ stub_location = CodeLocation(file="[identity-api]", line=0)
1709
+ for method, suffix, requires_auth in _IDENTITY_API_ROUTES:
1710
+ path = f"{group_prefix}/{suffix}"
1711
+ auth_refs = ["Authorize@identity"] if requires_auth else []
1712
+ routes.append(
1713
+ ExtractedRoute(
1714
+ method=method,
1715
+ path=path,
1716
+ handler_function=_str_to_qname(f"IdentityApi.{suffix}"),
1717
+ handler_location=stub_location,
1718
+ path_params=[],
1719
+ query_params=[],
1720
+ header_params=[],
1721
+ cookie_params=[],
1722
+ body=None,
1723
+ response=ExtractedResponse(status_code=200),
1724
+ tags=["identity-api"],
1725
+ dependency_refs=auth_refs,
1726
+ confidence=Confidence.HIGH,
1727
+ kind="http",
1728
+ )
1729
+ )
1730
+ return routes
1731
+
1732
+ def _extract_path_params_from_template(self, path: str) -> list[ExtractedParameter]:
1733
+ """
1734
+ Extract ``{name}`` placeholders from a (already-normalized) path template
1735
+ as PATH parameters.
1736
+
1737
+ After ``_normalize_path`` all constraints have been stripped, so we only
1738
+ need to match ``{name}`` forms.
1739
+ """
1740
+ params = []
1741
+ for name in re.findall(r"\{([^}]+)\}", path):
1742
+ params.append(
1743
+ ExtractedParameter(
1744
+ name=name,
1745
+ location=ParameterLocation.PATH,
1746
+ required=True,
1747
+ )
1748
+ )
1749
+ return params
1750
+
1751
+ # -------------------------------------------------------------------------
1752
+ # Parameter extraction
1753
+ # -------------------------------------------------------------------------
1754
+
1755
+ def _extract_params(
1756
+ self,
1757
+ method: ParsedFunction,
1758
+ path: str,
1759
+ context: AnalysisContext | None,
1760
+ parsed_file: ParsedFile,
1761
+ ) -> tuple[
1762
+ list[ExtractedParameter],
1763
+ list[ExtractedParameter],
1764
+ list[ExtractedParameter],
1765
+ list[ExtractedParameter],
1766
+ ExtractedBody | None,
1767
+ ]:
1768
+ path_params: list[ExtractedParameter] = []
1769
+ query_params: list[ExtractedParameter] = []
1770
+ header_params: list[ExtractedParameter] = []
1771
+ cookie_params: list[ExtractedParameter] = []
1772
+ body: ExtractedBody | None = None
1773
+
1774
+ path_tpl_names = set(re.findall(r"\{([^}:]+)(?::[^}]+)?\}", path))
1775
+
1776
+ for param in method.parameters:
1777
+ meta = param.metadata or {}
1778
+ constraints = self._extract_constraints(meta)
1779
+
1780
+ # [AsParameters] — properties bound individually from query/route/path.
1781
+ # Service bundles (Manager, Service, Context, etc.) are DI injections — skip them.
1782
+ # Other types are value-object param groups — treat as aggregate query param.
1783
+ if "AsParameters" in meta:
1784
+ if param.type_annotation:
1785
+ _svc_sfx = (
1786
+ "Services",
1787
+ "Manager",
1788
+ "Service",
1789
+ "Repository",
1790
+ "Context",
1791
+ "DbContext",
1792
+ "Factory",
1793
+ "Provider",
1794
+ "Sender",
1795
+ "Mediator",
1796
+ "Dispatcher",
1797
+ "Processor",
1798
+ "Client",
1799
+ "Queries",
1800
+ )
1801
+ base_type = param.type_annotation.split("<")[0].strip()
1802
+ if any(base_type.endswith(s) for s in _svc_sfx):
1803
+ continue # DI service bundle
1804
+ # Non-service [AsParameters]: emit as an aggregate query param
1805
+ query_params.append(
1806
+ ExtractedParameter(
1807
+ name=param.name,
1808
+ location=ParameterLocation.QUERY,
1809
+ type_annotation=param.type_annotation,
1810
+ required=False,
1811
+ code_location=param.location,
1812
+ )
1813
+ )
1814
+ continue
1815
+
1816
+ # Explicit [FromBody]
1817
+ if "FromBody" in meta:
1818
+ body = ExtractedBody(
1819
+ content_type="application/json",
1820
+ model_name=param.type_annotation,
1821
+ required=True,
1822
+ )
1823
+ continue
1824
+
1825
+ # Explicit [FromRoute]
1826
+ if "FromRoute" in meta:
1827
+ alias = meta.get("FromRoute")
1828
+ name = alias if isinstance(alias, str) and alias != "True" else param.name
1829
+ path_params.append(
1830
+ ExtractedParameter(
1831
+ name=name,
1832
+ location=ParameterLocation.PATH,
1833
+ type_annotation=param.type_annotation,
1834
+ required=True,
1835
+ constraints=constraints,
1836
+ code_location=param.location,
1837
+ )
1838
+ )
1839
+ continue
1840
+
1841
+ # Explicit [FromQuery]
1842
+ if "FromQuery" in meta:
1843
+ alias = meta.get("FromQuery")
1844
+ name = alias if isinstance(alias, str) and alias != "True" else param.name
1845
+ req = "Required" in meta
1846
+ query_params.append(
1847
+ ExtractedParameter(
1848
+ name=name,
1849
+ location=ParameterLocation.QUERY,
1850
+ type_annotation=param.type_annotation,
1851
+ required=req,
1852
+ default_value=param.default_value,
1853
+ constraints=constraints,
1854
+ code_location=param.location,
1855
+ )
1856
+ )
1857
+ continue
1858
+
1859
+ # Explicit [FromHeader]
1860
+ if "FromHeader" in meta:
1861
+ alias = meta.get("FromHeader")
1862
+ name = alias if isinstance(alias, str) and alias != "True" else param.name
1863
+ header_params.append(
1864
+ ExtractedParameter(
1865
+ name=name,
1866
+ location=ParameterLocation.HEADER,
1867
+ type_annotation=param.type_annotation,
1868
+ required=True,
1869
+ constraints=constraints,
1870
+ code_location=param.location,
1871
+ )
1872
+ )
1873
+ continue
1874
+
1875
+ # Explicit [FromForm]
1876
+ if "FromForm" in meta:
1877
+ if body is None:
1878
+ body = ExtractedBody(
1879
+ content_type="multipart/form-data",
1880
+ model_name=param.type_annotation,
1881
+ required=True,
1882
+ )
1883
+ continue
1884
+
1885
+ # No explicit annotation — infer from path template
1886
+ if param.name in path_tpl_names:
1887
+ path_params.append(
1888
+ ExtractedParameter(
1889
+ name=param.name,
1890
+ location=ParameterLocation.PATH,
1891
+ type_annotation=param.type_annotation,
1892
+ required=True,
1893
+ constraints=constraints,
1894
+ code_location=param.location,
1895
+ )
1896
+ )
1897
+ continue
1898
+
1899
+ # Skip framework-injected types
1900
+ skip_types = {
1901
+ "HttpContext",
1902
+ "HttpRequest",
1903
+ "HttpResponse",
1904
+ "CancellationToken",
1905
+ "ILogger",
1906
+ "ClaimsPrincipal",
1907
+ "IFormFile",
1908
+ "IFormFileCollection",
1909
+ }
1910
+ if param.type_annotation in skip_types:
1911
+ continue
1912
+ # Also skip types that start with "I" (interfaces injected via DI)
1913
+ if (
1914
+ param.type_annotation
1915
+ and param.type_annotation.startswith("I")
1916
+ and len(param.type_annotation) > 1
1917
+ and param.type_annotation[1].isupper()
1918
+ ):
1919
+ continue
1920
+
1921
+ # Complex type with no annotation → treat as [FromBody] (ASP.NET default)
1922
+ if param.type_annotation and self._is_complex_type(param.type_annotation):
1923
+ if body is None:
1924
+ body = ExtractedBody(
1925
+ content_type="application/json",
1926
+ model_name=param.type_annotation,
1927
+ required=True,
1928
+ )
1929
+ continue
1930
+
1931
+ # Scalar with no annotation → query param (ASP.NET Core default for scalars)
1932
+ query_params.append(
1933
+ ExtractedParameter(
1934
+ name=param.name,
1935
+ location=ParameterLocation.QUERY,
1936
+ type_annotation=param.type_annotation,
1937
+ required=param.default_value is None and param.default_value != "null",
1938
+ default_value=param.default_value,
1939
+ constraints=constraints,
1940
+ code_location=param.location,
1941
+ )
1942
+ )
1943
+
1944
+ return path_params, query_params, header_params, cookie_params, body
1945
+
1946
+ def _extract_constraints(self, meta: dict[str, Any]) -> dict[str, Any]:
1947
+ constraints: dict[str, Any] = {}
1948
+ if "Required" in meta:
1949
+ constraints["not_null"] = True
1950
+ if "Range" in meta:
1951
+ val = meta["Range"]
1952
+ if isinstance(val, dict):
1953
+ if "Minimum" in val:
1954
+ constraints["min"] = val["Minimum"]
1955
+ if "Maximum" in val:
1956
+ constraints["max"] = val["Maximum"]
1957
+ for attr in ("MinLength", "StringLength"):
1958
+ if attr in meta:
1959
+ val = meta[attr]
1960
+ n = self._to_number(val)
1961
+ if n is not None:
1962
+ constraints.setdefault("min_length", n)
1963
+ if "MaxLength" in meta:
1964
+ val = meta["MaxLength"]
1965
+ n = self._to_number(val)
1966
+ if n is not None:
1967
+ constraints["max_length"] = n
1968
+ if "RegularExpression" in meta:
1969
+ val = meta["RegularExpression"]
1970
+ constraints["pattern"] = val if isinstance(val, str) else str(val)
1971
+ if "EmailAddress" in meta:
1972
+ constraints["format"] = "email"
1973
+ if "Url" in meta:
1974
+ constraints["format"] = "url"
1975
+ return constraints
1976
+
1977
+ @staticmethod
1978
+ def _to_number(val: Any) -> int | float | None:
1979
+ if isinstance(val, (int, float)) and not isinstance(val, bool):
1980
+ return val
1981
+ if isinstance(val, str):
1982
+ try:
1983
+ return int(val)
1984
+ except ValueError:
1985
+ pass
1986
+ try:
1987
+ return float(val)
1988
+ except ValueError:
1989
+ pass
1990
+ return None
1991
+
1992
+ def _classify_lambda_params(
1993
+ self,
1994
+ lambda_params: list[tuple[str, str]],
1995
+ path_tpl_names: set[str],
1996
+ path_param_names: set[str],
1997
+ context: AnalysisContext | None,
1998
+ ) -> tuple[ExtractedBody | None, list[ExtractedParameter]]:
1999
+ """Classify typed lambda parameters into body / query params.
2000
+
2001
+ ASP.NET Core Minimal API binding rules (simplified):
2002
+ - Name matches a path template placeholder → already a path param (skip)
2003
+ - DI interface (starts with ``I`` + uppercase) → skip (framework-injected)
2004
+ - Framework special types (HttpContext, CancellationToken, …) → skip
2005
+ - Complex (non-scalar) type → request body (first one wins)
2006
+ - Scalar type → query param
2007
+ """
2008
+ _DI_SKIP: frozenset[str] = frozenset(
2009
+ {
2010
+ "HttpContext",
2011
+ "HttpRequest",
2012
+ "HttpResponse",
2013
+ "CancellationToken",
2014
+ "IFormFile",
2015
+ "IFormFileCollection",
2016
+ "ClaimsPrincipal",
2017
+ "ILogger",
2018
+ "LinkGenerator",
2019
+ # Common ASP.NET Core service types injected directly (not via interface)
2020
+ "SignInManager",
2021
+ "UserManager",
2022
+ "RoleManager",
2023
+ "ISender",
2024
+ "IMediator",
2025
+ }
2026
+ )
2027
+
2028
+ # Name suffixes that strongly indicate a DI service, not a data model
2029
+ _SERVICE_SUFFIXES: tuple[str, ...] = (
2030
+ "Manager",
2031
+ "Service",
2032
+ "Repository",
2033
+ "Context",
2034
+ "DbContext",
2035
+ "Factory",
2036
+ "Provider",
2037
+ "Sender",
2038
+ "Mediator",
2039
+ "Dispatcher",
2040
+ "Handler",
2041
+ "Processor",
2042
+ "Client",
2043
+ )
2044
+
2045
+ body: ExtractedBody | None = None
2046
+ query_params: list[ExtractedParameter] = []
2047
+
2048
+ for type_ann, param_name in lambda_params:
2049
+ # Skip path params already extracted from the template
2050
+ if param_name in path_tpl_names or param_name in path_param_names:
2051
+ continue
2052
+
2053
+ # Strip generics for interface check
2054
+ raw_type = type_ann.split("<")[0].strip()
2055
+
2056
+ # DI-injected interfaces (IRepository<T>, ILogger, etc.) and known services
2057
+ if raw_type in _DI_SKIP:
2058
+ continue
2059
+ if len(raw_type) > 1 and raw_type[0] == "I" and raw_type[1].isupper():
2060
+ continue
2061
+ if any(raw_type.endswith(sfx) for sfx in _SERVICE_SUFFIXES):
2062
+ continue
2063
+
2064
+ # Check type resolver first (best signal)
2065
+ resolved = None
2066
+ if context and context.type_resolver:
2067
+ resolved = context.type_resolver.resolve_type(type_ann)
2068
+
2069
+ # If the type resolves but is NOT a model it's a service/entity class → skip
2070
+ if resolved is not None and not resolved.is_model:
2071
+ continue
2072
+
2073
+ if resolved is not None and resolved.is_model:
2074
+ # Explicit model → body
2075
+ if body is None:
2076
+ fields = (
2077
+ context.type_resolver.get_model_fields(resolved.qualified_name)
2078
+ if context and context.type_resolver
2079
+ else []
2080
+ ) # type: ignore[union-attr]
2081
+ body = ExtractedBody(
2082
+ content_type="application/json",
2083
+ model_name=type_ann,
2084
+ model_fields=[f.name for f in fields] if fields else [],
2085
+ required=True,
2086
+ )
2087
+ elif self._is_complex_type(raw_type):
2088
+ # Unknown complex type — treat as body (ASP.NET default)
2089
+ if body is None:
2090
+ body = ExtractedBody(
2091
+ content_type="application/json",
2092
+ model_name=type_ann,
2093
+ required=True,
2094
+ )
2095
+ else:
2096
+ # Scalar → query param
2097
+ query_params.append(
2098
+ ExtractedParameter(
2099
+ name=param_name,
2100
+ location=ParameterLocation.QUERY,
2101
+ type_annotation=type_ann,
2102
+ required=True,
2103
+ )
2104
+ )
2105
+
2106
+ return body, query_params
2107
+
2108
+ def _is_complex_type(self, type_name: str) -> bool:
2109
+ # Strip nullable marker and generics before checking — handles string?, Guid?, etc.
2110
+ base = type_name.rstrip("?").split("<")[0].strip()
2111
+ simple = {
2112
+ "string",
2113
+ "int",
2114
+ "long",
2115
+ "double",
2116
+ "float",
2117
+ "decimal",
2118
+ "bool",
2119
+ "char",
2120
+ "byte",
2121
+ "short",
2122
+ "uint",
2123
+ "ulong",
2124
+ "Guid",
2125
+ "DateTime",
2126
+ "DateTimeOffset",
2127
+ "TimeSpan",
2128
+ "DateOnly",
2129
+ "TimeOnly",
2130
+ "object",
2131
+ }
2132
+ return base not in simple
2133
+
2134
+ # -------------------------------------------------------------------------
2135
+ # Auth
2136
+ # -------------------------------------------------------------------------
2137
+
2138
+ def _method_auth_refs(self, cls: ParsedClass, method: ParsedFunction) -> list[str]:
2139
+ class_authorize = any(d.name == "Authorize" for d in cls.decorators)
2140
+ method_authorize = any(d.name == "Authorize" for d in method.decorators)
2141
+ method_allow_anon = any(d.name == "AllowAnonymous" for d in method.decorators)
2142
+
2143
+ # [AllowAnonymous] on the method always wins over class-level [Authorize]
2144
+ if method_allow_anon:
2145
+ return []
2146
+ # Method-level [Authorize]: ref is qualified so it matches the method-level auth dep
2147
+ if method_authorize:
2148
+ return [f"{cls.name}.{method.name}"]
2149
+ # Class-level [Authorize]: ref is the class name — matches the class-level auth dep
2150
+ if class_authorize:
2151
+ return [cls.name]
2152
+ # Other security attributes (RequireScope, etc.)
2153
+ for dec in method.decorators:
2154
+ if dec.name in _SECURITY_ATTRS:
2155
+ return [f"{cls.name}.{method.name}"]
2156
+ return []
2157
+
2158
+ def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
2159
+ schemes: list[ExtractedAuthScheme] = []
2160
+
2161
+ for cls in parsed_file.classes:
2162
+ cls_lower = cls.name.lower()
2163
+
2164
+ # Startup / Program / WebApplicationBuilder auth registration
2165
+ if any(
2166
+ kw in cls_lower for kw in ("startup", "program", "serviceextension", "authconfig")
2167
+ ):
2168
+ for method in cls.methods:
2169
+ scheme = self._detect_auth_registration(method, cls)
2170
+ if scheme:
2171
+ schemes.append(scheme)
2172
+
2173
+ # Also scan top-level call sites (Program.cs minimal hosting).
2174
+ # Match on the *final* segment of the dotted callee name and require
2175
+ # exact equality so custom extension methods (`AddCookieSettings`)
2176
+ # don't masquerade as built-in registrations (`AddCookie`).
2177
+ for call in parsed_file.call_sites:
2178
+ method_segment = call.callee_name.rsplit(".", 1)[-1].lower()
2179
+ for pattern, scheme_type, name, confidence in _AUTH_SCHEME_CALL_PATTERNS:
2180
+ if method_segment == pattern:
2181
+ schemes.append(
2182
+ ExtractedAuthScheme(
2183
+ scheme_type=scheme_type,
2184
+ name=name,
2185
+ location=call.location,
2186
+ config={"detected_via": call.callee_name},
2187
+ confidence=confidence,
2188
+ )
2189
+ )
2190
+ break
2191
+
2192
+ return schemes
2193
+
2194
+ def _detect_auth_registration(
2195
+ self, method: ParsedFunction, cls: ParsedClass
2196
+ ) -> ExtractedAuthScheme | None:
2197
+ """Scan a method's name for auth registration patterns."""
2198
+ name_lower = method.name.lower()
2199
+ if "addauthentication" in name_lower or "configureauth" in name_lower:
2200
+ return ExtractedAuthScheme(
2201
+ scheme_type=AuthSchemeType.CUSTOM,
2202
+ name=method.name,
2203
+ location=method.location,
2204
+ config={"class": cls.name, "method": method.name, "framework": "aspnetcore"},
2205
+ confidence=Confidence.MEDIUM,
2206
+ )
2207
+ return None
2208
+
2209
+ def extract_auth_dependencies(
2210
+ self,
2211
+ parsed_file: ParsedFile,
2212
+ known_scheme_names: set[str] | None = None,
2213
+ **kwargs: Any,
2214
+ ) -> list[ExtractedAuthDependency]:
2215
+ auth_deps: list[ExtractedAuthDependency] = []
2216
+
2217
+ for cls in parsed_file.classes:
2218
+ # Class-level [Authorize] / [AllowAnonymous]
2219
+ for dec in cls.decorators:
2220
+ if dec.name == "Authorize":
2221
+ roles = self._extract_roles(dec)
2222
+ policy = dec.arguments.get("Policy") if dec.arguments else None
2223
+ if policy and isinstance(policy, str) and policy not in roles:
2224
+ roles = roles + [policy]
2225
+ auth_deps.append(
2226
+ ExtractedAuthDependency(
2227
+ name=cls.name,
2228
+ qualified_name=cls.qualified_name,
2229
+ location=cls.location,
2230
+ dependency_type=AuthDependencyType.ANNOTATION,
2231
+ requires_roles=roles,
2232
+ confidence=Confidence.HIGH,
2233
+ )
2234
+ )
2235
+
2236
+ # Method-level security annotations
2237
+ for method in cls.methods:
2238
+ roles: list[str] = []
2239
+ has_security = False
2240
+ for dec in method.decorators:
2241
+ if dec.name == "Authorize":
2242
+ has_security = True
2243
+ roles.extend(self._extract_roles(dec))
2244
+ elif dec.name == "AllowAnonymous":
2245
+ has_security = True
2246
+ if has_security:
2247
+ auth_deps.append(
2248
+ ExtractedAuthDependency(
2249
+ name=f"{cls.name}.{method.name}",
2250
+ qualified_name=method.qualified_name,
2251
+ location=method.location,
2252
+ dependency_type=AuthDependencyType.ANNOTATION,
2253
+ requires_roles=roles,
2254
+ confidence=Confidence.HIGH,
2255
+ )
2256
+ )
2257
+
2258
+ return auth_deps
2259
+
2260
+ def _extract_roles(self, dec: ParsedDecorator) -> list[str]:
2261
+ roles_val = dec.arguments.get("Roles", dec.arguments.get("roles", ""))
2262
+ if not roles_val:
2263
+ return []
2264
+ if isinstance(roles_val, str):
2265
+ return [r.strip() for r in roles_val.split(",") if r.strip()]
2266
+ return []
2267
+
2268
+ # -------------------------------------------------------------------------
2269
+ # Dependencies
2270
+ # -------------------------------------------------------------------------
2271
+
2272
+ def extract_dependencies(self, parsed_file: ParsedFile) -> list[ExtractedDependency]:
2273
+ deps: list[ExtractedDependency] = []
2274
+ for cls in parsed_file.classes:
2275
+ ann_names = {d.name for d in cls.decorators}
2276
+ if ann_names & {"Controller", "ApiController", "Service", "Repository", "Component"}:
2277
+ is_auth = any(kw in cls.name.lower() for kw in _AUTH_KEYWORDS)
2278
+ deps.append(
2279
+ ExtractedDependency(
2280
+ name=cls.name,
2281
+ qualified_name=cls.qualified_name,
2282
+ location=cls.location,
2283
+ dependency_type="class",
2284
+ provides_type=cls.name,
2285
+ is_auth_related=is_auth,
2286
+ confidence=Confidence.HIGH,
2287
+ )
2288
+ )
2289
+ return deps
2290
+
2291
+ # -------------------------------------------------------------------------
2292
+ # Middleware
2293
+ # -------------------------------------------------------------------------
2294
+
2295
+ def extract_middleware(self, parsed_file: ParsedFile) -> list[ExtractedMiddleware]:
2296
+ middleware: list[ExtractedMiddleware] = []
2297
+
2298
+ for cls in parsed_file.classes:
2299
+ mw_type = self._classify_middleware(cls)
2300
+ if mw_type:
2301
+ ops = self._infer_operations(cls)
2302
+ middleware.append(
2303
+ ExtractedMiddleware(
2304
+ name=cls.name,
2305
+ qualified_name=cls.qualified_name,
2306
+ location=cls.location,
2307
+ middleware_type=mw_type,
2308
+ # A class definition alone does NOT mean it is globally registered.
2309
+ # Only explicit app.UseMiddleware<T>() call sites set applies_to_all=True.
2310
+ applies_to_all=False,
2311
+ operations=ops,
2312
+ confidence=Confidence.MEDIUM,
2313
+ )
2314
+ )
2315
+
2316
+ # Detect app.UseMiddleware<T>() call sites
2317
+ for call in parsed_file.call_sites:
2318
+ callee = call.callee_name
2319
+ callee_lower = callee.lower()
2320
+ if "usemiddleware" in callee_lower:
2321
+ middleware.append(
2322
+ ExtractedMiddleware(
2323
+ name=callee,
2324
+ qualified_name=_str_to_qname(callee),
2325
+ location=call.location,
2326
+ middleware_type="middleware",
2327
+ applies_to_all=True,
2328
+ operations=["custom"],
2329
+ confidence=Confidence.MEDIUM,
2330
+ )
2331
+ )
2332
+ elif "usecors" in callee_lower:
2333
+ middleware.append(
2334
+ ExtractedMiddleware(
2335
+ name="CORS",
2336
+ qualified_name=_str_to_qname(callee),
2337
+ location=call.location,
2338
+ middleware_type="cors",
2339
+ applies_to_all=True,
2340
+ operations=["cors"],
2341
+ confidence=Confidence.HIGH,
2342
+ )
2343
+ )
2344
+ elif "useauthentication" in callee_lower:
2345
+ middleware.append(
2346
+ ExtractedMiddleware(
2347
+ name="Authentication",
2348
+ qualified_name=_str_to_qname(callee),
2349
+ location=call.location,
2350
+ middleware_type="auth",
2351
+ applies_to_all=True,
2352
+ # "auth_pipeline" rather than "auth": UseAuthentication only
2353
+ # reads the identity — it does not block unauthenticated requests
2354
+ # globally, so it must not be treated as a globally-enforcing guard.
2355
+ operations=["auth_pipeline"],
2356
+ confidence=Confidence.HIGH,
2357
+ )
2358
+ )
2359
+ elif "useauthorization" in callee_lower:
2360
+ middleware.append(
2361
+ ExtractedMiddleware(
2362
+ name="Authorization",
2363
+ qualified_name=_str_to_qname(callee),
2364
+ location=call.location,
2365
+ middleware_type="auth",
2366
+ applies_to_all=True,
2367
+ # "auth_pipeline": UseAuthorization enables the authorization
2368
+ # framework but only enforces auth where [Authorize] is present.
2369
+ operations=["auth_pipeline"],
2370
+ confidence=Confidence.HIGH,
2371
+ )
2372
+ )
2373
+ elif "usehttpsredirection" in callee_lower:
2374
+ middleware.append(
2375
+ ExtractedMiddleware(
2376
+ name="HttpsRedirection",
2377
+ qualified_name=_str_to_qname(callee),
2378
+ location=call.location,
2379
+ middleware_type="https_redirect",
2380
+ applies_to_all=True,
2381
+ operations=["https_redirect"],
2382
+ confidence=Confidence.HIGH,
2383
+ )
2384
+ )
2385
+
2386
+ return middleware
2387
+
2388
+ def _classify_middleware(self, cls: ParsedClass) -> str | None:
2389
+ for base in cls.base_classes:
2390
+ if base in ("IMiddleware", "IApplicationBuilder"):
2391
+ return "middleware"
2392
+ if "Filter" in base or base in (
2393
+ "IActionFilter",
2394
+ "IAuthorizationFilter",
2395
+ "IResourceFilter",
2396
+ "IResultFilter",
2397
+ "IExceptionFilter",
2398
+ ):
2399
+ return "filter"
2400
+ name_lower = cls.name.lower()
2401
+ if "middleware" in name_lower:
2402
+ return "middleware"
2403
+ if "filter" in name_lower:
2404
+ return "filter"
2405
+ return None
2406
+
2407
+ def _infer_operations(self, cls: ParsedClass) -> list[str]:
2408
+ combined = cls.name.lower() + " ".join(b.lower() for b in cls.base_classes)
2409
+ ops: list[str] = []
2410
+ if any(kw in combined for kw in ["auth", "jwt", "token", "security", "login"]):
2411
+ ops.append("auth")
2412
+ if "cors" in combined:
2413
+ ops.append("cors")
2414
+ if "log" in combined:
2415
+ ops.append("logging")
2416
+ if "rate" in combined or "throttl" in combined:
2417
+ ops.append("rate_limiting")
2418
+ return ops or ["custom"]
2419
+
2420
+ # -------------------------------------------------------------------------
2421
+ # JWT config extraction
2422
+ # -------------------------------------------------------------------------
2423
+
2424
+ _jwt_extractor = DotNetJwtConfigExtractor()
2425
+
2426
+ def extract_jwt_config(
2427
+ self,
2428
+ parsed_file: ParsedFile,
2429
+ context: AnalysisContext | None = None,
2430
+ ) -> ExtractedJwtConfig | None:
2431
+ return self._jwt_extractor.extract(parsed_file)
2432
+
2433
+ # -------------------------------------------------------------------------
2434
+ # Annotation helpers
2435
+ # -------------------------------------------------------------------------
2436
+
2437
+ def _dec_path(self, dec: ParsedDecorator) -> str | None:
2438
+ """Extract path string from a routing attribute."""
2439
+ if dec.positional_args:
2440
+ val = dec.positional_args[0]
2441
+ if isinstance(val, str):
2442
+ return val
2443
+ for key in ("template", "Template", "name", "Name"):
2444
+ val = dec.arguments.get(key)
2445
+ if val and isinstance(val, str):
2446
+ return val
2447
+ return None
2448
+
2449
+ def _join_paths(self, prefix: str, path: str) -> str:
2450
+ if path.startswith("/"):
2451
+ return path # absolute path override ignores controller prefix
2452
+ prefix = prefix.rstrip("/")
2453
+ result = (prefix + "/" + path).rstrip("/") if path else prefix
2454
+ if not result:
2455
+ return "/"
2456
+ if not result.startswith("/"):
2457
+ result = "/" + result
2458
+ return result
2459
+
2460
+ @staticmethod
2461
+ def _normalize_path(path: str) -> str:
2462
+ """Strip route constraint suffixes: {id:int} → {id}, {slug:regex(...)} → {slug}."""
2463
+ return re.sub(r"\{([^}:]+):[^}]+\}", r"{\1}", path)
2464
+
2465
+ def _extract_response_status(self, dec: ParsedDecorator) -> int:
2466
+ for val in dec.positional_args:
2467
+ if isinstance(val, int):
2468
+ return val
2469
+ if isinstance(val, str):
2470
+ code = _STATUS_NAMES.get(val)
2471
+ if code:
2472
+ return code
2473
+ try:
2474
+ return int(val)
2475
+ except ValueError:
2476
+ pass
2477
+ for key in ("StatusCode", "statusCode", "Type", "type"):
2478
+ val = dec.arguments.get(key)
2479
+ if val:
2480
+ n = _STATUS_NAMES.get(str(val))
2481
+ if n:
2482
+ return n
2483
+ return 200
2484
+
2485
+
2486
+ def _method_is_public_csharp(method: ParsedFunction) -> bool:
2487
+ """
2488
+ Best-effort access-modifier detection from the method's signature
2489
+ line. `ParsedFunction` doesn't track `access_modifier` for methods
2490
+ (only `ParsedField` does), so we read the source file at
2491
+ `method.location` and look for the `public` keyword on the
2492
+ signature line.
2493
+
2494
+ `method.location.line` may point at the topmost decorator line
2495
+ rather than the signature for attributed methods, so we scan
2496
+ forward for the first line containing `<MethodName>(`.
2497
+
2498
+ Returns True defensively when:
2499
+ - The location has no file path (in-memory `parse_source` with no
2500
+ `file_path=` — e.g. unit tests that construct fixtures inline).
2501
+ These tests have always behaved as if visibility were not
2502
+ enforced; preserve that.
2503
+ - The file isn't readable (network/permissions error).
2504
+ - The signature line can't be located within the next 15 lines.
2505
+
2506
+ Returns False only when an explicit `private`/`protected`/`internal`
2507
+ modifier is present, or when no modifier appears at all (C#
2508
+ in-class default is `private`).
2509
+ """
2510
+ if method.location is None or method.location.file is None:
2511
+ return True
2512
+ try:
2513
+ text = Path(method.location.file).read_text(
2514
+ encoding="utf-8",
2515
+ errors="replace",
2516
+ )
2517
+ except OSError:
2518
+ return True
2519
+ lines = text.splitlines()
2520
+ # Treat the unit-test in-memory case (file path looks synthetic /
2521
+ # virtual / nonexistent on disk) as "visibility unknown — include."
2522
+ if not lines:
2523
+ return True
2524
+ line_idx = method.location.line - 1
2525
+ if line_idx < 0:
2526
+ return True
2527
+ name_re = re.compile(rf"\b{re.escape(method.name)}\s*\(")
2528
+ for i in range(line_idx, min(len(lines), line_idx + 15)):
2529
+ if not name_re.search(lines[i]):
2530
+ continue
2531
+ pre_paren = lines[i].split("(", 1)[0]
2532
+ tokens = set(re.findall(r"\b\w+\b", pre_paren))
2533
+ if "public" in tokens:
2534
+ return True
2535
+ if tokens & {"private", "protected", "internal"}:
2536
+ return False
2537
+ return False # No explicit modifier — C# default is private
2538
+ return True # Couldn't locate signature line — defensive
2539
+
2540
+
2541
+ # =============================================================================
2542
+ # Registration
2543
+ # =============================================================================
2544
+
2545
+ _aspnet_plugin = AspNetCorePlugin()
2546
+ FrameworkPluginRegistry.register(_aspnet_plugin)