Skip to content
Merged
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ All AI agents and contributors must follow these rules when writing, reviewing,
- After resolving the host Windows Git path, prefer an explicit session-local binding for subsequent commands so the
shell does not fall back to Linux `/usr/bin/git` later in the same WSL session.

## Git Workflow Rules

- Every completed task MUST pass at least one build validation before it is considered done.
- If the task changes multiple projects or shared abstractions, prefer a solution-level or affected-project
`dotnet build ... -c Release`; otherwise use the smallest build command that still proves the result compiles.
- If the required build passes and there are task-related staged or unstaged changes, contributors MUST create a Git
commit automatically instead of leaving the task uncommitted, unless the user explicitly says not to commit.
- Commit messages MUST use Conventional Commits format: `<type>(<scope>): <summary>`.
- The commit `summary` MUST use simplified Chinese and briefly describe the main change.
- The commit `body` MUST use unordered list items, and each item MUST start with a verb such as `新增`、`修复`、`优化`、
`更新`、`补充`、`重构`.
- Each commit body bullet MUST describe one independent change point; avoid repeated or redundant descriptions.
- Keep technical terms in English when they are established project terms, such as `API`、`Model`、`System`.
- If a new task starts while the current branch is `main`, contributors MUST first try to update local `main` from the
remote, then create and switch to a dedicated branch before making substantive changes.
- The branch naming rule for a new task branch is `<type>/<topic-or-scope>`, where `<type>` should match the intended
Conventional Commit category as closely as practical.

## Subagent Usage Rules

- Use subagents only when the task is complex, the context is likely to grow too large, or the work can be split into
Expand Down
19 changes: 13 additions & 6 deletions GFramework.Core.Abstractions/Resource/IResourceManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ public interface IResourceManager : IUtility
T? Load<T>(string path) where T : class;

/// <summary>
/// 异步加载资源
/// 异步加载指定路径的资源,并在缓存中对并发加载进行去重。
/// </summary>
/// <typeparam name="T">资源类型</typeparam>
/// <param name="path">资源路径</param>
/// <returns>资源实例,如果加载失败返回 null</returns>
/// <param name="path">资源路径,不能为空或空白。</param>
/// <returns>加载成功返回资源实例;加载失败返回 <see langword="null"/>。</returns>
/// <exception cref="ArgumentException">当 <paramref name="path"/> 为空或空白时抛出。</exception>
/// <exception cref="InvalidOperationException">当未注册对应资源加载器时抛出。</exception>
/// <remarks>实现内部可能使用 <c>ConfigureAwait(false)</c>,异步延续不保证回到调用线程。</remarks>
Task<T?> LoadAsync<T>(string path) where T : class;

/// <summary>
Expand Down Expand Up @@ -70,10 +73,14 @@ public interface IResourceManager : IUtility
void UnregisterLoader<T>() where T : class;

/// <summary>
/// 预加载资源(加载但不返回)
/// 预加载资源到缓存中。
/// </summary>
/// <typeparam name="T">资源类型</typeparam>
/// <param name="path">资源路径</param>
/// <param name="path">资源路径,不能为空或空白。</param>
/// <returns>表示预加载流程完成的任务。</returns>
/// <exception cref="ArgumentException">当 <paramref name="path"/> 为空或空白时抛出。</exception>
/// <exception cref="InvalidOperationException">当未注册对应资源加载器时抛出。</exception>
/// <remarks>内部委托给 <see cref="LoadAsync{T}(string)"/>,同样不捕获同步上下文。</remarks>
Task PreloadAsync<T>(string path) where T : class;

