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); + } +}