module

Analyzer::CSharp::Common

Constants

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/
ATTRIBUTE_LIST_RE = /\[(?=\s*[A-Za-z_@])[^\]]*\]\s*/

An attribute list ([FromBody], [property: JsonPropertyName("x")]), not an array rank (int[], int[,]).

AUTO_PROPERTY_RE = /public\s+(?:required\s+|virtual\s+|override\s+|static\s+|readonly\s+)*[\w\?<>\[\],\s\.]+?\s+(\w+)\s*\{\s*get;/

A public auto-property (public string Name { get; set; }), its name in group 1 — how request DTOs declare their bindable fields.

BASE_AFTER_NAME_RE = /\A\s*(?:<(?:[^<>]|<[^<>]*>)*>)?\s*(?:\((?:[^()]|\([^()]*\))*\))?\s*:\s*(?:global::)?([A-Za-z_][\w.]*)/

After a declared type's name: its generic parameters and primary constructor / positional-record parameters, then : and the first base-list entry, which for a class or record is its base class.

BINDER_FIXED_KEY_RE = /\bValueProvider\s*\.\s*GetValue\s*\(\s*"([^"]+)"\s*\)/
BINDER_PARAM_NAME_READ_RE = /\b(?:ModelName|FieldName)\b/

Request access in a model binder's source: the value posted under the parameter's own name, a value provider, or the raw request.

BINDER_REQUEST_READ_RE = /\bValueProvider\b|\bRequest\s*\./
BINDER_TYPEOF_RE = /\btypeof\s*\(\s*([\w.]+)\s*\)/
BINDING_ATTRIBUTES = {"FromQuery" => "query", "FromRoute" => "path", "FromBody" => "json", "FromHeader" => "header", "FromForm" => "form", "FromServices" => "service", "FromKeyedServices" => "service"}
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.

CLASS_BASE_RE = /\bclass\s+\w+\s*(?:<[^<>{}]*>)?\s*:\s*(?:global::)?([A-Za-z_][\w.]*)/
EMPTY_STRING_VALUE_RE = /\A(?:global::)?(?:System\.)?(?:String|string)\.Empty\z/
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.

IDENTIFIER_VALUE_RE = /\A[A-Za-z_]\w*\z/
INTERPOLATED_PREFIX_RE = /\A(?:\$@?|@\$)"/
JSON_COLLECTION_BODY_RE = /\A(?:System\.Collections\.Generic\.)?(?:IEnumerable|ICollection|IList|List|IReadOnlyList|IReadOnlyCollection|ISet|HashSet)<\s*([A-Za-z_][\w.]*)\s*\??\s*>\z|\A([A-Za-z_][\w.]*)\??\[\]\z/

A one-element collection type a JSON array deserializes into, or an array of one: the element type is group 1 or 2.

JSON_IGNORE_RE = /\bJsonIgnore(?:Attribute)?\s*(?:[\],]|\(\s*\))|JsonIgnoreCondition\.Always/

A [JsonIgnore] that also skips the member on read (Condition = WhenWritingNull only affects writing).

JSON_PROPERTY_NAME_RE = /\bJsonPropertyName(?:Attribute)?\s*\(\s*"([^"]+)"/
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.

MAX_BASE_DEPTH = 8

Deepest base-class chain followed.

MAX_SIGNATURE_LINES = 256

Longest method signature build_signature reads.

MAX_STRING_VALUE_DEPTH = 6

---- compile-time string values ----------------------------------------

Route templates and area names are often not literals at the call site: pattern: $"{lang}/login/", [Area(AreaNames.ADMIN)], $"{{{RouteValue.SeName}}}". csharp_string_value reads the value an expression has when it is fixed by the source: a literal, an interpolation of readable holes, a local or const/static string, a Type.Member constant the scan declares, or a parameterless string method's single return (or an empty fall-through after setting-gated early returns). Anything else (a parameter, a concatenation, a ternary, a call with arguments) is nil, and the caller keeps its old behaviour.

