Diagnostics and Compile Time Validation - Garume/Manifold GitHub Wiki

Diagnostics and Compile-Time Validation

The Manifold source generator performs compile-time validation of all [Operation]-attributed code. When it detects invalid or ambiguous operation definitions, it emits diagnostics with the DMCF prefix that surface as compiler errors. These diagnostics prevent malformed operations from producing incorrect generated code, catching configuration mistakes at build time rather than at runtime.

All five diagnostic rules (DMCF001–DMCF005) are defined and enforced within OperationDescriptorGenerator in the Manifold.Generators package. They are organized into three categories: visibility conflicts, parameter binding errors, and class-based operation structural requirements. This page serves as the authoritative reference for each rule, its trigger conditions, and how to resolve violations. For attribute usage details, see Attributes and Operation Definition. For the broader generator architecture, see Source Generator — Manifold.Generators.

Diagnostic Rules Summary

Rule ID Title Applies To Severity Category
DMCF001 Conflicting CLI and MCP visibility Static methods, classes Error Manifold
DMCF002 Conflicting parameter binding Method parameters, request properties Error Manifold
DMCF003 Unsupported parameter binding Method parameters only Error Manifold
DMCF004 Unsupported operation class Class-based operations Error Manifold
DMCF005 Unsupported request property binding Request type properties Error Manifold

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:27–61

Validation Pipeline Architecture

The diagnostic validation is integrated into the incremental source generator pipeline. The generator analyzes each [Operation]-attributed symbol, collecting diagnostics alongside valid operation candidates. Diagnostics are reported before any code is emitted, ensuring that invalid operations never produce generated artifacts.

flowchart TD
    A["[Operation] Attributed Symbol"] --> B{"Symbol Kind?"}
    B -->|Method| C["CreateMethodCandidate"]
    B -->|Class| D["CreateClassCandidate"]
    C --> E["Validate Visibility\nDMCF001"]
    D --> F["Validate Visibility\nDMCF001"]
    E --> G["Validate Parameters\nDMCF002, DMCF003"]
    F --> H["Validate Interface\nDMCF004"]
    H --> I["Validate Properties\nDMCF002, DMCF005"]
    G --> J{"Any Diagnostics?"}
    I --> J
    J -->|Yes| K["Report via\nReportDiagnostic"]
    J -->|No| L["Emit Generated Code"]
    K --> M["Build Error"]
Loading

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:78–91

Analysis Flow

The CreateCandidate method dispatches based on symbol kind:

private static OperationAnalysisResult CreateCandidate(
    GeneratorAttributeSyntaxContext context,
    CancellationToken cancellationToken)
{
    AttributeData operationAttribute = context.Attributes[0];
    return context.TargetSymbol switch
    {
        IMethodSymbol methodSymbol => CreateMethodCandidate(context, methodSymbol, operationAttribute, cancellationToken),
        INamedTypeSymbol typeSymbol => CreateClassCandidate(context, typeSymbol, operationAttribute, cancellationToken),
        _ => new OperationAnalysisResult(null, [])
    };
}

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:78–91

Diagnostic Reporting

All collected diagnostics are reported in the Execute method before any source generation occurs. If any diagnostics exist for an operation, no candidate is produced for that operation — effectively excluding it from code generation.

foreach (OperationAnalysisResult analysisResult in collectedCandidates)
{
    foreach (OperationDiagnostic diagnostic in analysisResult.Diagnostics)
        context.ReportDiagnostic(Diagnostic.Create(
            diagnostic.Descriptor, diagnostic.Location, diagnostic.MessageArgs));

    if (analysisResult.Candidate is not null)
        operations.Add(analysisResult.Candidate);
}

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:620–633

DMCF001 — Conflicting CLI and MCP Visibility

Message: Operation '{0}' cannot be marked with both [CliOnly] and [McpOnly]

Description

