[LOADED_DOCS: 3 files, no new loads]

Add per-type EnablePolymorphDetectFeature flag

Replaces the global UsePolymorphType constant with a per-type EnablePolymorphDetectFeature flag on AcBinarySerializableAttribute. The source generator now emits or omits polymorphic type info for System.Object properties based on this flag, defaulting to enabled. Updates diagnostics, documentation, and SerializableClassInfo to support this feature, clarifying the risks of disabling it and improving attribute XML docs for all feature flags.
This commit is contained in:
Loretta 2026-05-15 09:00:18 +02:00
parent 89618c1d10
commit 67b04612a4
2 changed files with 76 additions and 58 deletions

View File

@ -18,32 +18,12 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
{
private const string AttributeName = "AyCode.Core.Serializers.Attributes.AcBinarySerializableAttribute";
// ────────────────────────────────────────────────────────────────────────────────────────────
// TEMPORARY (2026-05-08) — A/B test feature gates for hot-path overhead measurement.
//
// The generated SGen `WriteProperties` / `ScanObject` methods emit two kinds of overhead-blocks
// that are unconditionally present today but rarely exercised in typical workloads:
//
// 1. PropertyFilter guard (`UsePropertyFilter`) — every non-markerless property emit-site
// checks `context.HasPropertyFilter` + filter-context allocation + lambda-call.
// The benchmark workload never sets a property-filter → branch is always false →
// pure overhead (CPU cycles + i-cache pressure on the hot path).
//
// 2. Polymorphic object-with-type-name emit (`UsePolymorphType`) — `System.Object` declared
// properties emit `ObjectWithTypeName` marker + `WriteStringUtf8(AssemblyQualifiedName)`
// under `!context.UseMetadata`. Same: rarely used in typical DTO graphs.
//
// Setting either to `false` skips the corresponding emit at compile time → leaner generated
// code. The bench measures the actual delta vs MemPack apples-to-apples (which has neither
// of these features).
//
// Long-term: these flags will move to `[AcBinarySerializable(UsePropertyFilter = false, ...)]`
// attribute properties so consumers can opt out per type. Until then, keep both `false` for
// benchmark-vs-MemPack measurements; flip to `true` for production where the features are needed.
// ────────────────────────────────────────────────────────────────────────────────────────────
// UsePropertyFilter const removed — replaced by `[AcBinarySerializable(EnablePropertyFilterFeature = ...)]`
// attribute flag, propagated through SerializableClassInfo.EnablePropertyFilter to EmitProp/EmitScanProp.
private const bool UsePolymorphType = true;
// Feature gates on the SGen-emitted writer / scan code are driven by `[AcBinarySerializable]`
// attribute flags. Two such gates are wired through SerializableClassInfo:
// • EnablePropertyFilter → omits the per-property `HasPropertyFilter` branch when false.
// • EnablePolymorphDetect → omits the `ObjectWithTypeName + AQN` prefix on `System.Object`-
// declared properties when false (then ACBIN002 guards misuse).
// Both default `true`; opt-out is per type via the attribute ctor parameters.
private static readonly DiagnosticDescriptor CircularReferenceWarning = new(
id: "ACBIN001",
@ -56,26 +36,26 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
/// <summary>
/// ACCORE-BIN-I-T7K3 compile-time guard: a property declared as <c>System.Object</c> requires
/// polymorphic-prefix emit (<c>ObjectWithTypeName</c>) so the deserializer can resolve the
/// concrete runtime type. When <see cref="UsePolymorphType"/> is <c>false</c>, the prefix is
/// suppressed and the wire silently corrupts on round-trip (FixObj slot byte against
/// <c>typeof(object)</c> at read-time → 0-byte object wrapper → reader position drifts →
/// downstream <c>DECIMAL_DRIFT</c> / <c>IndexOutOfRangeException</c>).
/// concrete runtime type. When the type opts out of the feature via
/// <c>[AcBinarySerializable(enablePolymorphDetectFeature: false)]</c>, the prefix is suppressed
/// and the wire silently corrupts on round-trip (FixObj slot byte against <c>typeof(object)</c>
/// at read-time → 0-byte object wrapper → reader position drifts → downstream
/// <c>DECIMAL_DRIFT</c> / <c>IndexOutOfRangeException</c>).
///
/// Surface the misconfiguration at build time so the silent corruption is structurally
/// impossible. Three escape hatches for the developer:
/// 1. Enable the polymorphic feature (<see cref="UsePolymorphType"/> = true, or — once the
/// planned <c>[AcBinarySerializable(EnablePolymorphicFeature = true)]</c> flag lands — set
/// it on the type).
/// 1. Enable the polymorph-detect feature on the type
/// (<c>[AcBinarySerializable(...enablePolymorphDetectFeature: true)]</c> — default true).
/// 2. Change the property type to a concrete type (no polymorphism needed).
/// 3. Mark the property with <c>[AcBinaryIgnore]</c> — ignored properties are filtered
/// out at property enumeration, so this diagnostic does not fire for them.
/// 3. Mark the property with <c>[AcBinaryIgnore]</c> — ignored properties are filtered out
/// at property enumeration, so this diagnostic does not fire for them.
/// </summary>
private static readonly DiagnosticDescriptor PolymorphicPropertyWithFeatureDisabledError = new(
id: "ACBIN002",
title: "Polymorphic property requires polymorphic feature enabled",
messageFormat: "Type '{0}' contains property '{1}' declared as System.Object, but polymorphic serialization in the source generator is disabled (UsePolymorphType=false). " +
title: "Polymorphic property requires EnablePolymorphDetectFeature",
messageFormat: "Type '{0}' contains property '{1}' declared as System.Object, but EnablePolymorphDetectFeature is disabled on the type. " +
"The generated writer would silently corrupt the wire on round-trip. " +
"To fix: (1) enable polymorphic serialization, (2) change '{1}' to a concrete type, or (3) exclude it with [AcBinaryIgnore].",
"To fix: (1) enable EnablePolymorphDetectFeature on [AcBinarySerializable], (2) change '{1}' to a concrete type, or (3) exclude it with [AcBinaryIgnore].",
category: "AcBinarySerializer",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
@ -111,6 +91,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
var enableInternString = true;
var enableMetadata = true;
var enablePropertyFilter = true;
var enablePolymorphDetect = true;
var binarySerializableAttr = typeSymbol.GetAttributes().FirstOrDefault(a =>
a.AttributeClass?.ToDisplayString() == AttributeName);
if (binarySerializableAttr != null)
@ -124,10 +105,11 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
enableInternString = all;
enableMetadata = all;
enablePropertyFilter = all;
enablePolymorphDetect = all;
}
else if (binarySerializableAttr.ConstructorArguments.Length == 4)
{
// Four bool ctor: (metadata, idTracking, refHandling, internString) — filter defaults to true
// Four bool ctor: (metadata, idTracking, refHandling, internString) — filter + polymorph default to true
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
@ -135,13 +117,23 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
}
else if (binarySerializableAttr.ConstructorArguments.Length == 5)
{
// Five bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter)
// Five bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter) — polymorph defaults to true
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!;
}
else if (binarySerializableAttr.ConstructorArguments.Length == 6)
{
// Six bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter, polymorphDetect)
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!;
enablePolymorphDetect = (bool)binarySerializableAttr.ConstructorArguments[5].Value!;
}
}
foreach (var p in GetAllSerializablePropertySymbols(typeSymbol))
@ -423,7 +415,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
var typeNameHash = ComputeFnvHash(typeSymbol.Name);
var propertyNameHashes = properties.Select(prop => ComputeFnvHash(prop.Name)).ToArray();
var selfScanFlags = ComputeNeedsScan(typeSymbol);
return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, enableRefHandling, typeNameHash, propertyNameHashes, enableMetadata, enablePropertyFilter, selfScanFlags.needsIdScan, selfScanFlags.needsAllRefScan, selfScanFlags.needsInternScan);
return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, enableRefHandling, typeNameHash, propertyNameHashes, enableMetadata, enablePropertyFilter, enablePolymorphDetect, selfScanFlags.needsIdScan, selfScanFlags.needsAllRefScan, selfScanFlags.needsInternScan);
}
private static void Execute(ImmutableArray<SerializableClassInfo?> classes, SourceProductionContext context)
@ -447,15 +439,16 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
/// <summary>
/// ACCORE-BIN-I-T7K3 guard: emits <see cref="PolymorphicPropertyWithFeatureDisabledError"/>
/// (ACBIN002) for every <c>System.Object</c>-declared property on any
/// <c>[AcBinarySerializable]</c> type while <see cref="UsePolymorphType"/> is <c>false</c>.
/// Short-circuits when the feature is enabled — no per-property work needed.
/// <c>[AcBinarySerializable]</c> type whose <c>EnablePolymorphDetectFeature</c> is <c>false</c>.
/// Per-class gating: types with the feature enabled (default) skip the check entirely; only
/// opt-out types are scanned for misuse.
/// </summary>
private static void DetectAndReportPolymorphicMisuse(List<SerializableClassInfo> classes, SourceProductionContext spc)
{
if (UsePolymorphType) return; // Feature enabled → polymorphic prefix is emitted, no misuse possible.
foreach (var ci in classes)
{
if (ci.EnablePolymorphDetect) continue; // Feature enabled → polymorphic prefix is emitted, no misuse possible.
foreach (var p in ci.Properties)
{
if (p.IsObjectDeclaredType)
@ -603,7 +596,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
foreach (var p in ci.Properties)
{
sb.AppendLine();
EmitProp(sb, p, " ", ci.FullTypeName, ci.EnableMetadata, ci.EnablePropertyFilter);
EmitProp(sb, p, " ", ci.FullTypeName, ci.EnableMetadata, ci.EnablePropertyFilter, ci.EnablePolymorphDetect);
}
sb.AppendLine();
@ -757,7 +750,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
sb.AppendLine(" }");
}
private static void EmitProp(StringBuilder sb, PropInfo p, string i, string fullTypeName, bool enableMetadata, bool enablePropertyFilter)
private static void EmitProp(StringBuilder sb, PropInfo p, string i, string fullTypeName, bool enableMetadata, bool enablePropertyFilter, bool enablePolymorphDetect)
{
var a = $"obj.{p.Name}";
@ -825,14 +818,13 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
// System.Object property: runtime type unknown at compile time.
// Write ObjectWithTypeName prefix so deserializer can resolve the concrete type.
// Use value.GetType() for runtime type dispatch (not typeof(object)).
// Gated by `UsePolymorphType` (TEMPORARY const) — `false` skips the type-name emit
// entirely (deser will use the property's declared type, which is `object` so the
// round-trip would fail on polymorphic instances; safe ONLY when the workload is
// known not to use polymorphic object-typed properties — true for the benchmark).
// Gated by `[AcBinarySerializable(enablePolymorphDetectFeature: ...)]` — `false`
// skips the type-name emit entirely. Misuse (false + non-null object prop at runtime)
// is caught at build time by ACBIN002 in DetectAndReportPolymorphicMisuse.
sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);");
sb.AppendLine($"{i}else");
sb.AppendLine($"{i}{{");
if (UsePolymorphType)
if (enablePolymorphDetect)
{
sb.AppendLine($"{i} if (!context.UseMetadata)");
sb.AppendLine($"{i} {{");
@ -3065,6 +3057,12 @@ internal sealed class SerializableClassInfo
/// guard emission in WriteProperties / ScanObject. When false, the filter check is omitted entirely
/// → leaner generated code on the hot path (typical for high-throughput types that never use a filter).</summary>
public bool EnablePropertyFilter { get; }
/// <summary>True if EnablePolymorphDetectFeature is enabled — controls <c>ObjectWithTypeName</c> + AQN
/// prefix emit on <c>System.Object</c>-declared properties. When false, the prefix is suppressed
/// AND ACBIN002 fires at build time if such a property exists on this type (guarding against silent
/// wire corruption). Opt-out is intentional: dev guarantees no polymorphic <c>object</c> property
/// will be serialized on this type, or all such properties are excluded via <c>[AcBinaryIgnore]</c>.</summary>
public bool EnablePolymorphDetect { get; }
/// <summary>When true, type subtree has IId types needing scan (active in OnlyId + All).</summary>
public bool NeedsIdScan { get; }
/// <summary>When true, type subtree has non-IId ref tracking (active only in All mode).</summary>
@ -3075,8 +3073,8 @@ internal sealed class SerializableClassInfo
public bool NeedsRefScan => NeedsIdScan || NeedsAllRefScan;
/// <summary>Derived: any scan axis active.</summary>
public bool NeedsScan => NeedsIdScan || NeedsAllRefScan || NeedsInternScan;
public SerializableClassInfo(string ns, string cn, string ftn, List<PropInfo> p, bool isIId, string? idTypeName, bool enableRefHandling, int typeNameHash, int[] propertyNameHashes, bool enableMetadata, bool enablePropertyFilter, bool needsIdScan, bool needsAllRefScan, bool needsInternScan)
{ Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; EnableRefHandling = enableRefHandling; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; EnableMetadata = enableMetadata; EnablePropertyFilter = enablePropertyFilter; NeedsIdScan = needsIdScan; NeedsAllRefScan = needsAllRefScan; NeedsInternScan = needsInternScan; }
public SerializableClassInfo(string ns, string cn, string ftn, List<PropInfo> p, bool isIId, string? idTypeName, bool enableRefHandling, int typeNameHash, int[] propertyNameHashes, bool enableMetadata, bool enablePropertyFilter, bool enablePolymorphDetect, bool needsIdScan, bool needsAllRefScan, bool needsInternScan)
{ Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; EnableRefHandling = enableRefHandling; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; EnableMetadata = enableMetadata; EnablePropertyFilter = enablePropertyFilter; EnablePolymorphDetect = enablePolymorphDetect; NeedsIdScan = needsIdScan; NeedsAllRefScan = needsAllRefScan; NeedsInternScan = needsInternScan; }
}
internal sealed class PropInfo

View File

@ -4,12 +4,32 @@ namespace AyCode.Core.Serializers.Attributes;
/// <summary>
/// Marks a class or struct for Source Generator based binary serialization.
/// When applied, the Source Generator will create optimized serialize/deserialize methods
/// at compile time, eliminating the need for runtime reflection or compiled expressions.
/// When applied, the Source Generator creates optimized serialize/deserialize methods at compile
/// time, eliminating the need for runtime reflection or compiled expressions.
/// </summary>
/// <remarks>
/// If this attribute is not present, the serializer falls back to the existing
/// compiled expression based approach which works for all types.
/// <para>If this attribute is not present, the serializer falls back to the existing
/// compiled-expression-based approach which works for all types.</para>
///
/// <para><b>Feature flag semantics</b> — each <c>Enable*Feature</c> ctor parameter controls whether
/// the corresponding code-block is <i>emitted</i> by the source generator, NOT whether the feature
/// is <i>active</i> at runtime:</para>
/// <list type="bullet">
/// <item><b><c>true</c> (default):</b> the SGen-emitted writer / reader / scan code <i>includes</i>
/// the feature's branch. Runtime activation is then governed by the matching
/// <c>AcBinarySerializerOptions</c> setting (e.g. <c>UseStringInterning</c>,
/// <c>ReferenceHandling</c>, <c>PropertyFilter</c>, <c>UseMetadata</c>). The developer keeps the
/// choice per-call by setting the options accordingly.</item>
/// <item><b><c>false</c>:</b> the corresponding code-block is <i>omitted</i> from the generated
/// output entirely. Slightly leaner hot-path (fewer branches, less i-cache pressure on the JIT'd
/// bodies), but the feature is <i>permanently off</i> for this type regardless of runtime options.
/// A matching <c>AcBinarySerializerOptions</c> setting is silently ignored for this specific type.</item>
/// </list>
///
/// <para>Set a flag to <c>false</c> only when you can guarantee no consumer will ever need that
/// feature on this type (typical for high-throughput message DTOs where wire size and serialize
/// CPU dominate, and a feature like <c>PropertyFilter</c> is genuinely never used). Otherwise leave
/// it at the default <c>true</c>.</para>
/// </remarks>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
public sealed class AcBinarySerializableAttribute : Attribute