diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
index e5a03042..4f91f348 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
@@ -138,6 +138,135 @@ public class SchemaConfigGeneratorTests
});
}
+ ///
+ /// 验证 schema 顶层允许通过元数据覆盖默认配置目录,并会统一路径分隔符。
+ ///
+ [Test]
+ public void Run_Should_Use_Custom_Config_Path_Metadata_For_Generated_Registration()
+ {
+ 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",
+ "x-gframework-config-path": "config\\monster",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" }
+ }
+ }
+ """;
+
+ 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["MonsterConfigBindings.g.cs"],
+ Does.Contain("public const string ConfigRelativePath = \"config/monster\";"));
+ Assert.That(generatedSources["MonsterConfigBindings.g.cs"], Does.Contain("Metadata.ConfigRelativePath,"));
+ Assert.That(generatedSources["GeneratedConfigCatalog.g.cs"],
+ Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath"));
+ }
+
+ ///
+ /// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
+ ///
+ [Test]
+ public void Run_Should_Report_Diagnostic_When_Custom_Config_Path_Metadata_Is_Invalid()
+ {
+ const string source = """
+ namespace TestApp
+ {
+ public sealed class Dummy
+ {
+ }
+ }
+ """;
+
+ const string schema = """
+ {
+ "type": "object",
+ "x-gframework-config-path": "../monster",
+ "required": ["id"],
+ "properties": {
+ "id": { "type": "integer" }
+ }
+ }
+ """;
+
+ 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_007"));
+ Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
+ Assert.That(diagnostic.GetMessage(), Does.Contain("x-gframework-config-path"));
+ Assert.That(diagnostic.GetMessage(), Does.Contain("relative"));
+ });
+ }
+
///
/// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。
///
@@ -452,20 +581,33 @@ public class SchemaConfigGeneratorTests
Assert.That(catalogSource, Does.Contain("public static class GeneratedConfigCatalog"));
Assert.That(catalogSource, Does.Contain("public sealed class GeneratedConfigRegistrationOptions"));
Assert.That(catalogSource, Does.Contain("public static class GeneratedConfigRegistrationExtensions"));
- Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IReadOnlyCollection? IncludedConfigDomains { get; init; }"));
- Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IReadOnlyCollection? IncludedTableNames { get; init; }"));
- Assert.That(catalogSource, Does.Contain("public global::System.Predicate? TableFilter { get; init; }"));
- Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IEqualityComparer? ItemComparer { get; init; }"));
- Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IEqualityComparer? MonsterComparer { get; init; }"));
+ Assert.That(catalogSource,
+ Does.Contain(
+ "public global::System.Collections.Generic.IReadOnlyCollection? IncludedConfigDomains { get; init; }"));
+ Assert.That(catalogSource,
+ Does.Contain(
+ "public global::System.Collections.Generic.IReadOnlyCollection? IncludedTableNames { get; init; }"));
+ Assert.That(catalogSource,
+ Does.Contain(
+ "public global::System.Predicate? TableFilter { get; init; }"));
+ Assert.That(catalogSource,
+ Does.Contain(
+ "public global::System.Collections.Generic.IEqualityComparer? ItemComparer { get; init; }"));
+ Assert.That(catalogSource,
+ Does.Contain(
+ "public global::System.Collections.Generic.IEqualityComparer? MonsterComparer { get; init; }"));
Assert.That(catalogSource, Does.Contain("return RegisterAllGeneratedConfigTables(loader, options: null);"));
Assert.That(catalogSource, Does.Contain("GeneratedConfigRegistrationOptions? options"));
- Assert.That(catalogSource, Does.Contain("if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[0], options))"));
+ Assert.That(catalogSource,
+ Does.Contain("if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[0], options))"));
Assert.That(catalogSource, Does.Contain("loader.RegisterItemTable(options.ItemComparer);"));
- Assert.That(catalogSource, Does.Contain("if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[1], options))"));
+ Assert.That(catalogSource,
+ Does.Contain("if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[1], options))"));
Assert.That(catalogSource, Does.Contain("loader.RegisterMonsterTable(options.MonsterComparer);"));
Assert.That(catalogSource, Does.Contain("ItemConfigBindings.Metadata.TableName"));
Assert.That(catalogSource, Does.Contain("MonsterConfigBindings.Metadata.TableName"));
- Assert.That(catalogSource, Does.Contain("public static bool TryGetByTableName(string tableName, out TableMetadata metadata)"));
+ Assert.That(catalogSource,
+ Does.Contain("public static bool TryGetByTableName(string tableName, out TableMetadata metadata)"));
Assert.That(catalogSource, Does.Contain("private static bool ShouldRegisterTable("));
Assert.That(catalogSource, Does.Contain("private static bool MatchesOptionalAllowList("));
});
diff --git a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
index 9e6900ff..c7be999c 100644
--- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -3,30 +3,31 @@
### New Rules
- Rule ID | Category | Severity | Notes
------------------------|------------------------------------|----------|-------------------------
- GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics
- GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic
- GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics
- GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics
- GF_ContextRegistration_001 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
- GF_ContextRegistration_002 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
- GF_ContextRegistration_003 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
- GF_ConfigSchema_001 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- GF_ConfigSchema_002 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- 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
- GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic
- GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic
- GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer
+ Rule ID | Category | Severity | Notes
+----------------------------|------------------------------------|----------|--------------------------------
+ GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics
+ GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic
+ GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics
+ GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics
+ GF_ContextRegistration_001 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
+ GF_ContextRegistration_002 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
+ GF_ContextRegistration_003 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
+ GF_ConfigSchema_001 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_002 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ 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_ConfigSchema_007 | 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
+ GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic
+ GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic
+ GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index cd4e1392..7cc8690f 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -10,6 +10,7 @@ namespace GFramework.SourceGenerators.Config;
[Generator]
public sealed class SchemaConfigGenerator : IIncrementalGenerator
{
+ private const string ConfigPathMetadataKey = "x-gframework-config-path";
private const string GeneratedNamespace = "GFramework.Game.Config.Generated";
///
@@ -147,6 +148,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
var schemaBaseName = GetSchemaBaseName(file.Path);
+ var configRelativePath = ResolveConfigRelativePath(file.Path, root, schemaBaseName);
+ if (configRelativePath.Diagnostic is not null)
+ {
+ return SchemaParseResult.FromDiagnostic(configRelativePath.Diagnostic);
+ }
+
var schema = new SchemaFileSpec(
Path.GetFileName(file.Path),
entityName,
@@ -156,7 +163,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
idProperty.TypeSpec.ClrType.TrimEnd('?'),
idProperty.PropertyName,
schemaBaseName,
- schemaBaseName,
+ configRelativePath.Path!,
GetSchemaRelativePath(file.Path),
TryGetMetadataString(root, "title"),
TryGetMetadataString(root, "description"),
@@ -991,7 +998,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// ");
builder.AppendLine(" /// Initializes one generated table metadata entry.");
builder.AppendLine(" /// ");
- builder.AppendLine(" /// Logical config domain derived from the schema base name.");
+ builder.AppendLine(
+ " /// Logical config domain derived from the schema base name.");
builder.AppendLine(" /// Runtime registration name.");
builder.AppendLine(" /// Relative YAML directory path.");
builder.AppendLine(" /// Relative schema file path.");
@@ -1017,12 +1025,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" public string ConfigDomain { get; }");
builder.AppendLine();
builder.AppendLine(" /// ");
- builder.AppendLine(" /// Gets the runtime registration name used by .");
+ builder.AppendLine(
+ " /// Gets the runtime registration name used by .");
builder.AppendLine(" /// ");
builder.AppendLine(" public string TableName { get; }");
builder.AppendLine();
builder.AppendLine(" /// ");
- builder.AppendLine(" /// Gets the relative directory that stores YAML files for the generated config table.");
+ builder.AppendLine(
+ " /// Gets the relative directory that stores YAML files for the generated config table.");
builder.AppendLine(" /// ");
builder.AppendLine(" public string ConfigRelativePath { get; }");
builder.AppendLine();
@@ -1033,7 +1043,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// ");
- builder.AppendLine(" /// Gets metadata for every generated config table in the current consumer project.");
+ builder.AppendLine(
+ " /// Gets metadata for every generated config table in the current consumer project.");
builder.AppendLine(" /// ");
builder.AppendLine(
" public static global::System.Collections.Generic.IReadOnlyList Tables { get; } = global::System.Array.AsReadOnly(new TableMetadata[]");
@@ -1145,7 +1156,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
" /// Registers all generated config tables using schema-derived conventions so bootstrap code can stay one-line even as schemas grow.");
builder.AppendLine(" /// ");
builder.AppendLine(" /// Target YAML config loader.");
- builder.AppendLine(" /// The same loader instance after all generated table registrations have been applied.");
+ builder.AppendLine(
+ " /// The same loader instance after all generated table registrations have been applied.");
builder.AppendLine(
" /// When is null.");
builder.AppendLine(
@@ -1167,7 +1179,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// Target YAML config loader.");
builder.AppendLine(
" /// Optional per-table overrides for aggregate registration; when null, all tables use their default comparer behavior.");
- builder.AppendLine(" /// The same loader instance after all generated table registrations have been applied.");
+ builder.AppendLine(
+ " /// The same loader instance after all generated table registrations have been applied.");
builder.AppendLine(
" /// When is null.");
builder.AppendLine(
@@ -1189,7 +1202,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(
$" if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[{index.ToString(CultureInfo.InvariantCulture)}], options))");
builder.AppendLine(" {");
- builder.AppendLine($" loader.Register{schema.EntityName}Table(options.{schema.EntityName}Comparer);");
+ builder.AppendLine(
+ $" loader.Register{schema.EntityName}Table(options.{schema.EntityName}Comparer);");
builder.AppendLine(" }");
builder.AppendLine();
}
@@ -1202,7 +1216,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
" /// Applies the generated registration filters in a deterministic order so bootstrap code can narrow aggregate registration without hand-writing per-table calls.");
builder.AppendLine(" /// ");
builder.AppendLine(" /// Generated table metadata under consideration.");
- builder.AppendLine(" /// Aggregate registration options supplied by the caller.");
+ builder.AppendLine(
+ " /// Aggregate registration options supplied by the caller.");
builder.AppendLine(
" /// when the generated table should be registered; otherwise .");
builder.AppendLine(
@@ -1212,7 +1227,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" {");
builder.AppendLine(
" // Apply cheap generated allow-lists before invoking the optional caller predicate so startup filtering stays predictable.");
- builder.AppendLine(" if (!MatchesOptionalAllowList(options.IncludedConfigDomains, metadata.ConfigDomain))");
+ builder.AppendLine(
+ " if (!MatchesOptionalAllowList(options.IncludedConfigDomains, metadata.ConfigDomain))");
builder.AppendLine(" {");
builder.AppendLine(" return false;");
builder.AppendLine(" }");
@@ -1393,7 +1409,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// The property value to match.");
builder.AppendLine(
" /// The first matching config entry when lookup succeeds; otherwise .");
- builder.AppendLine(" /// when a matching config entry is found; otherwise .");
+ builder.AppendLine(
+ " /// when a matching config entry is found; otherwise .");
builder.AppendLine(" /// ");
builder.AppendLine(
" /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order.");
@@ -1767,6 +1784,98 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return string.IsNullOrWhiteSpace(value) ? null : value;
}
+ ///
+ /// 解析 schema 顶层配置目录元数据。
+ ///
+ /// Schema 文件路径。
+ /// Schema 顶层节点。
+ /// 默认配置目录。
+ /// 最终使用的配置目录或诊断。
+ private static (string? Path, Diagnostic? Diagnostic) ResolveConfigRelativePath(
+ string filePath,
+ JsonElement element,
+ string defaultRelativePath)
+ {
+ if (!element.TryGetProperty(ConfigPathMetadataKey, out var configPathElement))
+ {
+ return (defaultRelativePath, null);
+ }
+
+ if (configPathElement.ValueKind != JsonValueKind.String)
+ {
+ return (
+ null,
+ Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidConfigRelativePathMetadata,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ ConfigPathMetadataKey,
+ $"Expected a JSON string but found '{configPathElement.ValueKind}'."));
+ }
+
+ var configuredPath = configPathElement.GetString();
+ if (string.IsNullOrWhiteSpace(configuredPath))
+ {
+ return (
+ null,
+ Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidConfigRelativePathMetadata,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ ConfigPathMetadataKey,
+ "Path cannot be null, empty, or whitespace."));
+ }
+
+ var normalizedPath = NormalizeConfigRelativePath(configuredPath!);
+ if (normalizedPath is null)
+ {
+ return (
+ null,
+ Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidConfigRelativePathMetadata,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ ConfigPathMetadataKey,
+ "Path must be relative and cannot contain '..' segments."));
+ }
+
+ return (normalizedPath, null);
+ }
+
+ ///
+ /// 标准化配置目录元数据,统一斜杠并拒绝逃逸配置根目录的写法。
+ ///
+ /// Schema 中声明的相对目录。
+ /// 标准化后的相对目录;无效时返回空。
+ private static string? NormalizeConfigRelativePath(string configuredPath)
+ {
+ var normalizedPath = configuredPath.Replace('\\', '/').Trim();
+ if (string.IsNullOrWhiteSpace(normalizedPath) ||
+ normalizedPath.StartsWith("/", StringComparison.Ordinal) ||
+ Path.IsPathRooted(normalizedPath))
+ {
+ return null;
+ }
+
+ var normalizedSegments = new List();
+ foreach (var segment in normalizedPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (string.Equals(segment, ".", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (string.Equals(segment, "..", StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ normalizedSegments.Add(segment);
+ }
+
+ return normalizedSegments.Count == 0 ? null : string.Join("/", normalizedSegments);
+ }
+
///
/// 为标量字段构建可直接生成到属性上的默认值初始化器。
///
diff --git a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
index b03057a1..83193c4b 100644
--- a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
+++ b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
@@ -74,4 +74,15 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
-}
\ No newline at end of file
+
+ ///
+ /// schema 顶层自定义配置目录元数据无效。
+ ///
+ public static readonly DiagnosticDescriptor InvalidConfigRelativePathMetadata = new(
+ "GF_ConfigSchema_007",
+ "Config schema uses invalid custom config path metadata",
+ "Schema file '{0}' uses invalid '{1}' metadata: {2}",
+ SourceGeneratorsConfigCategory,
+ DiagnosticSeverity.Error,
+ true);
+}
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index e52ae0c5..2e38c5bf 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -37,6 +37,7 @@ GameProject/
{
"title": "Monster Config",
"description": "定义怪物静态配置。",
+ "x-gframework-config-path": "config/monster",
"type": "object",
"required": ["id", "name"],
"properties": {
@@ -71,6 +72,31 @@ GameProject/
}
```
+顶层可选元数据:
+
+- `x-gframework-config-path`:覆盖生成器默认的配置目录。未声明时,默认使用 schema 基名,例如
+ `monster.schema.json -> monster`
+
+例如项目希望继续把 YAML 放在 `config/monster/*.yaml` 下,而不是根目录 `monster/*.yaml`,可以这样声明:
+
+```json
+{
+ "type": "object",
+ "x-gframework-config-path": "config/monster",
+ "required": ["id"],
+ "properties": {
+ "id": { "type": "integer" }
+ }
+}
+```
+
+约束如下:
+
+- 必须是 JSON 字符串
+- 必须是相对路径
+- 不允许包含 `..` 段
+- 生成器会把反斜杠标准化为 `/`
+
## YAML 示例
```yaml