fix(pr269): 收口评审兼容性与生成器修复

- 恢复 EasyEvents、CollectionExtensions 与 logging 配置模型的公共 API 兼容形状

- 修复 ContextAwareGenerator 字段命名冲突、锁内读取路径与相关快照回归测试

- 更新 Cqrs 与 schema generator 的 null/cancellation 契约,并同步 ai-plan 跟踪与验证记录
This commit is contained in:
GeWuYou 2026-04-22 13:34:24 +08:00 committed by gewuyou
parent 6d4f9f2f94
commit 12f15961af
18 changed files with 382 additions and 142 deletions

View File

@ -96,6 +96,7 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
var interfaceName = iContextAware.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat);
var memberNames = CreateGeneratedContextMemberNames(symbol);
sb.AppendLine("/// <summary>");
sb.AppendLine("/// 为当前规则类型补充自动生成的架构上下文访问实现。");
sb.AppendLine("/// </summary>");
@ -107,15 +108,15 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
sb.AppendLine(
"/// 已缓存的实例上下文需要通过 <see cref=\"GFramework.Core.Abstractions.Rule.IContextAware.SetContext(GFramework.Core.Abstractions.Architectures.IArchitectureContext)\" /> 显式覆盖。");
sb.AppendLine(
"/// 与手动继承 <see cref=\"global::GFramework.Core.Rule.ContextAwareBase\" /> 的路径相比,生成实现会使用 <c>_gFrameworkContextAwareSync</c> 协调惰性初始化、provider 切换和显式上下文注入;");
$"/// 与手动继承 <see cref=\"global::GFramework.Core.Rule.ContextAwareBase\" /> 的路径相比,生成实现会使用 <c>{memberNames.SyncFieldName}</c> 协调惰性初始化、provider 切换和显式上下文注入;");
sb.AppendLine(
"/// <see cref=\"global::GFramework.Core.Rule.ContextAwareBase\" /> 则保持无锁的实例级缓存语义,更适合已经由调用方线程模型保证串行访问的简单场景。");
sb.AppendLine("/// </remarks>");
sb.AppendLine($"partial class {symbol.Name} : {interfaceName}");
sb.AppendLine("{");
GenerateContextProperty(sb);
GenerateInterfaceImplementations(sb, iContextAware);
GenerateContextProperty(sb, memberNames);
GenerateInterfaceImplementations(sb, iContextAware, memberNames);
sb.AppendLine("}");
return sb.ToString().TrimEnd();
@ -138,24 +139,29 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
/// 生成Context属性
/// </summary>
/// <param name="sb">字符串构建器</param>
private static void GenerateContextProperty(StringBuilder sb)
/// <param name="memberNames">当前目标类型应使用的上下文字段名。</param>
private static void GenerateContextProperty(
StringBuilder sb,
GeneratedContextMemberNames memberNames)
{
GenerateContextBackingFields(sb);
GenerateContextGetter(sb);
GenerateContextProviderConfiguration(sb);
GenerateContextBackingFields(sb, memberNames);
GenerateContextGetter(sb, memberNames);
GenerateContextProviderConfiguration(sb, memberNames);
}
/// <summary>
/// 生成上下文缓存和同步所需的字段。
/// </summary>
/// <param name="sb">字符串构建器。</param>
private static void GenerateContextBackingFields(StringBuilder sb)
private static void GenerateContextBackingFields(
StringBuilder sb,
GeneratedContextMemberNames memberNames)
{
sb.AppendLine(
" private global::GFramework.Core.Abstractions.Architectures.IArchitectureContext? _gFrameworkContextAwareContext;");
$" private global::GFramework.Core.Abstractions.Architectures.IArchitectureContext? {memberNames.ContextFieldName};");
sb.AppendLine(
" private static global::GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider? _gFrameworkContextAwareProvider;");
sb.AppendLine(" private static readonly object _gFrameworkContextAwareSync = new();");
$" private static global::GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider? {memberNames.ProviderFieldName};");
sb.AppendLine($" private static readonly object {memberNames.SyncFieldName} = new();");
sb.AppendLine();
}
@ -163,7 +169,9 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
/// 生成实例上下文访问器,包含显式注入优先和 provider 惰性回退语义。
/// </summary>
/// <param name="sb">字符串构建器。</param>
private static void GenerateContextGetter(StringBuilder sb)
private static void GenerateContextGetter(
StringBuilder sb,
GeneratedContextMemberNames memberNames)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// 获取当前实例绑定的架构上下文。");
@ -178,7 +186,7 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
sb.AppendLine(
" /// 或 <see cref=\"ResetContextProvider\" /> 不会自动清除此缓存;如需覆盖,请显式调用 <c>IContextAware.SetContext(...)</c>。");
sb.AppendLine(
" /// 当前实现还假设 <see cref=\"GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider.GetContext\" /> 可在持有 <c>_gFrameworkContextAwareSync</c> 时安全执行;");
$" /// 当前实现还假设 <see cref=\"GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider.GetContext\" /> 可在持有 <c>{memberNames.SyncFieldName}</c> 时安全执行;");
sb.AppendLine(
" /// 自定义 provider 不应在该调用链内重新进入当前类型的 provider 配置 API且应避免引入与外部全局锁相互等待的锁顺序。");
sb.AppendLine(" /// </remarks>");
@ -186,21 +194,15 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
sb.AppendLine(" {");
sb.AppendLine(" get");
sb.AppendLine(" {");
sb.AppendLine(" var context = _gFrameworkContextAwareContext;");
sb.AppendLine(" if (context is not null)");
sb.AppendLine(" {");
sb.AppendLine(" return context;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" // 在同一个同步域内协调懒加载与 provider 切换,避免读取到被并发重置的空提供者。");
sb.AppendLine(
" // provider 的 GetContext() 会在持有 _gFrameworkContextAwareSync 时执行;自定义 provider 必须避免在该调用链内回调 SetContextProvider/ResetContextProvider 或形成反向锁顺序。");
sb.AppendLine(" lock (_gFrameworkContextAwareSync)");
$" // provider 的 GetContext() 会在持有 {memberNames.SyncFieldName} 时执行;自定义 provider 必须避免在该调用链内回调 SetContextProvider/ResetContextProvider 或形成反向锁顺序。");
sb.AppendLine($" lock ({memberNames.SyncFieldName})");
sb.AppendLine(" {");
sb.AppendLine(
" _gFrameworkContextAwareProvider ??= new global::GFramework.Core.Architectures.GameContextProvider();");
sb.AppendLine(" _gFrameworkContextAwareContext ??= _gFrameworkContextAwareProvider.GetContext();");
sb.AppendLine(" return _gFrameworkContextAwareContext;");
$" {memberNames.ProviderFieldName} ??= new global::GFramework.Core.Architectures.GameContextProvider();");
sb.AppendLine($" {memberNames.ContextFieldName} ??= {memberNames.ProviderFieldName}.GetContext();");
sb.AppendLine($" return {memberNames.ContextFieldName};");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
@ -211,7 +213,9 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
/// 生成静态 provider 配置 API供测试和宿主在懒加载前替换默认上下文来源。
/// </summary>
/// <param name="sb">字符串构建器。</param>
private static void GenerateContextProviderConfiguration(StringBuilder sb)
private static void GenerateContextProviderConfiguration(
StringBuilder sb,
GeneratedContextMemberNames memberNames)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// 配置当前生成类型共享的上下文提供者。");
@ -229,9 +233,9 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
" public static void SetContextProvider(global::GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider provider)");
sb.AppendLine(" {");
sb.AppendLine(" global::System.ArgumentNullException.ThrowIfNull(provider);");
sb.AppendLine(" lock (_gFrameworkContextAwareSync)");
sb.AppendLine($" lock ({memberNames.SyncFieldName})");
sb.AppendLine(" {");
sb.AppendLine(" _gFrameworkContextAwareProvider = provider;");
sb.AppendLine($" {memberNames.ProviderFieldName} = provider;");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
@ -246,9 +250,9 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
sb.AppendLine(" /// </remarks>");
sb.AppendLine(" public static void ResetContextProvider()");
sb.AppendLine(" {");
sb.AppendLine(" lock (_gFrameworkContextAwareSync)");
sb.AppendLine($" lock ({memberNames.SyncFieldName})");
sb.AppendLine(" {");
sb.AppendLine(" _gFrameworkContextAwareProvider = null;");
sb.AppendLine($" {memberNames.ProviderFieldName} = null;");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
@ -265,7 +269,8 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
/// <param name="interfaceSymbol">接口符号</param>
private static void GenerateInterfaceImplementations(
StringBuilder sb,
INamedTypeSymbol interfaceSymbol)
INamedTypeSymbol interfaceSymbol,
GeneratedContextMemberNames memberNames)
{
var interfaceName = interfaceSymbol.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat);
@ -275,7 +280,7 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
if (method.MethodKind != MethodKind.Ordinary)
continue;
GenerateMethod(sb, interfaceName, method);
GenerateMethod(sb, interfaceName, method, memberNames);
sb.AppendLine();
}
}
@ -289,7 +294,8 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
private static void GenerateMethod(
StringBuilder sb,
string interfaceName,
IMethodSymbol method)
IMethodSymbol method,
GeneratedContextMemberNames memberNames)
{
var returnType = method.ReturnType.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat);
@ -302,7 +308,7 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
$" {returnType} {interfaceName}.{method.Name}({parameters})");
sb.AppendLine(" {");
GenerateMethodBody(sb, method);
GenerateMethodBody(sb, method, memberNames);
sb.AppendLine(" }");
}
@ -314,15 +320,16 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
/// <param name="method">方法符号</param>
private static void GenerateMethodBody(
StringBuilder sb,
IMethodSymbol method)
IMethodSymbol method,
GeneratedContextMemberNames memberNames)
{
switch (method.Name)
{
case "SetContext":
sb.AppendLine(" // 与 Context getter 共享同一同步协议,避免显式注入被并发懒加载覆盖。");
sb.AppendLine(" lock (_gFrameworkContextAwareSync)");
sb.AppendLine($" lock ({memberNames.SyncFieldName})");
sb.AppendLine(" {");
sb.AppendLine(" _gFrameworkContextAwareContext = context;");
sb.AppendLine($" {memberNames.ContextFieldName} = context;");
sb.AppendLine(" }");
break;
@ -338,4 +345,55 @@ public sealed class ContextAwareGenerator : MetadataAttributeClassGeneratorBase
break;
}
}
/// <summary>
/// 为生成字段选择不会与目标类型现有成员冲突的稳定名称。
/// </summary>
/// <param name="symbol">当前需要补充 ContextAware 实现的目标类型。</param>
/// <returns>当前生成轮次应使用的上下文字段名集合。</returns>
private static GeneratedContextMemberNames CreateGeneratedContextMemberNames(INamedTypeSymbol symbol)
{
var reservedNames = new HashSet<string>(
symbol.GetMembers()
.Where(static member => !member.IsImplicitlyDeclared)
.Select(static member => member.Name),
StringComparer.Ordinal);
return new GeneratedContextMemberNames(
AllocateGeneratedMemberName(reservedNames, "_gFrameworkContextAwareContext"),
AllocateGeneratedMemberName(reservedNames, "_gFrameworkContextAwareProvider"),
AllocateGeneratedMemberName(reservedNames, "_gFrameworkContextAwareSync"));
}
/// <summary>
/// 在固定前缀基础上按顺序追加数字后缀,直到找到可安全写入的成员名。
/// </summary>
/// <param name="reservedNames">当前类型已占用或已为其他生成字段保留的名称集合。</param>
/// <param name="baseName">优先尝试的基础名称。</param>
/// <returns>本轮生成可以使用的唯一成员名。</returns>
private static string AllocateGeneratedMemberName(
ISet<string> reservedNames,
string baseName)
{
if (reservedNames.Add(baseName))
return baseName;
for (var suffix = 1; ; suffix++)
{
var candidateName = $"{baseName}{suffix}";
if (reservedNames.Add(candidateName))
return candidateName;
}
}
/// <summary>
/// 描述一次 ContextAware 代码生成中选定的上下文字段名。
/// </summary>
/// <param name="ContextFieldName">实例上下文缓存字段名。</param>
/// <param name="ProviderFieldName">共享上下文提供者字段名。</param>
/// <param name="SyncFieldName">用于串行化访问的同步字段名。</param>
private readonly record struct GeneratedContextMemberNames(
string ContextFieldName,
string ProviderFieldName,
string SyncFieldName);
}