/// <summary>
Expand All @@ -86,4 +93,4 @@ public interface IResourceManager : IUtility
/// </summary>
/// <param name="strategy">资源释放策略</param>
void SetReleaseStrategy(IResourceReleaseStrategy strategy);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -635,23 +635,23 @@ private static bool TryGetRegistration(
if (targetMethod.TypeArguments[0] is not INamedTypeSymbol namedType)
return false;

if (targetMethod.Name == "RegisterModel" &&
if (string.Equals(targetMethod.Name, "RegisterModel", StringComparison.Ordinal) &&
SymbolHelpers.IsAssignableTo(targetMethod.ContainingType, symbols.IArchitectureType))
{
componentKind = ComponentKind.Model;
registeredType = namedType;
return true;
}

if (targetMethod.Name == "RegisterSystem" &&
if (string.Equals(targetMethod.Name, "RegisterSystem", StringComparison.Ordinal) &&
SymbolHelpers.IsAssignableTo(targetMethod.ContainingType, symbols.IArchitectureType))
{
componentKind = ComponentKind.System;
registeredType = namedType;
return true;
}

if (targetMethod.Name == "RegisterUtility" &&
if (string.Equals(targetMethod.Name, "RegisterUtility", StringComparison.Ordinal) &&
SymbolHelpers.IsAssignableTo(targetMethod.ContainingType, symbols.IArchitectureType))
{
componentKind = ComponentKind.Utility;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ private static void AnalyzeInvocation(
var method = invocation.TargetMethod;

// 检查方法名是否为 GetAll
if (method.Name != "GetAll")
if (!string.Equals(method.Name, "GetAll", StringComparison.Ordinal))
return;

// 检查是否为泛型方法
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,7 @@ private static List<RegistrationSpec> CollectRegistrations(
{
var registrations = new List<RegistrationSpec>();

foreach (var attribute in typeSymbol.GetAttributes()
// Roslyn 会把 partial 类型上的属性合并到同一个集合中。
// 先按语法树标识排序,才能让每个文件内的 Span.Start 成为可比较的稳定顺序键。
.OrderBy(GetAttributeSyntaxTreeOrderKey, StringComparer.Ordinal)
.ThenBy(GetAttributeOrder)
.ThenBy(GetAttributeTypeOrderKey, StringComparer.Ordinal))
foreach (var attribute in GetOrderedRegistrationAttributes(typeSymbol))
{
if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, registerModelAttribute))
{
Expand Down Expand Up @@ -239,6 +234,16 @@ private static List<RegistrationSpec> CollectRegistrations(
return registrations;
}

private static IOrderedEnumerable<AttributeData> GetOrderedRegistrationAttributes(INamedTypeSymbol typeSymbol)
{
// Roslyn 会把 partial 类型上的属性合并到同一个集合中。
// 先按语法树标识排序,才能让每个文件内的 Span.Start 成为可比较的稳定顺序键。
return typeSymbol.GetAttributes()
.OrderBy(GetAttributeSyntaxTreeOrderKey, StringComparer.Ordinal)
.ThenBy(GetAttributeOrder)
.ThenBy(GetAttributeTypeOrderKey, StringComparer.Ordinal);
}

private static bool TryCreateRegistration(
SourceProductionContext context,
INamedTypeSymbol ownerType,
Expand Down Expand Up @@ -323,7 +328,8 @@ private static string GenerateSource(INamedTypeSymbol typeSymbol, IReadOnlyList<
RegistrationKind.Model => "RegisterModel",
RegistrationKind.System => "RegisterSystem",
RegistrationKind.Utility => "RegisterUtility",
_ => throw new ArgumentOutOfRangeException(nameof(registration.Kind))
_ => throw new InvalidOperationException(
$"Unsupported registration kind '{registration.Kind}'.")
});
builder.Append("(new ");
builder.Append(registration.ComponentTypeDisplayName);
Expand Down
33 changes: 22 additions & 11 deletions GFramework.Core.SourceGenerators/Enums/EnumExtensionsGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,7 @@ protected override string Generate(INamedTypeSymbol symbol, AttributeData attr)
? null
: symbol.ContainingNamespace.ToDisplayString();

var generateIsMethods = GetNamedBooleanArgument(
attr,
nameof(GenerateEnumExtensionsAttribute.GenerateIsMethods),
true);
var generateIsInMethod = GetNamedBooleanArgument(
attr,
nameof(GenerateEnumExtensionsAttribute.GenerateIsInMethod),
true);
var generationOptions = GetGenerationOptions(attr);
var enumName = symbol.Name;
var fullEnumName = symbol.ToDisplayString();
var members = symbol.GetMembers()
Expand All @@ -104,15 +97,15 @@ protected override string Generate(INamedTypeSymbol symbol, AttributeData attr)
// 两个生成开关是彼此独立的契约,需要分别控制输出,并保持空行布局稳定,便于快照精确回归。
var hasGeneratedMembers = false;

if (generateIsMethods)
if (generationOptions.GenerateIsMethods)
{
hasGeneratedMembers = AppendIsMethods(
sb,
members,
fullEnumName);
}

if (generateIsInMethod)
if (generationOptions.GenerateIsInMethod)
{
if (hasGeneratedMembers)
{
Expand All @@ -130,6 +123,24 @@ protected override string Generate(INamedTypeSymbol symbol, AttributeData attr)
return sb.ToString();
}

/// <summary>
/// 读取枚举扩展生成选项,并在属性未显式指定时回退到契约默认值。
/// </summary>
/// <param name="attribute">待分析的特性数据。</param>
/// <returns>包含各个生成开关的选项元组。</returns>
private static (bool GenerateIsMethods, bool GenerateIsInMethod) GetGenerationOptions(AttributeData attribute)
{
return (
GetNamedBooleanArgument(
attribute,
nameof(GenerateEnumExtensionsAttribute.GenerateIsMethods),
true),
GetNamedBooleanArgument(
attribute,
nameof(GenerateEnumExtensionsAttribute.GenerateIsInMethod),
true));
}

/// <summary>
/// 获取生成文件的提示名称
/// </summary>
Expand All @@ -151,7 +162,7 @@ private static bool GetNamedBooleanArgument(AttributeData attribute, string argu
{
foreach (var namedArgument in attribute.NamedArguments)
{
if (namedArgument.Key == argumentName &&
if (string.Equals(namedArgument.Key, argumentName, StringComparison.Ordinal) &&
namedArgument.Value.Value is bool value)
{
return value;
Expand Down
20 changes: 19 additions & 1 deletion GFramework.Core.Tests/Extensions/NumericExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,24 @@ public void Between_Should_Throw_ArgumentException_When_Min_Is_Greater_Than_Max(
Assert.Throws<ArgumentException>(() => value.Between(100, 0));
}

/// <summary>
/// 测试Between方法在引用类型参数为null时抛出ArgumentNullException
/// </summary>
[Test]
public void Between_Should_Throw_ArgumentNullException_When_Reference_Arguments_Are_Null()
{
string? value = "m";
string? min = "a";
string? max = "z";

Assert.Multiple(() =>
{
Assert.Throws<ArgumentNullException>(() => NumericExtensions.Between(value!, null!, max!));
Assert.Throws<ArgumentNullException>(() => NumericExtensions.Between(value!, min!, null!));
Assert.Throws<ArgumentNullException>(() => NumericExtensions.Between(null!, min!, max!));
});
}

/// <summary>
/// 测试Lerp方法在t为0时返回起始值
/// </summary>
Expand Down Expand Up @@ -205,4 +223,4 @@ public void InverseLerp_Should_Throw_DivideByZeroException_When_From_Equals_To()
// Arrange & Act & Assert
Assert.Throws<DivideByZeroException>(() => 50f.InverseLerp(100f, 100f));
}
}
}
9 changes: 7 additions & 2 deletions GFramework.Core.Tests/Extensions/ResultExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,12 @@ public void Ensure_Should_Create_ArgumentException_With_Message()
{
var result = Result<int>.Succeed(-1);
var ensured = result.Ensure(x => x > 0, "Value must be positive");
Assert.That(ensured.Exception.Message, Is.EqualTo("Value must be positive"));
Assert.Multiple(() =>
{
Assert.That(ensured.Exception.Message, Does.StartWith("Value must be positive"));
Assert.That(ensured.Exception, Is.TypeOf<ArgumentException>());
Assert.That(((ArgumentException)ensured.Exception).ParamName, Is.EqualTo("result"));
});
}

/// <summary>
Expand Down Expand Up @@ -635,4 +640,4 @@ public void Should_Support_Ensure_Chaining_With_Multiple_Conditions()

Assert.That(result.IsSuccess, Is.True);
}
}
}
18 changes: 17 additions & 1 deletion GFramework.Core.Tests/Functional/ResultTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -429,4 +429,20 @@ public void ToString_Should_Return_Fail_With_Message_When_Failed()
// Assert
Assert.That(str, Is.EqualTo("Fail(Test error)"));
}
}

/// <summary>
/// 测试default(Result)不会在相等性比较、哈希计算和字符串格式化中触发空引用异常
/// </summary>
[Test]
public void Default_Result_Should_Be_Safe_For_Equality_HashCode_And_ToString()
{
var result = default(Result);

Assert.Multiple(() =>
{
Assert.That(result.Equals(default(Result)), Is.True);
Assert.That(result.GetHashCode(), Is.EqualTo(0));
Assert.That(result.ToString(), Is.EqualTo("Fail(null)"));
});
}
}
31 changes: 30 additions & 1 deletion GFramework.Core.Tests/Logging/LoggingConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,35 @@ public void CreateFactory_WithLoggerLevels_ShouldApplyCorrectLevels()
Assert.That(logger3.IsInfoEnabled(), Is.True);
}

[Test]
public void CreateFactory_WithOverlappingLoggerPrefixes_ShouldPreferLongestPrefixMatch()
{
var json = @"{
""minLevel"": ""Info"",
""appenders"": [
{
""type"": ""Console"",
""formatter"": ""Default""
}
],
""loggerLevels"": {
""GFramework"": ""Warning"",
""GFramework.Core"": ""Trace""
}
}";

var config = LoggingConfigurationLoader.LoadFromJsonString(json);
var factory = LoggingConfigurationLoader.CreateFactory(config);

var logger = factory.GetLogger("GFramework.Core.Logging");

Assert.Multiple(() =>
{
Assert.That(logger.IsTraceEnabled(), Is.True);
Assert.That(logger.IsDebugEnabled(), Is.True);
});
}

[Test]
public void CreateFactory_WithInvalidAppenderType_ShouldThrowException()
{
Expand Down Expand Up @@ -308,4 +337,4 @@ public void LoadFromJsonString_WithComplexConfiguration_ShouldWork()
Assert.That(config.Appenders[2].MaxFileSize, Is.EqualTo(10485760));
Assert.That(config.LoggerLevels.Count, Is.EqualTo(3));
}
}
}
2 changes: 1 addition & 1 deletion GFramework.Core.Tests/State/StateMachineSystemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ public class TestStateMachineSystemV5 : StateMachineSystem
/// 获取状态机内部的状态字典
/// </summary>
/// <returns>类型到状态实例的映射字典</returns>
public Dictionary<Type, IState> GetStates()
public IDictionary<Type, IState> GetStates()
{
return States;
}
Expand Down
9 changes: 5 additions & 4 deletions GFramework.Core/Architectures/Architecture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ public async Task InitializeAsync()
{
try
{
await InitializeInternalAsync(true);
await InitializeInternalAsync(true).ConfigureAwait(false);
}
catch (Exception e)
{
Expand All @@ -304,15 +304,16 @@ public async Task InitializeAsync()
/// <param name="asyncMode">是否启用异步模式</param>
private async Task InitializeInternalAsync(bool asyncMode)
{
_context = await _bootstrapper.PrepareForInitializationAsync(_context, Configurator, asyncMode);
_context = await _bootstrapper.PrepareForInitializationAsync(_context, Configurator, asyncMode)
.ConfigureAwait(false);

// === 用户 OnInitialize ===
_logger.Debug("Calling user OnInitialize()");
OnInitialize();
_logger.Debug("User OnInitialize() completed");

// === 组件初始化阶段 ===
await _lifecycle.InitializeAllComponentsAsync(asyncMode);
await _lifecycle.InitializeAllComponentsAsync(asyncMode).ConfigureAwait(false);

// === 初始化完成阶段 ===
_bootstrapper.CompleteInitialization();
Expand All @@ -337,7 +338,7 @@ public Task WaitUntilReadyAsync()
/// </summary>
public virtual async ValueTask DestroyAsync()
{
await _lifecycle.DestroyAsync();
await _lifecycle.DestroyAsync().ConfigureAwait(false);
}

/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions GFramework.Core/Architectures/ArchitectureBootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public async Task<IArchitectureContext> PrepareForInitializationAsync(

var context = EnsureContext(existingContext);
ConfigureServices(context, configurator);
await InitializeServiceModulesAsync(asyncMode);
await InitializeServiceModulesAsync(asyncMode).ConfigureAwait(false);
return context;
}

Expand Down Expand Up @@ -117,6 +117,6 @@ private void ConfigureServices(IArchitectureContext context, Action<IServiceColl
/// <param name="asyncMode">是否允许异步初始化服务模块。</param>
private async Task InitializeServiceModulesAsync(bool asyncMode)
{
await services.ModuleManager.InitializeAllAsync(asyncMode);
await services.ModuleManager.InitializeAllAsync(asyncMode).ConfigureAwait(false);
}
}
Loading
Loading