diff --git a/GFramework.Godot.Tests/Logging/GodotLogAppenderTests.cs b/GFramework.Godot.Tests/Logging/GodotLogAppenderTests.cs new file mode 100644 index 00000000..dd98e173 --- /dev/null +++ b/GFramework.Godot.Tests/Logging/GodotLogAppenderTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using GFramework.Core.Abstractions.Logging; +using GFramework.Godot.Logging; + +namespace GFramework.Godot.Tests.Logging; + +/// +/// Verifies the Godot appender edge that adapts Core log entries to Godot output rendering. +/// +[TestFixture] +public sealed class GodotLogAppenderTests +{ + /// + /// Verifies that the appender renders Core log entry data and merged structured properties. + /// + [Test] + public void Render_Should_Use_Core_LogEntry_And_Merged_Properties() + { + LogContext.Clear(); + using var sceneContext = LogContext.Push("Scene", "Boot"); + var appender = new GodotLogAppender(new GodotLoggerOptions + { + Mode = GodotLoggerMode.Release, + ReleaseOutputTemplate = "{timestamp:yyyyMMdd}|{level:u3}|{category}|{message}{properties}" + }); + var entry = new LogEntry( + new DateTime(2026, 5, 3, 4, 5, 6, DateTimeKind.Utc), + LogLevel.Info, + "Game.Services.Inventory", + "Ready", + null, + new Dictionary(StringComparer.Ordinal) + { + [" "] = "ignored", + ["Score"] = 12.5m + }); + + var result = appender.Render(entry); + + Assert.Multiple(() => + { + Assert.That(result, Does.StartWith("20260503|INF|Game.Services.Inventory|Ready | ")); + Assert.That(result, Does.Contain("Scene=Boot")); + Assert.That(result, Does.Contain("Score=12.5")); + }); + } + + /// + /// Verifies that dynamic option providers are evaluated for each rendered log entry. + /// + [Test] + public void Render_Should_Use_Latest_Options_From_Provider() + { + var options = new GodotLoggerOptions + { + Mode = GodotLoggerMode.Release, + ReleaseOutputTemplate = "[release] {message}" + }; + var appender = new GodotLogAppender(() => options); + var entry = new LogEntry( + DateTime.UtcNow, + LogLevel.Warning, + "Game", + "Reloaded", + null, + null); + + var releaseResult = appender.Render(entry); + + options = new GodotLoggerOptions + { + Mode = GodotLoggerMode.Debug, + DebugOutputTemplate = "[debug] {message}" + }; + + var debugResult = appender.Render(entry); + + Assert.Multiple(() => + { + Assert.That(releaseResult, Is.EqualTo("[release] Reloaded")); + Assert.That(debugResult, Is.EqualTo("[debug] Reloaded")); + }); + } +} diff --git a/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs b/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs index 749ac715..71f8ff18 100644 --- a/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs +++ b/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs @@ -192,9 +192,11 @@ public sealed class GodotLoggerSettingsLoaderTests [Test] public void StructuredProperties_Should_Skip_Blank_Keys_And_Trim_Valid_Keys() { - var formatProperties = typeof(GodotLogger).GetMethod( - "FormatProperties", + var toPropertiesDictionary = typeof(GodotLogger).GetMethod( + "ToPropertiesDictionary", BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(toPropertiesDictionary, Is.Not.Null, "Unable to reflect GodotLogger.ToPropertiesDictionary."); + var properties = new (string Key, object? Value)[] { (null!, "ignored"), @@ -202,7 +204,10 @@ public sealed class GodotLoggerSettingsLoaderTests (" Player ", 42) }; - var result = formatProperties?.Invoke(null, [properties]); + var dictionary = toPropertiesDictionary!.Invoke(null, [properties]) as IReadOnlyDictionary; + Assert.That(dictionary, Is.Not.Null, "ToPropertiesDictionary should return structured log properties."); + + var result = GodotLogAppender.FormatProperties(dictionary); Assert.That(result, Is.EqualTo(" | Player=42")); } diff --git a/GFramework.Godot/Logging/GodotLogAppender.cs b/GFramework.Godot/Logging/GodotLogAppender.cs new file mode 100644 index 00000000..dd41b8df --- /dev/null +++ b/GFramework.Godot/Logging/GodotLogAppender.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GFramework.Core.Abstractions.Logging; +using Godot; + +namespace GFramework.Godot.Logging; + +/// +/// Writes Core instances to the Godot output APIs. +/// +/// +/// This appender is the Godot-specific edge of the Core logging pipeline. It keeps formatting, color selection, and +/// Godot debugger routing in the host package while allowing consumers to compose Godot output with Core +/// features such as CompositeLogger, filters, and async appenders. The appender +/// does not own unmanaged resources; and are therefore no-op lifecycle +/// hooks that satisfy the shared appender contract. +/// +public sealed class GodotLogAppender : ILogAppender +{ + private static readonly IReadOnlyDictionary EmptyProperties = + new Dictionary(StringComparer.Ordinal); + + private readonly Func _optionsProvider; + + /// + /// Initializes a Godot appender with default Godot logger options. + /// + public GodotLogAppender() + : this(new GodotLoggerOptions()) + { + } + + /// + /// Initializes a Godot appender with fixed Godot logger options. + /// + /// The formatting and routing options used for every appended entry. + /// is . + public GodotLogAppender(GodotLoggerOptions options) + : this(CreateFixedOptionsProvider(options)) + { + } + + /// + /// Initializes a Godot appender with a dynamic options provider. + /// + /// + /// Provides the latest formatting and routing options for each append operation. + /// + /// + /// The Godot logger provider uses this constructor so cached loggers observe hot-reloaded settings without + /// being recreated. The provider must be fast and thread-safe because it is called on the logging path. + /// + internal GodotLogAppender(Func optionsProvider) + { + _optionsProvider = optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider)); + } + + /// + /// Appends one Core log entry to Godot's console and debugger output. + /// + /// The Core log entry to render. + /// is . + public void Append(LogEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var options = _optionsProvider(); + var rendered = Render(entry, options); + + if (options.Mode == GodotLoggerMode.Debug) + { + WriteDebug(entry.Level, rendered); + } + else + { + GD.Print(rendered); + } + + if (entry.Exception != null) + { + GD.PrintErr(entry.Exception.ToString()); + } + } + + /// + /// Completes pending writes. + /// + /// + /// Godot output APIs are synchronous from this appender's point of view, so there is no buffered state to + /// flush. + /// + public void Flush() + { + } + + /// + /// Releases appender resources. + /// + /// + /// The appender does not own disposable Godot resources. This method exists to honor the Core appender + /// lifecycle contract and to remain composable with factories that dispose appenders uniformly. + /// + public void Dispose() + { + } + + /// + /// Formats structured properties for the {properties} template placeholder. + /// + /// The already-merged property set from a Core . + /// + /// A leading separator plus formatted properties, or an empty string when no valid properties exist. + /// + /// + /// Blank keys are ignored because they cannot produce useful structured output and can come from + /// caller-provided tuples. Valid keys are trimmed at render time so the appender never mutates the original + /// property dictionary. + /// + internal static string FormatProperties(IReadOnlyDictionary? properties) + { + if (properties == null || properties.Count == 0) + { + return string.Empty; + } + + var formattedProperties = properties + .Where(static pair => !string.IsNullOrWhiteSpace(pair.Key)) + .Select(static pair => $"{pair.Key.Trim()}={FormatValue(pair.Value)}") + .ToArray(); + + return formattedProperties.Length == 0 + ? string.Empty + : " | " + string.Join(", ", formattedProperties); + } + + /// + /// Renders a Core log entry without writing it to Godot. + /// + /// The Core log entry to render. + /// The line that would be sent to the selected Godot output API. + /// + /// Tests use this method to verify template and structured-property behavior without depending on Godot's + /// static output APIs. + /// + internal string Render(LogEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + return Render(entry, _optionsProvider()); + } + + private static Func CreateFixedOptionsProvider(GodotLoggerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return () => options; + } + + private static string Render(LogEntry entry, GodotLoggerOptions options) + { + var templateText = options.Mode == GodotLoggerMode.Debug + ? options.DebugOutputTemplate + : options.ReleaseOutputTemplate; + var context = new GodotLogRenderContext( + entry.Timestamp, + entry.Level, + entry.LoggerName, + entry.Message, + options.GetColor(entry.Level), + FormatProperties(GetMergedProperties(entry))); + + return GodotLogTemplate.Parse(templateText).Render(context); + } + + private static IReadOnlyDictionary GetMergedProperties(LogEntry entry) + { + var allProperties = entry.GetAllProperties(); + return allProperties.Count == 0 ? EmptyProperties : allProperties; + } + + private static string FormatValue(object? value) + { + if (value == null) + { + return "null"; + } + + return value switch + { + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + } + + private static void WriteDebug(LogLevel level, string rendered) + { + GD.PrintRich(rendered); + + switch (level) + { + case LogLevel.Fatal: + case LogLevel.Error: + GD.PushError(rendered); + break; + case LogLevel.Warning: + GD.PushWarning(rendered); + break; + } + } +} diff --git a/GFramework.Godot/Logging/GodotLogger.cs b/GFramework.Godot/Logging/GodotLogger.cs index d6dbe974..87adeebe 100644 --- a/GFramework.Godot/Logging/GodotLogger.cs +++ b/GFramework.Godot/Logging/GodotLogger.cs @@ -1,19 +1,23 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.Linq; using GFramework.Core.Abstractions.Logging; using GFramework.Core.Logging; -using Godot; namespace GFramework.Godot.Logging; /// /// Godot platform logger implementation. /// +/// +/// This logger preserves the existing entry point while delegating output to +/// so Godot rendering remains compatible with the Core appender pipeline. +/// public sealed class GodotLogger : AbstractLogger { - private readonly Func _optionsProvider; + private static readonly IReadOnlyDictionary EmptyProperties = + new Dictionary(StringComparer.Ordinal); + + private readonly GodotLogAppender _appender; /// /// Initializes a logger that preserves the historical fixed-format template. @@ -45,11 +49,14 @@ public sealed class GodotLogger : AbstractLogger /// Initializes the core logger with dynamic options and level providers. /// /// The resolved logger name used in rendered output. - /// The provider that supplies the latest rendering options for each write. + /// + /// The provider that supplies the latest rendering options for each write. + /// /// The provider that supplies the latest effective minimum level. /// /// The Godot factory uses this constructor so cached logger instances can observe hot-reloaded settings without - /// being recreated. The default public constructor supplies a fixed provider to avoid allocation on the log path. + /// being recreated. The default public constructor supplies a fixed provider to avoid allocation on the log + /// path. /// internal GodotLogger( string name, @@ -57,7 +64,8 @@ public sealed class GodotLogger : AbstractLogger Func minLevelProvider) : base(name, minLevelProvider ?? throw new ArgumentNullException(nameof(minLevelProvider))) { - _optionsProvider = optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider)); + _appender = new GodotLogAppender( + optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider))); } /// @@ -74,6 +82,9 @@ public sealed class GodotLogger : AbstractLogger /// /// Uses Godot-aware structured rendering instead of the base string concatenation fallback. /// + /// The log level. + /// The message body before Godot template rendering. + /// Structured properties appended through the configured Godot template. public override void Log(LogLevel level, string message, params (string Key, object? Value)[] properties) { if (!IsEnabled(level)) @@ -87,6 +98,10 @@ public sealed class GodotLogger : AbstractLogger /// /// Uses Godot-aware structured rendering instead of the base string concatenation fallback. /// + /// The log level. + /// The message body before Godot template rendering. + /// The optional exception written after the rendered message. + /// Structured properties appended through the configured Godot template. public override void Log( LogLevel level, string message, @@ -107,73 +122,39 @@ public sealed class GodotLogger : AbstractLogger Exception? exception, (string Key, object? Value)[]? properties) { - var options = _optionsProvider(); - var templateText = options.Mode == GodotLoggerMode.Debug - ? options.DebugOutputTemplate - : options.ReleaseOutputTemplate; - var context = new GodotLogRenderContext( + var entry = new LogEntry( DateTime.UtcNow, level, Name(), message, - options.GetColor(level), - FormatProperties(properties)); - var rendered = GodotLogTemplate.Parse(templateText).Render(context); + exception, + ToPropertiesDictionary(properties)); - if (options.Mode == GodotLoggerMode.Debug) - { - WriteDebug(level, rendered); - } - else - { - GD.Print(rendered); - } - - if (exception != null) - { - GD.PrintErr(exception.ToString()); - } + _appender.Append(entry); } - private static string FormatProperties((string Key, object? Value)[]? properties) + private static IReadOnlyDictionary ToPropertiesDictionary( + (string Key, object? Value)[]? properties) { - var merged = MergeProperties(properties); - if (merged.Count == 0) - { - return string.Empty; - } - - return " | " + string.Join(", ", merged.Select(static pair => $"{pair.Key}={FormatValue(pair.Value)}")); - } - - private static IReadOnlyDictionary MergeProperties((string Key, object? Value)[]? properties) - { - var contextProperties = LogContext.Current; - if ((properties == null || properties.Length == 0) && contextProperties.Count == 0) + if (properties == null || properties.Length == 0) { return EmptyProperties; } - var merged = new Dictionary(contextProperties, StringComparer.Ordinal); - if (properties != null) + var result = new Dictionary(StringComparer.Ordinal); + foreach (var property in properties) { - foreach (var property in properties) + if (string.IsNullOrWhiteSpace(property.Key)) { - if (string.IsNullOrWhiteSpace(property.Key)) - { - continue; - } - - merged[property.Key.Trim()] = property.Value; + continue; } + + result[property.Key.Trim()] = property.Value; } - return merged; + return result.Count == 0 ? EmptyProperties : result; } - private static readonly IReadOnlyDictionary EmptyProperties = - new Dictionary(StringComparer.Ordinal); - private static Func CreateFixedOptionsProvider(LogLevel minLevel) { var options = GodotLoggerOptions.ForMinimumLevel(minLevel); @@ -191,34 +172,4 @@ public sealed class GodotLogger : AbstractLogger ArgumentNullException.ThrowIfNull(options); return () => options.GetEffectiveMinLevel(); } - - private static string FormatValue(object? value) - { - if (value == null) - { - return "null"; - } - - return value switch - { - IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), - _ => value.ToString() ?? string.Empty - }; - } - - private static void WriteDebug(LogLevel level, string rendered) - { - GD.PrintRich(rendered); - - switch (level) - { - case LogLevel.Fatal: - case LogLevel.Error: - GD.PushError(rendered); - break; - case LogLevel.Warning: - GD.PushWarning(rendered); - break; - } - } } diff --git a/GFramework.Godot/README.md b/GFramework.Godot/README.md index 3e42f487..7f89c45f 100644 --- a/GFramework.Godot/README.md +++ b/GFramework.Godot/README.md @@ -72,7 +72,7 @@ Scene / UI、配置、存储、设置、日志与协程能力接到 `Node`、`Sc - 节点扩展与 `Signal(...)` fluent API - `GodotTimeSource` 与协程时间分段 -- Godot 日志 provider +- Godot 日志 provider 与 `GodotLogAppender` - 暂停处理、节点池与富文本效果支持 这些目录都是“宿主适配层”,不是新的 gameplay 抽象层。 diff --git a/ai-plan/public/README.md b/ai-plan/public/README.md index 447d2d23..53b6415f 100644 --- a/ai-plan/public/README.md +++ b/ai-plan/public/README.md @@ -38,14 +38,15 @@ help the current worktree land on the right recovery documents without scanning - Purpose: continue the data repository persistence hardening plus the settings / serialization follow-up backlog. - Tracking: `ai-plan/public/data-repository-persistence/todos/data-repository-persistence-tracking.md` - Trace: `ai-plan/public/data-repository-persistence/traces/data-repository-persistence-trace.md` -- `godot-logging-compliance-polish` - - Purpose: continue Godot logging host integration, configuration reload, structured-output polish, and follow-up work without forking the Core logging model. - - Tracking: `ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md` - - Trace: `ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md` - `semantic-release-versioning` - Purpose: migrate release version calculation from fixed patch bumps to semantic-release while keeping the existing tag-driven NuGet publish flow. - Tracking: `ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md` - Trace: `ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md` +- `godot-logging-core-sink` + - Purpose: evaluate and implement the next Godot logging stage by unifying Godot output with the Core logging + appender / sink model instead of expanding a separate Godot-only logging pipeline. + - Tracking: `ai-plan/public/godot-logging-core-sink/todos/godot-logging-core-sink-tracking.md` + - Trace: `ai-plan/public/godot-logging-core-sink/traces/godot-logging-core-sink-trace.md` ## Worktree To Active Topic Map @@ -63,23 +64,12 @@ help the current worktree land on the right recovery documents without scanning - Branch: `feat/data-repository-persistence` - Worktree hint: `GFramework-data-repository-persistence` - Priority 1: `data-repository-persistence` -- Branch: `feat/godot-logging-compliance-polish` - - Worktree hint: `GFramework` - - Priority 1: `godot-logging-compliance-polish` - Branch: `feat/semantic-release-versioning` - Worktree hint: `GFramework` - Priority 1: `semantic-release-versioning` +- Branch: `feat/godot-logging-core-sink` + - Worktree hint: `GFramework` + - Priority 1: `godot-logging-core-sink` - Branch: `docs/sdk-update-documentation` - Worktree hint: `GFramework-update-documentation` - Priority 1: `documentation-full-coverage-governance` -## Archived Topics - -- `analyzer-warning-reduction` - - Archive root: `ai-plan/public/archive/analyzer-warning-reduction/` - - Note: 长期 warning-reduction 分支已收尾;PR #301 的最终 review follow-up 已本地闭环,后续仅作为历史恢复材料保留。 -- `cqrs-cache-docs-hardening` - - Archive root: `ai-plan/public/archive/cqrs-cache-docs-hardening/` - - Note: archived topics stay outside the default `boot` context until a user explicitly requests historical review. -- `documentation-governance-and-refresh` - - Archive root: `ai-plan/public/archive/documentation-governance-and-refresh/` - - Note: PR #268 已合并;文档治理与 Godot 栏目刷新阶段已完成,后续仅作为历史恢复材料保留。 diff --git a/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md b/ai-plan/public/archive/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md similarity index 85% rename from ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md rename to ai-plan/public/archive/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md index b2b18dcd..506d0425 100644 --- a/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md +++ b/ai-plan/public/archive/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md @@ -5,17 +5,17 @@ 继续把 `GFramework.Godot.Logging` 从“基础可用的 Godot 输出适配”收敛成“对齐 `GodotLogger` 优点、但保持 GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot / Core 日志统一留下清晰恢复点。 -## 当前恢复点 +## 完成状态 - 恢复点编号:`GODOT-LOGGING-COMPLIANCE-POLISH-RP-003` -- 当前阶段:`PR review follow-up` -- 当前焦点: +- 当前阶段:`已完成并归档` +- 完成结论: - 已补齐 `GodotLog` 静态入口、延迟 logger 解析、配置自动发现与热重载 - 已让 `GodotLoggerFactoryProvider` 对已缓存 logger 生效动态配置,而不是只在新建 logger 时读快照 - 已让 `GodotLogger` 支持 `{properties}` 占位符,并把 `IStructuredLogger` / `LogContext` 属性落到 Godot 输出 - 已兼容 `GodotLogger` 风格配置值,如 `Information` / `Critical` - 已处理 PR #314 最新 AI review 中仍适用的 XML docs、热路径分配、结构化属性兜底、文档示例和 tracking 精简问题 - - 下一轮优先刷新 PR review / CI 反馈,避免继续扩大 Godot logging API 面 + - PR #314 已合并到 `origin/main`,当前主题从默认 boot 路径移入归档 ## 当前状态摘要 @@ -31,7 +31,8 @@ GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot ## 当前活跃事实 -- 当前主题由分支 `feat/godot-logging-compliance-polish` 驱动,并已在 `ai-plan/public/README.md` 建立映射 +- 本主题归档前由分支 `feat/godot-logging-compliance-polish` 驱动,PR #314 合并后已从 + `ai-plan/public/README.md` 的 active topic 映射移除 - `ai-libs/GodotLogger` 的 MIT 许可证已复制到 `third-party-licenses/GodotLogger/LICENSE` - `GodotLog` 当前的配置发现顺序为: - `GODOT_LOGGER_CONFIG` @@ -53,12 +54,12 @@ GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot - PR #314 最新 follow-up 中,`DeferredLogger` 格式化重载现在委托给 inner logger,`GodotLogger` 默认 options provider 已改为构造时缓存,结构化属性会跳过空白 key 并使用 trimmed key -## 当前风险 +## 收尾风险 - 双入口生命周期风险:如果同一宿主同时混用 `LoggerFactoryResolver.Provider` 与 `GodotLog`,需要明确谁是最终默认 provider - 缓解措施:当前文档与实现都保留 `GodotLog.UseAsDefaultProvider()`,并继续把 `ArchitectureConfiguration` 方式写成默认推荐路径 - Core / Godot 管线分离风险:Godot 侧虽然已有热重载与配置发现,但还没有变成 Core 可组合 appender - - 缓解措施:下一轮只评估“Godot sink / appender 化”,不再继续扩张独立的 Godot logging 面 + - 缓解措施:若后续重启本方向,应新建独立 topic 评估“Godot sink / appender 化”,不要在已归档主题继续扩张独立的 Godot logging 面 - 配置热重载的宿主差异风险:Godot 编辑器、导出包和测试宿主的文件系统语义不完全一致 - 缓解措施:active 入口先锁定 discovery / reload 语义,后续若遇到平台差异,再用定向回归和文档补充收口 - `GodotLog.ConfigurationPath` 的“不会 materialize”语义没有加入自动化测试 @@ -80,11 +81,14 @@ GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot - `dotnet format GFramework.Godot.Tests/GFramework.Godot.Tests.csproj --verify-no-changes --no-restore --include ...` - 结果:通过 - 备注:include 范围为本轮修改的 C# 文件;全项目 format 仍命中既有行尾 / 编码问题,详见 trace +- `dotnet build GFramework.sln -c Release` + - 结果:通过,`0 warning / 0 error` + - 备注:2026-05-03 在归档维护分支补跑仓库级 Release build,验证归档改动不会影响解决方案构建 - 历史验证明细已保留在 [执行 trace](../traces/godot-logging-compliance-polish-trace.md) 的 `RP-001 验证` 与 `RP-002 验证` 小节,active tracking 入口只保留当前恢复点相关结果 -## 下一步 +## 归档说明 -1. 提交 RP-003 review follow-up 改动 -2. 刷新 PR review / CI 状态,确认最新 head 上 CodeRabbit 与 Greptile 线程是否关闭或变为 stale -3. 若 CI 仍报 MegaLinter `dotnet-format` restore 失败,优先复核 Actions restore 环境,而不是继续改本地格式 +1. 本主题已随 PR #314 合并到 `origin/main` +2. 默认 boot 索引不再指向本主题 +3. 后续若继续做 Godot logging 与 Core appender / sink 的统一设计,应建立新的 active topic diff --git a/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md b/ai-plan/public/archive/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md similarity index 87% rename from ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md rename to ai-plan/public/archive/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md index 915585c8..9823b69e 100644 --- a/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md +++ b/ai-plan/public/archive/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md @@ -117,3 +117,24 @@ 1. 提交 RP-003 review follow-up 改动 2. 刷新 PR review,确认 CodeRabbit / Greptile 线程是否关闭或 stale 3. 若 CI 仍只有 MegaLinter `dotnet-format` restore 失败,继续定位 Actions restore 环境而不是扩大本地格式清理范围 + +### 阶段:主题归档(RP-004) + +- PR #314 已合并,当前分支 head 与 `origin/main` 同为 merge commit `918a61f3` +- 旧 upstream branch `origin/feat/godot-logging-compliance-polish` 已不存在 +- 当前 batch stop condition 使用 `origin/main` 作为 baseline;归档前分支累计 diff 为 `0` 个文件 +- 接受的收尾动作: + - 将 `godot-logging-compliance-polish` 从默认 boot active topic 中移除 + - 将主题恢复文档移动到 `ai-plan/public/archive/godot-logging-compliance-polish/` + - 在 public index 的 archived topics 中保留主题位置和合并结论 + +### RP-004 验证 + +- `dotnet build GFramework.sln -c Release` + - 结果:通过,`0 warning / 0 error` + - 备注:归档维护只触及 `ai-plan/public/**`,本次 build 用于满足仓库完成标准并确认解决方案仍可构建 + +### RP-004 下一步 + +1. 若继续推进 Godot logging 与 Core 的统一输出管线,建立新的 active topic +2. 当前归档维护已完成;后续只需提交并发布归档分支 diff --git a/ai-plan/public/godot-logging-core-sink/todos/godot-logging-core-sink-tracking.md b/ai-plan/public/godot-logging-core-sink/todos/godot-logging-core-sink-tracking.md new file mode 100644 index 00000000..a87c1b70 --- /dev/null +++ b/ai-plan/public/godot-logging-core-sink/todos/godot-logging-core-sink-tracking.md @@ -0,0 +1,67 @@ +# Godot Logging Core Sink 跟踪 + +## 目标 + +在 `GFramework.Godot.Logging` 已完成宿主便利层收口后,评估并推进 Godot 输出与 `GFramework.Core` 日志扩展点的统一。 +本主题优先判断是否应把 Godot 输出沉淀为 Core 可组合的 appender / sink,而不是继续扩张 Godot-only logging 管线。 + +## 当前恢复点 + +- 恢复点编号:`GODOT-LOGGING-CORE-SINK-RP-004` +- 当前阶段:`PR review follow-up 复查已验证` +- 当前焦点: + - `GFramework.Godot.Logging.GodotLogAppender` 已作为 Core `ILogAppender` 的 Godot 宿主落点落地 + - `GodotLogger` 保留原有 `ILogger` 入口,但底层输出委托给 appender + - Godot / Core logging 文档已说明 provider 与 appender 的组合边界 + - PR #315 最新 AI review 中仍适用的测试稳定性、dead private wrapper、boot index 与 trace heading 问题已处理 + - 最新 CodeRabbit outside-diff 复查指出的反射测试诊断不清晰问题已处理 + +## 已知输入 + +- `godot-logging-compliance-polish` 已归档,PR #314 已合并到 `origin/main` +- 归档主题确认: + - `GFramework.Core` 仍是主日志框架 + - `GFramework.Godot.Logging` 已补齐 `GodotLog`、延迟 logger、配置发现、热重载和结构化属性渲染 + - 下一阶段应新建 topic 评估 Godot sink / appender 化,而不是继续在归档主题内扩张 +- 当前分支同时承载归档收尾与本 active topic 启动,避免为纯归档维护单独开 PR + +## 待办 + +1. 已完成:盘点 `GFramework.Core` 日志扩展点与 Godot 侧 logger/provider 的实际耦合点 +2. 已完成:确认现有 Core `ILogAppender` 足够承载 Godot 输出,无需新增第二套 sink API +3. 已完成:保留 `GodotLog` / `GodotLoggerFactoryProvider` 入口,并让 `GodotLogger` 底层走 `GodotLogAppender` +4. 已完成:补充 `GodotLogAppender` targeted tests 与 `docs/zh-CN/` adoption guidance +5. 已完成:处理 PR #315 最新 review follow-up,移除默认 boot index 的 archived topics 区块并消除 trace 重复 heading +6. 已完成:处理最新 CodeRabbit outside-diff 反馈,显式断言反射目标与返回类型以改善测试失败定位 +7. 待确认:是否还需要在后续阶段补一个配置化 factory 示例,把 `GodotLogAppender` 与文件 / async appender 显式组合 + +## 验证 + +- `dotnet build GFramework.sln -c Release` + - 结果:通过,`0 warning / 0 error` + - 备注:2026-05-03 在创建本 active topic 前已验证归档收尾分支;后续实现改动需要按受影响项目重新验证 +- `dotnet test GFramework.Godot.Tests -c Release` + - 结果:通过,`75 passed / 0 failed / 0 skipped` + - 备注:覆盖 `GodotLogAppender` 渲染、动态 options provider、既有 Godot logging tests +- `dotnet build GFramework.Godot -c Release` + - 结果:通过,`0 warning / 0 error` + - 备注:验证受影响运行时项目 +- `dotnet format GFramework.Godot --verify-no-changes --no-restore --include GFramework.Godot/Logging/GodotLogger.cs` + - 结果:通过 +- `dotnet format GFramework.Godot.Tests --verify-no-changes --no-restore --include GFramework.Godot.Tests/Logging/GodotLogAppenderTests.cs GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs` + - 结果:通过 +- `dotnet test GFramework.Godot.Tests -c Release` + - 结果:通过,`75 passed / 0 failed / 0 skipped` + - 备注:2026-05-03 最新 PR review outside-diff 复查后重新验证 +- `dotnet format GFramework.Godot.Tests --verify-no-changes --no-restore --include GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs` + - 结果:通过 + - 备注:覆盖最新改动的测试文件 +- `dotnet format GFramework.sln --verify-no-changes --no-restore` + - 结果:失败 + - 备注:失败集中在仓库既有的 whitespace、final newline 与 charset 诊断,跨 `GFramework.Core`、`GFramework.Cqrs`、`GFramework.Game.Abstractions` 等未触碰项目;本轮改动用 scoped format 验证 + +## 下一步 + +1. 提交最新 PR review follow-up +2. 等待 PR #315 复查并确认 CodeRabbit outside-diff 反馈是否关闭 +3. 如继续扩展本主题,优先评估是否需要示例化 `CompositeLogger + GodotLogAppender + FileAppender`,而不是新增 API diff --git a/ai-plan/public/godot-logging-core-sink/traces/godot-logging-core-sink-trace.md b/ai-plan/public/godot-logging-core-sink/traces/godot-logging-core-sink-trace.md new file mode 100644 index 00000000..dea0f040 --- /dev/null +++ b/ai-plan/public/godot-logging-core-sink/traces/godot-logging-core-sink-trace.md @@ -0,0 +1,117 @@ +# Godot Logging Core Sink Trace + +## 2026-05-03 + +### RP-001 启动 + +- 新建 active topic:`godot-logging-core-sink` +- 当前分支:`feat/godot-logging-core-sink` +- 启动背景: + - `godot-logging-compliance-polish` 已随 PR #314 合并并归档 + - 用户明确要求归档收尾不要作为独立分支推进,而是跟下一 active topic 一起提交 + - 本分支因此同时包含归档索引收口和新 topic 启动入口 + +### 初始边界 + +- 本主题要评估 Godot 输出是否应进入 Core appender / sink 模型 +- 不把 `Microsoft.Extensions.Logging` 生态原样搬入 GFramework +- 不新增第二套业务日志 API;`GodotLog` 应保持为 Godot 宿主便利入口 +- 不在已归档的 `godot-logging-compliance-polish` topic 中继续扩张新需求 + +### RP-001 验证 + +- `dotnet build GFramework.sln -c Release` + - 结果:通过,`0 warning / 0 error` + - 备注:本次 build 在创建 active topic 前执行,用于验证归档维护对解决方案无影响;实现阶段需要重新跑受影响项目验证 + +### RP-001 下一步 + +1. 只读盘点 Core logging 抽象与 Godot logger/provider 的耦合点 +2. 记录候选设计,明确哪些能力进入 Core,哪些保留在 Godot 宿主层 +3. 确认方案后进入实现与文档更新 + +### RP-002 Godot Appender 最小实现 + +- 盘点结论: + - Core 已有 `ILogAppender`、`LogEntry`、`CompositeLogger`、filter、formatter 与 async appender + - Godot 侧主要耦合点是 `GodotLogger` 直接持有模板渲染和 `GD.*` 输出逻辑 + - 不需要先新增 Core sink 抽象;把 Godot 输出沉淀为 Godot 包内的 `ILogAppender` 已能复用 Core 管线 +- 已实施: + - 新增 `GFramework.Godot.Logging.GodotLogAppender` + - `GodotLogger` 保留原有 public API,并把输出委托给 `GodotLogAppender` + - 新增 `GFramework.Godot.Tests/Logging/GodotLogAppenderTests.cs` + - 更新 `GFramework.Godot/README.md`、`docs/zh-CN/core/logging.md`、`docs/zh-CN/godot/index.md`、 + `docs/zh-CN/godot/logging.md` +- 采用的兼容边界: + - `GodotLog`、`GodotLoggerFactory`、`GodotLoggerFactoryProvider` 不改用户调用方式 + - Godot 输出可作为 Core appender 被自定义 factory / `CompositeLogger` 组合 + - 文件、JSON、namespace filter、async 等仍由 Core logging 组件负责,Godot 包只提供宿主控制台落点 + +### RP-002 验证 + +- `dotnet test GFramework.Godot.Tests -c Release` + - 结果:通过,`75 passed / 0 failed / 0 skipped` +- `dotnet build GFramework.Godot -c Release` + - 结果:通过,`0 warning / 0 error` + +### RP-002 下一步 + +1. 提交当前 appender 实现与文档更新 +2. 若继续推进本主题,优先补充组合示例或归档 topic,不新增第二套日志 API + +### RP-003 PR Review Follow-up + +- 使用 `$gframework-pr-review` 抓取 PR #315 最新 review payload: + - CodeRabbit:3 个 open thread,分别指向 appender test 顺序依赖、默认 boot index 包含 archived topic、trace 重复 heading + - Greptile:1 个 open thread,指出 `GodotLogger.FormatProperties` 为 dead private wrapper + - Gemini Code Assist:无 open thread + - GitHub Test Reporter:`2264 passed / 0 failed` + - MegaLinter:`dotnet-format` 报 restore failure;本地进一步验证时发现 solution-wide format 还有既有 repo-wide 诊断 +- 已实施: + - `GodotLogAppenderTests` 改为验证固定前缀与结构化属性集合内容,不再依赖 `Dictionary` 枚举顺序 + - 移除 `GodotLogger.FormatProperties` private wrapper,并把既有结构化属性测试改为验证生产路径使用的 `ToPropertiesDictionary` 与 `GodotLogAppender.FormatProperties` + - 从 `ai-plan/public/README.md` 移除 archived topics 区块,默认 boot index 只保留 active topic 与 worktree map + - 将 trace 中重复的 `### 验证` / `### 下一步` 改为 `RP-001` 与 `RP-002` 前缀,避免 MD024 anchor 冲突 + +### RP-003 验证 + +- `dotnet test GFramework.Godot.Tests -c Release` + - 结果:通过,`75 passed / 0 failed / 0 skipped` +- `dotnet build GFramework.Godot -c Release` + - 结果:通过,`0 warning / 0 error` +- `dotnet format GFramework.Godot --verify-no-changes --no-restore --include GFramework.Godot/Logging/GodotLogger.cs` + - 结果:通过 +- `dotnet format GFramework.Godot.Tests --verify-no-changes --no-restore --include GFramework.Godot.Tests/Logging/GodotLogAppenderTests.cs GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs` + - 结果:通过 +- `dotnet format GFramework.sln --verify-no-changes --no-restore` + - 结果:失败 + - 备注:失败为仓库既有的跨项目 whitespace、final newline 与 charset 诊断;本轮改动文件已通过 scoped format 验证 + +### RP-003 下一步 + +1. 提交 PR review follow-up +2. 等待 PR #315 复查,确认 CodeRabbit / Greptile open threads 是否关闭 + +### RP-004 PR Review Outside-Diff 复查 + +- 使用 `$gframework-pr-review` 重新抓取 PR #315 最新 review payload: + - CodeRabbit:无 open thread,但 latest review body 仍有 1 条 outside-diff 反馈 + - Greptile:无 open thread + - Gemini Code Assist:无 open thread + - GitHub Test Reporter:最新 run 显示 `2264 passed / 0 failed` + - MegaLinter:仍为 `dotnet-format` restore failure,未提供具体源文件格式诊断 +- 已实施: + - `GodotLoggerSettingsLoaderTests.StructuredProperties_Should_Skip_Blank_Keys_And_Trim_Valid_Keys` 在调用反射目标前显式断言 `ToPropertiesDictionary` 存在 + - 同一测试在交给 `GodotLogAppender.FormatProperties` 前显式断言反射返回值类型符合预期 + +### RP-004 验证 + +- `dotnet test GFramework.Godot.Tests -c Release` + - 结果:通过,`75 passed / 0 failed / 0 skipped` +- `dotnet format GFramework.Godot.Tests --verify-no-changes --no-restore --include GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs` + - 结果:通过 + +### RP-004 下一步 + +1. 提交最新 PR review follow-up +2. 等待 PR #315 复查,确认 CodeRabbit outside-diff 反馈是否关闭 diff --git a/docs/zh-CN/core/logging.md b/docs/zh-CN/core/logging.md index 0b46635c..9382cf37 100644 --- a/docs/zh-CN/core/logging.md +++ b/docs/zh-CN/core/logging.md @@ -79,6 +79,10 @@ using (LogContext.Push("RequestId", requestId)) `LoggingConfigurationLoader` 读取 `LoggingConfiguration`,再把自定义 `ILoggerFactoryProvider` 挂到 `ArchitectureConfiguration.LoggerProperties.LoggerFactoryProvider` 或 `LoggerFactoryResolver.Provider`。 +宿主包也可以提供自己的 appender。Godot 项目如果需要把 Core 日志管线输出到 Godot 控制台,可以引用 +`GFramework.Godot.Logging.GodotLogAppender`,再用 `CompositeLogger` 或自定义 factory 把它和文件、JSON、异步输出等 +Core 组件组合在同一条调用面下。 + ## 什么时候该换 provider 下面这些场景通常不该只靠改 `MinLevel`: @@ -87,5 +91,6 @@ using (LogContext.Push("RequestId", requestId)) - 需要按 namespace / level 做过滤 - 需要 JSON 格式日志 - 需要组合多个 appender +- 需要把输出落到 Godot、Unity 或其他宿主控制台 这时更合理的做法是保留 `ILogger` 调用面不变,只替换 provider / factory / formatter / appender 组合。 diff --git a/docs/zh-CN/godot/index.md b/docs/zh-CN/godot/index.md index e3b6d1f0..5b0bfe34 100644 --- a/docs/zh-CN/godot/index.md +++ b/docs/zh-CN/godot/index.md @@ -16,7 +16,7 @@ description: 以当前 GFramework.Godot 源码、测试与 CoreGrid 接线为准 - 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)` - Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory` - Godot 特化的存储、设置与配置加载:`GodotFileStorage`、`GodotAudioSettings`、`GodotYamlConfigLoader` -- 少量面向运行时交互的扩展:`Signal(...)` fluent API、暂停处理、富文本效果、协程时间源 +- 少量面向运行时交互的扩展:`Signal(...)` fluent API、`GodotLogAppender`、暂停处理、富文本效果、协程时间源 它不是 `[GetNode]`、`[BindNodeSignal]`、`AutoLoads`、`InputActions` 的来源。这些能力属于 `GFramework.Godot.SourceGenerators`。 diff --git a/docs/zh-CN/godot/logging.md b/docs/zh-CN/godot/logging.md index b6a71b44..a850d247 100644 --- a/docs/zh-CN/godot/logging.md +++ b/docs/zh-CN/godot/logging.md @@ -9,6 +9,7 @@ description: 以当前 GFramework.Godot.Logging 源码与 CoreGrid 接线为准 除了把日志写到 Godot 控制台,它现在还补上了 Godot 宿主常见的接入便利层: - `GodotLog` 静态入口 +- `GodotLogAppender`,用于接入 Core appender 管线 - 配置文件自动发现 - 运行期配置热重载 - 延迟 logger 解析,适合 `static readonly` 字段 @@ -20,7 +21,9 @@ description: 以当前 GFramework.Godot.Logging 源码与 CoreGrid 接线为准 ### `GodotLogger` -`GodotLogger` 继承自 `AbstractLogger`,负责把日志写到 Godot 的输出 API: +`GodotLogger` 继承自 `AbstractLogger`,保留原有 `ILogger` 使用面。它现在把实际输出委托给 +`GodotLogAppender`,所以 `GodotLoggerFactoryProvider` 继续可用,同时 Godot 输出也能作为 Core appender 管线的一个 +可组合目标: ```csharp public sealed class GodotLogger( @@ -32,10 +35,27 @@ public sealed class GodotLogger( 当前实现里的几个关键语义: - 时间戳使用 `DateTime.UtcNow` -- 输出前缀格式是 `[yyyy-MM-dd HH:mm:ss.fff] LEVEL [LoggerName]` -- `exception` 不会被单独结构化处理,而是直接追加到消息后面 -- `Trace` / `Debug` 走 `GD.PrintRich(...)` -- `Info` / `Warning` / `Error` / `Fatal` 分别走 Godot 自身的普通、警告和错误输出通道 +- 模板、级别、颜色仍由 `GodotLoggerOptions` 或配置文件控制 +- 结构化属性来自 `IStructuredLogger` 参数和 `LogContext` +- `exception` 会在渲染后的主消息之后写入 Godot 错误输出 + +### `GodotLogAppender` + +`GodotLogAppender` 实现 Core 的 `ILogAppender`: + +```csharp +public sealed class GodotLogAppender : ILogAppender +{ + public GodotLogAppender(); + public GodotLogAppender(GodotLoggerOptions options); + public void Append(LogEntry entry); + public void Flush(); +} +``` + +它适合在已经使用 `CompositeLogger`、`AsyncLogAppender`、filter 或自定义 factory 的项目里,把 Godot 控制台输出作为 +其中一个落点,而不是为 Godot 重新定义一套业务日志 API。`Flush()` 和 `Dispose()` 没有额外副作用,因为 Godot 输出 API +对这个 appender 来说没有持有的缓冲区或外部资源。 ### `GodotLoggerFactory` @@ -109,7 +129,7 @@ var logger = GodotLog.CreateLogger
(); "Mode": "Debug", "DebugMinLevel": "Debug", "ReleaseMinLevel": "Info", - "DebugOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [color={color}][{level:u3}][/color] [{category:l16}] {message}{properties}", + "DebugOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [color={color}]{level:u3}[/color] {message}{properties}", "ReleaseOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [{level:u3}] [{category:l16}] {message}{properties}", "Colors": { "Info": "white", @@ -214,16 +234,16 @@ RegisterHandler(new LoggingTransitionHandler()); ## Godot 控制台输出语义 -当前 `GodotLogger.Write(...)` 的级别映射如下: +当前 `GodotLogAppender.Append(...)` 的级别映射如下: | 日志级别 | Godot 输出 API | 当前行为 | | --- | --- | --- | -| `Trace` | `GD.PrintRich(...)` | 使用灰色富文本输出 | -| `Debug` | `GD.PrintRich(...)` | 使用青色富文本输出 | -| `Info` | `GD.Print(...)` | 普通控制台输出 | -| `Warning` | `GD.PushWarning(...)` | 进入 Godot 警告通道 | -| `Error` | `GD.PrintErr(...)` | 输出到错误流 | -| `Fatal` | `GD.PushError(...)` | 进入 Godot 错误通道 | +| `Trace` | `GD.PrintRich(...)` 或 `GD.Print(...)` | Debug 模式使用富文本,Release 模式使用普通输出 | +| `Debug` | `GD.PrintRich(...)` 或 `GD.Print(...)` | Debug 模式使用富文本,Release 模式使用普通输出 | +| `Info` | `GD.PrintRich(...)` 或 `GD.Print(...)` | Debug 模式使用富文本,Release 模式使用普通输出 | +| `Warning` | `GD.PrintRich(...)` + `GD.PushWarning(...)` 或 `GD.Print(...)` | Debug 模式同时进入 Godot 警告通道 | +| `Error` | `GD.PrintRich(...)` + `GD.PushError(...)` 或 `GD.Print(...)` | Debug 模式同时进入 Godot 错误通道 | +| `Fatal` | `GD.PrintRich(...)` + `GD.PushError(...)` 或 `GD.Print(...)` | Debug 模式同时进入 Godot 错误通道 | 结构化属性如果通过 `IStructuredLogger` 或 `LogContext` 传入,也会追加到模板里的 `{properties}` 占位符。 @@ -259,8 +279,8 @@ System.IO.IOException: ... - 当前推荐接法仍然是把 `GodotLoggerFactoryProvider` 放进 `ArchitectureConfiguration.LoggerProperties`;如果项目是纯 Godot 宿主,也可以在入口直接调用 `GodotLog.UseAsDefaultProvider()` -- `GFramework.Godot.Logging` 只解决 Godot 控制台输出,不提供文件落盘、JSON formatter、异步 appender 或按 namespace - 的复杂过滤 +- `GFramework.Godot.Logging` 只提供 Godot 控制台 appender;文件落盘、JSON formatter、异步 appender 或按 namespace + 的复杂过滤继续使用 Core 日志组件组合 - `GodotLogger` 只改变输出方式,不改变 `ILogger` 接口本身;业务代码不需要切换到 Godot 专用日志 API - `[Log]`、`[ContextAware]` 这类字段注入能力不属于 `GFramework.Godot.Logging` - Scene / UI 的 `LoggingTransitionHandler` 位于 `GFramework.Game`,Godot 侧只是通过 provider 让它们输出到 Godot 控制台