View File

@ -99,14 +99,14 @@ public class EasyEventsTests
}
/// <summary>
/// 测试 AddEvent 对重复事件类型给出状态冲突异常
/// 测试 AddEvent 对重复事件类型保持兼容的参数异常类型
/// </summary>
[Test]
public void AddEvent_Should_Throw_When_Already_Registered()
{
_easyEvents.AddEvent<Event<int>>();
Assert.Throws<InvalidOperationException>(() => _easyEvents.AddEvent<Event<int>>());
Assert.Throws<ArgumentException>(() => _easyEvents.AddEvent<Event<int>>());
}
/// <summary>

View File

@ -165,6 +165,20 @@ public class CollectionExtensionsTests
Assert.That(result["c"], Is.EqualTo(3));
}
/// <summary>
/// 测试ToDictionarySafe保持具体Dictionary返回类型避免公开API继续收窄。
/// </summary>
[Test]
public void ToDictionarySafe_Should_Preserve_Concrete_Return_Type()
{
var method = typeof(GFramework.Core.Extensions.CollectionExtensions)
.GetMethods()
.Single(static method => method.Name == nameof(GFramework.Core.Extensions.CollectionExtensions.ToDictionarySafe));
Assert.That(method.ReturnType.IsGenericType, Is.True);
Assert.That(method.ReturnType.GetGenericTypeDefinition(), Is.EqualTo(typeof(Dictionary<,>)));
}
/// <summary>
/// 测试ToDictionarySafe方法在存在重复键时覆盖前面的值
/// </summary>
@ -224,4 +238,4 @@ public class CollectionExtensionsTests
Assert.Throws<ArgumentNullException>(() =>
items.ToDictionarySafe<(string, int), string, int>(x => x.Item1, null!));
}
}
}

