fix(source-generators): 收口PR269剩余review与构建规范

- 修复 SchemaConfigGenerator 的归一化字段名冲突诊断,并补充对应 generator 回归测试

- 修复 CqrsHandlerRegistryGenerator 对 dynamic 的运行时类型引用,避免生成非法 typeof(dynamic)

- 更新 AGENTS 与 analyzer-warning-reduction 跟踪,明确受影响模块必须独立 build 并处理或显式报告 warning
This commit is contained in:
gewuyou 2026-04-23 07:34:31 +08:00
parent 4ef9406ee9
commit 050f4321c6
9 changed files with 319 additions and 20 deletions

View File

@ -31,6 +31,12 @@ All AI agents and contributors must follow these rules when writing, reviewing,
- Every completed task MUST pass at least one build validation before it is considered done.
- If the task changes multiple projects or shared abstractions, prefer a solution-level or affected-project
`dotnet build ... -c Release`; otherwise use the smallest build command that still proves the result compiles.
- When a task adds a feature or modifies code, contributors MUST run a Release build for every directly affected
module/project instead of relying on an unrelated project or solution slice that does not actually compile the touched
code.
- Warnings reported by those affected-module builds are part of the task scope. Contributors MUST resolve the touched
module's build warnings in the same change, or stop and explicitly report the exact warning IDs and blocker instead of
deferring them to a separate long-lived cleanup branch by default.
- If the required build passes and there are task-related staged or unstaged changes, contributors MUST create a Git
commit automatically instead of leaving the task uncommitted, unless the user explicitly says not to commit.
- Commit messages MUST use Conventional Commits format: `<type>(<scope>): <summary>`.

View File

