Skip to content

[http-client-csharp] Emit inline #pragma warning disable for [Experimental] members referenced from generated serialization code #11518

Description

@m-nash

Problem

When a generated model - or a single property on an otherwise GA model - is marked [Experimental("XXXX001")], the generated serialization code references that member and the experimental diagnostic fires inside src/Generated/Models/*.Serialization.cs. Because that code is generated, library authors cannot annotate it: any hand-added #pragma is wiped on the next regen.

Today the only escape hatch for Azure SDK partner teams is to hand-maintain an approved suppression in eng/analyzerallowlist/<Package>.txt in azure-sdk-for-net, listing every affected generated member by DocumentationCommentId. That is manual, brittle (the ids change whenever the spec adds a parameter), and provides no review value.

Real entries currently checked in - eng/analyzerallowlist/Azure.AI.Projects.Agents.txt:

nowarn:AAIP001 M:Azure.AI.Projects.Agents.ProjectsAgentDefinition.DeserializeProjectsAgentDefinition(System.Text.Json.JsonElement,System.ClientModel.Primitives.ModelReaderWriterOptions)
nowarn:AAIP001 M:Azure.AI.Projects.Agents.ToolboxTool.DeserializeToolboxTool(System.Text.Json.JsonElement,System.ClientModel.Primitives.ModelReaderWriterOptions)
nowarn:AAIP001 M:Azure.AI.Projects.Agents.ProjectsAgentVersionCreationOptions.JsonModelWriteCore(System.Text.Json.Utf8JsonWriter,System.ClientModel.Primitives.ModelReaderWriterOptions)
nowarn:AAIP001 M:Azure.AI.Projects.Agents.ProjectsAgentVersion.#ctor(System.Collections.Generic.IDictionary{System.String,System.String},System.String,System.String,System.String,System.String,System.String,System.DateTimeOffset,Azure.AI.Projects.Agents.ProjectsAgentDefinition,System.Nullable{System.Boolean},System.Nullable{Azure.AI.Projects.Agents.AgentVersionStatus},Azure.AI.Projects.Agents.AgentIdentity,Azure.AI.Projects.Agents.AgentIdentity,Azure.AI.Projects.Agents.AgentBlueprintReference,System.String,System.Collections.Generic.IDictionary{System.String,System.BinaryData})

eng/analyzerallowlist/Azure.AI.Projects.txt spells out exactly why the workaround exists:

File-scoped pragmas would be wiped on every codegen regen of the ~27 affected generated files and add no review value.

The generator is the only party that knows both that a member is [Experimental] (it emits the attribute) and that the generated serialization code references it. It should emit the suppression itself.

Why suppressing here is safe

The [Experimental] diagnostic exists so consumers explicitly opt into a preview surface. Suppressing it inside a model's own serialization plumbing leaks nothing: an end user still gets the full diagnostic the moment they touch the experimental type, property, or model factory method. The suppression is scoped to code the user cannot call directly and did not write.

Cases to cover

Using Azure.AI.Projects.Agents as the reference:

1. Discriminated-union deserializer dispatching to experimental derived types

ProjectsAgentDefinition is GA; HostedAgentDefinition and WorkflowAgentDefinition carry [Experimental("AAIP001")]. The generated base deserializer references them:

internal static ProjectsAgentDefinition DeserializeProjectsAgentDefinition(JsonElement element, ModelReaderWriterOptions options)
{
    ...
    switch (discriminator.GetString())
    {
        case "hosted":
            return HostedAgentDefinition.DeserializeHostedAgentDefinition(element, options);     // AAIP001
        case "workflow":
            return WorkflowAgentDefinition.DeserializeWorkflowAgentDefinition(element, options); // AAIP001
    }
    ...
}

2. JsonModelWriteCore / DeserializeX touching an experimental property on a GA model

ProjectsAgentVersionCreationOptions is GA, but the generator emits [Experimental("AAIP001")] on its Draft property:

[Experimental("AAIP001")]
public bool? Draft { get; set; }

and then references it unguarded from the generated writer:

if (Optional.IsDefined(Draft))              // AAIP001
{
    writer.WritePropertyName("draft"u8);
    writer.WriteBooleanValue(Draft.Value);  // AAIP001
}

3. The internal serialization constructor

The deserialization #ctor takes experimental-typed parameters and/or assigns experimental properties, so it fires as well - see the ProjectsAgentVersion.#ctor entry above.

Worth covering in the same pass: PersistableModelCreateCore / JsonModelCreateCore, the XML and multipart serialization variants, and the model factory, wherever they reference experimental symbols.

Proposal

Emit the suppression at the tightest stable scope: around the individual property-handling statements inside the generated serialization/deserialization bodies, not around the whole member. The generator builds these statements per property, so at the point each statement is constructed it already holds the PropertyProvider / ModelProvider whose [Experimental(id)] causes the diagnostic.

No new infrastructure is needed - SuppressionStatement already accepts an arbitrary MethodBodyStatement as its inner node:

public SuppressionStatement(MethodBodyStatement? inner, ValueExpression code, string justification)

Serializer - JsonModelWriteCore

MrwSerializationTypeDefinition.CreateWritePropertyStatement is called once per property from CreateWritePropertiesStatements. Wrap its result when that property carries [Experimental(id)]:

#pragma warning disable AAIP001 // Draft is experimental and may change in future versions.
if (Optional.IsDefined(Draft))
{
    writer.WritePropertyName("draft"u8);
    writer.WriteBooleanValue(Draft.Value);
}
#pragma warning restore AAIP001 // Draft is experimental and may change in future versions.

Every other property in JsonModelWriteCore keeps the analyzer live.

Serialization constructor

The generated deserializer reads into locals (bool? draft = default;), so an experimental property does not actually fire inside DeserializeX - it fires on the assignment in the serialization constructor, which is a single statement:

internal ProjectsAgentVersionCreationOptions(..., bool? draft, ...)
{
    Metadata = metadata;
    Description = description;
#pragma warning disable AAIP001 // Draft is experimental and may change in future versions.
    Draft = draft;
#pragma warning restore AAIP001 // Draft is experimental and may change in future versions.
    _additionalBinaryDataProperties = additionalBinaryDataProperties;
}

Discriminated-union deserializer

GetDiscriminatorSwitchCases builds one SwitchCaseStatement per derived model. Wrap only the arms whose derived model is experimental:

switch (discriminator.GetString())
{
#pragma warning disable AAIP001 // HostedAgentDefinition is experimental and may change in future versions.
    case "hosted":
        return HostedAgentDefinition.DeserializeHostedAgentDefinition(element, options);
#pragma warning restore AAIP001 // HostedAgentDefinition is experimental and may change in future versions.
    case "prompt":
        return DeclarativeAgentDefinition.DeserializeDeclarativeAgentDefinition(element, options);
}

Deserializer property blocks

BuildDeserializePropertiesStatements builds one if (prop.NameEquals("..."u8)) { ... } block per property. Wrap that block when the property's declared type is experimental (an experimental property alone does not fire here, per the locals note above).

Notes

  • Collect the distinct set of ids referenced by the wrapped statement; emit one disable/restore pair per id.
  • Skip emission when the containing type already carries the same [Experimental(id)] - the pragma would be redundant on a fully-experimental model.
  • SuppressionStatement.Write currently only appends a newline after Inner when Inner is AttributeStatement; reusing it for body statements may need a small formatting tweak.

This already exists for the MRW context - just not for serialization

ModelReaderWriterContextDefinition already does precisely this for [ModelReaderWriterBuildable] registrations, in generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelReaderWriterContextDefinition.cs:

if (experimentalOrObsoleteAttribute?.Type.Equals(typeof(ExperimentalAttribute)) == true)
{
    string justification = $"{typeProvider.Type} is experimental and may change in future versions.";
    attributes.Add(key, new SuppressionStatement(attributeStatement, experimentalOrObsoleteAttribute.Arguments[0], justification));
}

which produces this baseline (test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/ExperimentalModelsHaveAttributeSuppression.cs):

#pragma warning disable TEST001 // global::Sample.Models.ExperimentalModel is experimental and may change in future versions.
[global::System.ClientModel.Primitives.ModelReaderWriterBuildableAttribute(typeof(global::Sample.Models.ExperimentalModel))]
#pragma warning restore TEST001 // global::Sample.Models.ExperimentalModel is experimental and may change in future versions.

So this is extending an established, already-shipped pattern to MrwSerializationTypeDefinition (and its XML / multipart variants) rather than inventing a new one.

Building blocks already in the repo

  • generator/Microsoft.TypeSpec.Generator/src/Statements/SuppressionStatement.cs - wraps a statement in PragmaWarningDisableStatement / PragmaWarningRestoreStatement with a justification.
  • CodeWriter.WriteMethodDeclarationNoScope(MethodSignatureBase methodBase, params string[] disabledWarnings) already emits #pragma warning disable {disabledWarning} ahead of a method declaration (generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs).
  • ScmModelProvider / ScmMethodProviderCollection / ClientSettingsProvider already model experimental diagnostic ids (SCME0001, SCME0002, SCME0004, ...), so the plumbing for "this symbol is experimental with id X" is present.

Outcome

Partner teams stop hand-writing per-member allow-list entries for code they did not author. The suppression lives next to the code that needs it, regenerates automatically as the spec evolves, and stays invisible to end users - who continue to get the experimental warning at every public surface they actually touch.


🤖 m-nash-copilot

Metadata

Metadata

Assignees

No one assigned

    Labels

    emitter:client:csharpIssue for the C# client emitter: @typespec/http-client-csharpfeatureNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions