diff --git a/CHANGELOG.md b/CHANGELOG.md index 3805c7228..c7c2f2fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ * 撮合通知异步执行与停用退订(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`,宿主自定义举报存储必须遵循同一原子唯一性语义 +* Wrapper 生成器崩溃止血与不可编译产物清理(C431,GFX-1171,GitHub #917):`[TimeOut]` 实参防御式解析(仅数值字面量可静态求值——常量名/缺参/空参/表达式/下划线开头的常量名输出 Error 诊断并降级为框架默认超时,不再令生成器整体崩溃丢失全部 wrapper);`GetFullName` 重写为向上收集命名空间与嵌套类型段(无命名空间代理返回类型链短名,顺带覆盖 struct/enum 容器形态);`Tools.GetNameSpace` 取真命名空间(首个 `+` 前最后一个 `.`,无命名空间返回空串且不发射空 using),嵌套代理基类改容器限定名 `Outer.Inner`、wrapper 类名携带容器链(同命名空间同名代理不再碰撞);partial 合并键升为完全限定名(跨命名空间同名无关类不再产生幽灵 Wrapper);async 被包装方法统一 `return await`(修复非泛型 `async Task`/`async ValueTask` 生成体不可编译);含 `ref/out/in` 参数与 `[Discard]` 返回类型非 Task/ValueTask(按类型名最后一段比较,全限定名不误报)的方法报 Error 且不进入生成面(校验先行);`[Discard]` 非 ThreadSafe 入队 lambda 改 `async ()=> await` 统一绑定 `EnqueueAsync(Func)` 重载(ValueTask 一次性消费契约不再被违反)。新增 16 例生成器测试(含独立审查 R1/R2/R3 修复钉住),既有 3 例 Discard lambda 断言随契约更新。 ### Added * 行版本条件写内存管线收口共享助手(C409,GFX-1117):新增 `GameFrameX.DataBase/Abstractions/RowVersionCasWriteHelper` 与 `RowVersionBackfillWriteHelper`——`UpdateIfRowVersionAsync`(CAS)与 `UpdateIfRowVersionIsNullAsync`(回填)两原语在 Mongo/PostgreSQL/SQLite 三 Provider 重复的内存侧样板(Id 一致性校验、版本对齐 + checked 递增、写库前 `ToBytes()` 快照、失败回滚 / 成功精确提交)收敛为单一事实源,Provider 仅保留谓词 / 语句 / 重试外壳等写库差异;语义零变化(失败回滚、异常不回滚、`long.MaxValue` 溢出写库前抛出、SQLite null 守卫现状均冻结),回归依托 `DatabaseConditionalWritePrimitivesTests` 既有套件。 diff --git a/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentInfo.cs b/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentInfo.cs index 27f4a8fb4..56d9fad3f 100644 --- a/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentInfo.cs +++ b/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentInfo.cs @@ -56,12 +56,13 @@ public class AgentInfo public string Name { get; set; } /// - /// 获取或设置父类(被包装的代理类)名称。 + /// 获取或设置父类(被包装的代理类)引用名:剥离命名空间后的容器限定名,嵌套代理为 Outer.Inner 形态。 /// /// - /// Gets or sets the parent (wrapped agent) class name. + /// Gets or sets the parent (wrapped agent) class reference: the container-qualified name with the + /// namespace stripped; nested agents use the Outer.Inner form. Top-level agents equal the simple name. /// - /// 父类名称 / Parent class name + /// 父类引用名 / Parent class reference name public string Super { get; set; } /// diff --git a/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs b/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs index 602da0244..4c934115d 100644 --- a/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs +++ b/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs @@ -131,7 +131,7 @@ private static void AppendDiscardBody(MethodInfoData infoMethod) else { TemplateStringBuilder.AppendLine("\t\t\tlong callChainId = GameFrameX.Core.Actors.Impl.WorkerActor.NextChainId();"); - TemplateStringBuilder.AppendLine($"\t\t\t_ = base.Actor.WorkerActor.EnqueueAsync(()=>base.{infoMethod.Name}{infoMethod.Typeparams}({infoMethod.ParamString}), callChainId, true{BuildTimeoutArgument(infoMethod)});"); + 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;"); @@ -146,7 +146,8 @@ private static void AppendDiscardBody(MethodInfoData infoMethod) /// 方法元数据 / Method metadata private static void AppendInvocationBody(MethodInfoData infoMethod) { - var needAwait = infoMethod.IsAsync && infoMethod.ReturnType.Contains("<"); + // 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{"); diff --git a/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs b/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs index 4c3537e5e..27424fa42 100644 --- a/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs +++ b/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs @@ -29,6 +29,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using GameFrameX.Hotfix.WrapperGenerator.Utils; @@ -167,8 +168,9 @@ private static List CollectAgents(IReadOnlyList m.Text == "partial")) { @@ -178,7 +180,7 @@ private static List CollectAgents(IReadOnlyList partialClassCount, out string outFileName) { var info = new AgentInfo(); - info.Super = agent.Identifier.Text; - info.Name = info.Super + WrapperNameSuffix; // C292:生成类命名空间镜像代理源命名空间(GameFrameX.Hotfix.Wrapper.Agent + 源命名空间相对 // GameFrameX.Hotfix. 的段)。仓内存在同名代理(如 Player/Login 与 ServerRole/Login 各一个 @@ -250,6 +255,18 @@ private static AgentInfo CreateAgentInfo(ClassDeclarationSyntax agent, string fu : sourceNamespace; info.Space = HotfixNameSpaceNamePrefix + WrapperNamespaceSegment + (relativeNamespace.Length == 0 ? string.Empty : "." + relativeNamespace); + // 基类引用取剥离命名空间后的容器链(嵌套分隔符「+」改写为源码层级的「.」): + // 顶层代理结果与简单名逐字一致;嵌套代理(Outer+Inner)发射 Outer.Inner,配合镜像命名空间 + // 与 using 源命名空间,生成类的基类在生成文件作用域内可解析。 + var containerQualifiedTypeName = (sourceNamespace.Length == 0 ? fullName : fullName.Substring(sourceNamespace.Length + 1)).Replace('+', '.'); + info.Super = containerQualifiedTypeName; + + // wrapper 类名同样携带容器链(容器分隔符折叠为「_」):镜像命名空间只含真命名空间,同命名空间下 + // 嵌套代理与顶层同名代理(Outer+FooAgent 与 FooAgent)、不同容器同名嵌套代理若都只取简单名会得到 + // 同名同命名空间的两个 wrapper(CS0101/CS0263);仍以 ComponentAgentWrapper 结尾,HotfixModule + // 后缀识别不受影响。顶层代理与「简单名+Wrapper」逐字一致。 + info.Name = containerQualifiedTypeName.Replace('.', '_') + WrapperNameSuffix; + var isPartialClass = agent.Modifiers.ToList().FindIndex(s => s.Text == "partial") >= 0; if (isPartialClass) { @@ -317,7 +334,7 @@ private static void AddAgentMethod(SourceProductionContext context, AgentInfo in mth.ReturnType = method.ReturnType?.ToString() ?? "void"; foreach (var attributeListSyntax in method.AttributeLists) { - ApplyAttribute(attributeListSyntax, mth); + ApplyAttribute(context, fullName, method, attributeListSyntax, mth); } CollectMethod(context, info, fullName, method, mth); @@ -367,10 +384,14 @@ 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 + /// 代理类完全限定名 / Fully qualified name of the agent class + /// 方法声明语法节点 / Method declaration syntax node /// 属性列表语法节点 / Attribute list syntax node /// 待填充的方法元数据 / Method metadata to populate - private static void ApplyAttribute(AttributeListSyntax attributeListSyntax, MethodInfoData mth) + private static void ApplyAttribute(SourceProductionContext context, string fullName, MethodDeclarationSyntax method, AttributeListSyntax attributeListSyntax, MethodInfoData mth) { foreach (var attribute in attributeListSyntax.Attributes) { @@ -392,8 +413,20 @@ private static void ApplyAttribute(AttributeListSyntax attributeListSyntax, Meth } else if (string.Equals(name, StripAttributeSuffix(TimeOutAttributeName), StringComparison.OrdinalIgnoreCase)) { - mth.HasTimeout = true; - mth.TimeOut = ParseTimeout(attribute); + mth.TimeOut = ParseTimeout(attribute, out var parsed); + if (parsed) + { + mth.HasTimeout = true; + } + else + { + // 解析失败降级为未配置超时(走 EnqueueAsync 框架默认值),并以诊断指明写法约束 + mth.HasTimeout = false; + 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}"); + } } else if (string.Equals(name, StripAttributeSuffix(ThreadSafeAttributeName), StringComparison.OrdinalIgnoreCase)) { @@ -419,22 +452,96 @@ private static string StripAttributeSuffix(string name) } /// - /// 从命中的 TimeOut 特性自身的参数中解析超时时间(毫秒)。 + /// 从命中的 TimeOut 特性自身的参数中防御式解析超时时间(毫秒)。 /// /// - /// Parses the timeout duration (in milliseconds) from the matched TimeOut attribute's own argument. + /// Defensively parses the timeout duration (in milliseconds) from the matched TimeOut attribute's own argument. + /// 仅数值字面量可静态求值(数字分隔符「_」与负号在字面量/一元负表达式中合法);常量名、其他表达式、 + /// 属性赋值、缺参/空参等一切形态都返回 0 并置 为 false,由调用方 + /// 降级处理——任何输入形态都不抛异常(生成器异常 = 全部 wrapper 消失)。 + /// Only numeric literals are statically evaluated (digit separators "_" and a unary minus are legal); + /// constant names, any other expression, property assignments and missing/empty argument lists return 0 + /// with set to false for caller-side degradation — no input shape may + /// throw (a generator crash removes every wrapper). /// /// 命中的 TimeOut 特性语法节点 / The matched TimeOut attribute syntax node - /// 超时时间(毫秒)/ Timeout duration (in milliseconds) - private static int ParseTimeout(AttributeSyntax attribute) + /// 是否成功解析出整数字面量 / Whether an integer literal was successfully parsed + /// 超时时间(毫秒);解析失败返回 0(不使用)/ Timeout duration (in milliseconds); 0 when parsing failed (unused) + private static int ParseTimeout(AttributeSyntax attribute, out bool parsed) + { + parsed = false; + if (attribute.ArgumentList == null || attribute.ArgumentList.Arguments.Count == 0) + { + return 0; + } + + var argument = attribute.ArgumentList.Arguments[0]; + if (argument.NameEquals != null) + { + // 属性赋值实参(Name = value)不是构造参数,无法取值 + return 0; + } + + var milliseconds = ResolveIntegerLiteral(argument.Expression); + if (!milliseconds.HasValue) + { + return 0; + } + + parsed = true; + return milliseconds.Value; + } + + /// + /// 把实参表达式解析为整数值:仅接受数值字面量(含数字分隔符「_」)与其一元负表达式。 + /// + /// + /// Resolves an argument expression to an integer: only numeric literals (digit separators "_" included) + /// and their unary-minus expressions are accepted. + /// 常量名/其他表达式(含十六进制字面量等 TryParse 不支持的书写)都返回 null 交由调用方降级诊断, + /// 杜绝「标识符文本恰好剥掉下划线后成数字」一类静默误析。 + /// Constant names and any other expression (including hex literals TryParse cannot handle) return + /// null for caller-side diagnostic degradation, preventing silent mis-parsing of identifier texts + /// that happen to become digits after underscore stripping. + /// + /// 实参表达式语法节点 / The argument expression syntax node + /// 整数值;不可静态求值时为 null / The integer value; null when not statically evaluable + private static int? ResolveIntegerLiteral(ExpressionSyntax expression) { - var argStr = attribute.ArgumentList.Arguments[0].ToString(); - if (argStr.IndexOf(":", StringComparison.OrdinalIgnoreCase) >= 0) + if (expression is PrefixUnaryExpressionSyntax prefixUnaryExpression + && prefixUnaryExpression.IsKind(SyntaxKind.UnaryMinusExpression) + && prefixUnaryExpression.Operand is LiteralExpressionSyntax minusOperand) + { + var negatedValue = ResolveNumericLiteralToken(minusOperand); + return negatedValue.HasValue ? -negatedValue.Value : (int?)null; + } + + if (expression is LiteralExpressionSyntax literalExpression) { - return int.Parse(argStr.Split(':')[1].Trim()); + return ResolveNumericLiteralToken(literalExpression); } - return int.Parse(argStr); + return null; + } + + /// + /// 把数值字面量表达式的 token 文本解析为整数(剥除数字分隔符「_」)。 + /// + /// + /// Parses the token text of a numeric literal expression into an integer (stripping digit separators "_"). + /// + /// 数值字面量表达式 / The numeric literal expression + /// 整数值;token 不是数值字面量或越界时为 null / The integer value; null when the token is not a numeric literal or overflows + private static int? ResolveNumericLiteralToken(LiteralExpressionSyntax literalExpression) + { + if (!literalExpression.Token.IsKind(SyntaxKind.NumericLiteralToken)) + { + return null; + } + + return int.TryParse(literalExpression.Token.Text.Replace("_", string.Empty), NumberStyles.Integer, CultureInfo.InvariantCulture, out var milliseconds) + ? milliseconds + : (int?)null; } /// @@ -442,6 +549,7 @@ private static int ParseTimeout(AttributeSyntax attribute) /// /// /// 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 /// 代理信息 / Agent info @@ -459,6 +567,12 @@ private static void CollectMethod(SourceProductionContext context, AgentInfo inf ReportContractViolations(context, fullName, method, mth); + // 有阻断违规的方法绝不进入生成面:报 Error 的同时跳过收集,产出保持可编译 + if (mth.HasBlockingViolation) + { + return; + } + if (mth.IsVirtual) { AddWrappedMethod(info, context, fullName, method, mth); @@ -517,10 +631,12 @@ private static bool ShouldWrapMethod(MethodInfoData mth) } /// - /// 报告契约相关的诊断错误(非 ThreadSafe 的 Api 必须为异步、标记 Api/ThreadSafe/Discard 必须申明为 virtual)。 + /// 报告契约相关的诊断错误(非 ThreadSafe 的 Api 必须为异步、标记 Api/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 Api must be async; Api/ThreadSafe/Discard 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 /// 代理类完全限定名 / Fully qualified name of the agent class @@ -537,13 +653,44 @@ private static void ReportContractViolations(SourceProductionContext context, st { context.LogError($"{fullName}.{method.Identifier.Text}标记了【AsyncApi】【{ThreadSafeAttributeName}】【{DiscardAttributeName}】注解的函数必须申明为virtual"); } + + // 包装体以 lambda 捕获转发参数:ref/out/in 参数无法跨 Actor 队列转发引用(生成即 CS1628),显式拒绝 + foreach (var parameter in method.ParameterList.Parameters) + { + 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 队列转发引用)"); + mth.HasBlockingViolation = true; + } + } + + if (mth.Discard) + { + // 类型名取最后一段比较:System.Threading.Tasks.Task 等全限定写法与短名等价,不误报; + // Task/ValueTask 仍拒绝——生成体契约是「返回 Xxx.CompletedTask」,泛型任务无该静态字段 + var returnTypeName = mth.ReturnType; + var lastSegmentStart = returnTypeName.LastIndexOf('.'); + if (lastSegmentStart >= 0) + { + returnTypeName = returnTypeName.Substring(lastSegmentStart + 1); + } + + var isPlainTask = string.Equals(returnTypeName, nameof(Task), StringComparison.Ordinal) + || string.Equals(returnTypeName, nameof(ValueTask), StringComparison.Ordinal); + if (!isPlainTask) + { + context.LogError($"{fullName}.{method.Identifier.Text}只有返回值为Task类型或ValueTask类型才能添加【{DiscardAttributeName}】注解"); + mth.HasBlockingViolation = true; + } + } } /// - /// 将虚方法收集为包装方法:加入代理信息的方法列表,并填充签名与参数元数据。 + /// 将虚方法收集为包装方法:填充签名与参数元数据并加入代理信息的方法列表(校验已由 ReportContractViolations 先行完成)。 /// /// - /// Collects a virtual method as a wrapped method: adds it to the agent info's method list and populates signature and parameter metadata. + /// Collects a virtual method as a wrapped method: populates signature and parameter metadata and adds it to the agent info's method list + /// (validation already happened up front in ReportContractViolations). /// /// 代理信息 / Agent info /// 源代码生成上下文 / Source production context @@ -552,20 +699,16 @@ private static void ReportContractViolations(SourceProductionContext context, st /// 方法元数据 / Method metadata private static void AddWrappedMethod(AgentInfo info, SourceProductionContext context, string fullName, MethodDeclarationSyntax method, MethodInfoData mth) { - info.Methods.Add(mth); mth.Name = method.Identifier.Text; mth.ParamDeclare = method.ParameterList.ToString(); - if (mth.Discard && !mth.ReturnType.Equals(nameof(Task)) && !mth.ReturnType.Equals(nameof(ValueTask))) - { - context.LogError($"{fullName}.{method.Identifier.Text}只有返回值为Task类型或ValueTask类型才能添加【Discard】注解"); - } - mth.Constraint = method.ConstraintClauses.ToString(); mth.Typeparams = method.TypeParameterList?.ToString(); foreach (var p in method.ParameterList.Parameters) { mth.Params.Add(p.Identifier.Text); } + + info.Methods.Add(mth); } /// diff --git a/GameFrameX.Hotfix.WrapperGenerator/Agent/MethodInfoData.cs b/GameFrameX.Hotfix.WrapperGenerator/Agent/MethodInfoData.cs index b3aff61de..7516666be 100644 --- a/GameFrameX.Hotfix.WrapperGenerator/Agent/MethodInfoData.cs +++ b/GameFrameX.Hotfix.WrapperGenerator/Agent/MethodInfoData.cs @@ -167,6 +167,17 @@ public string Declare /// 如果丢弃返回值则为 true;否则为 false / true if discarding return value; otherwise false public bool Discard { get; set; } + /// + /// 获取或设置方法是否存在阻断违规(ref/out/in 参数、Discard 返回类型契约不满足)。 + /// + /// + /// Gets or sets whether the method has a blocking violation (ref/out/in parameters, or a Discard return-type contract failure). + /// 置位的方法已输出 Error 诊断,不得进入生成面(收集前判定)。 + /// A flagged method has already produced an Error diagnostic and must never enter the generation surface (decided before collection). + /// + /// 存在阻断违规则为 true;否则为 false / true if a blocking violation exists; otherwise false + public bool HasBlockingViolation { get; set; } + /// /// 获取或设置方法是否设置了超时时间。 /// diff --git a/GameFrameX.Hotfix.WrapperGenerator/Utils/ClassDeclarationSyntaxExt.cs b/GameFrameX.Hotfix.WrapperGenerator/Utils/ClassDeclarationSyntaxExt.cs index 568e4f70b..8fcb15f73 100644 --- a/GameFrameX.Hotfix.WrapperGenerator/Utils/ClassDeclarationSyntaxExt.cs +++ b/GameFrameX.Hotfix.WrapperGenerator/Utils/ClassDeclarationSyntaxExt.cs @@ -28,8 +28,6 @@ // ========================================================================================== using System.Collections.Generic; -using System.Diagnostics.Contracts; -using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -61,52 +59,44 @@ public static class ClassDeclarationSyntaxExt public const string NamespaceClassDelimiter = "."; /// - /// 获取类声明的完全限定名,包含命名空间和嵌套类路径。 + /// 获取类声明的完全限定名,包含命名空间和嵌套类型路径。 /// /// - /// Gets the fully qualified name of the class declaration, including namespace and nested class path. + /// Gets the fully qualified name of the class declaration, including namespace and nested type path. + /// 向上收集容器链:命名空间段自带尾部「.」、嵌套类型段自带尾部「+」,直接连接后追加类名, + /// 任意声明形态(块/文件作用域命名空间、无命名空间、嵌套类型容器)都返回确定值: + /// 有命名空间时为 Namespace.OuterClass+InnerClass;无命名空间时仅返回类型链(OuterClass+InnerClass 或短名)。 + /// Collects the container chain upward: each namespace segment carries a trailing "." and each nested + /// type segment a trailing "+"; concatenating them and appending the class name yields a deterministic + /// value for any declaration shape (block/file-scoped namespace, no namespace, nested type containers). /// /// 类声明语法节点 / Class declaration syntax node /// 完全限定名(如 Namespace.OuterClass+InnerClass)/ Fully qualified name (e.g., Namespace.OuterClass+InnerClass) public static string GetFullName(this ClassDeclarationSyntax source) { - Contract.Requires(null != source); - - var items = new List(); - var parent = source.Parent; - while (parent.IsKind(SyntaxKind.ClassDeclaration)) + if (source == null) { - var parentClass = parent as ClassDeclarationSyntax; - Contract.Assert(null != parentClass); - items.Add(parentClass.Identifier.Text); - - parent = parent.Parent; + return string.Empty; } - if (parent is FileScopedNamespaceDeclarationSyntax) + var containers = new List(); + for (var node = source.Parent; node != null; node = node.Parent) { - var nameSpace = parent as FileScopedNamespaceDeclarationSyntax; - Contract.Assert(null != nameSpace); - var sb = new StringBuilder().Append(nameSpace.Name).Append(NamespaceClassDelimiter); - items.Reverse(); - items.ForEach(i => { sb.Append(i).Append(NestedClassDelimiter); }); - sb.Append(source.Identifier.Text); - - var result = sb.ToString(); - return result; + if (node is BaseTypeDeclarationSyntax typeDeclaration) + { + // 嵌套类型容器:类名后跟嵌套分隔符(OuterClass+InnerClass 中的 OuterClass+) + containers.Add(typeDeclaration.Identifier.Text + NestedClassDelimiter); + } + else if (node is BaseNamespaceDeclarationSyntax namespaceDeclaration) + { + // 命名空间声明名本身已含全部上级段(嵌套命名空间声明为全名),取到即止 + containers.Add(namespaceDeclaration.Name.ToString() + NamespaceClassDelimiter); + break; + } } - else - { - var nameSpace = parent as NamespaceDeclarationSyntax; - Contract.Assert(null != nameSpace); - var sb = new StringBuilder().Append(nameSpace.Name).Append(NamespaceClassDelimiter); - items.Reverse(); - items.ForEach(i => { sb.Append(i).Append(NestedClassDelimiter); }); - sb.Append(source.Identifier.Text); - var result = sb.ToString(); - return result; - } + containers.Reverse(); + return string.Concat(containers) + source.Identifier.Text; } } \ No newline at end of file diff --git a/GameFrameX.Hotfix.WrapperGenerator/Utils/Tools.cs b/GameFrameX.Hotfix.WrapperGenerator/Utils/Tools.cs index 8b198cce5..3195280f6 100644 --- a/GameFrameX.Hotfix.WrapperGenerator/Utils/Tools.cs +++ b/GameFrameX.Hotfix.WrapperGenerator/Utils/Tools.cs @@ -42,23 +42,35 @@ public static class Tools /// /// /// Extracts the namespace portion from a fully qualified type name. + /// 完全限定名形态为 Namespace.OuterType+InnerType:命名空间与最外层类型之间是「.」, + /// 类型之间是「+」。真命名空间 = 首个「+」之前的最后一个「.」之前的部分;不含「+」时退化为最后一个「.」之前的部分; + /// 两者皆无(无命名空间的顶层或嵌套类型)返回空串,调用方据此跳过 using 发射。 + /// The fully qualified name has the shape Namespace.OuterType+InnerType: a "." separates the + /// namespace from the outermost type while "+" separates nested types. The namespace is the portion + /// before the last "." that precedes the first "+" (or before the last "." when no "+" exists); + /// when neither delimiter exists the type has no namespace and an empty string is returned. /// /// 类型的完全限定名 / Fully qualified type name - /// 命名空间字符串;如果输入不包含点号或为空,则返回原始字符串 / Namespace string; returns the original string if input contains no dots or is empty + /// 命名空间字符串;无命名空间时返回空串 / Namespace string; an empty string when the type has no namespace public static string GetNameSpace(string fullName) { if (string.IsNullOrEmpty(fullName)) { - return fullName; + return string.Empty; } - var i = fullName.LastIndexOf('.'); - if (i < 0) + // 嵌套类型链起点:首个「+」之前只可能是「命名空间.最外层类型」 + var nestedTypeStart = fullName.IndexOf('+'); + var searchEnd = nestedTypeStart < 0 ? fullName.Length : nestedTypeStart; + for (var i = searchEnd - 1; i >= 0; i--) { - return fullName; + if (fullName[i] == '.') + { + return fullName.Substring(0, i); + } } - return fullName.Substring(0, i); + return string.Empty; } } \ No newline at end of file diff --git a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorAttributeParsingTests.cs b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorAttributeParsingTests.cs index e7cbc06d3..8ac89c25f 100644 --- a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorAttributeParsingTests.cs +++ b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorAttributeParsingTests.cs @@ -81,7 +81,7 @@ public void Combined_service_and_discard_attributes_are_both_recognized() Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); // Discard 分支执行:async 修饰符被剥除、生成丢弃体(true 入队 + CompletedTask),而非等待调用体 Assert.Contains("public override Task FlushAsync()", generatedText); - Assert.Contains("EnqueueAsync(()=>base.FlushAsync(), callChainId, true)", generatedText); + Assert.Contains("EnqueueAsync(async ()=> await base.FlushAsync(), callChainId, true)", generatedText); Assert.Contains("return Task.CompletedTask;", generatedText); } @@ -92,7 +92,7 @@ public void Timeout_is_parsed_from_the_matched_attribute_in_a_combined_list() Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); // 超时值取自命中的 TimeOut 特性自身参数,而非属性列表首个特性 - Assert.Contains("EnqueueAsync(()=>base.SaveAsync(), callChainId, true, 3000)", generatedText); + Assert.Contains("EnqueueAsync(async ()=> await base.SaveAsync(), callChainId, true, 3000)", generatedText); } [Fact] diff --git a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorContractViolationTests.cs b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorContractViolationTests.cs new file mode 100644 index 000000000..131a9ec29 --- /dev/null +++ b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorContractViolationTests.cs @@ -0,0 +1,201 @@ +// ========================================================================================== +// 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 legal 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.Linq; +using GameFrameX.Hotfix.WrapperGenerator.Agent; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace GameFrameX.Tests.Hotfix; + +/// +/// Wrapper 生成器契约违规测试:ref/out/in 参数方法被诊断拒绝且不生成包装体、Discard 的 ValueTask 方法经 +/// 异步 lambda 入队(绑定 EnqueueAsync(Func<Task>) 重载)、跨命名空间同简单名类不并入代理、 +/// 嵌套代理的命名空间镜像不含「+」且 using 不含类型名。 +/// +/// +/// Contract violation tests for the wrapper generator: methods with ref/out/in parameters are rejected by a +/// diagnostic with no generated body; Discard ValueTask methods enqueue through an async lambda (binding the +/// EnqueueAsync(Func<Task>) overload); same-simple-name classes in different namespaces are never merged into +/// an agent; nested agents mirror their namespace without "+" and without type names in usings. +/// +public sealed class HotfixWrapperGeneratorContractViolationTests +{ + [Fact] + public void Service_MethodWithRefOrOutParameter_ReportsDiagnosticAndSkipsGeneration() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class RefParameterComponentAgent : BaseComponentAgent + { + [Service] + public virtual Task WriteRefAsync(ref int value) { return null; } + + [Service] + public virtual Task WriteOutAsync(out int value) { value = 0; return null; } + + [Service] + public virtual Task ValidSiblingAsync() { return null; } + } + } + """); + + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error + && diagnostic.GetMessage().Contains("WriteRefAsync") + && diagnostic.GetMessage().Contains("不允许 ref/out/in 参数")); + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error + && diagnostic.GetMessage().Contains("WriteOutAsync") + && diagnostic.GetMessage().Contains("不允许 ref/out/in 参数")); + // 拒绝诊断与生成面互斥:同类合法方法照常生成(钉住「跳过的是方法而非整类」),违规方法不发射 + var generatedText = JoinGeneratedText(result); + Assert.Contains("ValidSiblingAsync", generatedText); + Assert.DoesNotContain("WriteRefAsync", generatedText); + Assert.DoesNotContain("WriteOutAsync", generatedText); + } + + [Fact] + public void Discard_ValueTaskMethod_UsesAsyncLambdaEnqueue() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class DiscardValueTaskComponentAgent : BaseComponentAgent + { + [Discard] + public virtual ValueTask NotifyValueAsync() { return default; } + } + } + """); + + Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + var generatedText = JoinGeneratedText(result); + // 异步 lambda(async ()=> await ...)统一绑定 EnqueueAsync(Func) 重载: + // ValueTask 不再作为 Func 的 T 被当作普通返回值,其异步完成被正确观察(一次性消费契约不被违反) + Assert.Contains("EnqueueAsync(async ()=> await base.NotifyValueAsync(), callChainId, true)", generatedText); + Assert.Contains("return ValueTask.CompletedTask;", generatedText); + } + + [Fact] + public void SameSimpleNameInDifferentNamespaces_OnlyAgentSideGeneratesWrapper() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public partial class LoginComponentAgent : BaseComponentAgent + { + [Service] + public virtual Task LoginAsync() { return null; } + } + } + + namespace GameFrameX.Hotfix.ServerRole + { + // 与代理同简单名的无关 partial 辅助类:不得按简单名并入代理得到幽灵 Wrapper + public partial class LoginComponentAgent + { + [Service] + public virtual Task HelperLoginAsync() { return null; } + } + } + """); + + var generatedText = JoinGeneratedText(result); + Assert.Contains("LoginAsync", generatedText); + Assert.Contains("GameFrameX.Hotfix.Wrapper.Agent.Player", generatedText); + Assert.DoesNotContain("ServerRole", generatedText); + Assert.DoesNotContain("HelperLoginAsync", generatedText); + } + + [Fact] + public void NestedAgent_NamespaceMirroredWithoutPlus() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class OuterHolder + { + public class NestedComponentAgent : BaseComponentAgent + { + [Service] + public virtual Task NestedPingAsync() { return null; } + } + } + + // 与嵌套代理同简单名的顶层代理:镜像命名空间相同,wrapper 类名必须携带容器链避免同名碰撞 + public class NestedComponentAgent : BaseComponentAgent + { + [Service] + public virtual Task TopLevelPingAsync() { return null; } + } + } + """); + + Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + var generatedText = JoinGeneratedText(result); + // 命名空间镜像取真命名空间:不含嵌套分隔符「+」,using 也不得引入类型名(OuterHolder 不是命名空间) + Assert.Contains("namespace GameFrameX.Hotfix.Wrapper.Agent.Player", generatedText); + Assert.DoesNotContain("+", generatedText); + Assert.Contains("using GameFrameX.Hotfix.Player;", generatedText); + Assert.DoesNotContain("using GameFrameX.Hotfix.Player.OuterHolder", generatedText); + // 嵌套代理基类用容器限定名、wrapper 类名携带容器链(. 折叠为 _):与同简单名顶层代理的 wrapper + // 同命名空间共存不碰撞(CS0101/CS0263),且两者都仍以 ComponentAgentWrapper 结尾满足 HotfixModule 识别 + Assert.Contains("class OuterHolder_NestedComponentAgentWrapper : OuterHolder.NestedComponentAgent", generatedText); + Assert.Contains("class NestedComponentAgentWrapper : NestedComponentAgent", generatedText); + Assert.Contains("NestedPingAsync", generatedText); + Assert.Contains("TopLevelPingAsync", generatedText); + } + + /// + /// 以测试源驱动 Wrapper 生成器并返回运行结果(生成树 + 生成器诊断)。 + /// + /// 测试源码 / Test source + /// 生成器运行结果 / The generator run result + private static GeneratorDriverRunResult RunWrapperGenerator(string source) + { + var compilation = CSharpCompilation.Create( + "HotfixWrapperGeneratorContractViolationTestsCompilation", + new[] { CSharpSyntaxTree.ParseText(source) }); + + var driver = CSharpGeneratorDriver.Create(new HotfixWrapperGenerator()); + return driver.RunGenerators(compilation).GetRunResult(); + } + + /// + /// 合并全部生成树的文本。 + /// + /// 生成器运行结果 / The generator run result + /// 合并后的生成源码文本 / The combined generated source text + private static string JoinGeneratedText(GeneratorDriverRunResult result) + { + return string.Join("\n", result.GeneratedTrees.Select(tree => tree.GetText().ToString())); + } +} diff --git a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorIncrementalSafetyTests.cs b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorIncrementalSafetyTests.cs index 8f5ed1eb1..883fff671 100644 --- a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorIncrementalSafetyTests.cs +++ b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorIncrementalSafetyTests.cs @@ -104,7 +104,7 @@ public void Explicit_timeout_is_still_emitted() var (generatedText, diagnostics) = RunWrapperGenerator(AgentSource); Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); - Assert.Contains("EnqueueAsync(()=>base.SaveAsync(), callChainId, true, 3000)", generatedText); + Assert.Contains("EnqueueAsync(async ()=> await base.SaveAsync(), callChainId, true, 3000)", generatedText); } [Fact] diff --git a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorRobustnessTests.cs b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorRobustnessTests.cs new file mode 100644 index 000000000..debbfcb49 --- /dev/null +++ b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorRobustnessTests.cs @@ -0,0 +1,320 @@ +// ========================================================================================== +// 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 legal 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.Linq; +using GameFrameX.Hotfix.WrapperGenerator.Agent; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace GameFrameX.Tests.Hotfix; + +/// +/// Wrapper 生成器健壮性测试:TimeOut 实参防御式解析(常量名/缺参/空参/表达式不崩溃、命名实参与数字分隔符仍解析)、 +/// 无命名空间与文件作用域命名空间代理的完全限定名、async 非泛型 Task 的 await 转发、Discard 返回类型校验先行。 +/// +/// +/// Robustness tests for the wrapper generator: defensive TimeOut argument parsing (constant names, missing/empty +/// argument lists and expressions must not crash; named arguments and digit separators still parse), fully qualified +/// names for namespace-less and file-scoped agents, await-forwarding for async non-generic Task methods, and +/// up-front Discard return-type validation. +/// +public sealed class HotfixWrapperGeneratorRobustnessTests +{ + [Fact] + public void TimeOut_WithConstName_ReportsDiagnosticAndUsesDefaultTimeout() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class ConstTimeoutComponentAgent : BaseComponentAgent + { + [Service, TimeOut(TimeoutMilliseconds)] + public virtual Task SaveWithConstAsync() { return null; } + } + } + """); + + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error + && diagnostic.GetMessage().Contains("SaveWithConstAsync") + && diagnostic.GetMessage().Contains("【TimeOut】参数必须是整数字面量")); + // 解析失败降级为未配置超时:入队调用不携带超时实参(走框架默认值) + Assert.Contains(result.GeneratedTrees, tree => tree.GetText().ToString().Contains("EnqueueAsync(()=>base.SaveWithConstAsync(), chainId, false)")); + } + + [Fact] + public void TimeOut_WithoutArgumentList_ReportsDiagnosticInsteadOfCrashing() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class BareTimeoutComponentAgent : BaseComponentAgent + { + [Service, TimeOut] + public virtual Task SaveBareAsync() { return null; } + } + } + """); + + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error + && diagnostic.GetMessage().Contains("SaveBareAsync") + && diagnostic.GetMessage().Contains("【TimeOut】参数必须是整数字面量")); + Assert.Contains(result.GeneratedTrees, tree => tree.GetText().ToString().Contains("EnqueueAsync(()=>base.SaveBareAsync(), chainId, false)")); + } + + [Fact] + public void TimeOut_WithEmptyArgumentList_ReportsDiagnosticInsteadOfCrashing() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class EmptyTimeoutComponentAgent : BaseComponentAgent + { + [Service, TimeOut()] + public virtual Task SaveEmptyAsync() { return null; } + } + } + """); + + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error + && diagnostic.GetMessage().Contains("SaveEmptyAsync") + && diagnostic.GetMessage().Contains("【TimeOut】参数必须是整数字面量")); + } + + [Fact] + public void TimeOut_WithExpression_ReportsDiagnosticInsteadOfCrashing() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class ExpressionTimeoutComponentAgent : BaseComponentAgent + { + [Service, TimeOut((int)TimeSpan.FromSeconds(5).TotalMilliseconds)] + public virtual Task SaveExpressionAsync() { return null; } + } + } + """); + + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error + && diagnostic.GetMessage().Contains("SaveExpressionAsync") + && diagnostic.GetMessage().Contains("【TimeOut】参数必须是整数字面量")); + Assert.Contains(result.GeneratedTrees, tree => tree.GetText().ToString().Contains("EnqueueAsync(()=>base.SaveExpressionAsync(), chainId, false)")); + } + + [Fact] + public void TimeOut_WithNamedArgument_StillEmitsTimeout() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class NamedTimeoutComponentAgent : BaseComponentAgent + { + [Service, TimeOut(timeOut: 2500)] + public virtual Task SaveNamedAsync() { return null; } + } + } + """); + + Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + Assert.Contains(result.GeneratedTrees, tree => tree.GetText().ToString().Contains("EnqueueAsync(()=>base.SaveNamedAsync(), chainId, false, 2500)")); + } + + [Fact] + public void TimeOut_WithDigitSeparator_StillEmitsTimeout() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class SeparatorTimeoutComponentAgent : BaseComponentAgent + { + [Service, TimeOut(3_000)] + public virtual Task SaveSeparatorAsync() { return null; } + } + } + """); + + Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + Assert.Contains(result.GeneratedTrees, tree => tree.GetText().ToString().Contains("EnqueueAsync(()=>base.SaveSeparatorAsync(), chainId, false, 3000)")); + } + + [Fact] + public void TimeOut_WithUnderscoreLeadingIdentifier_ReportsDiagnosticInsteadOfMisParsing() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class UnderscoreIdentifierTimeoutComponentAgent : BaseComponentAgent + { + [Service, TimeOut(_3000)] + public virtual Task SaveUnderscoreIdentifierAsync() { return null; } + } + } + """); + + // 下划线开头的合法常量名不得被「剥掉下划线后恰好是数字」静默误析为超时值 + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error + && diagnostic.GetMessage().Contains("SaveUnderscoreIdentifierAsync") + && diagnostic.GetMessage().Contains("【TimeOut】参数必须是整数字面量")); + Assert.Contains(result.GeneratedTrees, tree => tree.GetText().ToString().Contains("EnqueueAsync(()=>base.SaveUnderscoreIdentifierAsync(), chainId, false)")); + } + + [Fact] + public void Agent_WithoutNamespace_GeneratesWrapperWithoutCrash() + { + var result = RunWrapperGenerator(""" + public class NoNamespaceComponentAgent : BaseComponentAgent + { + [Service] + public virtual Task PingNoNamespaceAsync() { return null; } + } + """); + + var generatedText = JoinGeneratedText(result); + // 无命名空间代理:命名空间回退到固定的 Wrapper.Agent 段,不发射空 using + Assert.Contains("namespace GameFrameX.Hotfix.Wrapper.Agent", generatedText); + Assert.Contains("class NoNamespaceComponentAgentWrapper : NoNamespaceComponentAgent", generatedText); + Assert.DoesNotContain("using ;", generatedText); + } + + [Fact] + public void Agent_InFileScopedNamespace_GeneratesMirroredNamespace() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player; + + public class FileScopedComponentAgent : BaseComponentAgent + { + [Service] + public virtual Task PingFileScopedAsync() { return null; } + } + """); + + var generatedText = JoinGeneratedText(result); + Assert.Contains("namespace GameFrameX.Hotfix.Wrapper.Agent.Player", generatedText); + Assert.Contains("class FileScopedComponentAgentWrapper : FileScopedComponentAgent", generatedText); + Assert.Contains("using GameFrameX.Hotfix.Player;", generatedText); + } + + [Fact] + public void Service_AsyncNonGenericTask_GeneratesAwaitReturn() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class AsyncTaskComponentAgent : BaseComponentAgent + { + [Service] + public virtual async Task SyncAsync() { await Task.Delay(1); } + } + } + """); + + var generatedText = JoinGeneratedText(result); + // async 非泛型 Task:override async 方法体内必须 return await 转发(是否泛型与该判定无关) + Assert.Contains("public override async Task SyncAsync()", generatedText); + Assert.Contains("return await base.SyncAsync();", generatedText); + } + + [Fact] + public void Discard_OnTaskOfInt_ReportsDiagnosticWithoutGeneratedBody() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class DiscardTaskOfIntComponentAgent : BaseComponentAgent + { + [Discard] + public virtual Task CountDiscardAsync() { return 0; } + + [Discard] + public virtual Task ValidDiscardSiblingAsync() { return null; } + } + } + """); + + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error + && diagnostic.GetMessage().Contains("CountDiscardAsync") + && diagnostic.GetMessage().Contains("只有返回值为Task类型或ValueTask类型才能添加【Discard】注解")); + // 校验先行:违规方法绝不进入生成面(不再生成 Task.CompletedTask 一类不可编译产物); + // 同类合法方法照常生成,钉住「跳过的是方法而非整类」 + Assert.Contains("ValidDiscardSiblingAsync", JoinGeneratedText(result)); + Assert.DoesNotContain("CountDiscardAsync", JoinGeneratedText(result)); + } + + [Fact] + public void Discard_OnFullyQualifiedTask_NoFalsePositive() + { + var result = RunWrapperGenerator(""" + namespace GameFrameX.Hotfix.Player + { + public class DiscardQualifiedTaskComponentAgent : BaseComponentAgent + { + [Discard] + public virtual System.Threading.Tasks.Task SaveQualifiedAsync() { return null; } + } + } + """); + + Assert.DoesNotContain(result.Diagnostics, diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error + && diagnostic.GetMessage().Contains("SaveQualifiedAsync")); + var generatedText = JoinGeneratedText(result); + Assert.Contains("return System.Threading.Tasks.Task.CompletedTask;", generatedText); + } + + /// + /// 以测试源驱动 Wrapper 生成器并返回运行结果(生成树 + 生成器诊断)。 + /// + /// 测试源码 / Test source + /// 生成器运行结果 / The generator run result + private static GeneratorDriverRunResult RunWrapperGenerator(string source) + { + var compilation = CSharpCompilation.Create( + "HotfixWrapperGeneratorRobustnessTestsCompilation", + new[] { CSharpSyntaxTree.ParseText(source) }); + + var driver = CSharpGeneratorDriver.Create(new HotfixWrapperGenerator()); + return driver.RunGenerators(compilation).GetRunResult(); + } + + /// + /// 合并全部生成树的文本。 + /// + /// 生成器运行结果 / The generator run result + /// 合并后的生成源码文本 / The combined generated source text + private static string JoinGeneratedText(GeneratorDriverRunResult result) + { + return string.Join("\n", result.GeneratedTrees.Select(tree => tree.GetText().ToString())); + } +}