@ -81,6 +81,14 @@ public sealed partial class CqrsHandlerRegistryGenerator
return false;
}
// Roslyn models dynamic as a pseudo-type, but generated C# cannot emit typeof(dynamic).
// Normalize it to the CLR runtime type so precise reflected registrations stay compilable.
if (type.TypeKind == TypeKind.Dynamic)
{
runtimeTypeReference = RuntimeTypeReferenceSpec.FromDirectReference("global::System.Object");
return true;
}
if (CanReferenceFromGeneratedRegistry(compilation, type))
{
runtimeTypeReference = RuntimeTypeReferenceSpec.FromDirectReference(
@ -255,7 +263,7 @@ public sealed partial class CqrsHandlerRegistryGenerator
{
// Roslyn error symbols stringify to unresolved type names; emitting them via typeof(...) would turn
// an existing user-code error into a second generator-produced compile error instead of falling back.
if (type.TypeKind == TypeKind.Error)
if (type.TypeKind is TypeKind.Error or TypeKind.Dynamic)
return false;
switch (type)
@ -279,8 +287,8 @@ public sealed partial class CqrsHandlerRegistryGenerator
case ITypeParameterSymbol:
return false;
default:
// Treat other Roslyn type kinds, such as dynamic, as referenceable for now.
// If a real-world case proves unsafe, tighten this branch instead of broadening the named-type path above.
// Remaining Roslyn type kinds that reach this branch have already been normalized by earlier guards
// and can continue through the direct-reference path without emitting fallback reflection code.
return true;
}
}

View File

@ -18,3 +18,4 @@
GF_ConfigSchema_011 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_012 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_013 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_014 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics

View File

@ -356,20 +356,16 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
var properties = new List<SchemaPropertySpec>();
foreach (var property in propertiesElement.EnumerateObject())
{
var parsedProperty = ParseProperty(
if (!TryParseObjectProperties(
filePath,
property,
requiredProperties.Contains(property.Name),
CombinePath(displayPath, property.Name),
isDirectChildOfRoot: isRoot);
if (parsedProperty.Diagnostic is not null)
{
return ParsedObjectResult.FromDiagnostic(parsedProperty.Diagnostic);
}
properties.Add(parsedProperty.Property!);
displayPath,
isRoot,
propertiesElement,
requiredProperties,
properties,
out var propertyDiagnostic))
{
return ParsedObjectResult.FromDiagnostic(propertyDiagnostic!);
}
return ParsedObjectResult.FromObject(new SchemaObjectSpec(
@ -381,6 +377,60 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
properties));
}
/// <summary>
/// 解析对象 schema 的直接子属性,并在进入代码发射前阻止归一化后的属性名冲突落入生成输出。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">当前对象的逻辑路径。</param>
/// <param name="isRoot">当前对象是否为根对象。</param>
/// <param name="propertiesElement">对象的 <c>properties</c> JSON 节点。</param>
/// <param name="requiredProperties">当前对象声明的必填字段集合。</param>
/// <param name="properties">成功时返回的已解析属性列表。</param>
/// <param name="diagnostic">解析失败时返回的首个诊断。</param>
/// <returns>当所有属性都可安全生成时返回 <see langword="true" />。</returns>
private static bool TryParseObjectProperties(
string filePath,
string displayPath,
bool isRoot,
JsonElement propertiesElement,
ISet<string> requiredProperties,
ICollection<SchemaPropertySpec> properties,
out Diagnostic? diagnostic)
{
var schemaKeyByGeneratedPropertyName = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var property in propertiesElement.EnumerateObject())
{
var propertyDisplayPath = CombinePath(displayPath, property.Name);
var parsedProperty = ParseProperty(
filePath,
property,
requiredProperties.Contains(property.Name),
propertyDisplayPath,
isDirectChildOfRoot: isRoot);
if (parsedProperty.Diagnostic is not null)
{
diagnostic = parsedProperty.Diagnostic;
return false;
}
if (!TryRegisterGeneratedPropertyName(
filePath,
propertyDisplayPath,
property.Name,
parsedProperty.Property!.PropertyName,
schemaKeyByGeneratedPropertyName,
out diagnostic))
{
return false;
}
properties.Add(parsedProperty.Property!);
}
diagnostic = null;
return true;
}
/// <summary>
/// 解析单个 schema 属性定义。
/// </summary>
@ -4052,6 +4102,43 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return false;
}
/// <summary>
/// 记录同一对象节点内已分配的生成属性名,并在 schema key 归一化后发生冲突时返回明确诊断。
/// 该校验会在生成器进入源码发射前阻止重复属性、查询方法与索引成员名落入后续编译阶段。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">当前字段的逻辑路径。</param>
/// <param name="schemaName">当前字段的原始 schema key。</param>
/// <param name="propertyName">当前字段归一化后的 CLR 属性名。</param>
/// <param name="schemaKeyByGeneratedPropertyName">同一对象内已分配的属性名与原始 schema key 对照表。</param>
/// <param name="diagnostic">检测到冲突时返回的诊断。</param>
/// <returns>当生成属性名在当前对象作用域内唯一时返回 <see langword="true" />。</returns>
private static bool TryRegisterGeneratedPropertyName(
string filePath,
string displayPath,
string schemaName,
string propertyName,
IDictionary<string, string> schemaKeyByGeneratedPropertyName,
out Diagnostic? diagnostic)
{
if (!schemaKeyByGeneratedPropertyName.TryGetValue(propertyName, out var existingSchemaName))
{
schemaKeyByGeneratedPropertyName.Add(propertyName, schemaName);
diagnostic = null;
return true;
}
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.DuplicateGeneratedIdentifier,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
schemaName,
propertyName,
existingSchemaName);
return false;
}
/// <summary>
/// 将 schema 文件名派生的根实体名验证为生成代码可直接使用的根类型标识符。
/// 这里与属性名验证保持一致,避免文件名中的前导数字或其他非法字符把根配置类型/表类型生成为无效 C# 标识符。

View File

@ -151,4 +151,15 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
/// <summary>
/// schema 字段名在标识符归一化后发生冲突。
/// </summary>
public static readonly DiagnosticDescriptor DuplicateGeneratedIdentifier = new(
"GF_ConfigSchema_014",
"Config schema property names collide after C# identifier normalization",
"Property '{1}' in schema file '{0}' uses schema key '{2}', which generates duplicate C# identifier '{3}' already produced by schema key '{4}'",
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
}

View File

@ -1975,6 +1975,49 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 验证同一对象内不同 schema key 若归一化后映射到同一属性名,会在生成前直接给出冲突诊断。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Schema_Keys_Collide_After_Identifier_Normalization()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "integer" },
"foo-bar": { "type": "string" },
"foo_bar": { "type": "string" }
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", schema));
var diagnostic = result.Results.Single().Diagnostics.Single();
Assert.Multiple(() =>
{
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_014"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("foo_bar"));
Assert.That(diagnostic.GetMessage(), Does.Contain("FooBar"));
Assert.That(diagnostic.GetMessage(), Does.Contain("foo-bar"));
});
}
/// <summary>
/// 验证 schema 顶层允许通过元数据覆盖默认配置目录,并会统一路径分隔符。
/// </summary>

