Public API Surface Contracts¶
Public API surface contracts declare the intended exported API surface of one or more assemblies — every public, protected, or protected internal type and member — and report any exported type or member that isn't declared. This governs the published boundary of a library, catching accidental exports before they reach a NuGet package, not general dependency direction between layers.
Groups:
strict_public_api_surfaceaudit_public_api_surface
Example¶
contracts:
strict_public_api_surface:
- id: core-public-api
name: core-public-api-declared
assemblies: [ArchLinterNet.Core]
declared_api:
- "class ArchLinterNet.Core.Foo"
- "ctor ArchLinterNet.Core.Foo()"
- "method ArchLinterNet.Core.Foo.Bar(System.Int32): System.Void"
- "property ArchLinterNet.Core.Foo.Name: System.String"
- "const ArchLinterNet.Core.Foo.Version: System.String"
forbid_public_constants_unless_declared: true
allowed_public_constants:
- "ArchLinterNet.Core.Foo.Version"
reason: Track the exact exported surface of Core before every NuGet release.
When to use¶
Use public API surface contracts for library assemblies you ship to consumers (NuGet packages, shared internal libraries), where an accidental public type or member is a silent breaking-change/compatibility risk:
- catch a type or member left
publicby mistake (missinginternal); - make public constants visible in code review before they're inlined into consumer binaries;
- give CI a deterministic gate on API surface growth ahead of a release.
This is not a substitute for .NET binary/package compatibility validation (detecting a breaking change to an already-declared member) — it only detects undeclared exported surface, additions that were never acknowledged.
Semantics¶
Declaring the exported surface¶
assemblies names one or more target assemblies; each name must be declared in analysis.target_assemblies, and a contract with an empty or missing assemblies list is rejected at policy load time. Both are policy load-time errors, not silent no-ops — an assembly name that doesn't resolve is never treated as "nothing to check for it."
declared_api is a list of normalized signature strings. The grammar is "<kind> <FullyQualifiedName>[(<param types>)][: <member type>]", where kind is one of class, interface, struct, enum, delegate, const, field, property, event, method, or ctor (records reflect as an ordinary class/struct; reflection cannot reliably distinguish a record from a hand-written type). Examples:
class MyApp.Fooctor MyApp.Foo(System.Int32)method MyApp.Foo.Bar(System.Int32, System.String): System.Voidproperty MyApp.Foo.Name: System.Stringfield MyApp.Foo.Count: System.Int32const MyApp.Foo.Version: System.Stringevent MyApp.Foo.Changed: System.EventHandler- Nested type:
class MyApp.Outer+Inner(CLR nested-type notation,+not.). - Generic type:
class MyApp.Box`1(arity comes from the CLR, same asType.FullName). - Generic method:
method MyApp.Foo.Map`1(!0): !!0— generic parameters are rendered positionally, not by their source name:!Nis the Nth type parameter of the declaring type,!!Nis the Nth type parameter of the declaring method. This means renaming a generic parameter alone never changes the declared signature. - Array rank is preserved:
int[]renders asSystem.Int32[],int[,]asSystem.Int32[,],int[,,]asSystem.Int32[,,]— each rank is a distinct signature.
Parameter, field, property, and return types are rendered via their CLR full name (e.g. System.Int32, not int) — this is a deterministic, own grammar, not an attempt at C#-idiomatic pretty-printing.
What counts as exported¶
A type is exported if it is public, or if it is protected/protected internal and every enclosing type in its nesting chain is itself exported. A protected nested type inside an internal outer type is unreachable from outside the assembly, so it is out of scope even though the modifier says "protected."
For an exported type, its own directly declared members (constructors, methods, properties, fields including const, and events) are in scope if they are public, protected, or protected internal. Compiler-generated members (property/event backing fields, get_/set_/add_/remove_ accessor methods — represented instead by the property/event itself) are excluded, as is an enum's synthesized value__ backing field (an enum's real exported surface is its literal members, e.g. const MyApp.Color.Red: MyApp.Color, not this CLR implementation detail). Members inherited from a base type are not re-reported against the derived type; they belong to the base type's own declared surface.
Undeclared surface¶
Any exported type or member whose normalized signature is not present in declared_api is a violation.
Public constants¶
By default (forbid_public_constants_unless_declared: false), an exported const field is treated exactly like any other member — undeclared is a violation, declared passes.
Setting forbid_public_constants_unless_declared: true adds a stricter, independent check: an exported const field is a violation unless its fully-qualified member name (e.g. MyApp.Foo.Version, no signature/type suffix) is listed in allowed_public_constants — even if its full signature is already present in declared_api. This matters because public constants are inlined by consumers at compile time, so acknowledging them in the general API-surface list is not the same as deliberately deciding to keep exposing one as a const (versus, say, a static readonly field).
Violations¶
Each violation identifies the contract, the declaring assembly, the declaring type, the normalized signature of the undeclared member or forbidden constant, the member's visibility (public, protected, or protected internal), and whether the violation reason is an undeclared exported member or a forbidden public constant. When a const field is simultaneously undeclared and fails the forbid_public_constants_unless_declared check, it is reported once, as a forbidden public constant (the stricter of the two reasons). ignored_violations entries use the same source_type/forbidden_reference/reason shape as other contract families, where forbidden_reference is the normalized signature string.
Scope: what's not covered here¶
- No detection of removed or changed declared signatures — only additions. This is not a substitute for full binary/package compatibility validation.
- No runtime dependency-injection resolution or semantic data-flow analysis.
- No automatic API-review approval or code-ownership enforcement.
- No automatic rewriting of source visibility.
- Reflection-based (like
protectedandtype_placement), not project-aware Roslyn compilation.