An operation cannot be simultaneously restricted to CLI-only and MCP-only surfaces. The [CliOnly] and [McpOnly] attributes are mutually exclusive — an operation must be visible on both surfaces (the default), restricted to one, or hidden entirely via the Hidden property on [Operation].

Trigger Conditions

This diagnostic is emitted when both [CliOnly] and [McpOnly] attributes are present on:

  • A static method decorated with [Operation]
  • A class decorated with [Operation]

Validation Logic

Both CreateMethodCandidate and CreateClassCandidate perform the same check:

bool hasCliOnly = HasAttribute(methodSymbol, CliOnlyAttributeMetadataName);
bool hasMcpOnly = HasAttribute(methodSymbol, McpOnlyAttributeMetadataName);
if (hasCliOnly && hasMcpOnly)
{
    diagnosticBuilder.Add(new OperationDiagnostic(
        ConflictingVisibilityDescriptor,
        GetBestLocation(methodSymbol),
        [methodSymbol.Name]));
}

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:101–109, Manifold.Generators/OperationDescriptorGenerator.cs:167–175

Example — Invalid Code

[Operation("sample.conflict")]
[CliOnly]
[McpOnly]
public static string Conflict([Argument(0)] string name) => name;
// Error DMCF001: Operation 'Conflict' cannot be marked with both [CliOnly] and [McpOnly]

Resolution

Remove one of the two attributes. Use [CliOnly] to restrict the operation to the CLI surface, or [McpOnly] to restrict it to the MCP surface. To hide an operation from both surfaces, use [Operation("id", Hidden = true)] instead.

Sources: Manifold.Generators.Tests/OperationDescriptorGeneratorDiagnosticsTests.cs:11–30

DMCF002 — Conflicting Parameter Binding

Message: Parameter '{0}' on operation '{1}' cannot be marked with both [Option] and [Argument]

Description

A parameter or request property cannot be bound as both an option (named --flag style) and a positional argument simultaneously. Each parameter must use exactly one binding strategy.

Trigger Conditions

This diagnostic is emitted when both [Option] and [Argument] attributes are present on:

  • A method parameter in a static-method operation
  • A property on a request type in a class-based operation

Validation Logic

For method parameters (CreateParameterCandidate):

AttributeData? optionAttribute = GetAttribute(parameterSymbol, OptionAttributeMetadataName);
AttributeData? argumentAttribute = GetAttribute(parameterSymbol, ArgumentAttributeMetadataName);

if (optionAttribute is not null && argumentAttribute is not null)
{
    return ParameterAnalysisResult.FromDiagnostic(
        new OperationDiagnostic(
            ConflictingParameterBindingDescriptor,
            GetBestLocation(parameterSymbol),
            [parameterSymbol.Name, operationName]));
}

The same check is performed for request type properties in CreatePropertyCandidate.

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:295–305, Manifold.Generators/OperationDescriptorGenerator.cs:360–370

Example — Invalid Code

[Operation("sample.conflict-parameter")]
public static string Conflict([Option("name")][Argument(0)] string name) => name;
// Error DMCF002: Parameter 'name' on operation 'Conflict' cannot be marked
//                with both [Option] and [Argument]

Resolution

Choose either [Option("name")] for named binding or [Argument(0)] for positional binding. See Parameter Binding and Type Conversion for guidance on which to use.

Sources: Manifold.Generators.Tests/OperationDescriptorGeneratorDiagnosticsTests.cs:33–50

DMCF003 — Unsupported Parameter Binding

Message: Parameter '{0}' on operation '{1}' must be bound with [Option], [Argument], [FromServices], or be a CancellationToken

Description

Every parameter on a static-method operation must declare how it receives its value. The generator requires each parameter to be one of:

  • A named option via [Option]
  • A positional argument via [Argument]
  • A service injection via [FromServices]
  • A CancellationToken (recognized automatically)

A bare, unattributed parameter is ambiguous and cannot be bound by either CLI parsing or MCP JSON deserialization.

Trigger Conditions