View File

@ -1463,12 +1463,106 @@ public class CqrsHandlerRegistryGeneratorTests
Assert.That(generatedCompilationErrors, Is.Empty);
Assert.That(generatorErrors, Is.Empty);
Assert.That(execution.GeneratedSources, Has.Length.EqualTo(1));
Assert.That(execution.GeneratedSources[0].filename, Is.EqualTo("CqrsHandlerRegistry.g.cs"));
var generatedSource = execution.GeneratedSources[0].content;
Assert.That(
execution.GeneratedSources[0].content,
generatedSource,
Does.Contain("registryAssembly.GetType(\"MissingResponse\", throwOnError: false, ignoreCase: false);"));
Assert.That(
execution.GeneratedSources[0].content,
generatedSource,
Does.Contain("internal sealed class __GFrameworkGeneratedCqrsHandlerRegistry"));
Assert.That(generatedSource, Does.Not.Contain("typeof(MissingResponse)"));
Assert.That(generatedSource, Does.Not.Contain("CqrsReflectionFallbackAttribute("));
});
}
/// <summary>
/// 验证 <see langword="dynamic" /> 响应类型会在生成阶段归一化为 <see cref="System.Object" />
/// 避免注册器发射非法的 <c>typeof(dynamic)</c>。
/// </summary>
[Test]
public void Emits_Object_Type_Reference_When_Handler_Response_Uses_Dynamic()
{
const string source = """
using System;
namespace Microsoft.Extensions.DependencyInjection
{
public interface IServiceCollection { }
public static class ServiceCollectionServiceExtensions
{
public static void AddTransient(IServiceCollection services, Type serviceType, Type implementationType) { }
}
}
namespace GFramework.Core.Abstractions.Logging
{
public interface ILogger
{
void Debug(string msg);
}
}
namespace GFramework.Cqrs.Abstractions.Cqrs
{
public interface IRequest<TResponse> { }
public interface INotification { }
public interface IStreamRequest<TResponse> { }
public interface IRequestHandler<in TRequest, TResponse> where TRequest : IRequest<TResponse> { }
public interface INotificationHandler<in TNotification> where TNotification : INotification { }
public interface IStreamRequestHandler<in TRequest, out TResponse> where TRequest : IStreamRequest<TResponse> { }
}
namespace GFramework.Cqrs
{
public interface ICqrsHandlerRegistry
{
void Register(Microsoft.Extensions.DependencyInjection.IServiceCollection services, GFramework.Core.Abstractions.Logging.ILogger logger);
}
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class CqrsHandlerRegistryAttribute : Attribute
{
public CqrsHandlerRegistryAttribute(Type registryType) { }
}
}
namespace TestApp
{
using GFramework.Cqrs.Abstractions.Cqrs;
public sealed record DynamicRequest() : IRequest<dynamic>;
public sealed class DynamicHandler : IRequestHandler<DynamicRequest, dynamic>
{
}
}
""";
var execution = ExecuteGenerator(source);
var inputCompilationErrors = execution.InputCompilationDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var generatedCompilationErrors = execution.GeneratedCompilationDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var generatorErrors = execution.GeneratorDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
Assert.Multiple(() =>
{
Assert.That(inputCompilationErrors.Select(static diagnostic => diagnostic.Id), Does.Contain("CS1966"));
Assert.That(generatedCompilationErrors, Is.Empty);
Assert.That(generatorErrors, Is.Empty);
Assert.That(execution.GeneratedSources, Has.Length.EqualTo(1));
Assert.That(execution.GeneratedSources[0].filename, Is.EqualTo("CqrsHandlerRegistry.g.cs"));
var generatedSource = execution.GeneratedSources[0].content;
Assert.That(generatedSource, Does.Contain("typeof(global::System.Object)"));
Assert.That(generatedSource, Does.Not.Contain("typeof(dynamic)"));
Assert.That(generatedSource, Does.Contain("internal sealed class __GFrameworkGeneratedCqrsHandlerRegistry"));
});
}

View File

