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