This diagnostic is emitted when a method parameter has none of [Option], [Argument], [FromServices], and is not a CancellationToken.

Validation Logic

if (!isCancellationToken && !hasFromServices && optionAttribute is null && argumentAttribute is null)
{
    return ParameterAnalysisResult.FromDiagnostic(
        new OperationDiagnostic(
            UnsupportedParameterBindingDescriptor,
            GetBestLocation(parameterSymbol),
            [parameterSymbol.Name, operationName]));
}

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:307–314

Example — Invalid Code

[Operation("sample.unbound")]
public static string Unbound(string name) => name;
// Error DMCF003: Parameter 'name' on operation 'Unbound' must be bound
//                with [Option], [Argument], [FromServices], or be a CancellationToken

Resolution

Add one of the required binding attributes:

// As a named option:
public static string Unbound([Option("name")] string name) => name;

// As a positional argument:
public static string Unbound([Argument(0)] string name) => name;

// As an injected service:
public static string Unbound([FromServices] IMyService service) => service.GetName();

Note that this diagnostic applies only to static-method operations. Class-based operations bind parameters through request type properties, which are validated separately. See Dependency Injection and Service Resolution for [FromServices] usage.

Sources: Manifold.Generators.Tests/OperationDescriptorGeneratorDiagnosticsTests.cs:53–70

DMCF004 — Unsupported Operation Class

Message: Operation class '{0}' must implement IOperation<TRequest, TResult> and expose ExecuteAsync(TRequest, OperationContext)

Description

A class decorated with [Operation] must follow the class-based operation contract. This requires two conditions:

  1. The class must implement IOperation<TRequest, TResult>
  2. The class must expose a non-static ExecuteAsync(TRequest, OperationContext) method

This diagnostic catches classes that are decorated as operations but do not conform to the expected shape.

Trigger Conditions

DMCF004 is emitted in three scenarios within CreateClassCandidate:

  1. Missing interface implementation — the class does not implement IOperation<TRequest, TResult>
  2. Missing ExecuteAsync method — the class implements the interface but lacks the expected method signature
  3. Non-named request type — the request type argument is not an INamedTypeSymbol (fallback case)
flowchart TD
    A["Class with [Operation]"] --> B{"Implements\nIOperation?"}
    B -->|No| C["DMCF004:\nMissing interface"]
    B -->|Yes| D{"Has ExecuteAsync\nmethod?"}
    D -->|No| E["DMCF004:\nMissing method"]
    D -->|Yes| F{"Request type is\nINamedTypeSymbol?"}
    F -->|No| G["DMCF004:\nInvalid request type"]
    F -->|Yes| H["Validate Properties"]
Loading

Validation Logic

The generator performs two sequential checks:

// Check 1: Interface implementation
INamedTypeSymbol? operationInterface = typeSymbol.AllInterfaces.FirstOrDefault(
    static candidate => string.Equals(
        candidate.OriginalDefinition.ToDisplayString(),
        OperationInterfaceMetadataName, StringComparison.Ordinal));
if (operationInterface is null)
{
    diagnosticBuilder.Add(new OperationDiagnostic(
        UnsupportedOperationClassDescriptor,
        GetBestLocation(typeSymbol),
        [typeSymbol.Name]));
    return new OperationAnalysisResult(null, diagnosticBuilder.ToImmutable());
}

// Check 2: ExecuteAsync method
IMethodSymbol? executeMethod = FindExecuteMethod(typeSymbol, requestType);
if (executeMethod is null)
{
    diagnosticBuilder.Add(new OperationDiagnostic(
        UnsupportedOperationClassDescriptor,
        GetBestLocation(typeSymbol),
        [typeSymbol.Name]));
    return new OperationAnalysisResult(null, diagnosticBuilder.ToImmutable());
}

The FindExecuteMethod helper looks for a specific method shape:

private static IMethodSymbol? FindExecuteMethod(
    INamedTypeSymbol typeSymbol, ITypeSymbol requestType)
{
    return typeSymbol.GetMembers("ExecuteAsync")
        .OfType<IMethodSymbol>()
        .FirstOrDefault(methodSymbol =>
            !methodSymbol.IsStatic &&
            methodSymbol.Parameters.Length == 2 &&
            SymbolEqualityComparer.Default.Equals(
                methodSymbol.Parameters[0].Type, requestType) &&
            string.Equals(
                methodSymbol.Parameters[1].Type.ToDisplayString(),
                OperationContextMetadataName, StringComparison.Ordinal));
}

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:177–197, Manifold.Generators/OperationDescriptorGenerator.cs:250–254, Manifold.Generators/OperationDescriptorGenerator.cs:257–266

Example — Invalid Code

[Operation("sample.invalid-class")]
public sealed class InvalidOperation
{
    // Missing IOperation<TRequest, TResult> implementation
    // Missing ExecuteAsync method
}
// Error DMCF004: Operation class 'InvalidOperation' must implement
//                IOperation<TRequest, TResult> and expose ExecuteAsync(TRequest, OperationContext)

Resolution

Implement the IOperation<TRequest, TResult> interface and provide the ExecuteAsync method:

[Operation("sample.valid-class")]
public sealed class ValidOperation : IOperation<ValidOperation.Request, string>
{
    public ValueTask<string> ExecuteAsync(Request request, OperationContext context)
    {
        return ValueTask.FromResult(request.Name);
    }

    public sealed class Request
    {
        [Option("name")]
        public string Name { get; init; } = "";
    }
}

See Core Contracts — Manifold Package for the IOperation interface definition.

Sources: Manifold.Generators.Tests/OperationDescriptorGeneratorDiagnosticsTests.cs:73–86

DMCF005 — Non-Writable Request Property

Message: Property '{0}' on request type '{1}' for operation '{2}' must be writable with a public init or set accessor

Description

In class-based operations, the generator populates request type properties by setting their values during parameter binding. Properties decorated with [Option] or [Argument] must therefore be writable — they must have a public set or init accessor. Read-only properties (expression-bodied, get-only) cannot be populated by the generated binding code.

Trigger Conditions

This diagnostic is emitted when a request type property decorated with [Option] or [Argument] either:

  • Has no setter at all (e.g., expression-bodied => "value" or { get; })
  • Has a non-public setter (e.g., { get; private set; })

Validation Logic

if (propertySymbol.SetMethod is null ||
    propertySymbol.SetMethod.DeclaredAccessibility != Accessibility.Public)
{
    return ParameterAnalysisResult.FromDiagnostic(
        new OperationDiagnostic(
            UnsupportedRequestPropertyBindingDescriptor,
            GetBestLocation(propertySymbol),
            [propertySymbol.Name, requestTypeSymbol.Name, operationName]));
}

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:372–379

Example — Invalid Code

[Operation("sample.invalid-request")]
public sealed class InvalidRequestOperation : IOperation<InvalidRequestOperation.Request, string>
{
    public ValueTask<string> ExecuteAsync(Request request, OperationContext context)
        => ValueTask.FromResult(request.Name);

    public sealed class Request
    {
        [Option("name")]
        public string Name => "blocked";  // Expression-bodied, no setter
    }
}
// Error DMCF005: Property 'Name' on request type 'Request' for operation
//                'InvalidRequestOperation' must be writable with a public init or set accessor

Resolution

Add a public init or set accessor to the property:

public sealed class Request
{
    [Option("name")]
    public string Name { get; init; } = "";  // init accessor — preferred

    // Or: public string Name { get; set; } = "";
}

Sources: Manifold.Generators.Tests/OperationDescriptorGeneratorDiagnosticsTests.cs:89–112

Diagnostic Applicability by Operation Style

Not all diagnostics apply to both operation authoring styles. The following table shows which diagnostics can fire for static-method operations versus class-based operations:

