diff --git a/GFramework.Game.Tests/Config/YamlConfigTableRegistrationOptionsTests.cs b/GFramework.Game.Tests/Config/YamlConfigTableRegistrationOptionsTests.cs
new file mode 100644
index 00000000..b2d01d72
--- /dev/null
+++ b/GFramework.Game.Tests/Config/YamlConfigTableRegistrationOptionsTests.cs
@@ -0,0 +1,46 @@
+using GFramework.Game.Config;
+
+namespace GFramework.Game.Tests.Config;
+
+///
+/// 验证 YAML 配置表注册选项会在构造阶段建立最小不变量,避免非法路径状态继续向后传播。
+///
+[TestFixture]
+public class YamlConfigTableRegistrationOptionsTests
+{
+ ///
+ /// 验证构造函数会拒绝空的或仅空白字符的表名。
+ ///
+ /// 待验证的表名。
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ public void Constructor_Should_Throw_When_Table_Name_Is_Null_Or_Whitespace(string? tableName)
+ {
+ var exception = Assert.Throws(() =>
+ _ = new YamlConfigTableRegistrationOptions(
+ tableName!,
+ "monster",
+ static config => config.Length));
+
+ Assert.That(exception!.ParamName, Is.EqualTo("tableName"));
+ }
+
+ ///
+ /// 验证构造函数会拒绝空的或仅空白字符的相对目录路径。
+ ///
+ /// 待验证的相对目录路径。
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ public void Constructor_Should_Throw_When_Relative_Path_Is_Null_Or_Whitespace(string? relativePath)
+ {
+ var exception = Assert.Throws(() =>
+ _ = new YamlConfigTableRegistrationOptions(
+ "monster",
+ relativePath!,
+ static config => config.Length));
+
+ Assert.That(exception!.ParamName, Is.EqualTo("relativePath"));
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs
index 6b15d3e5..491505fd 100644
--- a/GFramework.Game/Config/YamlConfigLoader.cs
+++ b/GFramework.Game/Config/YamlConfigLoader.cs
@@ -161,6 +161,10 @@ public sealed class YamlConfigLoader : IConfigLoader
/// 配置项主键提取器。
/// 可选主键比较器。
/// 当前加载器实例,以便链式注册。
+ ///
+ /// 当 或 为 null、空字符串或空白字符串时抛出。
+ ///
+ /// 当 为 null 时抛出。
public YamlConfigLoader RegisterTable(
string tableName,
string relativePath,
@@ -188,6 +192,11 @@ public sealed class YamlConfigLoader : IConfigLoader
/// 配置项主键提取器。
/// 可选主键比较器。
/// 当前加载器实例,以便链式注册。
+ ///
+ /// 当 、 或
+ /// 为 null、空字符串或空白字符串时抛出。
+ ///
+ /// 当 为 null 时抛出。
public YamlConfigLoader RegisterTable(
string tableName,
string relativePath,
@@ -214,6 +223,11 @@ public sealed class YamlConfigLoader : IConfigLoader
/// 配置表注册选项。
/// 当前加载器实例,以便链式注册。
/// 当 为空时抛出。
+ ///
+ /// 当 内的 、
+ /// 或
+ /// 为 null、空字符串或空白字符串时抛出。
+ ///
public YamlConfigLoader RegisterTable(YamlConfigTableRegistrationOptions options)
where TKey : notnull
{
diff --git a/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs
index 16f1a7b7..0f34687d 100644
--- a/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs
+++ b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs
@@ -10,18 +10,34 @@ namespace GFramework.Game.Config;
public sealed class YamlConfigTableRegistrationOptions
where TKey : notnull
{
+ private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace.";
+ private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace.";
+
///
/// 使用最小必需参数创建配置表注册选项。
///
/// 运行时配置表名称。
/// 相对配置根目录的子目录。
/// 配置项主键提取器。
+ ///
+ /// 当 或 为 null、空字符串或空白字符串时抛出。
+ ///
/// 当 为 null 时抛出。
public YamlConfigTableRegistrationOptions(
string tableName,
string relativePath,
Func keySelector)
{
+ if (string.IsNullOrWhiteSpace(tableName))
+ {
+ throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName));
+ }
+
+ if (string.IsNullOrWhiteSpace(relativePath))
+ {
+ throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath));
+ }
+
ArgumentNullException.ThrowIfNull(keySelector);
TableName = tableName;
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
index 507bf36f..9c777f4c 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
@@ -91,4 +91,144 @@ public class SchemaConfigGeneratorTests
Assert.That(diagnostic.GetMessage(), Does.Contain("array"));
});
}
+
+ ///
+ /// 验证 schema 字段名无法映射为合法 C# 标识符时会直接给出诊断,而不是生成不可编译代码。
+ ///
+ [Test]
+ public void Run_Should_Report_Diagnostic_When_Schema_Key_Maps_To_Invalid_CSharp_Identifier()
+ {
+ const string source = """
+ namespace TestApp
+ {
+ public sealed class Dummy
+ {
+ }
+ }
+ """;
+
+ const string schema = """
+ {
+ "type": "object",
+ "required": ["id", "drop$item"],
+ "properties": {
+ "id": { "type": "integer" },
+ "drop$item": { "type": "string" }
+ }
+ }
+ """;
+
+ var result = SchemaGeneratorTestDriver.Run(
+ source,
+ ("monster.schema.json", schema));
+
+ var diagnostic = result.Results.Single().Diagnostics.Single();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_006"));
+ Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
+ Assert.That(diagnostic.GetMessage(), Does.Contain("drop$item"));
+ Assert.That(diagnostic.GetMessage(), Does.Contain("Drop$item"));
+ });
+ }
+
+ ///
+ /// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。
+ ///
+ [Test]
+ public void Run_Should_Assign_Globally_Unique_Reference_Metadata_Member_Names()
+ {
+ const string source = """
+ using System;
+ using System.Collections.Generic;
+
+ namespace GFramework.Game.Abstractions.Config
+ {
+ public interface IConfigTable
+ {
+ Type KeyType { get; }
+ Type ValueType { get; }
+ int Count { get; }
+ }
+
+ public interface IConfigTable : IConfigTable
+ where TKey : notnull
+ {
+ TValue Get(TKey key);
+ bool TryGet(TKey key, out TValue? value);
+ bool ContainsKey(TKey key);
+ IReadOnlyCollection All();
+ }
+
+ public interface IConfigRegistry
+ {
+ IConfigTable GetTable(string name)
+ where TKey : notnull;
+
+ bool TryGetTable(string name, out IConfigTable? table)
+ where TKey : notnull;
+ }
+ }
+
+ namespace GFramework.Game.Config
+ {
+ public sealed class YamlConfigLoader
+ {
+ public YamlConfigLoader RegisterTable(
+ string tableName,
+ string relativePath,
+ string schemaRelativePath,
+ Func keySelector,
+ IEqualityComparer? comparer = null)
+ where TKey : notnull
+ {
+ return this;
+ }
+ }
+ }
+ """;
+
+ const string schema = """
+ {
+ "type": "object",
+ "required": ["id"],
+ "properties": {
+ "id": { "type": "integer" },
+ "drop-items": {
+ "type": "array",
+ "items": { "type": "string" },
+ "x-gframework-ref-table": "item"
+ },
+ "drop_items": {
+ "type": "array",
+ "items": { "type": "string" },
+ "x-gframework-ref-table": "item"
+ },
+ "dropItems1": {
+ "type": "string",
+ "x-gframework-ref-table": "item"
+ }
+ }
+ }
+ """;
+
+ var result = SchemaGeneratorTestDriver.Run(
+ source,
+ ("monster.schema.json", schema));
+
+ var generatedSources = result.Results
+ .Single()
+ .GeneratedSources
+ .ToDictionary(
+ static sourceResult => sourceResult.HintName,
+ static sourceResult => sourceResult.SourceText.ToString(),
+ StringComparer.Ordinal);
+
+ Assert.That(result.Results.Single().Diagnostics, Is.Empty);
+ Assert.That(generatedSources.TryGetValue("MonsterConfigBindings.g.cs", out var bindingsSource), Is.True);
+ Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems ="));
+ Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems1 ="));
+ Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems11 ="));
+ }
}
\ No newline at end of file
diff --git a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
index 356e4cbe..1f5a1cc8 100644
--- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -20,6 +20,7 @@
GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_006 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic
GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic
GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index 7ee0770f..dc33408f 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -253,7 +253,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
var title = TryGetMetadataString(property.Value, "title");
var description = TryGetMetadataString(property.Value, "description");
var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table");
- var propertyName = ToPascalCase(property.Name);
+ if (!TryBuildPropertyIdentifier(filePath, displayPath, property.Name, out var propertyName, out var diagnostic))
+ {
+ return ParsedPropertyResult.FromDiagnostic(diagnostic!);
+ }
switch (schemaType)
{
@@ -934,26 +937,37 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// 生成期引用元数据集合。
private static IEnumerable CollectReferenceSpecs(SchemaObjectSpec rootObject)
{
- var memberNameCounts = new Dictionary(StringComparer.Ordinal);
+ var nextSuffixByBaseMemberName = new Dictionary(StringComparer.Ordinal);
+ var allocatedMemberNames = new HashSet(StringComparer.Ordinal);
foreach (var referenceSeed in EnumerateReferenceSeeds(rootObject.Properties))
{
var baseMemberName = BuildReferenceMemberName(referenceSeed.DisplayPath);
- if (memberNameCounts.TryGetValue(baseMemberName, out var duplicateCount))
+ var memberName = baseMemberName;
+ if (!allocatedMemberNames.Add(memberName))
{
- // Reuse the tracked duplicate count so repeated reference paths keep their generated member names stable.
- duplicateCount++;
- memberNameCounts[baseMemberName] = duplicateCount;
- baseMemberName =
- $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}";
+ // Track globally allocated member names because a suffixed duplicate from one path can collide
+ // with the unsuffixed base name produced by a later, different path.
+ var duplicateCount = nextSuffixByBaseMemberName.TryGetValue(baseMemberName, out var nextSuffix)
+ ? nextSuffix + 1
+ : 1;
+
+ memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}";
+ while (!allocatedMemberNames.Add(memberName))
+ {
+ duplicateCount++;
+ memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}";
+ }
+
+ nextSuffixByBaseMemberName[baseMemberName] = duplicateCount;
}
else
{
- memberNameCounts[baseMemberName] = 0;
+ nextSuffixByBaseMemberName[baseMemberName] = 0;
}
yield return new GeneratedReferenceSpec(
- baseMemberName,
+ memberName,
referenceSeed.DisplayPath,
referenceSeed.ReferencedTableName,
referenceSeed.ValueSchemaType,
@@ -1165,6 +1179,40 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine($"{indent}/// ");
}
+ ///
+ /// 将 schema 字段名转换并验证为生成代码可直接使用的属性标识符。
+ /// 生成器会在这里拒绝无法映射为合法 C# 标识符的外部输入,避免生成源码后才在编译阶段失败。
+ ///
+ /// Schema 文件路径。
+ /// 逻辑字段路径。
+ /// Schema 原始字段名。
+ /// 生成后的属性名。
+ /// 字段名非法时生成的诊断。
+ /// 是否成功生成合法属性标识符。
+ private static bool TryBuildPropertyIdentifier(
+ string filePath,
+ string displayPath,
+ string schemaName,
+ out string propertyName,
+ out Diagnostic? diagnostic)
+ {
+ propertyName = ToPascalCase(schemaName);
+ if (SyntaxFacts.IsValidIdentifier(propertyName))
+ {
+ diagnostic = null;
+ return true;
+ }
+
+ diagnostic = Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidGeneratedIdentifier,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ displayPath,
+ schemaName,
+ propertyName);
+ return false;
+ }
+
///
/// 从 schema 文件路径提取实体基础名。
///
diff --git a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
index 9229868f..b03057a1 100644
--- a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
+++ b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
@@ -63,4 +63,15 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
+
+ ///
+ /// schema 字段名无法安全映射为 C# 标识符。
+ ///
+ public static readonly DiagnosticDescriptor InvalidGeneratedIdentifier = new(
+ "GF_ConfigSchema_006",
+ "Config schema property name cannot be converted to a valid C# identifier",
+ "Property '{1}' in schema file '{0}' uses schema key '{2}', which generates invalid C# identifier '{3}'",
+ SourceGeneratorsConfigCategory,
+ DiagnosticSeverity.Error,
+ true);
}
\ No newline at end of file