@ -7,8 +7,8 @@
## 当前恢复点
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-024`
- 当前阶段:`Phase 24`
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-025`
- 当前阶段:`Phase 25`
- 当前焦点:
- 已完成 `GFramework.Core` 当前 `MA0016` / `MA0002` / `MA0015` / `MA0077` 低风险收口批次
- 已复核 `net10.0` 下的 `MA0158` 基线:`GFramework.Core` / `GFramework.Cqrs` 当前共有 `16` 个 object lock
@ -27,6 +27,11 @@
并补齐 `LoggingConfigurationTests``CollectionExtensionsTests``Cqrs` helper 抽取与 `ai-plan` 命令文本修正
- 已完成当前 PR #269 第四轮 follow-up`CqrsHandlerRegistryGenerator` 的 Roslyn error type 直接引用改为
运行时精确查找路径,并为 `SchemaConfigGenerator` 补上根 `type` 非字符串时的防御与回归测试
- 已完成当前 PR #269 第五轮 follow-up`SchemaConfigGenerator` 补上归一化后属性名冲突诊断并新增
`GF_ConfigSchema_014``CqrsHandlerRegistryGenerator``dynamic` 归一化为 `global::System.Object`
同时收紧相关 generator regression tests
- 已更新 `AGENTS.md`:变更模块必须运行对应 `dotnet build -c Release`,并处理或显式报告模块构建 warning
不再默认留给长期 warning 清理分支
- `CoroutineScheduler` 的 tag/group 字典已显式使用 `StringComparer.Ordinal`,保持既有区分大小写语义
- `EasyEvents.AddEvent<T>()` 的重复注册路径已恢复为 `ArgumentException`,以保持既有异常契约
- `Option<T>` 已声明 `IEquatable<Option<T>>`,与已有强类型 `Equals(Option<T>)` 契约对齐
@ -63,6 +68,8 @@
并恢复 `EasyEvents` / `CollectionExtensions` / logging 配置模型的公共 API 兼容形状
- 已完成当前 PR #269 的第四轮 review follow-up确认 5 个 latest-head 未解决线程中仅剩 2 个本地仍成立,
已分别在 `CqrsHandlerRegistryGenerator``SchemaConfigGenerator` 中收口,并补齐定向 generator regression tests
- 已完成当前 PR #269 的第五轮 review follow-up收口 `SchemaConfigGenerator` 的归一化字段名冲突诊断、
`CqrsHandlerRegistryGenerator``dynamic` 类型引用风险,并同步更新 `AGENTS.md` 的模块 build / warning 治理规范
- 已完成 `GFramework.Game.SourceGenerators``SchemaConfigGenerator` 的第一批 `MA0051` 收口warnings-only 基线剩余 `9`
`MA0051`
@ -117,6 +124,9 @@
- `RP-024` 使用 `$gframework-pr-review` 继续复核 PR #269 latest-head unresolved threads确认 `EasyEvents` 异常契约、
`SchemaConfigGenerator` 取消传播与 `ContextAwareGenerator` 快照冲突线程均已在本地收口,仅剩 `Cqrs` error type
直接引用与根 schema `type` 非字符串防御仍成立;现已补齐实现与回归测试
- `RP-025` 继续复核 PR #269 剩余 outside-diff / nitpick 信号后,确认本地仍成立的是 `SchemaConfigGenerator`
的归一化字段名冲突与 `Cqrs``dynamic` 的直接类型引用;已分别补上诊断、运行时类型归一化与回归测试,
并把“变更模块必须运行对应 build 且处理 warning”的治理规则写回 `AGENTS.md`
- 当前工作树分支 `fix/analyzer-warning-reduction-batch` 已在 `ai-plan/public/README.md` 建立 topic 映射
## 当前风险
@ -132,6 +142,9 @@
- source generator warning 外溢风险:运行 `GFramework.SourceGenerators.Tests` 会构建相邻 generator/test 项目并显示既有
`GFramework.Game.SourceGenerators` 与测试项目 warning
- 缓解措施:继续以被修改 generator 项目的独立 warnings-only build 作为主验收,并用 focused generator test 验证行为
- source generator test warning 治理风险:`GFramework.SourceGenerators.Tests` 当前仍有既有 `MA0051` / `MA0004` / `MA0048`
warning本轮 focused test 已通过,但测试项目整包 warning 尚未进入本轮写集
- 缓解措施:后续若继续修改该测试项目,应按新增 `AGENTS.md` 规则先跑其独立 build并在进入下一轮实现前明确 warning 收口范围
- Godot 资产文件环境风险:当前 worktree 的 `GFramework.Godot` restore/build 仍会命中 Windows fallback package folder
- 缓解措施:后续若继续触达 Godot 模块,先用 Linux 侧 restore 资产或 Windows-hosted 构建链刷新该项目,再补跑定向 build
- 并行实现风险:批量收敛时若 subagent 写入边界不清晰,容易引入命名冲突或重复重构