Rule Static-Method Operations Class-Based Operations
DMCF001 Yes — on the method Yes — on the class
DMCF002 Yes — on method parameters Yes — on request properties
DMCF003 Yes — on method parameters No — not applicable
DMCF004 No — not applicable Yes — on the class
DMCF005 No — not applicable Yes — on request properties
flowchart TD
    subgraph SM["Static-Method Operations"]
        SM1["DMCF001\nVisibility"]
        SM2["DMCF002\nParam Binding"]
        SM3["DMCF003\nUnbound Param"]
    end

    subgraph CB["Class-Based Operations"]
        CB1["DMCF001\nVisibility"]
        CB2["DMCF002\nProp Binding"]
        CB4["DMCF004\nMissing Interface"]
        CB5["DMCF005\nRead-Only Prop"]
    end

    A["[Operation] Attribute"] --> SM
    A --> CB
Loading

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:93–157, Manifold.Generators/OperationDescriptorGenerator.cs:159–255

Internal Data Structures

The diagnostic system uses three internal types to collect and transport diagnostic information through the generator pipeline.

OperationDiagnostic

Wraps a Roslyn DiagnosticDescriptor with a source location and message arguments for deferred reporting:

private sealed class OperationDiagnostic
{
    public DiagnosticDescriptor Descriptor { get; }
    public Location? Location { get; }
    public object?[] MessageArgs { get; }
}

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:2754–2769

OperationAnalysisResult

Pairs an optional valid OperationCandidate with any diagnostics discovered during analysis. When diagnostics are present, the candidate is null:

private sealed class OperationAnalysisResult
{
    public OperationCandidate? Candidate { get; }
    public ImmutableArray<OperationDiagnostic> Diagnostics { get; }
}

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:2678–2690

ParameterAnalysisResult

Represents either a successfully validated parameter or a diagnostic. Created via factory methods to enforce the mutual exclusion:

private sealed class ParameterAnalysisResult
{
    public ParameterCandidate? Candidate { get; }
    public ImmutableArray<OperationDiagnostic> Diagnostics { get; }

    public static ParameterAnalysisResult FromCandidate(ParameterCandidate candidate);
    public static ParameterAnalysisResult FromDiagnostic(OperationDiagnostic diagnostic);
}

Sources: Manifold.Generators/OperationDescriptorGenerator.cs:2730–2752

classDiagram
    class OperationAnalysisResult {
        +OperationCandidate? Candidate
        +ImmutableArray~OperationDiagnostic~ Diagnostics
    }
    class ParameterAnalysisResult {
        +ParameterCandidate? Candidate
        +ImmutableArray~OperationDiagnostic~ Diagnostics
        +FromCandidate()$ ParameterAnalysisResult
        +FromDiagnostic()$ ParameterAnalysisResult
    }
    class OperationDiagnostic {
        +DiagnosticDescriptor Descriptor
        +Location? Location
        +object?[] MessageArgs
    }
    OperationAnalysisResult --> OperationDiagnostic
    ParameterAnalysisResult --> OperationDiagnostic
Loading

Testing

All five diagnostic rules have dedicated unit tests in OperationDescriptorGeneratorDiagnosticsTests. Each test compiles a snippet of C# code through the generator and asserts that the expected diagnostic is emitted with the correct rule ID and message fragment.

Test Method Validates
Run_generates_diagnostic_for_conflicting_visibility DMCF001
Run_generates_diagnostic_for_conflicting_parameter_binding DMCF002
Run_generates_diagnostic_for_unbound_parameter DMCF003
Run_generates_diagnostic_for_invalid_operation_class_shape DMCF004
Run_generates_diagnostic_for_read_only_request_property DMCF005

The test infrastructure creates a CSharpCompilation with the test source and runs the generator via CSharpGeneratorDriver, then inspects GetRunResult().Diagnostics for the expected entries.

Sources: Manifold.Generators.Tests/OperationDescriptorGeneratorDiagnosticsTests.cs:1–139

Related Pages

⚠️ **GitHub.com Fallback** ⚠️