View File

@ -39,6 +39,35 @@ public class LoggingConfigurationTests
Assert.That(config.LoggerLevels["GFramework.Core"], Is.EqualTo(LogLevel.Trace));
}
[Test]
public void Configuration_Collections_Should_Preserve_Public_Concrete_Types()
{
Assert.Multiple(() =>
{
Assert.That(
typeof(LoggingConfiguration).GetProperty(nameof(LoggingConfiguration.Appenders))!.PropertyType,
Is.EqualTo(typeof(List<AppenderConfiguration>)));
Assert.That(
typeof(LoggingConfiguration).GetProperty(nameof(LoggingConfiguration.LoggerLevels))!.PropertyType,
Is.EqualTo(typeof(Dictionary<string, LogLevel>)));
Assert.That(
typeof(FilterConfiguration).GetProperty(nameof(FilterConfiguration.Namespaces))!.PropertyType,
Is.EqualTo(typeof(List<string>)));
Assert.That(
typeof(FilterConfiguration).GetProperty(nameof(FilterConfiguration.Filters))!.PropertyType,
Is.EqualTo(typeof(List<FilterConfiguration>)));
});
}
[Test]
public void LoggerLevels_Should_Remain_Case_Sensitive_By_Default()
{
var config = new LoggingConfiguration();
config.LoggerLevels["GFramework.Core"] = LogLevel.Info;
Assert.That(config.LoggerLevels.ContainsKey("gframework.core"), Is.False);
}
[Test]
public void LoadFromJsonString_WithInvalidJson_ShouldThrow()
{

View File

@ -53,12 +53,14 @@ public class EasyEvents
/// 添加指定类型的事件到事件字典中
/// </summary>
/// <typeparam name="T">事件类型必须实现IEasyEvent接口且具有无参构造函数</typeparam>
/// <exception cref="InvalidOperationException">当事件类型已存在时抛出。</exception>
/// <exception cref="ArgumentException">当事件类型已存在时抛出。</exception>
public void AddEvent<T>() where T : IEvent, new()
{
if (!_mTypeEvents.TryAdd(typeof(T), new T()))
{
throw new InvalidOperationException($"Event type {typeof(T).Name} already registered.");
#pragma warning disable MA0015 // Preserve the public ArgumentException contract without inventing a fake parameter name.
throw new ArgumentException($"Event type {typeof(T).Name} already registered.");
#pragma warning restore MA0015
}
}

View File

@ -81,10 +81,12 @@ public static class CollectionExtensions
/// // dict["a"] == 3 (最后一个值)
/// </code>
/// </example>
public static IDictionary<TKey, TValue> ToDictionarySafe<T, TKey, TValue>(
#pragma warning disable MA0016 // Preserve the established concrete return type for public API compatibility.
public static Dictionary<TKey, TValue> ToDictionarySafe<T, TKey, TValue>(
this IEnumerable<T> source,
Func<T, TKey> keySelector,
Func<T, TValue> valueSelector) where TKey : notnull
#pragma warning restore MA0016
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(keySelector);

View File

@ -20,10 +20,14 @@ public sealed class FilterConfiguration
/// <summary>
/// 命名空间前缀列表(用于 Namespace 过滤器)。
/// </summary>
public IList<string>? Namespaces { get; set; }
#pragma warning disable MA0016 // Preserve the established concrete configuration API surface.
public List<string>? Namespaces { get; set; }
#pragma warning restore MA0016
/// <summary>
/// 子过滤器列表(用于 Composite 过滤器)。
/// </summary>
public IList<FilterConfiguration>? Filters { get; set; }
#pragma warning disable MA0016 // Preserve the established concrete configuration API surface.
public List<FilterConfiguration>? Filters { get; set; }
#pragma warning restore MA0016
}

View File

@ -15,11 +15,15 @@ public sealed class LoggingConfiguration
/// <summary>
/// Appender 配置列表
/// </summary>
public IList<AppenderConfiguration> Appenders { get; set; } = new List<AppenderConfiguration>();
#pragma warning disable MA0016 // Preserve the established concrete configuration API surface.
public List<AppenderConfiguration> Appenders { get; set; } = new();
#pragma warning restore MA0016
/// <summary>
/// 特定 Logger 的日志级别配置
/// </summary>
public IDictionary<string, LogLevel> LoggerLevels { get; set; } =
#pragma warning disable MA0016 // Preserve the established concrete configuration API surface.
public Dictionary<string, LogLevel> LoggerLevels { get; set; } =
new Dictionary<string, LogLevel>(StringComparer.Ordinal);
#pragma warning restore MA0016
}