View File

@ -1,5 +1,41 @@
# Analyzer Warning Reduction 追踪
## 2026-04-23 — RP-025
### 阶段PR #269 第五轮 review follow-up 与模块 build / warning 治理补充RP-025
- 启动复核:
- 继续使用 `$gframework-pr-review` 读取 PR #269 当前 latest review、outside-diff comment、nitpick comment 与 open-thread 摘要
- 本地核对后确认 `SchemaConfigGenerator` 的取消传播、根 `type` 非字符串防御、`ContextAware` 冲突快照与
`Cqrs` error type 线程均已是陈旧信号;仍成立的是归一化字段名冲突与 `dynamic` 运行时类型引用问题
- 决策:
- `SchemaConfigGenerator` 不复用 `GF_ConfigSchema_006`,改为新增专门的冲突诊断 `GF_ConfigSchema_014`
避免把“标识符非法”和“归一化后重名”混成同一类错误
- `CqrsHandlerRegistryGenerator``dynamic` 采用“生成期归一化为 `global::System.Object`”策略,而不是退回更宽泛的
fallback 路径,保持精确注册能力且避免发射 `typeof(dynamic)`
- `AGENTS.md` 增加模块级 build / warning 治理规则,要求后续改代码时必须对受影响模块跑 Release build并处理或显式报告 warning
- 实施调整:
- 为 `SchemaConfigGenerator` 增加对象级生成属性名登记 helper`ParseObjectSpec(...)` 中拦截 `foo-bar` /
`foo_bar` 这类归一化后冲突,并新增 `ConfigSchemaDiagnostics.DuplicateGeneratedIdentifier`
- 为 `SchemaConfigGeneratorTests` 补上冲突诊断回归测试;为 `CqrsHandlerRegistryGeneratorTests` 收紧
unresolved-type 断言并新增 `dynamic` 类型归一化回归测试
- 为 `CqrsHandlerRegistryGenerator.RuntimeTypeReferences` 增加 `TypeKind.Dynamic` 归一化处理,并保持
`TypeKind.Error` 的保守回退
- 为 `AGENTS.md` 补充“受影响模块必须独立 build 且 warning 不能默认甩给长期分支”的硬性规范
- 验证结果:
- `dotnet restore GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -p:RestoreFallbackFolders="" -nologo`
- 结果:通过;并行 restore 时出现一次共享 `obj` 文件已存在的竞争噪音,串行验证后未再复现
- `dotnet build GFramework.Cqrs.SourceGenerators/GFramework.Cqrs.SourceGenerators.csproj -c Release --no-restore -p:RestoreFallbackFolders="" -clp:"Summary;WarningsOnly" -nologo`
- 结果:`0 Warning(s)``0 Error(s)`
- `dotnet build GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -c Release --no-restore -p:RestoreFallbackFolders="" -clp:"Summary;WarningsOnly" -nologo`
- 结果:`9 Warning(s)``0 Error(s)`;维持既有 `SchemaConfigGenerator.cs` `MA0051` 基线,未新增 warning
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --filter "FullyQualifiedName~Run_Should_Report_Diagnostic_When_Schema_Keys_Collide_After_Identifier_Normalization|FullyQualifiedName~Emits_Object_Type_Reference_When_Handler_Response_Uses_Dynamic|FullyQualifiedName~Emits_Runtime_Type_Lookup_When_Handler_Contract_Contains_Unresolved_Error_Types" -m:1 -p:RestoreFallbackFolders="" -nologo`
- 结果:`3 Passed``0 Failed`
- 说明:测试项目构建仍打印既有 `MA0051` / `MA0004` / `MA0048` warning不属于本轮 generator 模块写集,但已在 tracking 风险中记录
- 下一步建议:
- 若继续收口 PR #269,可再次抓取最新 unresolved threads确认 GitHub 上剩余 open thread 是否全部转为陈旧信号
- 若回到 analyzer 主线,继续推进 `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` 剩余 `MA0051`
## 2026-04-22 — RP-024
### 阶段PR #269 第四轮 review follow-up 收口RP-024