module Analyzer::CSharp::Common

Direct including types

Defined in:

analyzer/analyzers/csharp/common.cr

Constant Summary

ASPNET_CORE_NAMESPACE_RE = /\bMicrosoft\.AspNetCore\b/

ASP.NET Core and classic ASP.NET MVC 5 spell a controller almost identically — public class UserController : Controller with [HttpGet] action attributes — and both analyzers are handed every .cs file in the scan. In a solution holding both (a migration in progress, the shape noir gets pointed at) each read the other's controllers: 35 phantom cs_aspnet_core_mvc endpoints out of the classic fixture, 6 the other way, several with the wrong route template applied on the way out.

The namespaces are unmistakable and mutually exclusive — a type can only come from one of them — so each analyzer skips a file that names the other's. Nothing positive is required, so a helper file that names neither is still analyzed by both, exactly as before.

ASPNET_FRAMEWORK_NAMESPACE_RE = /\bSystem\.Web\.(?:Mvc|Http|Routing)\b/
CARTER_MODULE_RE = /\bI?CarterModule\b/

A Carter module is either class X : ICarterModule or class X : CarterModule — the latter being Carter's abstract base, which adds a constructor base path (: base("/directors")) and the request filters. Both shapes own their AddRoutes body, so the Carter analyzer claims them and the minimal-API analyzer skips them.

\bCarterModule\b alone does not match inside ICarterModule (I and C are both word characters), hence the explicit optional I.

EXPLICIT_BINDING_NAME_RE = /\[From(?:Query|Route|Body|Header|Form|Cookie)\s*\(\s*(?:[A-Za-z]+\s*(?:=|:)\s*)?@?"([^"]+)"/

[FromRoute(Name = "organizationId")] Guid sponsoringOrgId binds the route value organizationId; the C# identifier is only the local name. Reporting the identifier invents a parameter the client cannot send and hides the one it must.

KNOWN_SERVICE_TYPES = Set {"CancellationToken", "HttpContext", "HttpRequest", "HttpResponse", "ClaimsPrincipal", "IServiceProvider", "LinkGenerator", "ILoggerFactory", "IConfiguration", "IWebHostEnvironment", "IHostEnvironment"}

Concrete framework types that are always resolved from DI / the request pipeline rather than bound from user input.

SERVICE_FORM_INPUT_TYPES = Set {"IFormFile", "IFormFileCollection", "IFormCollection"}

IFormFile/IFormFileCollection/IFormCollection are interfaces but bind from the request body (file upload / form), not from DI — keep them as request inputs even though they match the interface rule below.

SERVICE_TYPE_SUFFIXES = ["Repository", "Service", "Services", "Manager", "Mediator", "Mapper", "Accessor", "Dispatcher", "Publisher", "DbContext", "Context", "Logger", "Handler", "DataSource"] of ::String

High-precision suffixes that mark a type as a dependency-injected collaborator. Deliberately conservative: suffixes that collide with common domain/entity names (e.g. Client, Provider, Factory) are left out so request DTOs aren't dropped by mistake. Interface-typed DI is caught separately by the I<Pascal> rule.

Handler and DataSource joined the list after a minimal-API sweep: Bitwarden injects AccessRequestEndpointsHandler handler straight into its handler delegates and Carter's sample injects EndpointDataSource, both of which surfaced as query parameters. Neither suffix names a request DTO in practice.

Class Method Summary

Class Method Detail

def self.aspnet_core_source?(content : String) : Bool #

[View source]
def self.aspnet_framework_source?(content : String) : Bool #

[View source]
def self.carter_module_source?(content : String) : Bool #

[View source]
def self.csharp_service_type?(type_name : String) : Bool #

Heuristic for whether a parameter's type names a dependency-injected service (DbContext, repository, MediatR sender, mapper, …) rather than a value bound from the request. ASP.NET Core can't be statically resolved against its DI container, so we lean on near-universal naming conventions in real code:

  • Interfaces (I + PascalCase) are never deserialized from a request body and never bound from the query string — they're DI or special pipeline types. The [a-z] third-char guard keeps acronym value types like IPAddress (I-P-A) out of the net.
  • A small set of concrete framework types and service suffixes.

Returns false for the form-upload interfaces, which are request inputs.


[View source]
def self.csharp_test_path?(relative_path : String) : Bool #

Standard .NET test-source conventions:

  • /test/ and /tests/ parent directories — Microsoft's own repos park unit + integration tests under src/<Project>/test/... (aspnetcore) or tests/... (smaller solutions).
  • /testassets/ — aspnetcore's helper-controller convention for spinning up a real server inside the test harness.
  • Tests.cs / Test.cs filename — xUnit / NUnit / MSTest suffix convention.

dotnet/aspnetcore alone parks ~3,600 phantom endpoints under src/Mvc/test/... and similar trees. Production code never adopts any of these. Takes the scan-base-relative path (Analyzer#base_relative_path), never the absolute one. The conventions describe a location inside the solution, so on an absolute path a test/ directory above the scan base suppressed the whole project.


[View source]
def self.explicit_binding_name(param_def : String) : String | Nil #

[View source]
def self.project_root_for(path : String, roots : Array(String)) : String | Nil #

The longest project root containing path, or nil when the file sits outside every discovered project (no .csproj in the scan at all, the shape most fixtures and single-file samples have).


[View source]
def self.project_roots(csproj_paths : Array(String)) : Array(String) #

Directories that own a .csproj, longest first. A .NET project is the unit that owns a routing table: MapControllerRoute in one project's Program.cs says nothing about a controller compiled into a sibling project, even though both sit under one configured scan base.


[View source]
def self.route_placeholder_name(raw : String) : String #

Strips a route-template placeholder down to its bare parameter name: {id:int}id, {slug?}slug, {*catchAll}catchAll, {id=5}id (a default value), {**slug:regex(a=b)}slug.

The =default form was previously kept verbatim, so a template like /items/{id=5} emitted a parameter literally named id=5.


[View source]