View File

@ -105,9 +105,10 @@ public sealed partial class CqrsHandlerRegistryGenerator
out runtimeTypeReference);
}
if (type is INamedTypeSymbol namedType)
if (type is INamedTypeSymbol namedType &&
TryCreateNamedRuntimeTypeReference(compilation, namedType, out var namedTypeReference))
{
runtimeTypeReference = CreateNamedRuntimeTypeReference(compilation, namedType);
runtimeTypeReference = namedTypeReference;
return true;
}
@ -162,17 +163,32 @@ public sealed partial class CqrsHandlerRegistryGenerator
/// </summary>
/// <param name="compilation">当前生成轮次的编译上下文。</param>
/// <param name="namedType">需要在运行时解析的命名类型。</param>
/// <returns>适合写入生成注册器的命名类型运行时引用。</returns>
private static RuntimeTypeReferenceSpec CreateNamedRuntimeTypeReference(
/// <param name="runtimeTypeReference">
/// 当方法返回 <see langword="true" /> 时,包含适合写入生成注册器的命名类型运行时引用;
/// 当返回 <see langword="false" /> 时,调用方应回退到更保守的注册路径。
/// </param>
/// <returns>当命名类型可安全编码为运行时引用时返回 <see langword="true" />。</returns>
private static bool TryCreateNamedRuntimeTypeReference(
Compilation compilation,
INamedTypeSymbol namedType)
INamedTypeSymbol namedType,
out RuntimeTypeReferenceSpec? runtimeTypeReference)
{
if (SymbolEqualityComparer.Default.Equals(namedType.ContainingAssembly, compilation.Assembly))
return RuntimeTypeReferenceSpec.FromReflectionLookup(GetReflectionTypeMetadataName(namedType));
{
runtimeTypeReference = RuntimeTypeReferenceSpec.FromReflectionLookup(GetReflectionTypeMetadataName(namedType));
return true;
}
return RuntimeTypeReferenceSpec.FromExternalReflectionLookup(
if (namedType.ContainingAssembly is null)
{
runtimeTypeReference = null;
return false;
}
runtimeTypeReference = RuntimeTypeReferenceSpec.FromExternalReflectionLookup(
namedType.ContainingAssembly.Identity.ToString(),
GetReflectionTypeMetadataName(namedType));
return true;
}
/// <summary>
@ -214,6 +230,12 @@ public sealed partial class CqrsHandlerRegistryGenerator
return true;
}
if (genericTypeDefinition.ContainingAssembly is null)
{
genericTypeDefinitionReference = null;
return false;
}
genericTypeDefinitionReference = RuntimeTypeReferenceSpec.FromExternalReflectionLookup(
genericTypeDefinition.ContainingAssembly.Identity.ToString(),
GetReflectionTypeMetadataName(genericTypeDefinition));

View File