MAX_STRING_VALUE_SIZE = 1024
MAX_STRING_VALUE_STEPS = 64
MEMBER_ACCESS_VALUE_RE = /\A((?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*)\.([A-Za-z_]\w*)\z/
MEMBER_BLOCK_RE = /\b\w+\s*(?:<[^<>{}();]*>)?\s*\(([^{};]*)\)\s*(?:where\b[^{};]*)?\z/
MODEL_BINDER_TYPEOF_RE = /\bModelBinder(?:Attribute)?\s*\([^\]]*?\btypeof\s*\(\s*([\w.]+)\s*\)/
NAMEOF_VALUE_RE = /\Anameof\s*\(\s*(?:[\w.]+\.)?(\w+)\s*\)\z/
NAMESPACE_DECL_RE = /^\s*namespace\s+([\w.]+)/m

A file's namespace X.Y; / namespace X.Y { and its using X.Y; imports (an alias or using static imports no namespace).

PARAM_ATTRIBUTE_NAME_RE = /[\[,]\s*([A-Z]\w*)\s*(?=[\(\],])/
PARAM_LIST_CLAUSES = Set {"where", "As", "Handles", "Implements"}
PARAMETERLESS_CALL_VALUE_RE = /\A(?:this\.|base\.)?([A-Za-z_]\w*)\s*\(\s*\)\z/
PUBLIC_PROPERTY_RE = /((?:\[[^\]]*\]\s*)*)(?:public|internal)\s+(?:(?:required|virtual|override|new)\s+)*((?:[\w<>\[\]\?\.]|,\s*)+)\s+([A-Za-z_]\w*)\s*(\{)\s*(?:get|init|set)\b/

A public property with the attributes above it ([JsonIgnore], [FromHeader(Name = "X-Tenant")]): {attributes, type, name, "{"}, the last group marking where the accessor list opens.

PUBLIC_SETTER_RE = /(?<!private |protected |internal )\b(?:set|init)\b/

A public set/init accessor: what a binder or serializer writes.

SERIALIZER_INCLUDE_RE = /\b(?:JsonInclude|JsonProperty|DataMember)(?:Attribute)?\s*[\](,]/

Opts a member without a public setter into deserialization.

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", "Db"] 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. Db is the DbContext naming of Microsoft's own minimal-API samples (TodoDb : DbContext, injected as TodoDb db).

STATEMENT_BLOCK_RE = /\b(?:if|else|for|foreach|while|do|switch|using|lock|catch|try|finally|fixed|checked|unchecked)\s*(?:\([^{};]*\))?\s*\z|=>\s*\z/

A block opener that is not a member body: locals of the enclosing member stay visible inside it.

STRING_LITERAL_VALUE_RE = /\A"((?:[^"\\\n]|\\.)*)"\z/
TYPE_DECLARATION_RE = /\b(?:class|record|struct)\s+(?:class\s+|struct\s+)?([A-Z]\w*)/

A type declaration's name, for the cross-file type index.

USING_DECL_RE = /^\s*(?:global\s+)?using\s+([\w.]+)\s*;/m
VERBATIM_LITERAL_VALUE_RE = /\A@"((?:[^"]|"")*)"\z/

Class methods

aspnet_core_source?(content : String) : Bool
Source
aspnet_framework_source?(content : String) : Bool
Source
binding_attribute_type(param_def : String) : String | Nil

The request source a [FromX] attribute binds a parameter from.

Source
carter_module_source?(content : String) : Bool
Source
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.

Source
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 (.vb alike) — 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.

Source
explicit_binding_name(param_def : String) : String | Nil
Source
json_camel_case(name : String) : String

The JSON camelCase naming policy (System.Text.Json and Json.NET agree): lower the leading run of capitals, except the one that starts the next word, so OTP → otp, URLValue → urlValue, Id → id.

Source
member_name(member_def : String) : String

The C# name a member definition declares ([Attr] string Name = "x").

Source
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).

Source
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.

Source
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.

Source

Nested types