diff --git a/.editorconfig b/.editorconfig
index 01a8f2e09..fde3db835 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -11,6 +11,11 @@ dotnet_diagnostic.GFX0019.severity = error
dotnet_diagnostic.GFX0020.severity = error
dotnet_diagnostic.GFX0021.severity = error
+# Hotfix Wrapper 源码生成器诊断(GameFrameX.Hotfix.WrapperGenerator/Utils/Logger.cs 分配):
+# GFW 前缀与分析器 GFX0001-0021 错开;GFW0001=契约违规(Error)、GFW0002=契约警示(Warning),可按 ID 抑制或升级
+dotnet_diagnostic.GFW0001.severity = error
+dotnet_diagnostic.GFW0002.severity = warning
+
# PB 库(vendored 生态移植,不按本仓风格改造,整目录排除)
[GameFrameX.ProtoBuf.Net/**.cs]
csharp_prefer_braces = true:none
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82926307d..6c76c54eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@
* 架构分析器守卫三项收口(C429/C430,GFX-1170,GitHub #916):GFX0016 候选集从仅 Class 放宽为全部命名类型种类——struct/interface/record 声明面引用 `MongoDbService`/`PostgreSqlDbService`/`MultiDbRegistry` 不再漏报(自行注册 NamedType 符号分析,实现层白名单与豁免不变);GFX0001 补抽象类豁免(与 GFX0005/0008/0009 一致,非 Apps 程序集的抽象中间状态类不再被误拦);生成 Wrapper 豁免改为「`GeneratedCodeAttribute` 标记(tool 名精确匹配)+ `.g.cs` 文件路径 + 仅 Hotfix 内名称后缀兜底」三段判定,Hotfix 之外手写 `ComponentAgentWrapper` 后缀类不再同时逃过 GFX0005/GFX0014;WrapperGenerator 生成类头追加 `GeneratedCodeAttribute` 标记(与分析器 `WrapperGeneratorToolName` 同值契约)。
* 入队与定时撮合解耦(C406,GFX-1104):删除每次入队的全量撮合及最新票据扫描,复用 `matchmaker-run` 按宿主调度节奏批量处理队列(默认 1000ms)。单轮撮合算法保持不变;桥测试覆盖四名玩家单批次形成两组及批次前取消不参与分配。
+* Hotfix Wrapper 生成器诊断体验、增量管道与低风险清理(C432,GFX-1172,GitHub #918,CODE_REVIEW CR-M06/M07/L03/L04/L05):①诊断改为静态描述符 + 稳定 ID `GFW0001`(契约违规 Error)/ `GFW0002`(契约警示 Warning),携带违规方法 `GetLocation()` 位置(错误列表可点击定位、可经 `.editorconfig` `dotnet_diagnostic_GFW000x.severity` 按 ID 抑制),消息统一引用 `Service`/`ThreadSafe`/`Discard`/`TimeOut` 真实特性常量(消灭不存在的【Api】/【AsyncApi】幻觉名);②增量管道按「代理类声明 → 输出」粒度注册(`WithComparer` 文本等值缓存 + `WithTrackingName`),替代 `Collect()` 全量折叠——单个代理类编辑只重算该代理 wrapper(生产 25 个代理全量生成产物逐字符不变,非 partial hintName 逐字节不变),partial 部件 hintName 序号改声明位置稳定键(FNV-1a 64,跨进程确定);语义调整:无基类列表的 partial 类不再按简单名并入生成面(生产零实例,需要拆分的代理应每部件自带相同基类列表);③`AgentTemplate` 删 `[ThreadStatic]` 静态 `StringBuilder`(局部实例参数化传递,消除矛盾注释);④`ComponentAgentBaseNames` 删除全仓不存在的 `QueryComponentAgent` 死条目。
* 撮合通知异步执行与停用退订(C407,GFX-1105,GitHub #733):`ICapabilityEventBus.Subscribe` 返回幂等 `IDisposable` 退订句柄(void → IDisposable 为二进制破坏性变更——包族消费方需重新编译;源码侧忽略返回值的既有调用不受影响);online-match 模块撮合分配事件回调改为纯入队(移除同步总线回调内 `ResolveAssignmentAsync` / `session.WriteAsync` 两处 `GetAwaiter().GetResult()` 死锁面),新增 `match-assignment-notification-tick` 每轮有限批次(64)异步解析并推送(条目级失败不阻断同批其余条目,异常交调度器既有日志路径);`DeactivateAsync` 停用收口——关闭入队、退订、清空待处理并等待在途批次完成,热更/停机后旧模块零通知。回归 13 例:总线退订契约 5 + 模块生命周期 8(含对旧同步实现确定性红的发布不同步解析 / 停用退订 / 停用清空 / 容错批次)。
* 举报入口频控与未结案去重(C405,GFX-1115):举报提交接入双固定窗口限流(`OnlineSocialOptions.Report.MaximumSubmissionsPerMinute=5` / `MaximumSubmissionsPerDay=50`,键=租户/App/举报人,超限 7001 且不落案件不发事件,分钟窗惰性清扫仅删双窗均过期的桶);`IOnlineReportStore.SaveIfAbsentAsync` 升级为两层原子唯一(案件标识重放 + 未结案组合键「举报人+被举报人+场景」,Submitted/Reviewing 占位、终态释放),重复举报返回原案件原证据且仅首建发 `ReportChanged`;`ReplaceAsync` 身份三要素不可改、终态释放与新占位归属在 CAS 同锁维护;`OnlineSocialDecisionService` 构造新增必填 `OnlineReportOptions`,宿主自定义举报存储必须遵循同一原子唯一性语义
diff --git a/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs b/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs
index 1ecf3182f..fe61f2d23 100644
--- a/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs
+++ b/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs
@@ -39,21 +39,6 @@ namespace GameFrameX.Hotfix.WrapperGenerator.Agent;
///
public static class AgentTemplate
{
- ///
- /// 用于拼接生成代码的字符串构建器。
- ///
- ///
- /// String builder for assembling generated code.
- ///
- ///
- /// 每次构建局部的 StringBuilder(并发驱动运行互不共享状态,产出各自完整一致)。
- ///
- ///
- /// A local StringBuilder per run: concurrent driver executions share no state and each produces complete, consistent output.
- ///
- [ThreadStatic]
- private static StringBuilder TemplateStringBuilder;
-
///
/// 生成器标识:写入生成类 GeneratedCodeAttribute 的 tool 名。
///
@@ -71,8 +56,6 @@ public static class AgentTemplate
/// The version argument written into the GeneratedCodeAttribute on generated wrapper classes.
///
private const string GeneratedCodeVersion = "1.0";
-
-
///
/// 根据代理信息生成包装器分部类的源代码。
///
@@ -83,33 +66,34 @@ public static class AgentTemplate
/// 生成的 C# 源代码字符串 / Generated C# source code string
public static string Run(AgentInfo info)
{
- TemplateStringBuilder = new StringBuilder();
-
+ // C432:局部 StringBuilder 逐级传参——模板无任何跨调用静态状态(并发驱动运行互不共享,
+ // 产出各自完整一致);替代历史 [ThreadStatic] 静态字段形态。
+ var templateStringBuilder = new StringBuilder();
foreach (var value in info.UsingSpaces)
{
- TemplateStringBuilder.AppendLine("using " + value + ";");
+ templateStringBuilder.AppendLine("using " + value + ";");
}
- TemplateStringBuilder.AppendLine();
+ templateStringBuilder.AppendLine();
- TemplateStringBuilder.AppendLine("namespace " + info.Space);
- TemplateStringBuilder.AppendLine("{");
+ templateStringBuilder.AppendLine("namespace " + info.Space);
+ templateStringBuilder.AppendLine("{");
if (info.EmitGeneratedCodeMarker)
{
- TemplateStringBuilder.AppendLine("\t[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"" + GeneratedCodeToolName + "\", \"" + GeneratedCodeVersion + "\")]");
+ templateStringBuilder.AppendLine("\t[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"" + GeneratedCodeToolName + "\", \"" + GeneratedCodeVersion + "\")]");
}
- TemplateStringBuilder.AppendLine("\tpublic partial class " + info.Name + " : " + info.Super);
- TemplateStringBuilder.AppendLine("\t{");
+ templateStringBuilder.AppendLine("\tpublic partial class " + info.Name + " : " + info.Super);
+ templateStringBuilder.AppendLine("\t{");
foreach (var infoMethod in info.Methods)
{
- AppendMethod(infoMethod);
+ AppendMethod(templateStringBuilder, infoMethod);
}
- TemplateStringBuilder.AppendLine("\t}");
- TemplateStringBuilder.AppendLine("}");
+ templateStringBuilder.AppendLine("\t}");
+ templateStringBuilder.AppendLine("}");
- return TemplateStringBuilder.ToString();
+ return templateStringBuilder.ToString();
}
///
@@ -118,23 +102,24 @@ public static string Run(AgentInfo info)
///
/// Appends the method declaration and body for a single method.
///
+ /// 生成代码的字符串构建器 / String builder for the generated code
/// 方法元数据 / Method metadata
- private static void AppendMethod(MethodInfoData infoMethod)
+ private static void AppendMethod(StringBuilder templateStringBuilder, MethodInfoData infoMethod)
{
- TemplateStringBuilder.AppendLine("\t\t" + infoMethod.Declare);
- TemplateStringBuilder.AppendLine("\t\t{");
+ templateStringBuilder.AppendLine("\t\t" + infoMethod.Declare);
+ templateStringBuilder.AppendLine("\t\t{");
if (infoMethod.Discard)
{
- AppendDiscardBody(infoMethod);
+ AppendDiscardBody(templateStringBuilder, infoMethod);
}
else
{
- AppendInvocationBody(infoMethod);
+ AppendInvocationBody(templateStringBuilder, infoMethod);
}
- TemplateStringBuilder.AppendLine("\t\t}");
- TemplateStringBuilder.AppendLine();
+ templateStringBuilder.AppendLine("\t\t}");
+ templateStringBuilder.AppendLine();
}
///
@@ -143,20 +128,21 @@ private static void AppendMethod(MethodInfoData infoMethod)
///
/// Appends the method body for discard (fire-and-forget) invocations.
///
+ /// 生成代码的字符串构建器 / String builder for the generated code
/// 方法元数据 / Method metadata
- private static void AppendDiscardBody(MethodInfoData infoMethod)
+ private static void AppendDiscardBody(StringBuilder templateStringBuilder, MethodInfoData infoMethod)
{
if (infoMethod.IsThreadSafe)
{
- TemplateStringBuilder.AppendLine($"\t\t\t_ = base.{infoMethod.Name}{infoMethod.Typeparams}({infoMethod.ParamString});");
+ templateStringBuilder.AppendLine($"\t\t\t_ = base.{infoMethod.Name}{infoMethod.Typeparams}({infoMethod.ParamString});");
}
else
{
- TemplateStringBuilder.AppendLine("\t\t\tlong callChainId = GameFrameX.Core.Actors.Impl.WorkerActor.NextChainId();");
- TemplateStringBuilder.AppendLine($"\t\t\t_ = base.Actor.WorkerActor.EnqueueAsync(async ()=> await base.{infoMethod.Name}{infoMethod.Typeparams}({infoMethod.ParamString}), callChainId, true{BuildTimeoutArgument(infoMethod)});");
+ templateStringBuilder.AppendLine("\t\t\tlong callChainId = GameFrameX.Core.Actors.Impl.WorkerActor.NextChainId();");
+ templateStringBuilder.AppendLine($"\t\t\t_ = base.Actor.WorkerActor.EnqueueAsync(async ()=> await base.{infoMethod.Name}{infoMethod.Typeparams}({infoMethod.ParamString}), callChainId, true{BuildTimeoutArgument(infoMethod)});");
}
- TemplateStringBuilder.AppendLine($"\t\t\treturn {infoMethod.ReturnType}.CompletedTask;");
+ templateStringBuilder.AppendLine($"\t\t\treturn {infoMethod.ReturnType}.CompletedTask;");
}
///
@@ -165,24 +151,25 @@ private static void AppendDiscardBody(MethodInfoData infoMethod)
///
/// Appends the method body for invocations that await a result.
///
+ /// 生成代码的字符串构建器 / String builder for the generated code
/// 方法元数据 / Method metadata
- private static void AppendInvocationBody(MethodInfoData infoMethod)
+ private static void AppendInvocationBody(StringBuilder templateStringBuilder, MethodInfoData infoMethod)
{
// async 方法转发底层任务必须 await(非泛型 Task/ValueTask 同样如此);是否泛型与该判定无关
var needAwait = infoMethod.IsAsync;
- TemplateStringBuilder.AppendLine("\t\t\t(bool needEnqueue, long chainId)= base.Actor.WorkerActor.IsNeedEnqueue();");
- TemplateStringBuilder.AppendLine("\t\t\tif (!needEnqueue)");
- TemplateStringBuilder.AppendLine("\t\t\t{");
- TemplateStringBuilder.AppendLine($"\t\t\t\treturn {(needAwait ? "await " : string.Empty)} base.{infoMethod.Name}{infoMethod.Typeparams}({infoMethod.ParamString});");
- TemplateStringBuilder.AppendLine("\t\t\t}");
- TemplateStringBuilder.AppendLine($"\t\t\treturn {(needAwait ? "await " : string.Empty)} base.Actor.WorkerActor.EnqueueAsync(()=>base.{infoMethod.Name}{infoMethod.Typeparams}({infoMethod.ParamString}), chainId, {(infoMethod.Discard ? "true" : "false")}{BuildTimeoutArgument(infoMethod)});");
+ templateStringBuilder.AppendLine("\t\t\t(bool needEnqueue, long chainId)= base.Actor.WorkerActor.IsNeedEnqueue();");
+ templateStringBuilder.AppendLine("\t\t\tif (!needEnqueue)");
+ templateStringBuilder.AppendLine("\t\t\t{");
+ templateStringBuilder.AppendLine($"\t\t\t\treturn {(needAwait ? "await " : string.Empty)} base.{infoMethod.Name}{infoMethod.Typeparams}({infoMethod.ParamString});");
+ templateStringBuilder.AppendLine("\t\t\t}");
+ templateStringBuilder.AppendLine($"\t\t\treturn {(needAwait ? "await " : string.Empty)} base.Actor.WorkerActor.EnqueueAsync(()=>base.{infoMethod.Name}{infoMethod.Typeparams}({infoMethod.ParamString}), chainId, {(infoMethod.Discard ? "true" : "false")}{BuildTimeoutArgument(infoMethod)});");
}
///
/// 构建超时实参片段:显式配置 [TimeOut] 时发射「, 超时值」,未配置时省略实参走 EnqueueAsync 框架默认值。
///
///
- /// Builds the timeout argument fragment: emits ", " only when [TimeOut] was explicitly configured;
+ /// Builds the timeout argument fragment: emits ", <ms>" only when [TimeOut] was explicitly configured;
/// otherwise the argument is omitted so EnqueueAsync's framework default applies.
///
/// 方法元数据 / Method metadata
diff --git a/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs b/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs
index 95ea7a108..8ca69006a 100644
--- a/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs
+++ b/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs
@@ -105,105 +105,67 @@ public class HotfixWrapperGenerator : IIncrementalGenerator
private const string TimeOutAttributeName = "TimeOut";
///
- /// 初始化增量生成器,注册语法提供者和源代码输出。
+ /// 增量管线输出步骤的跟踪名:测试与诊断经 TrackedSteps[该名] 断言逐声明的缓存/重算行为。
///
///
- /// Initializes the incremental generator, registering syntax providers and source output.
+ /// Tracking name of the incremental pipeline's output step: tests and diagnostics assert per-declaration
+ /// cached/recomputed behavior via TrackedSteps[this name].
///
- /// 增量生成器初始化上下文 / Incremental generator initialization context
- public void Initialize(IncrementalGeneratorInitializationContext context)
- {
- var candidates = context.SyntaxProvider
- .CreateSyntaxProvider(
- static (node, _) =>
- {
- var c = node as ClassDeclarationSyntax;
- if (c == null)
- {
- return false;
- }
-
- return c.BaseList != null || c.Modifiers.Any(m => m.Text == "partial");
- },
- static (ctx, _) => (ClassDeclarationSyntax)ctx.Node)
- .Collect();
-
- context.RegisterSourceOutput(candidates, static (productionContext, classes) => { Execute(productionContext, classes); });
- }
-
- ///
- /// 执行代码生成,遍历所有候选类并为符合条件的代理类生成包装器代码。
- ///
- ///
- /// Executes code generation, iterating all candidate classes and generating wrapper code for qualifying agent classes.
- ///
- /// 源代码生成上下文 / Source production context
- /// 所有候选类声明语法节点列表 / List of all candidate class declaration syntax nodes
- private static void Execute(SourceProductionContext context, IReadOnlyList allClasses)
- {
- var agents = CollectAgents(allClasses);
- var partialClassCount = new Dictionary();
- var emittedWrapperCount = new Dictionary();
-
- foreach (var agent in agents)
- {
- ProcessAgent(context, agent, partialClassCount, emittedWrapperCount);
- }
- }
+ private const string WrapperOutputTrackingName = "HotfixWrapperOutput";
///
- /// 从候选类中收集组件代理类,并把与代理类同名的分部类合并进结果列表。
+ /// 初始化增量生成器:按「代理类声明 → 输出」粒度注册管道与输出。
///
///
- /// Collects component agent classes from the candidates and merges same-named partial classes into the result list.
+ /// Initializes the incremental generator, registering the pipeline and outputs at the granularity of
+ /// one agent class declaration per output.
///
- /// 所有候选类声明语法节点列表 / List of all candidate class declaration syntax nodes
- /// 合并分部类后的代理类列表 / The list of agent classes after merging partial classes
- private static List CollectAgents(IReadOnlyList allClasses)
+ /// 增量生成器初始化上下文 / Incremental generator initialization context
+ public void Initialize(IncrementalGeneratorInitializationContext context)
{
- var agentNames = new HashSet();
- var agents = new List();
- var partials = new List();
-
- foreach (var c in allClasses)
- {
- if (IsCompAgent(c))
+ // C432:逐声明输出注册替代 Collect() 全量折叠——单个代理类声明的编辑只重算该声明的 wrapper,
+ // 未变更声明经 WithComparer 文本等值命中缓存(下游输出回调不重跑),IDE 增量行为与内存分配
+ // 不再随工程规模线性恶化。谓词含 BaseList 先导门:partial 但无基类列表的类不进入生成面
+ // (C# 允许 partial 各部件重复相同基类列表,需要拆分的代理应让每个部件自带基类列表)。
+ var wrappers = context.SyntaxProvider
+ .CreateSyntaxProvider(
+ static (node, _) =>
+ {
+ var declaration = node as ClassDeclarationSyntax;
+ return declaration != null && declaration.BaseList != null && IsCompAgent(declaration);
+ },
+ static (syntaxContext, _) => GenerateWrapper((ClassDeclarationSyntax)syntaxContext.Node))
+ .WithComparer(WrapperResultComparer.Instance)
+ .WithTrackingName(WrapperOutputTrackingName);
+
+ context.RegisterSourceOutput(wrappers, static (productionContext, result) =>
+ {
+ foreach (var diagnostic in result.Diagnostics)
{
- // 合并键用完全限定名:跨命名空间同简单名的无关 partial 类不得并入代理(幽灵 Wrapper)
- agents.Add(c);
- agentNames.Add(c.GetFullName());
+ productionContext.ReportDiagnostic(diagnostic);
}
- else if (c.Modifiers.Any(m => m.Text == "partial"))
- {
- partials.Add(c);
- }
- }
- foreach (var p in partials)
- {
- if (agentNames.Contains(p.GetFullName()))
+ if (!string.IsNullOrEmpty(result.Source))
{
- agents.Add(p);
+ productionContext.AddSource(result.HintName, result.Source);
}
- }
-
- return agents;
+ });
}
///
- /// 处理单个代理类:构建代理信息、收集方法元数据并生成包装器源代码。
+ /// 为单个代理类声明生成包装器结果:构建代理信息、收集方法元数据与诊断,产出源代码文本。
///
///
- /// Processes a single agent class: builds agent info, collects method metadata, and emits the wrapper source code.
+ /// Generates the wrapper result for a single agent class declaration: builds agent info, collects
+ /// method metadata and diagnostics, and produces the source text.
///
- /// 源代码生成上下文 / Source production context
/// 代理类声明语法节点 / Agent class declaration syntax node
- /// 分部类输出文件名计数表 / Partial class output filename counter map
- /// 每个代理已落盘的 Wrapper 分部数计数表 / Emitted wrapper piece counter map per agent
- private static void ProcessAgent(SourceProductionContext context, ClassDeclarationSyntax agent, Dictionary partialClassCount, Dictionary emittedWrapperCount)
+ /// 该声明的生成结果(无可包装方法时 Source 为 null,仅携带诊断)/ The result for this declaration (Source is null and only diagnostics travel when there is no wrappable method)
+ private static WrapperResult GenerateWrapper(ClassDeclarationSyntax agent)
{
var fullName = agent.GetFullName();
- var info = CreateAgentInfo(agent, fullName, partialClassCount, out var outFileName);
+ var info = CreateAgentInfo(agent, fullName, out var outFileName);
+ var diagnostics = new List();
var root = agent.SyntaxTree.GetCompilationUnitRoot();
foreach (var element in root.Usings)
@@ -220,23 +182,15 @@ private static void ProcessAgent(SourceProductionContext context, ClassDeclarati
foreach (var member in agent.Members)
{
- AddAgentMethod(context, info, fullName, member);
+ AddAgentMethod(diagnostics, info, fullName, member);
}
if (info.Methods.Count == 0)
{
// 无可包装方法的代理:不产出任何 wrapper 分部类(空壳无行为,只污染生成面)。
- return;
+ return new WrapperResult(outFileName, null, diagnostics.ToArray());
}
- // GeneratedCodeAttribute 的 AllowMultiple 为 false:同一 Wrapper 的多个落盘分部中只有首个可携带标记,
- // 否则分部代理会因重复特性编译失败(CS0579)。无可包装方法的分部不落盘,
- // 故按「实际落盘分部」计数而非声明序号——标记始终随首个落盘分部发射。
- emittedWrapperCount.TryGetValue(fullName, out var emittedCount);
- info.EmitGeneratedCodeMarker = emittedCount == 0;
- emittedWrapperCount[fullName] = emittedCount + 1;
-
- var source = AgentTemplate.Run(info);
- context.AddSource(outFileName, source);
+ return new WrapperResult(outFileName, AgentTemplate.Run(info), diagnostics.ToArray());
}
///
@@ -247,10 +201,9 @@ private static void ProcessAgent(SourceProductionContext context, ClassDeclarati
///
/// 代理类声明语法节点 / Agent class declaration syntax node
/// 代理类完全限定名 / Fully qualified name of the agent class
- /// 分部类输出文件名计数表 / Partial class output filename counter map
/// 生成的输出文件名 / The generated output filename
/// 填充好的代理信息 / The populated agent info
- private static AgentInfo CreateAgentInfo(ClassDeclarationSyntax agent, string fullName, Dictionary partialClassCount, out string outFileName)
+ private static AgentInfo CreateAgentInfo(ClassDeclarationSyntax agent, string fullName, out string outFileName)
{
var info = new AgentInfo();
@@ -276,18 +229,18 @@ private static AgentInfo CreateAgentInfo(ClassDeclarationSyntax agent, string fu
// 后缀识别不受影响。顶层代理与「简单名+Wrapper」逐字一致。
info.Name = containerQualifiedTypeName.Replace('.', '_') + WrapperNameSuffix;
+ // C432:partial 声明的 hintName 序号由声明位置稳定键派生(替代历史全局计数——计数依赖跨类
+ // 处理顺序,与逐声明独立缓存互斥);非 partial 声明无后缀,与既有输出文件名逐字节一致。
var isPartialClass = agent.Modifiers.ToList().FindIndex(s => s.Text == "partial") >= 0;
- if (isPartialClass)
- {
- // 计数键用完全限定名:同名代理分属不同命名空间时各自独立编号,不再互相污染
- partialClassCount.TryGetValue(fullName, out var count);
- partialClassCount[fullName] = count + 1;
- outFileName = BuildHintName(fullName, count);
- }
- else
- {
- outFileName = BuildHintName(fullName, null);
- }
+
+ // GeneratedCodeAttribute 的 AllowMultiple 为 false:同一 Wrapper 的多个落盘分部若都携带标记会因
+ // 重复特性编译失败(CS0579)。逐声明独立缓存无法稳定选举「首个落盘分部」这一跨声明状态,
+ // 故仅非 partial 的单体声明发射标记;partial 分部省略标记,架构分析器豁免由 hintName 的
+ // 「.g.cs」路径段与 ComponentAgentWrapper 名称后缀兜底(三段判定,见 GFX0016/GFX0014 收口)。
+ info.EmitGeneratedCodeMarker = !isPartialClass;
+ outFileName = isPartialClass
+ ? BuildHintName(fullName, BuildDeclarationPositionKey(agent))
+ : BuildHintName(fullName, null);
return info;
}
@@ -299,14 +252,53 @@ private static AgentInfo CreateAgentInfo(ClassDeclarationSyntax agent, string fu
/// Builds the unique generated file name (hintName) from the agent's fully qualified name.
///
/// 代理类完全限定名 / Fully qualified name of the agent class
- /// 分部类序号(非分部类为 null)/ Partial class sequence number (null for non-partial)
+ /// 分部类声明的位置稳定键(非分部类为 null)/ Position-stable key of the partial declaration (null for non-partial)
/// 唯一的 hintName / A unique hintName
- private static string BuildHintName(string fullName, int? sequence)
+ private static string BuildHintName(string fullName, string declarationPositionKey)
{
// 命名空间分隔符与嵌套类分隔符折叠为不同占位(. → _、+ → __),保证映射单射:
// NS.A.B(命名空间 NS.A 的类 B)与 NS.A+B(NS 下类 A 的嵌套类 B)净化后不再碰撞
var sanitized = (fullName + WrapperNameSuffix).Replace(".", "_").Replace("+", "__");
- return sequence.HasValue ? $"{sanitized}{sequence.Value}.g.cs" : $"{sanitized}.g.cs";
+ return string.IsNullOrEmpty(declarationPositionKey)
+ ? sanitized + ".g.cs"
+ : sanitized + declarationPositionKey + ".g.cs";
+ }
+
+ ///
+ /// 由声明位置(语法树文件路径 + 声明起始偏移)派生跨进程稳定的十六进制键,用作分部类 hintName 后缀。
+ ///
+ ///
+ /// Derives a process-stable hexadecimal key from the declaration position (syntax tree file path plus
+ /// declaration start offset), used as the partial-class hintName suffix.
+ ///
+ /// 分部类声明语法节点 / Partial class declaration syntax node
+ /// 16 位十六进制稳定键 / A 16-digit hexadecimal stable key
+ private static string BuildDeclarationPositionKey(ClassDeclarationSyntax declaration)
+ {
+ // FNV-1a 64 位:与运行进程无关的确定性哈希。禁止 string.GetHashCode——其随机化种子使 hintName
+ // 跨进程/跨运行漂移,会破坏生成文件名的稳定性。同一 partial 类两部件的声明位置必不相同,
+ // 64 位键碰撞概率可忽略;路径与偏移之间插入 ':' 分隔,避免「路径A+偏移12」与「路径A1+偏移2」一类拼接歧义。
+ const ulong offsetBasis = 14695981039346656037;
+ const ulong prime = 1099511628211;
+ var hash = offsetBasis;
+ foreach (var character in declaration.SyntaxTree.FilePath ?? string.Empty)
+ {
+ hash ^= character;
+ hash *= prime;
+ }
+
+ hash ^= ':';
+ hash *= prime;
+
+ var spanStart = (ulong)declaration.SpanStart;
+ while (spanStart > 0)
+ {
+ hash ^= spanStart & 0xFF;
+ hash *= prime;
+ spanStart >>= 8;
+ }
+
+ return hash.ToString("X16", CultureInfo.InvariantCulture);
}
///
@@ -315,11 +307,11 @@ private static string BuildHintName(string fullName, int? sequence)
///
/// Processes a single member of the agent class: if it is a method to wrap, reads its metadata and collects it into the agent info.
///
- /// 源代码生成上下文 / Source production context
+ /// 诊断缓冲(收集期间产生的违规诊断)/ Diagnostics buffer (violations found during collection)
/// 代理信息 / Agent info
/// 代理类完全限定名 / Fully qualified name of the agent class
/// 成员声明语法节点 / Member declaration syntax node
- private static void AddAgentMethod(SourceProductionContext context, AgentInfo info, string fullName, MemberDeclarationSyntax member)
+ private static void AddAgentMethod(List diagnostics, AgentInfo info, string fullName, MemberDeclarationSyntax member)
{
if (!(member is MethodDeclarationSyntax method))
{
@@ -343,10 +335,10 @@ private static void AddAgentMethod(SourceProductionContext context, AgentInfo in
mth.ReturnType = method.ReturnType?.ToString() ?? "void";
foreach (var attributeListSyntax in method.AttributeLists)
{
- ApplyAttribute(context, fullName, method, attributeListSyntax, mth);
+ ApplyAttribute(diagnostics, fullName, method, attributeListSyntax, mth);
}
- CollectMethod(context, info, fullName, method, mth);
+ CollectMethod(diagnostics, info, fullName, method, mth);
}
///
@@ -395,12 +387,12 @@ private static void ReadModifiers(MethodDeclarationSyntax method, MethodInfoData
/// Parses every attribute in a single attribute list, exactly matching Service/Discard/TimeOut/ThreadSafe by name and populating the method metadata.
/// TimeOut 实参经防御式解析:无法静态求值时输出 Error 诊断并按未配置超时处理,绝不让异常外泄令生成器整体崩溃。
///
- /// 源代码生成上下文 / Source production context
+ /// 诊断缓冲 / Diagnostics buffer
/// 代理类完全限定名 / Fully qualified name of the agent class
/// 方法声明语法节点 / Method declaration syntax node
/// 属性列表语法节点 / Attribute list syntax node
/// 待填充的方法元数据 / Method metadata to populate
- private static void ApplyAttribute(SourceProductionContext context, string fullName, MethodDeclarationSyntax method, AttributeListSyntax attributeListSyntax, MethodInfoData mth)
+ private static void ApplyAttribute(List diagnostics, string fullName, MethodDeclarationSyntax method, AttributeListSyntax attributeListSyntax, MethodInfoData mth)
{
foreach (var attribute in attributeListSyntax.Attributes)
{
@@ -434,7 +426,7 @@ private static void ApplyAttribute(SourceProductionContext context, string fullN
var rawArgument = attribute.ArgumentList != null && attribute.ArgumentList.Arguments.Count > 0
? attribute.ArgumentList.Arguments[0].ToString()
: string.Empty;
- context.LogError($"{fullName}.{method.Identifier.Text}【{TimeOutAttributeName}】参数必须是整数字面量(毫秒),当前无法解析:{rawArgument}");
+ diagnostics.Add(Logger.Error($"{fullName}.{method.Identifier.Text}【{TimeOutAttributeName}】参数必须是整数字面量(毫秒),当前无法解析:{rawArgument}", method.GetLocation()));
}
}
else if (string.Equals(name, StripAttributeSuffix(ThreadSafeAttributeName), StringComparison.OrdinalIgnoreCase))
@@ -560,21 +552,21 @@ private static int ParseTimeout(AttributeSyntax attribute, out bool parsed)
/// Validates attribute combinations, applies skip rules and contract checks for a method whose metadata has been read, and collects it as a wrapped method when eligible.
/// 校验先行:阻断违规(ref/out/in 参数、Discard 返回类型契约不满足)的方法输出 Error 诊断后不进入生成面。
///
- /// 源代码生成上下文 / Source production context
+ /// 诊断缓冲 / Diagnostics buffer
/// 代理信息 / Agent info
/// 代理类完全限定名 / Fully qualified name of the agent class
/// 方法声明语法节点 / Method declaration syntax node
/// 方法元数据 / Method metadata
- private static void CollectMethod(SourceProductionContext context, AgentInfo info, string fullName, MethodDeclarationSyntax method, MethodInfoData mth)
+ private static void CollectMethod(List diagnostics, AgentInfo info, string fullName, MethodDeclarationSyntax method, MethodInfoData mth)
{
- ReportAttributeViolations(context, fullName, method, mth);
+ ReportAttributeViolations(diagnostics, fullName, method, mth);
if (!ShouldWrapMethod(mth))
{
return;
}
- ReportContractViolations(context, fullName, method, mth);
+ ReportContractViolations(diagnostics, fullName, method, mth);
// 有阻断违规的方法绝不进入生成面:报 Error 的同时跳过收集,产出保持可编译
if (mth.HasBlockingViolation)
@@ -584,43 +576,43 @@ private static void CollectMethod(SourceProductionContext context, AgentInfo inf
if (mth.IsVirtual)
{
- AddWrappedMethod(info, context, fullName, method, mth);
+ AddWrappedMethod(info, diagnostics, fullName, method, mth);
}
}
///
- /// 报告属性组合相关的诊断错误(ThreadSafe 与 TimeOut 冲突、非 Api/Discard 却带 TimeOut)。
+ /// 报告属性组合相关的诊断错误(ThreadSafe 与 TimeOut 冲突、非 Service/Discard 却带 TimeOut)。
///
///
- /// Reports diagnostics for attribute-combination violations (ThreadSafe conflicting with TimeOut, and TimeOut used without Api/Discard).
+ /// Reports diagnostics for attribute-combination violations (ThreadSafe conflicting with TimeOut, and TimeOut used without Service/Discard).
///
- /// 源代码生成上下文 / Source production context
+ /// 诊断缓冲 / Diagnostics buffer
/// 代理类完全限定名 / Fully qualified name of the agent class
/// 方法声明语法节点 / Method declaration syntax node
/// 方法元数据 / Method metadata
- private static void ReportAttributeViolations(SourceProductionContext context, string fullName, MethodDeclarationSyntax method, MethodInfoData mth)
+ private static void ReportAttributeViolations(List diagnostics, string fullName, MethodDeclarationSyntax method, MethodInfoData mth)
{
if (mth.IsThreadSafe && mth.HasTimeout)
{
- context.LogError($"{fullName}.{method.Identifier.Text}无法为标记【{ThreadSafeAttributeName}】的函数指定超时时间");
+ diagnostics.Add(Logger.Error($"{fullName}.{method.Identifier.Text}无法为标记【{ThreadSafeAttributeName}】的函数指定超时时间", method.GetLocation()));
}
if (mth.IsThreadSafe && !mth.Discard)
{
- context.LogWarning($"{fullName}.{method.Identifier.Text}【{ThreadSafeAttributeName}】注解需配合【{DiscardAttributeName}】使用,单独使用时不会生成包装器,方法将被跳过");
+ diagnostics.Add(Logger.Warning($"{fullName}.{method.Identifier.Text}【{ThreadSafeAttributeName}】注解需配合【{DiscardAttributeName}】使用,单独使用时不会生成包装器,方法将被跳过", method.GetLocation()));
}
if (!mth.IsApi && !mth.Discard && mth.HasTimeout)
{
- context.LogError($"{fullName}.{method.Identifier.Text}【{TimeOutAttributeName}】注解只能配合【Api】或【{DiscardAttributeName}】使用");
+ diagnostics.Add(Logger.Error($"{fullName}.{method.Identifier.Text}【{TimeOutAttributeName}】注解只能配合【{ServiceAttributeName}】或【{DiscardAttributeName}】使用", method.GetLocation()));
}
}
///
- /// 判断方法是否需要生成包装器(排除无 Api/Discard/ThreadSafe 标记,以及 ThreadSafe 但非 Discard 的方法)。
+ /// 判断方法是否需要生成包装器(排除无 Service/Discard/ThreadSafe 标记,以及 ThreadSafe 但非 Discard 的方法)。
///
///
- /// Determines whether the method needs a generated wrapper (excluding methods without Api/Discard/ThreadSafe, and ThreadSafe-but-not-Discard methods).
+ /// Determines whether the method needs a generated wrapper (excluding methods without Service/Discard/ThreadSafe, and ThreadSafe-but-not-Discard methods).
///
/// 方法元数据 / Method metadata
/// 如果需要生成包装器则为 true;否则为 false / true if a wrapper should be generated; otherwise false
@@ -640,27 +632,27 @@ private static bool ShouldWrapMethod(MethodInfoData mth)
}
///
- /// 报告契约相关的诊断错误(非 ThreadSafe 的 Api 必须为异步、标记 Api/ThreadSafe/Discard 必须申明为 virtual),
+ /// 报告契约相关的诊断错误(非 ThreadSafe 的 Service 返回类型必须是 Task/ValueTask、标记 Service/ThreadSafe/Discard 必须申明为 virtual),
/// 并对无法生成合法包装体的形态(ref/out/in 参数、Discard 返回类型契约不满足)置阻断标记。
///
///
- /// Reports diagnostics for contract violations (non-ThreadSafe Api must be async; Api/ThreadSafe/Discard must be declared virtual)
+ /// Reports diagnostics for contract violations (non-ThreadSafe Service methods must return Task/ValueTask; Service/ThreadSafe/Discard methods must be declared virtual)
/// and marks blocking violations (ref/out/in parameters; Discard return-type contract) that can never yield compilable wrappers.
///
- /// 源代码生成上下文 / Source production context
+ /// 诊断缓冲 / Diagnostics buffer
/// 代理类完全限定名 / Fully qualified name of the agent class
/// 方法声明语法节点 / Method declaration syntax node
/// 方法元数据 / Method metadata
- private static void ReportContractViolations(SourceProductionContext context, string fullName, MethodDeclarationSyntax method, MethodInfoData mth)
+ private static void ReportContractViolations(List diagnostics, string fullName, MethodDeclarationSyntax method, MethodInfoData mth)
{
if (mth.IsApi && !mth.IsThreadSafe && !mth.ReturnType.Contains("Task"))
{
- context.LogError($"{fullName}.{method.Identifier.Text}, 非【{ThreadSafeAttributeName}】的【Api】接口只能是异步函数");
+ diagnostics.Add(Logger.Error($"{fullName}.{method.Identifier.Text}, 非【{ThreadSafeAttributeName}】的【{ServiceAttributeName}】接口返回类型必须是 Task 或 ValueTask", method.GetLocation()));
}
if ((mth.IsApi || mth.Discard || mth.IsThreadSafe) && !mth.IsVirtual)
{
- context.LogError($"{fullName}.{method.Identifier.Text}标记了【AsyncApi】【{ThreadSafeAttributeName}】【{DiscardAttributeName}】注解的函数必须申明为virtual");
+ diagnostics.Add(Logger.Error($"{fullName}.{method.Identifier.Text}标记了【{ServiceAttributeName}】【{ThreadSafeAttributeName}】【{DiscardAttributeName}】注解的函数必须申明为virtual", method.GetLocation()));
}
// 包装体以 lambda 捕获转发参数:ref/out/in 参数无法跨 Actor 队列转发引用(生成即 CS1628),显式拒绝
@@ -668,7 +660,7 @@ private static void ReportContractViolations(SourceProductionContext context, st
{
if (parameter.Modifiers.Any(modifier => modifier.Text == "ref" || modifier.Text == "out" || modifier.Text == "in"))
{
- context.LogError($"{fullName}.{method.Identifier.Text}代理方法不允许 ref/out/in 参数(Wrapper 无法跨 Actor 队列转发引用)");
+ diagnostics.Add(Logger.Error($"{fullName}.{method.Identifier.Text}代理方法不允许 ref/out/in 参数(Wrapper 无法跨 Actor 队列转发引用)", method.GetLocation()));
mth.HasBlockingViolation = true;
}
}
@@ -688,7 +680,7 @@ private static void ReportContractViolations(SourceProductionContext context, st
|| string.Equals(returnTypeName, nameof(ValueTask), StringComparison.Ordinal);
if (!isPlainTask)
{
- context.LogError($"{fullName}.{method.Identifier.Text}只有返回值为Task类型或ValueTask类型才能添加【{DiscardAttributeName}】注解");
+ diagnostics.Add(Logger.Error($"{fullName}.{method.Identifier.Text}只有返回值为Task类型或ValueTask类型才能添加【{DiscardAttributeName}】注解", method.GetLocation()));
mth.HasBlockingViolation = true;
}
}
@@ -702,14 +694,15 @@ private static void ReportContractViolations(SourceProductionContext context, st
/// (validation already happened up front in ReportContractViolations).
///
/// 代理信息 / Agent info
- /// 源代码生成上下文 / Source production context
+ /// 诊断缓冲 / Diagnostics buffer
/// 代理类完全限定名 / Fully qualified name of the agent class
/// 方法声明语法节点 / Method declaration syntax node
/// 方法元数据 / Method metadata
- private static void AddWrappedMethod(AgentInfo info, SourceProductionContext context, string fullName, MethodDeclarationSyntax method, MethodInfoData mth)
+ private static void AddWrappedMethod(AgentInfo info, List diagnostics, string fullName, MethodDeclarationSyntax method, MethodInfoData mth)
{
mth.Name = method.Identifier.Text;
mth.ParamDeclare = method.ParameterList.ToString();
+
mth.Constraint = method.ConstraintClauses.ToString();
mth.Typeparams = method.TypeParameterList?.ToString();
foreach (var p in method.ParameterList.Parameters)
@@ -721,24 +714,24 @@ private static void AddWrappedMethod(AgentInfo info, SourceProductionContext con
}
///
- /// 组件代理基类名集合(StateComponentAgent、FuncComponentAgent、QueryComponentAgent、BaseComponentAgent)。
+ /// 组件代理基类名集合(StateComponentAgent、FuncComponentAgent、BaseComponentAgent,与 Core 实际类型一一对应)。
///
///
- /// Set of component agent base class names (StateComponentAgent, FuncComponentAgent, QueryComponentAgent, BaseComponentAgent).
+ /// Set of component agent base class names (StateComponentAgent, FuncComponentAgent, BaseComponentAgent, matching the actual Core types).
///
private static readonly HashSet ComponentAgentBaseNames = new HashSet(StringComparer.Ordinal)
{
+ // C432:移除全仓不存在的 QueryComponentAgent 死条目;Core 实际只有三类组件代理基类,未来引入再登记。
"StateComponentAgent",
"FuncComponentAgent",
- "QueryComponentAgent",
"BaseComponentAgent",
};
///
- /// 判断类声明是否为组件代理类(继承自 StateComponentAgent、FuncComponentAgent、QueryComponentAgent 或 BaseComponentAgent)。
+ /// 判断类声明是否为组件代理类(继承自 StateComponentAgent、FuncComponentAgent 或 BaseComponentAgent)。
///
///
- /// Determines whether the class declaration is a component agent (inheriting from StateComponentAgent, FuncComponentAgent, QueryComponentAgent, or BaseComponentAgent).
+ /// Determines whether the class declaration is a component agent (inheriting from StateComponentAgent, FuncComponentAgent, or BaseComponentAgent).
///
/// 要检查的类声明语法节点 / Class declaration syntax node to check
/// 如果是组件代理类则为 true;否则为 false / true if it is a component agent; otherwise false
diff --git a/GameFrameX.Hotfix.WrapperGenerator/Agent/WrapperResult.cs b/GameFrameX.Hotfix.WrapperGenerator/Agent/WrapperResult.cs
new file mode 100644
index 000000000..e98a73caa
--- /dev/null
+++ b/GameFrameX.Hotfix.WrapperGenerator/Agent/WrapperResult.cs
@@ -0,0 +1,169 @@
+// ==========================================================================================
+// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利
+// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights
+// 均受中华人民共和国及相关国际法律法规保护。
+// are protected by the laws of the People's Republic of China and relevant international regulations.
+// 使用本项目须严格遵守相应法律法规及开源许可证之规定。
+// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses.
+// 本项目采用 Apache License 2.0 单协议分发,
+// This project is licensed solely under the Apache License 2.0,
+// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。
+// please refer to the LICENSE file in the root directory of the source code for the full license text.
+// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、
+// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order,
+// 侵犯他人合法权益等法律法规所禁止的行为!
+// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations!
+// 因基于本项目二次开发所产生的一切法律纠纷与责任,
+// Any legal disputes and liabilities arising from secondary development based on this project
+// 本项目组织与贡献者概不承担。
+// shall be borne solely by the developer; the project organization and contributors assume no responsibility.
+// GitHub 仓库:https://github.com/GameFrameX
+// GitHub Repository: https://github.com/GameFrameX
+// Gitee 仓库:https://gitee.com/GameFrameX
+// Gitee Repository: https://gitee.com/GameFrameX
+// CNB 仓库:https://cnb.cool/GameFrameX
+// CNB Repository: https://cnb.cool/GameFrameX
+// 官方文档:https://gameframex.doc.alianblank.com/
+// Official Documentation: https://gameframex.doc.alianblank.com/
+// ==========================================================================================
+
+using System;
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace GameFrameX.Hotfix.WrapperGenerator.Agent;
+
+///
+/// 单个代理类声明的生成结果:hintName、生成源码文本与缓冲的生成期诊断。
+/// 作为增量管线按值缓存的比较单元——声明未变化时整个对象命中缓存,输出阶段不重跑。
+///
+///
+/// Generation result for a single agent class declaration: hintName, generated source text, and
+/// buffered generation-time diagnostics. It is the value-cached unit of the incremental pipeline:
+/// unchanged declarations hit the cache as a whole and their output stage does not re-run.
+///
+internal sealed class WrapperResult
+{
+ ///
+ /// 初始化生成结果。
+ ///
+ ///
+ /// Initializes the generation result.
+ ///
+ /// 生成文件名(hintName)/ Generated file name (hintName)
+ /// 生成的源码文本;无可包装方法时为 null(不产出文件)/ Generated source text; null when there is no wrappable method (no file emitted)
+ /// 生成期诊断(可能为空数组)/ Generation-time diagnostics (possibly an empty array)
+ public WrapperResult(string hintName, string source, Diagnostic[] diagnostics)
+ {
+ HintName = hintName;
+ Source = source;
+ Diagnostics = diagnostics;
+ }
+
+ ///
+ /// 获取生成文件名(hintName)。
+ ///
+ ///
+ /// Gets the generated file name (hintName).
+ ///
+ public string HintName { get; }
+
+ ///
+ /// 获取生成的源码文本;无可包装方法的代理为 null(仅上报诊断,不产出文件)。
+ ///
+ ///
+ /// Gets the generated source text; null for agents with no wrappable method (diagnostics only, no file emitted).
+ ///
+ public string Source { get; }
+
+ ///
+ /// 获取该声明收集到的生成期诊断(无诊断时为空数组,绝不为 null)。
+ ///
+ ///
+ /// Gets the generation-time diagnostics collected for this declaration (an empty array when there are none, never null).
+ ///
+ public Diagnostic[] Diagnostics { get; }
+}
+
+///
+/// 按值比较 :hintName、源码文本与诊断序列均等值才视为相等,
+/// 供增量管线 WithComparer 缓存判定(文本未变的声明不触发下游输出重跑)。
+///
+///
+/// Value comparer for : two results are equal only when their hintNames,
+/// source texts, and diagnostic sequences are all equal. Used by the incremental pipeline's
+/// WithComparer cache so declarations whose text did not change do not re-run downstream outputs.
+///
+internal sealed class WrapperResultComparer : IEqualityComparer
+{
+ ///
+ /// 共享单例(比较器无状态)。
+ ///
+ ///
+ /// Shared singleton (the comparer is stateless).
+ ///
+ public static readonly WrapperResultComparer Instance = new WrapperResultComparer();
+
+ ///
+ /// 判定两个生成结果是否等值:hintName、源码文本逐字符相等,且诊断逐条 等值。
+ ///
+ ///
+ /// Determines whether two results are equal: hintName and source text compare ordinally, and diagnostics
+ /// compare equal one by one via .
+ ///
+ /// 左侧结果 / Left result
+ /// 右侧结果 / Right result
+ /// 等值则为 true;否则为 false / true if equal; otherwise false
+ public bool Equals(WrapperResult left, WrapperResult right)
+ {
+ if (ReferenceEquals(left, right))
+ {
+ return true;
+ }
+
+ if (left == null || right == null)
+ {
+ return false;
+ }
+
+ if (!string.Equals(left.HintName, right.HintName, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (!string.Equals(left.Source, right.Source, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (left.Diagnostics.Length != right.Diagnostics.Length)
+ {
+ return false;
+ }
+
+ // 诊断按序列化文本等值(含 ID、位置与消息),保证诊断变化也能使缓存失效
+ for (var index = 0; index < left.Diagnostics.Length; index++)
+ {
+ if (!string.Equals(left.Diagnostics[index].ToString(), right.Diagnostics[index].ToString(), StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// 取结果哈希码:以 hintName 为基(等值结果必同 hintName;不同 hintName 仅降低桶分布,不影响正确性)。
+ ///
+ ///
+ /// Gets the hash code: based on the hintName (equal results always share the hintName; different
+ /// hintNames merely reduce bucket distribution and never affect correctness).
+ ///
+ /// 待哈希的结果 / The result to hash
+ /// 哈希码 / The hash code
+ public int GetHashCode(WrapperResult value)
+ {
+ return StringComparer.Ordinal.GetHashCode(value.HintName ?? string.Empty);
+ }
+}
diff --git a/GameFrameX.Hotfix.WrapperGenerator/GameFrameX.Hotfix.WrapperGenerator.csproj b/GameFrameX.Hotfix.WrapperGenerator/GameFrameX.Hotfix.WrapperGenerator.csproj
index 8064c5294..fe6ae0b89 100644
--- a/GameFrameX.Hotfix.WrapperGenerator/GameFrameX.Hotfix.WrapperGenerator.csproj
+++ b/GameFrameX.Hotfix.WrapperGenerator/GameFrameX.Hotfix.WrapperGenerator.csproj
@@ -4,6 +4,8 @@
netstandard2.0
+
+ $(NoWarn);RS2008
GameFrameX.Hotfix.WrapperGenerator,GameFrameX 框架的 Hotfix Wrapper Roslyn 源码生成器模块. Hotfix Wrapper Roslyn source generator module for the GameFrameX framework. Documentation: https://gameframex.doc.alianblank.com
GameFrameX,Hotfix,WrapperGenerator,SourceGenerator,Roslyn,Analyzer,Server,GameServer
true
diff --git a/GameFrameX.Hotfix.WrapperGenerator/Utils/Logger.cs b/GameFrameX.Hotfix.WrapperGenerator/Utils/Logger.cs
index e50132019..373addd30 100644
--- a/GameFrameX.Hotfix.WrapperGenerator/Utils/Logger.cs
+++ b/GameFrameX.Hotfix.WrapperGenerator/Utils/Logger.cs
@@ -32,48 +32,97 @@
namespace GameFrameX.Hotfix.WrapperGenerator.Utils;
///
-/// 提供源代码生成器的日志记录扩展方法。
+/// 提供源代码生成器的诊断工厂:以稳定 ID 创建携带源位置的错误/警告诊断。
///
///
-/// Provides logging extension methods for source code generators.
+/// Provides the diagnostic factory for source generators: creates error/warning diagnostics
+/// with stable identifiers and source locations.
///
public static class Logger
{
///
- /// 向源代码生成上下文报告错误诊断信息。
+ /// 契约违规(Error)诊断 ID。GFW 前缀与架构分析器 GFX0001-0021 错开,
+ /// 可经 dotnet_diagnostic_GFW0001.severity(.editorconfig)按 ID 抑制或升级。
///
///
- /// Reports an error diagnostic to the source production context.
+ /// Diagnostic identifier for contract violations (Error). The GFW prefix avoids collisions
+ /// with analyzer IDs GFX0001-0021, enabling per-ID suppression via
+ /// dotnet_diagnostic_GFW0001.severity (.editorconfig).
///
- /// 源代码生成上下文 / Source production context
- /// 错误消息 / Error message
- public static void LogError(this SourceProductionContext context, string msg)
+ public const string ErrorDiagnosticIdentifier = "GFW0001";
+
+ ///
+ /// 契约警示(Warning)诊断 ID。GFW 前缀与分析器 GFX 系列错开,可按 ID 配置严重级别。
+ ///
+ ///
+ /// Diagnostic identifier for contract warnings (Warning). The GFW prefix avoids collisions
+ /// with the analyzer GFX series so the severity can be configured per ID.
+ ///
+ public const string WarningDiagnosticIdentifier = "GFW0002";
+
+ ///
+ /// 诊断类别(与分析器 GFX 系列的类别命名约定一致,取生成器自身全名)。
+ ///
+ ///
+ /// Diagnostic category (the generator's own full name, following the GFX analyzer category convention).
+ ///
+ public const string DiagnosticCategory = "GameFrameX.Hotfix.WrapperGenerator";
+
+ ///
+ /// 契约违规(Error)的静态描述符:全部 Error 诊断共用,避免每次调用分配新实例。
+ ///
+ ///
+ /// Static descriptor for contract violations (Error): shared by all Error diagnostics to avoid per-call allocations.
+ ///
+ internal static readonly DiagnosticDescriptor ErrorDescriptor = new DiagnosticDescriptor(
+ ErrorDiagnosticIdentifier,
+ "Hotfix wrapper generator contract violation",
+ "{0}",
+ DiagnosticCategory,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 契约警示(Warning)的静态描述符:全部 Warning 诊断共用,避免每次调用分配新实例。
+ ///
+ ///
+ /// Static descriptor for contract warnings (Warning): shared by all Warning diagnostics to avoid per-call allocations.
+ ///
+ internal static readonly DiagnosticDescriptor WarningDescriptor = new DiagnosticDescriptor(
+ WarningDiagnosticIdentifier,
+ "Hotfix wrapper generator contract warning",
+ "{0}",
+ DiagnosticCategory,
+ DiagnosticSeverity.Warning,
+ true);
+
+ ///
+ /// 创建携带源位置的错误诊断。诊断在增量管线缓存阶段构造、随结果模型传递,输出阶段统一上报。
+ ///
+ ///
+ /// Creates an error diagnostic carrying a source location. Diagnostics are constructed in the
+ /// incremental pipeline caching stage, travel with the result model, and are reported in the output stage.
+ ///
+ /// 错误消息 / Error message
+ /// 违规成员的源位置(通常为方法声明位置)/ Source location of the violating member (usually the method declaration)
+ /// 错误诊断 / The error diagnostic
+ public static Diagnostic Error(string message, Location location)
{
- var invalidXmlWarning = new DiagnosticDescriptor("Error",
- "Code Generator Error",
- "{0}",
- "CodeGenerator",
- DiagnosticSeverity.Error,
- true);
- context.ReportDiagnostic(Diagnostic.Create(invalidXmlWarning, Location.None, msg));
+ return Diagnostic.Create(ErrorDescriptor, location ?? Location.None, message);
}
///
- /// 向源代码生成上下文报告警告诊断信息。
+ /// 创建携带源位置的警告诊断。诊断在增量管线缓存阶段构造、随结果模型传递,输出阶段统一上报。
///
///
- /// Reports a warning diagnostic to the source production context.
+ /// Creates a warning diagnostic carrying a source location. Diagnostics are constructed in the
+ /// incremental pipeline caching stage, travel with the result model, and are reported in the output stage.
///
- /// 源代码生成上下文 / Source production context
- /// 警告消息 / Warning message
- public static void LogWarning(this SourceProductionContext context, string msg)
+ /// 警告消息 / Warning message
+ /// 违规成员的源位置(通常为方法声明位置)/ Source location of the violating member (usually the method declaration)
+ /// 警告诊断 / The warning diagnostic
+ public static Diagnostic Warning(string message, Location location)
{
- var warning = new DiagnosticDescriptor("Warning",
- "Code Generator Warning",
- "{0}",
- "CodeGenerator",
- DiagnosticSeverity.Warning,
- true);
- context.ReportDiagnostic(Diagnostic.Create(warning, Location.None, msg));
+ return Diagnostic.Create(WarningDescriptor, location ?? Location.None, message);
}
-}
\ No newline at end of file
+}
diff --git a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorBaseNameMatchTests.cs b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorBaseNameMatchTests.cs
index d262e9f97..a83ff6903 100644
--- a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorBaseNameMatchTests.cs
+++ b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorBaseNameMatchTests.cs
@@ -39,12 +39,12 @@
namespace GameFrameX.Tests.Hotfix;
///
-/// 组件代理基类识别测试:基类名精确匹配四个组件代理基类
-/// (含泛型与限定名形态),基类名仅包含子串(如 NonStateComponentAgent)的类不被识别为组件代理。
+/// 组件代理基类识别测试:基类名精确匹配三个组件代理基类
+/// (StateComponentAgent、FuncComponentAgent、BaseComponentAgent,含泛型与限定名形态),基类名仅包含
+/// 子串(如 NonStateComponentAgent)的类不被识别为组件代理。
/// Tests for component agent base type recognition in : base names
-/// must match the four component agent base classes exactly (including generic and qualified forms), while
+/// must match the three component agent base classes exactly (including generic and qualified forms), while
/// classes whose base names merely contain the substring (e.g. NonStateComponentAgent) are not recognized.
-///
public sealed class HotfixWrapperGeneratorBaseNameMatchTests
{
///
@@ -55,7 +55,7 @@ public sealed class HotfixWrapperGeneratorBaseNameMatchTests
private const string TestSource = @"
namespace GameFrameX.Hotfix.Player
{
- // 负例基类:名称仅包含组件代理基类名子串,不属于四个基类之一
+ // 负例基类:名称仅包含组件代理基类名子串,不属于三个基类之一
public class NonStateComponentAgent { }
public class MyFuncComponentAgentFactory { }
public class MyQueryComponentAgentHelper { }
@@ -96,7 +96,7 @@ public class SimpleAgent : FuncComponentAgent
}
// 正例:限定名基类(QualifiedNameSyntax 兜底分支)
- public class QualifiedAgent : GameFrameX.Agents.QueryComponentAgent
+ public class QualifiedAgent : GameFrameX.Agents.FuncComponentAgent
{
[Service]
public virtual System.Threading.Tasks.Task GetQualifiedValueAsync() { return null; }
@@ -105,8 +105,8 @@ public class QualifiedAgent : GameFrameX.Agents.QueryComponentAgent
";
///
- /// 基类名与四个组件代理基类精确一致(泛型 / 非泛型 / 限定名形态)时生成包装器。
- /// Wrapper generation must happen when base names exactly equal one of the four component agent
+ /// 基类名与三个组件代理基类精确一致(泛型 / 非泛型 / 限定名形态)时生成包装器。
+ /// Wrapper generation must happen when base names exactly equal one of the three component agent
/// base classes (generic / non-generic / qualified forms).
///
[Fact]
diff --git a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorDiagnosticReportingTests.cs b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorDiagnosticReportingTests.cs
new file mode 100644
index 000000000..209e4975d
--- /dev/null
+++ b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorDiagnosticReportingTests.cs
@@ -0,0 +1,217 @@
+// ==========================================================================================
+// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利
+// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights
+// 均受中华人民共和国及相关国际法律法规保护。
+// are protected by the laws of the People's Republic of China and related international regulations.
+// 使用本项目须严格遵守相应法律法规与开源许可证之规定。
+// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses.
+// 本项目采用 Apache License 2.0 单协议分发,
+// This project is licensed solely under the Apache License 2.0,
+// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。
+// please refer to the LICENSE file in the root directory of the source code for the full license text.
+// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、
+// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order,
+// 侵犯他人合法权益等法律法规所禁止的行为!
+// or to infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations!
+// 因基于本项目二次开发所产生的一切法律纠纷与责任,
+// Any legal disputes and liabilities arising from secondary development based on this project
+// 本组织与贡献者概不承担。
+// shall be borne solely by the developer; the project organization and contributors assume no responsibility.
+// GitHub 仓库:https://github.com/GameFrameX
+// GitHub Repository: https://github.com/GameFrameX
+// Gitee 仓库:https://gitee.com/GameFrameX
+// Gitee Repository: https://gitee.com/GameFrameX
+// CNB 仓库:https://cnb.cool/GameFrameX
+// CNB Repository: https://cnb.cool/GameFrameX
+// 官方文档:https://gameframex.doc.alianblank.com/
+// Official Documentation: https://gameframex.doc.alianblank.com/
+// ==========================================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using GameFrameX.Hotfix.WrapperGenerator.Agent;
+using GameFrameX.Hotfix.WrapperGenerator.Utils;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Text;
+using Xunit;
+
+namespace GameFrameX.Tests.Hotfix;
+
+///
+/// 验证 Hotfix Wrapper 生成器的诊断形态(C432 / CR-M07+L05):全部诊断携带稳定 ID
+/// (GFW0001=契约违规 Error、GFW0002=契约警示 Warning,与分析器 GFX 系列前缀错开)与违规方法的
+/// 源位置(可点击定位、可按 ID 抑制);诊断消息引用真实特性名【Service】等常量,不再出现
+/// 框架中不存在的【Api】/【AsyncApi】幻觉特性名; 无静态可变状态。
+/// Verifies the Hotfix wrapper generator's diagnostic shape (C432 / CR-M07+L05): every diagnostic
+/// carries a stable identifier (GFW0001 = contract violation Error, GFW0002 = contract warning Warning,
+/// prefixed to avoid the GFX analyzer series) and the violating method's source location (clickable and
+/// suppressible per ID); messages reference the real attribute names like 【Service】 instead of the
+/// nonexistent 【Api】/【AsyncApi】 spellings; and holds no static mutable state.
+///
+public sealed class HotfixWrapperGeneratorDiagnosticReportingTests
+{
+ private const string TestSource = """
+ namespace GameFrameX.Hotfix.Player
+ {
+ public class DiagnosticsComponentAgent : BaseComponentAgent
+ {
+ [TimeOut(3000)]
+ public virtual System.Threading.Tasks.Task TimeOutOnlyAsync()
+ {
+ return null;
+ }
+
+ [ThreadSafe]
+ public virtual System.Threading.Tasks.Task ThreadSafeOnlyAsync()
+ {
+ return null;
+ }
+
+ [Service]
+ public virtual int SyncValue()
+ {
+ return 0;
+ }
+
+ [Service]
+ public int NotVirtualValue()
+ {
+ return 0;
+ }
+ }
+ }
+ """;
+
+ ///
+ /// 全部诊断使用稳定 ID GFW0001/GFW0002 与生成器专属类别,不再使用 "Error"/"Warning" 占位 ID。
+ /// All diagnostics use the stable identifiers GFW0001/GFW0002 with the generator-specific category
+ /// instead of the "Error"/"Warning" placeholder identifiers.
+ ///
+ [Fact]
+ public void All_diagnostics_use_stable_generator_identifiers()
+ {
+ var diagnostics = RunGeneratorDiagnostics();
+
+ Assert.Equal(5, diagnostics.Length);
+ Assert.All(diagnostics, diagnostic =>
+ {
+ Assert.True(
+ diagnostic.Id == Logger.ErrorDiagnosticIdentifier || diagnostic.Id == Logger.WarningDiagnosticIdentifier,
+ $"诊断 ID 必须是 GFW 系列,实际: {diagnostic.Id}");
+ Assert.Equal(Logger.DiagnosticCategory, diagnostic.Descriptor.Category);
+ });
+ Assert.Equal(4, diagnostics.Count(diagnostic => diagnostic.Id == Logger.ErrorDiagnosticIdentifier && diagnostic.Severity == DiagnosticSeverity.Error));
+ Assert.Single(diagnostics, diagnostic => diagnostic.Id == Logger.WarningDiagnosticIdentifier && diagnostic.Severity == DiagnosticSeverity.Warning);
+ }
+
+ ///
+ /// 每条诊断都携带违规方法的源位置(非 Location.None,落在测试源文件上,且声明跨度覆盖方法名),
+ /// 错误列表点击可直达违规方法。
+ /// Every diagnostic carries the violating method's source location (not Location.None, on the test
+ /// source file, with the declaration span covering the method name) so the error list can navigate
+ /// directly to the violating method.
+ ///
+ [Fact]
+ public void Every_diagnostic_locates_the_violating_method()
+ {
+ var syntaxTree = ParseTestTree();
+ var diagnostics = RunGeneratorDiagnostics(syntaxTree);
+
+ Assert.All(diagnostics, diagnostic =>
+ {
+ Assert.NotEqual(Location.None, diagnostic.Location);
+ Assert.Same(syntaxTree, diagnostic.Location.SourceTree);
+ });
+ Assert.Contains(diagnostics, diagnostic =>
+ diagnostic.Id == Logger.ErrorDiagnosticIdentifier
+ && GetLocatedFragment(diagnostic).Contains("TimeOutOnlyAsync"));
+ Assert.Contains(diagnostics, diagnostic =>
+ diagnostic.Id == Logger.WarningDiagnosticIdentifier
+ && GetLocatedFragment(diagnostic).Contains("ThreadSafeOnlyAsync"));
+ Assert.Contains(diagnostics, diagnostic =>
+ diagnostic.Id == Logger.ErrorDiagnosticIdentifier
+ && GetLocatedFragment(diagnostic).Contains("SyncValue"));
+ Assert.Contains(diagnostics, diagnostic =>
+ diagnostic.Id == Logger.ErrorDiagnosticIdentifier
+ && GetLocatedFragment(diagnostic).Contains("NotVirtualValue"));
+ }
+
+ ///
+ /// 诊断消息引用真实特性名(【Service】/【Discard】/【ThreadSafe】常量),不出现框架中不存在的
+ /// 【Api】/【AsyncApi】字样(CR-L05)。
+ /// Diagnostic messages reference the real attribute names (the 【Service】/【Discard】/【ThreadSafe】
+ /// constants) and never mention the nonexistent 【Api】/【AsyncApi】 spellings (CR-L05).
+ ///
+ [Fact]
+ public void Messages_reference_real_attribute_names_only()
+ {
+ var diagnostics = RunGeneratorDiagnostics();
+
+ Assert.All(diagnostics, diagnostic =>
+ {
+ Assert.DoesNotContain("AsyncApi", diagnostic.GetMessage(), StringComparison.Ordinal);
+ Assert.DoesNotContain("【Api】", diagnostic.GetMessage(), StringComparison.Ordinal);
+ });
+
+ var timeoutOnlyMessage = diagnostics.Single(diagnostic => diagnostic.GetMessage().Contains("TimeOutOnlyAsync")).GetMessage();
+ Assert.Contains("【Service】", timeoutOnlyMessage, StringComparison.Ordinal);
+ Assert.Contains("【Discard】", timeoutOnlyMessage, StringComparison.Ordinal);
+
+ var syncValueMessage = diagnostics.Single(diagnostic => diagnostic.GetMessage().Contains("SyncValue")).GetMessage();
+ Assert.Contains("【Service】", syncValueMessage, StringComparison.Ordinal);
+ Assert.Contains("Task 或 ValueTask", syncValueMessage, StringComparison.Ordinal);
+
+ // NotVirtualValue 触发两条 Error(返回类型必须 Task/ValueTask + 必须申明为 virtual)
+ var notVirtualMessages = diagnostics.Where(diagnostic => diagnostic.GetMessage().Contains("NotVirtualValue")).ToList();
+ Assert.Equal(2, notVirtualMessages.Count);
+ Assert.Contains(notVirtualMessages, message => message.GetMessage().Contains("Task 或 ValueTask", StringComparison.Ordinal));
+ Assert.Contains(notVirtualMessages, message => message.GetMessage().Contains("必须申明为virtual", StringComparison.Ordinal));
+ Assert.All(notVirtualMessages, message => Assert.Contains("【Service】", message.GetMessage(), StringComparison.Ordinal));
+ }
+
+ ///
+ /// 无任何静态字段:模板无跨调用静态可变状态(CR-L03,并发驱动安全)。
+ /// has no static fields at all: the template holds no cross-call static
+ /// mutable state (CR-L03, safe for concurrent drivers).
+ ///
+ [Fact]
+ public void AgentTemplate_has_no_static_fields()
+ {
+ var staticFields = typeof(AgentTemplate).GetFields(
+ System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic);
+
+ Assert.Empty(staticFields);
+ }
+
+ private static SyntaxTree ParseTestTree()
+ {
+ return CSharpSyntaxTree.ParseText(SourceText.From(TestSource, Encoding.UTF8), path: "DiagnosticsComponentAgent.cs");
+ }
+
+ private static Diagnostic[] RunGeneratorDiagnostics(SyntaxTree syntaxTree = null)
+ {
+ syntaxTree ??= ParseTestTree();
+ var compilation = CSharpCompilation.Create(
+ "HotfixWrapperGeneratorDiagnosticReportingTestsCompilation",
+ new[] { syntaxTree },
+ new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) });
+
+ var driver = CSharpGeneratorDriver.Create(new HotfixWrapperGenerator());
+ return driver.RunGenerators(compilation).GetRunResult().Diagnostics.ToArray();
+ }
+
+ ///
+ /// 取诊断定位覆盖的源文本片段(即违规方法声明本身)。
+ /// Gets the source fragment covered by the diagnostic's location (the violating method declaration itself).
+ ///
+ /// 诊断 / The diagnostic
+ /// 源文本片段 / The source fragment
+ private static string GetLocatedFragment(Diagnostic diagnostic)
+ {
+ var sourceText = diagnostic.Location.SourceTree.GetText().ToString();
+ return sourceText.Substring(diagnostic.Location.SourceSpan.Start, diagnostic.Location.SourceSpan.Length);
+ }
+}
diff --git a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorIncrementalPipelineTests.cs b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorIncrementalPipelineTests.cs
new file mode 100644
index 000000000..b2fbab07a
--- /dev/null
+++ b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorIncrementalPipelineTests.cs
@@ -0,0 +1,436 @@
+// ==========================================================================================
+// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利
+// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights
+// 均受中华人民共和国及相关国际法律法规保护。
+// are protected by the laws of the People's Republic of China and related international regulations.
+// 使用本项目须严格遵守相应法律法规与开源许可证之规定。
+// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses.
+// 本项目采用 Apache License 2.0 单协议分发,
+// This project is licensed solely under the Apache License 2.0,
+// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。
+// please refer to the LICENSE file in the root directory of the source code for the full license text.
+// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、
+// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order,
+// 侵犯他人合法权益等法律法规所禁止的行为!
+// or to infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations!
+// 因基于本项目二次开发所产生的一切法律纠纷与责任,
+// Any legal disputes or liabilities arising from secondary development based on this project
+// 本组织与贡献者概不承担。
+// shall be borne solely by the developer; the project organization and contributors assume no responsibility.
+// GitHub 仓库:https://github.com/GameFrameX
+// GitHub Repository: https://github.com/GameFrameX
+// Gitee 仓库:https://gitee.com/GameFrameX
+// Gitee Repository: https://gitee.com/GameFrameX
+// CNB 仓库:https://cnb.cool/GameFrameX
+// CNB Repository: https://cnb.cool/GameFrameX
+// 官方文档:https://gameframex.doc.alianblank.com/
+// Official Documentation: https://gameframex.doc.alianblank.com/
+// ==========================================================================================
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using GameFrameX.Hotfix.WrapperGenerator.Agent;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Text;
+using Xunit;
+
+namespace GameFrameX.Tests.Hotfix;
+
+///
+/// 验证 Hotfix Wrapper 生成器增量管线(C432):按「代理类声明 → 输出」粒度注册,经
+/// WithTrackingName 暴露的 TrackedSteps 断言逐声明的缓存/重算行为——编辑单个代理类只重算
+/// 该代理的 wrapper 且仅其输出回调重跑;输出等值编辑与无关文件变更不触发任何输出重跑;partial
+/// 部件独立产出各自的 wrapper 文件。
+/// Verifies the Hotfix wrapper generator's incremental pipeline (C432): outputs are registered per agent
+/// class declaration, and the tracking-name-exposed TrackedSteps assert per-declaration cache/recompute
+/// behavior—editing one agent recomputes only its wrapper and reruns only its output callback; output-equal
+/// edits and unrelated file changes rerun no output callbacks; partial parts each produce their own wrapper file.
+///
+public sealed class HotfixWrapperGeneratorIncrementalPipelineTests
+{
+ ///
+ /// 生成器输出步骤的跟踪名(与 内部常量一致)。
+ /// Tracking name of the generator's output step (matching the internal constant).
+ ///
+ private const string WrapperOutputTrackingName = "HotfixWrapperOutput";
+
+ ///
+ /// 注册输出步骤的自动跟踪名(Roslyn 对 RegisterSourceOutput 的内置命名)。
+ /// Auto tracking name of the registered output step (Roslyn's built-in name for RegisterSourceOutput).
+ ///
+ private const string SourceOutputTrackingName = "SourceOutput";
+
+ private const string FirstAgentSource = """
+ namespace GameFrameX.Hotfix.Player
+ {
+ public class FirstComponentAgent : BaseComponentAgent
+ {
+ [Service]
+ public virtual System.Threading.Tasks.Task QueryFirstAsync(int value)
+ {
+ return System.Threading.Tasks.Task.FromResult(value);
+ }
+ }
+ }
+ """;
+
+ private const string SecondAgentSource = """
+ namespace GameFrameX.Hotfix.Server
+ {
+ public class SecondComponentAgent : BaseComponentAgent
+ {
+ [Service]
+ public virtual System.Threading.Tasks.Task QuerySecondAsync(int value)
+ {
+ return System.Threading.Tasks.Task.FromResult(value);
+ }
+ }
+ }
+ """;
+
+ private const string EditedSecondAgentSource = """
+ namespace GameFrameX.Hotfix.Server
+ {
+ public class SecondComponentAgent : BaseComponentAgent
+ {
+ [Service]
+ public virtual System.Threading.Tasks.Task QuerySecondAsync(int value)
+ {
+ return System.Threading.Tasks.Task.FromResult(value);
+ }
+
+ [Service]
+ public virtual System.Threading.Tasks.Task QuerySecondEditedAsync(int value)
+ {
+ return System.Threading.Tasks.Task.FromResult(value);
+ }
+ }
+ }
+ """;
+
+ private const string OutputEqualEditFirstAgentSource = """
+ namespace GameFrameX.Hotfix.Player
+ {
+ public class FirstComponentAgent : BaseComponentAgent
+ {
+ [Service]
+ public virtual System.Threading.Tasks.Task QueryFirstAsync(int value)
+ {
+ return System.Threading.Tasks.Task.FromResult(value);
+ }
+
+ public void PlainMethodNotWrapped()
+ {
+ }
+ }
+ }
+ """;
+
+ private const string PartialAgentSource = """
+ namespace GameFrameX.Hotfix.Player
+ {
+ public partial class PartialComponentAgent : BaseComponentAgent
+ {
+ [Service]
+ public virtual System.Threading.Tasks.Task QueryPartOneAsync(int value)
+ {
+ return System.Threading.Tasks.Task.FromResult(value);
+ }
+ }
+
+ public partial class PartialComponentAgent : BaseComponentAgent
+ {
+ [Service]
+ public virtual System.Threading.Tasks.Task QueryPartTwoAsync(int value)
+ {
+ return System.Threading.Tasks.Task.FromResult(value);
+ }
+ }
+ }
+ """;
+
+ ///
+ /// 编辑单个代理类所在文件(新增被包装方法)后:仅该代理的声明重算(Modified)且仅其输出回调
+ /// 重跑(SourceOutput 恰一个 Modified),另一代理的输出命中缓存(Cached)——逐声明输出注册的增量契约。
+ /// After editing the file of one agent (adding a wrapped method): only that agent's declaration
+ /// recomputes (Modified) and only its output callback reruns (exactly one Modified in SourceOutput),
+ /// while the other agent's output hits the cache (Cached)—the per-declaration incremental contract.
+ ///
+ [Fact]
+ public void Editing_one_agent_file_reruns_only_that_agents_wrapper()
+ {
+ var firstTree = ParseTree(FirstAgentSource, "FirstComponentAgent.cs");
+ var secondTree = ParseTree(SecondAgentSource, "SecondComponentAgent.cs");
+ var compilation = CreateCompilation(firstTree, secondTree);
+ var driver = CreateTrackingDriver();
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(compilation);
+
+ var editedTree = ParseTree(EditedSecondAgentSource, "SecondComponentAgent.cs");
+ var updatedCompilation = compilation.ReplaceSyntaxTree(secondTree, editedTree);
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(updatedCompilation);
+
+ // 被编辑代理声明:输入变化且产出新值 → 恰一个 Modified;其余声明不得重算出新值
+ var wrapperReasons = GetTrackedReasons(driver, WrapperOutputTrackingName);
+ Assert.Equal(2, wrapperReasons.Count);
+ Assert.Equal(1, wrapperReasons.Count(reason => reason == IncrementalStepRunReason.Modified));
+ Assert.Equal(1, wrapperReasons.Count(reason => reason == IncrementalStepRunReason.Cached || reason == IncrementalStepRunReason.Unchanged));
+
+ // 注册输出步骤:只有被编辑代理的输出回调重跑,另一代理命中缓存
+ var outputReasons = GetTrackedReasons(driver, SourceOutputTrackingName);
+ Assert.Equal(2, outputReasons.Count);
+ Assert.Equal(1, outputReasons.Count(reason => reason == IncrementalStepRunReason.Modified));
+ Assert.Equal(1, outputReasons.Count(reason => reason == IncrementalStepRunReason.Cached));
+
+ var generatedText = JoinGeneratedText(driver);
+ Assert.Contains("QueryFirstAsync", generatedText);
+ Assert.Contains("QuerySecondEditedAsync", generatedText);
+ }
+
+ ///
+ /// 对代理类做输出等值编辑(新增不被包装的普通方法)后:该声明重算但产出文本等值(Unchanged),
+ /// 全部输出回调命中缓存(SourceOutput 全 Cached,无 Modified),生成文本不变。
+ /// After an output-equal edit to an agent (adding a plain method that is not wrapped): that declaration
+ /// recomputes but produces an equal text (Unchanged), all output callbacks hit the cache (SourceOutput
+ /// all Cached, no Modified), and the generated text is unchanged.
+ ///
+ [Fact]
+ public void Output_equal_edit_reports_unchanged_without_output_rerun()
+ {
+ var firstTree = ParseTree(FirstAgentSource, "FirstComponentAgent.cs");
+ var secondTree = ParseTree(SecondAgentSource, "SecondComponentAgent.cs");
+ var compilation = CreateCompilation(firstTree, secondTree);
+ var driver = CreateTrackingDriver();
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(compilation);
+ var firstRunText = JoinGeneratedText(driver);
+
+ var editedTree = ParseTree(OutputEqualEditFirstAgentSource, "FirstComponentAgent.cs");
+ var updatedCompilation = compilation.ReplaceSyntaxTree(firstTree, editedTree);
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(updatedCompilation);
+
+ // 普通方法不入包装面:生成文本与首轮逐字符等值 → Unchanged(WithComparer 文本缓存生效)
+ var wrapperReasons = GetTrackedReasons(driver, WrapperOutputTrackingName);
+ Assert.Equal(2, wrapperReasons.Count);
+ Assert.Contains(IncrementalStepRunReason.Unchanged, wrapperReasons);
+ Assert.DoesNotContain(IncrementalStepRunReason.Modified, wrapperReasons);
+
+ var outputReasons = GetTrackedReasons(driver, SourceOutputTrackingName);
+ Assert.Equal(2, outputReasons.Count);
+ Assert.All(outputReasons, reason => Assert.Equal(IncrementalStepRunReason.Cached, reason));
+
+ Assert.Equal(NormalizeLineEndings(firstRunText), NormalizeLineEndings(JoinGeneratedText(driver)));
+ }
+
+ ///
+ /// 追加与代理无关的语法树后:没有任何声明重算出新值,全部输出回调命中缓存,生成 hint 集合不变。
+ /// After appending a syntax tree unrelated to agents: no declaration recomputes a new value, all output
+ /// callbacks hit the cache, and the generated hint set is unchanged.
+ ///
+ [Fact]
+ public void Unrelated_tree_addition_keeps_all_wrapper_outputs_cached()
+ {
+ var firstTree = ParseTree(FirstAgentSource, "FirstComponentAgent.cs");
+ var secondTree = ParseTree(SecondAgentSource, "SecondComponentAgent.cs");
+ var compilation = CreateCompilation(firstTree, secondTree);
+ var driver = CreateTrackingDriver();
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(compilation);
+ var firstRunHints = GetGeneratedHintNames(driver);
+
+ var updatedCompilation = compilation.AddSyntaxTrees(ParseTree(
+ """
+ namespace GameFrameX.Hotfix.Unrelated
+ {
+ public class UnrelatedClass
+ {
+ }
+ }
+ """,
+ "Unrelated.cs"));
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(updatedCompilation);
+
+ var wrapperReasons = GetTrackedReasons(driver, WrapperOutputTrackingName);
+ Assert.Equal(2, wrapperReasons.Count);
+ Assert.DoesNotContain(IncrementalStepRunReason.Modified, wrapperReasons);
+ Assert.DoesNotContain(IncrementalStepRunReason.New, wrapperReasons);
+
+ var outputReasons = GetTrackedReasons(driver, SourceOutputTrackingName);
+ Assert.Equal(2, outputReasons.Count);
+ Assert.All(outputReasons, reason => Assert.Equal(IncrementalStepRunReason.Cached, reason));
+ Assert.Equal(
+ firstRunHints.OrderBy(hint => hint, StringComparer.Ordinal),
+ GetGeneratedHintNames(driver).OrderBy(hint => hint, StringComparer.Ordinal));
+ }
+
+ ///
+ /// partial 代理各部件独立产出各自的 wrapper 文件(每部件自带相同基类列表):两份生成文件各含
+ /// 本部件的被包装方法,hintName 互不相同且重复运行稳定(声明位置稳定键)。
+ /// Each partial agent part independently produces its own wrapper file (each part carries the same
+ /// base list): two generated files each containing its own part's wrapped method, with distinct
+ /// hintNames that stay stable across repeated runs (declaration-position stable key).
+ ///
+ [Fact]
+ public void Partial_agent_parts_each_produce_their_own_wrapper_file_with_stable_hints()
+ {
+ var compilation = CreateCompilation(ParseTree(PartialAgentSource, "PartialComponentAgent.cs"));
+ var driver = CreateTrackingDriver();
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(compilation);
+
+ var firstRunHintNames = GetGeneratedHintNames(driver);
+ Assert.Equal(2, firstRunHintNames.Count);
+ Assert.Equal(2, firstRunHintNames.Distinct(StringComparer.Ordinal).Count());
+ Assert.All(firstRunHintNames, hintName => Assert.Matches(@".*PartialComponentAgentWrapper[0-9A-F]{16}\.g\.cs$", hintName));
+
+ // 每个部件的方法各自进入自己部件的 wrapper 文件,不串件
+ var generatedFiles = GetGeneratedFilesText(driver);
+ var partOneWrapper = generatedFiles.First(file => file.Contains("QueryPartOneAsync"));
+ var partTwoWrapper = generatedFiles.First(file => file.Contains("QueryPartTwoAsync"));
+ Assert.DoesNotContain("QueryPartTwoAsync", partOneWrapper);
+ Assert.DoesNotContain("QueryPartOneAsync", partTwoWrapper);
+ Assert.All(generatedFiles, file => Assert.Contains("public partial class PartialComponentAgentWrapper : PartialComponentAgent", file));
+
+ // 重复运行:声明位置稳定键保证 hintName 逐字节复用,输出全命中缓存
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(compilation);
+ Assert.Equal(
+ firstRunHintNames.OrderBy(hint => hint, StringComparer.Ordinal),
+ GetGeneratedHintNames(driver).OrderBy(hint => hint, StringComparer.Ordinal));
+ Assert.All(GetTrackedReasons(driver, SourceOutputTrackingName), reason => Assert.Equal(IncrementalStepRunReason.Cached, reason));
+ }
+
+ /// 谓词收窄守卫(C432 语义调整):无基类列表的 partial 类即使与代理同名(同命名空间或跨命名空间)
+ /// 也不进入生成面——partial 代理需每个部件自带相同基类列表才会逐部件产出 wrapper。
+ /// Predicate-narrowing guard (C432 semantic adjustment): a partial class without a base list never
+ /// enters the generation surface even when it shares its name with an agent (same or other
+ /// namespace)—partial agents must carry the same base list on every part to get per-part wrappers.
+ ///
+ [Fact]
+ public void Partial_class_without_base_list_never_generates_a_wrapper()
+ {
+ var source = """
+ namespace GameFrameX.Hotfix.Player
+ {
+ public partial class GuardedComponentAgent : BaseComponentAgent
+ {
+ [Service]
+ public virtual System.Threading.Tasks.Task QueryGuardedAsync(int value)
+ {
+ return System.Threading.Tasks.Task.FromResult(value);
+ }
+ }
+
+ public partial class GuardedComponentAgent
+ {
+ [Service]
+ public virtual System.Threading.Tasks.Task QueryBaselessPartAsync(int value)
+ {
+ return System.Threading.Tasks.Task.FromResult(value);
+ }
+ }
+ }
+
+ namespace GameFrameX.Hotfix.Server
+ {
+ public partial class GuardedComponentAgent
+ {
+ [Service]
+ public virtual System.Threading.Tasks.Task QueryCrossNamespacePartAsync(int value)
+ {
+ return System.Threading.Tasks.Task.FromResult(value);
+ }
+ }
+ }
+ """;
+ var compilation = CreateCompilation(ParseTree(source, "GuardedComponentAgent.cs"));
+ var driver = CreateTrackingDriver();
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(compilation);
+
+ // 仅带基类列表的部件产出 wrapper;两个无基类部件(含同名同命名空间与跨命名空间)都不产出
+ var generatedFiles = GetGeneratedFilesText(driver);
+ var wrapperFile = Assert.Single(generatedFiles);
+ Assert.Contains("QueryGuardedAsync", wrapperFile);
+ Assert.DoesNotContain("QueryBaselessPartAsync", wrapperFile);
+ Assert.DoesNotContain("QueryCrossNamespacePartAsync", wrapperFile);
+ }
+
+ ///
+ /// 非 partial 代理的 hintName 保持无后缀形态(与历史输出文件名逐字节一致,C292 单射契约不变)。
+ /// Non-partial agents keep the suffix-less hintName form (byte-identical to the historical output
+ /// filenames; the C292 injective contract is unchanged).
+ ///
+ [Fact]
+ public void Non_partial_agent_hint_name_has_no_position_suffix()
+ {
+ var compilation = CreateCompilation(ParseTree(FirstAgentSource, "FirstComponentAgent.cs"));
+ var driver = CreateTrackingDriver();
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(compilation);
+
+ var hintName = Assert.Single(GetGeneratedHintNames(driver));
+ Assert.Equal("GameFrameX_Hotfix_Player_FirstComponentAgentWrapper.g.cs", hintName);
+ }
+
+ ///
+ /// 创建开启增量步骤跟踪的驱动:TrackedSteps 断言依赖
+ /// trackIncrementalGeneratorSteps: true(默认关闭)。
+ /// Creates a driver with incremental step tracking enabled: TrackedSteps assertions require
+ /// trackIncrementalGeneratorSteps: true (off by default).
+ ///
+ /// 跟踪模式驱动 / The tracking-enabled driver
+ private static CSharpGeneratorDriver CreateTrackingDriver()
+ {
+ var driverOptions = new GeneratorDriverOptions(disabledOutputs: IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true);
+ return CSharpGeneratorDriver.Create(
+ new ISourceGenerator[] { new HotfixWrapperGenerator().AsSourceGenerator() },
+ additionalTexts: null,
+ parseOptions: null,
+ optionsProvider: null,
+ driverOptions: driverOptions);
+ }
+
+ private static SyntaxTree ParseTree(string source, string path)
+ {
+ return CSharpSyntaxTree.ParseText(SourceText.From(source, Encoding.UTF8), path: path);
+ }
+
+ private static CSharpCompilation CreateCompilation(params SyntaxTree[] syntaxTrees)
+ {
+ return CSharpCompilation.Create(
+ "HotfixWrapperGeneratorIncrementalPipelineTestsCompilation",
+ syntaxTrees,
+ new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) });
+ }
+
+ private static IReadOnlyList GetTrackedReasons(CSharpGeneratorDriver driver, string trackingName)
+ {
+ var runResult = driver.GetRunResult();
+ var generatorResult = Assert.Single(runResult.Results);
+ return generatorResult.TrackedSteps[trackingName]
+ .SelectMany(step => step.Outputs)
+ .Select(output => output.Item2)
+ .ToList();
+ }
+
+ private static IReadOnlyList GetGeneratedHintNames(CSharpGeneratorDriver driver)
+ {
+ var runResult = driver.GetRunResult();
+ // 生成树 FilePath 形如「生成器程序集/生成器类型/hintName」,取末段即 hintName
+ return runResult.GeneratedTrees.Select(tree => Path.GetFileName(tree.FilePath)).ToList();
+ }
+
+ private static IReadOnlyList GetGeneratedFilesText(CSharpGeneratorDriver driver)
+ {
+ var runResult = driver.GetRunResult();
+ return runResult.GeneratedTrees.Select(tree => tree.ToString()).ToList();
+ }
+
+ private static string JoinGeneratedText(CSharpGeneratorDriver driver)
+ {
+ return string.Join("\n", GetGeneratedFilesText(driver));
+ }
+
+ private static string NormalizeLineEndings(string text)
+ {
+ return text.Replace("\r", string.Empty);
+ }
+}