@ -143,7 +143,7 @@ public sealed partial class CqrsHandlerRegistryGenerator
if (sourceShape.HasExternalAssemblyTypeLookups)
{
builder.AppendLine();
AppendReflectionHelpers(builder, sourceShape.HasExternalAssemblyTypeLookups);
AppendReflectionHelpers(builder);
}
builder.AppendLine("}");
@ -792,53 +792,48 @@ public sealed partial class CqrsHandlerRegistryGenerator
return variableBaseName;
}
private static void AppendReflectionHelpers(
StringBuilder builder,
bool includeExternalAssemblyTypeLookupHelpers)
private static void AppendReflectionHelpers(StringBuilder builder)
{
if (includeExternalAssemblyTypeLookupHelpers)
{
builder.AppendLine(
" private static global::System.Type? ResolveReferencedAssemblyType(string assemblyIdentity, string typeMetadataName)");
builder.AppendLine(" {");
builder.AppendLine(" var assembly = ResolveReferencedAssembly(assemblyIdentity);");
builder.AppendLine(
" return assembly?.GetType(typeMetadataName, throwOnError: false, ignoreCase: false);");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
" private static global::System.Reflection.Assembly? ResolveReferencedAssembly(string assemblyIdentity)");
builder.AppendLine(" {");
builder.AppendLine(" global::System.Reflection.AssemblyName targetAssemblyName;");
builder.AppendLine(" try");
builder.AppendLine(" {");
builder.AppendLine(
" targetAssemblyName = new global::System.Reflection.AssemblyName(assemblyIdentity);");
builder.AppendLine(" }");
builder.AppendLine(" catch");
builder.AppendLine(" {");
builder.AppendLine(" return null;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
" foreach (var assembly in global::System.AppDomain.CurrentDomain.GetAssemblies())");
builder.AppendLine(" {");
builder.AppendLine(
" if (global::System.Reflection.AssemblyName.ReferenceMatchesDefinition(targetAssemblyName, assembly.GetName()))");
builder.AppendLine(" return assembly;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" try");
builder.AppendLine(" {");
builder.AppendLine(
" return global::System.Reflection.Assembly.Load(targetAssemblyName);");
builder.AppendLine(" }");
builder.AppendLine(" catch");
builder.AppendLine(" {");
builder.AppendLine(" return null;");
builder.AppendLine(" }");
builder.AppendLine(" }");
}
builder.AppendLine(
" private static global::System.Type? ResolveReferencedAssemblyType(string assemblyIdentity, string typeMetadataName)");
builder.AppendLine(" {");
builder.AppendLine(" var assembly = ResolveReferencedAssembly(assemblyIdentity);");
builder.AppendLine(
" return assembly?.GetType(typeMetadataName, throwOnError: false, ignoreCase: false);");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
" private static global::System.Reflection.Assembly? ResolveReferencedAssembly(string assemblyIdentity)");
builder.AppendLine(" {");
builder.AppendLine(" global::System.Reflection.AssemblyName targetAssemblyName;");
builder.AppendLine(" try");
builder.AppendLine(" {");
builder.AppendLine(
" targetAssemblyName = new global::System.Reflection.AssemblyName(assemblyIdentity);");
builder.AppendLine(" }");
builder.AppendLine(" catch");
builder.AppendLine(" {");
builder.AppendLine(" return null;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
" foreach (var assembly in global::System.AppDomain.CurrentDomain.GetAssemblies())");
builder.AppendLine(" {");
builder.AppendLine(
" if (global::System.Reflection.AssemblyName.ReferenceMatchesDefinition(targetAssemblyName, assembly.GetName()))");
builder.AppendLine(" return assembly;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" try");
builder.AppendLine(" {");
builder.AppendLine(
" return global::System.Reflection.Assembly.Load(targetAssemblyName);");
builder.AppendLine(" }");
builder.AppendLine(" catch");
builder.AppendLine(" {");
builder.AppendLine(" return null;");
builder.AppendLine(" }");
builder.AppendLine(" }");
}
private static string EscapeStringLiteral(string value)

View File

@ -134,6 +134,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
{
text = file.GetText(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
text = null;

View File

@ -6,6 +6,32 @@ namespace GFramework.SourceGenerators.Tests.Config;
[TestFixture]
public class SchemaConfigGeneratorTests
{
/// <summary>
/// 验证 AdditionalFiles 读取被取消时会向上传播取消,而不是伪造成 schema 诊断。
/// </summary>
[Test]
public void Run_Should_Propagate_Cancellation_When_AdditionalText_Read_Is_Cancelled()
{
var method = typeof(global::GFramework.Game.SourceGenerators.Config.SchemaConfigGenerator)
.GetMethod(
"TryReadSchemaText",
global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static);
var cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.Cancel();
var invocationArguments = new object?[]
{
new ThrowingAdditionalText("monster.schema.json"),
cancellationTokenSource.Token,
null,
null
};
var exception = Assert.Throws<global::System.Reflection.TargetInvocationException>(() =>
method!.Invoke(null, invocationArguments));
Assert.That(exception!.InnerException, Is.TypeOf<OperationCanceledException>());
}
/// <summary>
/// 验证缺失必填 id 字段时会产生命名明确的诊断。
/// </summary>
@ -46,6 +72,30 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 用于模拟 AdditionalFiles 读取阶段直接收到取消请求的测试桩。
/// </summary>
private sealed class ThrowingAdditionalText : AdditionalText
{
/// <summary>
/// 创建一个在读取时抛出取消异常的 AdditionalText。
/// </summary>
/// <param name="path">虚拟 schema 路径。</param>
public ThrowingAdditionalText(string path)
{
Path = path;
}
/// <inheritdoc />
public override string Path { get; }
/// <inheritdoc />
public override SourceText GetText(CancellationToken cancellationToken = default)
{
throw new OperationCanceledException(cancellationToken);
}
}
/// <summary>
/// 验证空字符串 <c>const</c> 不会在生成 XML 文档时被当成“缺失约束”跳过。
/// </summary>

View File

@ -20,6 +20,23 @@ public static class SchemaGeneratorTestDriver
public static GeneratorDriverRunResult Run(
string source,
params (string path, string content)[] additionalFiles)
{
return Run(
source,
additionalFiles
.Select(static item => (AdditionalText)new InMemoryAdditionalText(item.path, item.content))
.ToArray());
}
/// <summary>
/// 运行 schema 配置生成器,并允许测试自定义 AdditionalText 行为。
/// </summary>
/// <param name="source">测试用源码。</param>
/// <param name="additionalTexts">自定义 AdditionalText 集合。</param>
/// <returns>生成器运行结果。</returns>
public static GeneratorDriverRunResult Run(
string source,
params AdditionalText[] additionalTexts)
{
var syntaxTree = CSharpSyntaxTree.ParseText(source);
var compilation = CSharpCompilation.Create(
@ -28,13 +45,9 @@ public static class SchemaGeneratorTestDriver
GetMetadataReferences(),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var additionalTexts = additionalFiles
.Select(static item => (AdditionalText)new InMemoryAdditionalText(item.path, item.content))
.ToImmutableArray();
GeneratorDriver driver = CSharpGeneratorDriver.Create(
generators: new[] { new SchemaConfigGenerator().AsSourceGenerator() },
additionalTexts: additionalTexts,
additionalTexts: additionalTexts.ToImmutableArray(),
parseOptions: (CSharpParseOptions)syntaxTree.Options);
driver = driver.RunGenerators(compilation);

View File

@ -154,6 +154,9 @@ public class ContextAwareGeneratorSnapshotTests
private readonly string _context = "user-field";
private static readonly string _contextProvider = "user-provider";
private static readonly object _contextSync = new();
private IArchitectureContext? _gFrameworkContextAwareContext;
private static IArchitectureContextProvider? _gFrameworkContextAwareProvider;
private static readonly object _gFrameworkContextAwareSync = new();
}
}
""";

View File

@ -10,14 +10,14 @@ namespace TestApp;
/// 生成代码会在实例级缓存首次解析到的上下文,并在未显式配置提供者时回退到 <see cref="GFramework.Core.Architectures.GameContextProvider" />。
/// 同一生成类型的所有实例共享一个静态上下文提供者;切换或重置提供者只会影响尚未缓存上下文的新实例或未初始化实例,
/// 已缓存的实例上下文需要通过 <see cref="GFramework.Core.Abstractions.Rule.IContextAware.SetContext(GFramework.Core.Abstractions.Architectures.IArchitectureContext)" /> 显式覆盖。
/// 与手动继承 <see cref="global::GFramework.Core.Rule.ContextAwareBase" /> 的路径相比,生成实现会使用 <c>_gFrameworkContextAwareSync</c> 协调惰性初始化、provider 切换和显式上下文注入;
/// 与手动继承 <see cref="global::GFramework.Core.Rule.ContextAwareBase" /> 的路径相比,生成实现会使用 <c>_gFrameworkContextAwareSync1</c> 协调惰性初始化、provider 切换和显式上下文注入;
/// <see cref="global::GFramework.Core.Rule.ContextAwareBase" /> 则保持无锁的实例级缓存语义,更适合已经由调用方线程模型保证串行访问的简单场景。
/// </remarks>
partial class CollisionProneRule : global::GFramework.Core.Abstractions.Rule.IContextAware
{
private global::GFramework.Core.Abstractions.Architectures.IArchitectureContext? _gFrameworkContextAwareContext;
private static global::GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider? _gFrameworkContextAwareProvider;
private static readonly object _gFrameworkContextAwareSync = new();
private global::GFramework.Core.Abstractions.Architectures.IArchitectureContext? _gFrameworkContextAwareContext1;
private static global::GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider? _gFrameworkContextAwareProvider1;
private static readonly object _gFrameworkContextAwareSync1 = new();
/// <summary>
/// 获取当前实例绑定的架构上下文。
@ -27,26 +27,20 @@ partial class CollisionProneRule : global::GFramework.Core.Abstractions.Rule.ICo
/// 当静态提供者尚未配置时,生成代码会回退到 <see cref="GFramework.Core.Architectures.GameContextProvider" />。
/// 一旦某个实例成功缓存上下文,后续 <see cref="SetContextProvider(GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider)" />
/// 或 <see cref="ResetContextProvider" /> 不会自动清除此缓存;如需覆盖,请显式调用 <c>IContextAware.SetContext(...)</c>。
/// 当前实现还假设 <see cref="GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider.GetContext" /> 可在持有 <c>_gFrameworkContextAwareSync</c> 时安全执行;
/// 当前实现还假设 <see cref="GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider.GetContext" /> 可在持有 <c>_gFrameworkContextAwareSync1</c> 时安全执行;
/// 自定义 provider 不应在该调用链内重新进入当前类型的 provider 配置 API且应避免引入与外部全局锁相互等待的锁顺序。
/// </remarks>
protected global::GFramework.Core.Abstractions.Architectures.IArchitectureContext Context
{
get
{
var context = _gFrameworkContextAwareContext;
if (context is not null)
{
return context;
}
// 在同一个同步域内协调懒加载与 provider 切换,避免读取到被并发重置的空提供者。
// provider 的 GetContext() 会在持有 _gFrameworkContextAwareSync 时执行;自定义 provider 必须避免在该调用链内回调 SetContextProvider/ResetContextProvider 或形成反向锁顺序。
lock (_gFrameworkContextAwareSync)
// provider 的 GetContext() 会在持有 _gFrameworkContextAwareSync1 时执行;自定义 provider 必须避免在该调用链内回调 SetContextProvider/ResetContextProvider 或形成反向锁顺序。
lock (_gFrameworkContextAwareSync1)
{
_gFrameworkContextAwareProvider ??= new global::GFramework.Core.Architectures.GameContextProvider();
_gFrameworkContextAwareContext ??= _gFrameworkContextAwareProvider.GetContext();
return _gFrameworkContextAwareContext;
_gFrameworkContextAwareProvider1 ??= new global::GFramework.Core.Architectures.GameContextProvider();
_gFrameworkContextAwareContext1 ??= _gFrameworkContextAwareProvider1.GetContext();
return _gFrameworkContextAwareContext1;
}
}
}
@ -64,9 +58,9 @@ partial class CollisionProneRule : global::GFramework.Core.Abstractions.Rule.ICo
public static void SetContextProvider(global::GFramework.Core.Abstractions.Architectures.IArchitectureContextProvider provider)
{
global::System.ArgumentNullException.ThrowIfNull(provider);
lock (_gFrameworkContextAwareSync)
lock (_gFrameworkContextAwareSync1)
{
_gFrameworkContextAwareProvider = provider;
_gFrameworkContextAwareProvider1 = provider;
}
}
@ -80,18 +74,18 @@ partial class CollisionProneRule : global::GFramework.Core.Abstractions.Rule.ICo
/// </remarks>
public static void ResetContextProvider()
{
lock (_gFrameworkContextAwareSync)
lock (_gFrameworkContextAwareSync1)
{
_gFrameworkContextAwareProvider = null;
_gFrameworkContextAwareProvider1 = null;
}
}
void global::GFramework.Core.Abstractions.Rule.IContextAware.SetContext(global::GFramework.Core.Abstractions.Architectures.IArchitectureContext context)
{
// 与 Context getter 共享同一同步协议,避免显式注入被并发懒加载覆盖。
lock (_gFrameworkContextAwareSync)
lock (_gFrameworkContextAwareSync1)
{
_gFrameworkContextAwareContext = context;
_gFrameworkContextAwareContext1 = context;
}
}

View File

@ -34,12 +34,6 @@ partial class MyRule : global::GFramework.Core.Abstractions.Rule.IContextAware
{
get
{
var context = _gFrameworkContextAwareContext;
if (context is not null)
{
return context;
}
// 在同一个同步域内协调懒加载与 provider 切换,避免读取到被并发重置的空提供者。
// provider 的 GetContext() 会在持有 _gFrameworkContextAwareSync 时执行;自定义 provider 必须避免在该调用链内回调 SetContextProvider/ResetContextProvider 或形成反向锁顺序。
lock (_gFrameworkContextAwareSync)

View File

@ -7,8 +7,8 @@
## 当前恢复点
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-021`
- 当前阶段:`Phase 21`
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-022`
- 当前阶段:`Phase 22`
- 当前焦点:
- 已完成 `GFramework.Core` 当前 `MA0016` / `MA0002` / `MA0015` / `MA0077` 低风险收口批次
- 已复核 `net10.0` 下的 `MA0158` 基线:`GFramework.Core` / `GFramework.Cqrs` 当前共有 `16` 个 object lock
@ -20,10 +20,11 @@
- 已完成 `SchemaConfigGenerator.cs` 的第一批 `MA0051` 结构拆分schema 入口解析、属性解析、schema 遍历、数组属性解析、
约束文档生成与若干生成代码发射 helper 已拆出语义阶段
- 已完成当前 PR #269 review follow-up`CqrsHandlerRegistryGenerator` 按职责拆分为 partial 生成器文件,
`ContextAwareGenerator` 改用稳定前缀字段并补上 provider null 防御,`Option<T>` 补齐 `<remarks>` 契约说明
- `LoggingConfiguration``FilterConfiguration``CollectionExtensions` 已改用集合抽象接口,并保留内部具体集合默认值
`ContextAwareGenerator` 已补上字段名去冲突与锁内读取修正,`Option<T>` 补齐 `<remarks>` 契约说明
- 已完成当前 PR #269 第二轮 follow-up恢复 `EasyEvents``CollectionExtensions``LoggingConfiguration`
`FilterConfiguration` 的公共 API 兼容形状,并将 analyzer 兼容性处理收敛到局部 pragma
- `CoroutineScheduler` 的 tag/group 字典已显式使用 `StringComparer.Ordinal`,保持既有区分大小写语义
- `EasyEvents.AddEvent<T>()` 的重复注册路径已改为状态冲突异常,避免把泛型类型参数伪装成方法参数名
- `EasyEvents.AddEvent<T>()` 的重复注册路径已恢复为 `ArgumentException`,以保持既有异常契约
- `Option<T>` 已声明 `IEquatable<Option<T>>`,与已有强类型 `Equals(Option<T>)` 契约对齐
- 当前 `GFramework.Core` `net8.0` warnings-only 基线已降到 `0`
- 当前 `GFramework.Core.SourceGenerators` warnings-only 基线已降到 `0`
@ -53,8 +54,9 @@
- 已完成当前 `GFramework.Core` `net8.0` 剩余低风险 analyzer warning 批次warnings-only 基线已降到 `0`
- 已完成 `GFramework.Core.SourceGenerators``ContextAwareGenerator` 的剩余 `MA0051` 收口warnings-only 基线已降到 `0`
- 已完成 `GFramework.Cqrs.SourceGenerators``CqrsHandlerRegistryGenerator` 的剩余 `MA0051` 收口warnings-only 基线已降到 `0`
- 已完成当前 PR #269 的 review follow-up收口 `ContextAwareGenerator` 的字段命名冲突 / provider null 契约、
`Option<T>` 的 XML 文档缺口,以及 `CqrsHandlerRegistryGenerator` 的超大文件拆分
- 已完成当前 PR #269 的 review follow-up收口 `ContextAwareGenerator` 的字段命名冲突 / 锁内读取契约、
`CqrsHandlerRegistryGenerator` 的运行时类型 null 防御与超大文件拆分、`SchemaConfigGenerator` 的取消语义,
并恢复 `EasyEvents` / `CollectionExtensions` / logging 配置模型的公共 API 兼容形状
- 已完成 `GFramework.Game.SourceGenerators``SchemaConfigGenerator` 的第一批 `MA0051` 收口warnings-only 基线剩余 `9`
`MA0051`
@ -100,12 +102,15 @@
- `RP-021` 使用 `$gframework-pr-review` 复核当前分支 PR #269 后,修复仍在本地成立的 4 个项:将
`CqrsHandlerRegistryGenerator` 拆分为职责清晰的 partial 文件、为 `ContextAwareGenerator` 生成字段增加稳定前缀并补上
`SetContextProvider` 的运行时 null 校验、为 `Option<T>` 补齐 `<remarks>`,并新增字段重名场景的生成器快照测试
- `RP-022` 继续复核 PR #269 的 latest-head review threads 与 nitpick确认仍成立的项包括公共 API 兼容回退、
`ContextAwareGenerator` 字段名真正去冲突与锁内读取、`SchemaConfigGenerator` 取消传播、`Cqrs` 运行时类型 null 防御;
已补齐对应回归测试与 focused build/test 验证
- 当前工作树分支 `fix/analyzer-warning-reduction-batch` 已在 `ai-plan/public/README.md` 建立 topic 映射
## 当前风险
- 公共契约兼容风险:本轮将部分配置与扩展方法返回值从具体集合类型改为集合抽象接口
- 缓解措施:保留具体集合默认值,并通过配置反序列化、工厂创建与集合扩展定向测试覆盖主要消费路径
- analyzer 收口回退风险:后续若继续压 `MA0015` / `MA0016`,容易再次把公共 API 收窄成与既有契约不兼容的形状
- 缓解措施:优先保留既有公共 API并将兼容性例外收敛到局部 pragma继续用反射断言覆盖返回类型、属性类型与异常类型
- 测试宿主稳定性风险:部分 Godot 失败路径在当前 .NET 测试宿主下仍不稳定
- 缓解措施:继续优先使用稳定的 targeted test、项目构建和相邻 smoke test 组合验证
- 多目标框架 warning 解释风险:同一源位置会在多个 target framework 下重复计数
@ -242,6 +247,17 @@
- 结果:先并行运行两条 `dotnet test` 时触发共享输出文件锁冲突;改为串行重跑后 `ContextAwareGeneratorSnapshotTests=2 Passed`
`CqrsHandlerRegistryGeneratorTests=14 Passed`
- 说明:失败来自测试宿主并行写入同一 build 输出,不是代码回归;串行重跑后快照新增的字段重名场景和 CQRS 快照均通过
- `RP-022` 的验证结果:
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders=\"\" -v minimal`
- 结果:通过;`EasyEvents``CollectionExtensions` 与 logging 配置模型的兼容性回退未引入新的 `net8.0` 构建错误
- `dotnet build GFramework.Cqrs.SourceGenerators/GFramework.Cqrs.SourceGenerators.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders=\"\" -v minimal`
- 结果:通过;`ContainingAssembly` null 防御与发射 helper 精简未引入新的构建错误
- `dotnet build GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders=\"\" -v minimal`
- 结果:通过;仍保留既有 `9``SchemaConfigGenerator.cs` `MA0051` warning非本轮新增
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --filter "FullyQualifiedName~SchemaConfigGeneratorTests|FullyQualifiedName~ContextAwareGeneratorSnapshotTests|FullyQualifiedName~CqrsHandlerRegistryGeneratorTests" -m:1 -p:RestoreFallbackFolders=\"\" -v minimal`
- 结果:`63 Passed``0 Failed`
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-restore --filter "FullyQualifiedName~EasyEventsTests|FullyQualifiedName~CollectionExtensionsTests|FullyQualifiedName~LoggingConfigurationTests|FullyQualifiedName~ConfigurableLoggerFactoryTests" -m:1 -p:RestoreFallbackFolders=\"\" -v minimal`
- 结果:`38 Passed``0 Failed`
- active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,不再重复保存已完成阶段的长篇历史
## 下一步

View File

@ -1,5 +1,41 @@
# Analyzer Warning Reduction 追踪
## 2026-04-22 — RP-022
### 阶段PR #269 第二轮 review follow-up 收口RP-022
- 启动复核:
- 延续 `$gframework-pr-review` 的 PR #269 结果,继续核对 latest-head unresolved threads 与 nitpick comment
- 结合本地实现确认仍成立的项不止第一轮记录的 4 个,还包括公共 API 兼容回退、`SchemaConfigGenerator` 取消传播、
`ContextAwareGenerator` 真正的字段名去冲突与锁内读取修正、`Cqrs` 运行时类型 null 防御
- 决策:
- 对公共 API 兼容项优先保持既有契约,不为了压 analyzer 而继续收窄返回类型、属性类型或异常类型
- `ContextAwareGenerator` 采用保守并发修复:移除未加锁 fast-path统一在锁内读取上下文缓存并让生成字段名按已有成员去冲突
- `SchemaConfigGenerator` 在取消已请求时直接重新抛出 `OperationCanceledException`,避免把取消误报告成普通诊断
- 实施调整:
- 将 `EasyEvents.AddEvent<T>()` 的重复注册异常恢复为 `ArgumentException`,并在测试中恢复既有异常契约断言
- 将 `CollectionExtensions.ToDictionarySafe(...)` 返回类型恢复为 `Dictionary<TKey, TValue>`,并新增反射测试锁定公开 API 形状
- 将 `LoggingConfiguration` / `FilterConfiguration` 的公开集合属性恢复为具体 `List<>` / `Dictionary<,>` 类型,
并新增反射测试与默认 comparer 语义断言
- 为 `CqrsHandlerRegistryGenerator` 的命名类型引用构造补上 `ContainingAssembly is null` 防御,移除发射 helper 冗余布尔参数
- 为 `SchemaConfigGenerator` 补上“仅在 cancellationToken 已取消时重抛”的 catch 分支,并为测试驱动添加多 `AdditionalText` 重载
- 为 `ContextAwareGenerator` 增加生成成员名分配逻辑,新增 `_gFrameworkContextAware*` 与旧 `_context*` 双冲突快照场景,
同时移除 getter 中未加锁 fast-path
- 验证结果:
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -v minimal`
- 结果:通过
- `dotnet build GFramework.Cqrs.SourceGenerators/GFramework.Cqrs.SourceGenerators.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -v minimal`
- 结果:通过
- `dotnet build GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders="" -v minimal`
- 结果:通过;仍有既有 `9``MA0051`
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --filter "FullyQualifiedName~SchemaConfigGeneratorTests|FullyQualifiedName~ContextAwareGeneratorSnapshotTests|FullyQualifiedName~CqrsHandlerRegistryGeneratorTests" -m:1 -p:RestoreFallbackFolders="" -v minimal`
- 结果:`63 Passed``0 Failed`
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-restore --filter "FullyQualifiedName~EasyEventsTests|FullyQualifiedName~CollectionExtensionsTests|FullyQualifiedName~LoggingConfigurationTests|FullyQualifiedName~ConfigurableLoggerFactoryTests" -m:1 -p:RestoreFallbackFolders="" -v minimal`
- 结果:`38 Passed``0 Failed`
- 下一步建议:
- 回到 `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` 的剩余 `MA0051`
- 若后续 review 再提 analyzer 兼容建议,先做公共契约回归检查,再决定是否接受该建议
## 2026-04-22 — RP-021
### 阶段PR #269 review follow-up 收口RP-021