diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index 54c5eca7..d4103b9c 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -1407,13 +1407,25 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { if (requiredProperty.ValueKind != JsonValueKind.String) { - continue; + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' must declare 'required' entries as parent property-name strings."); + return false; } var requiredPropertyName = requiredProperty.GetString(); if (string.IsNullOrWhiteSpace(requiredPropertyName)) { - continue; + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' cannot declare blank property names in 'required'."); + return false; } var normalizedRequiredPropertyName = requiredPropertyName!; diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs index 2e4edde7..e68d190e 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs @@ -380,6 +380,86 @@ public sealed class YamlConfigLoaderAllOfTests }); } + /// + /// 验证 allOf 条目的 required 项必须是字符串字段名。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Required_Item_Is_Not_A_String() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "required": [1] + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("must declare 'required' entries as property-name strings")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目的 required 不允许空白字段名。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Required_Item_Is_Blank() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "required": [""] + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("cannot declare blank property names in 'required'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证 allOf 条目不能要求父对象未声明的字段。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs new file mode 100644 index 00000000..145ab2f9 --- /dev/null +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs @@ -0,0 +1,494 @@ +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 承载对象级 schema 关键字的解析与元数据校验逻辑。 +/// 该 partial 将 minPropertiesmaxProperties、 +/// dependentRequireddependentSchemasallOf +/// 从主校验文件中拆出,降低超大文件继续堆叠对象关键字时的维护成本。 +/// +internal static partial class YamlConfigSchemaValidator +{ + /// + /// 解析对象节点支持的属性数量约束与对象关键字约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 当前对象已声明的属性集合。 + /// 对象约束模型;未声明时返回空。 + private static YamlConfigObjectConstraints? ParseObjectConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + IReadOnlyDictionary properties) + { + var minProperties = TryParseObjectPropertyCountConstraint( + tableName, + schemaPath, + propertyPath, + element, + "minProperties"); + var maxProperties = TryParseObjectPropertyCountConstraint( + tableName, + schemaPath, + propertyPath, + element, + "maxProperties"); + var dependentRequired = ParseDependentRequiredConstraints(tableName, schemaPath, propertyPath, element, properties); + var dependentSchemas = ParseDependentSchemasConstraints(tableName, schemaPath, propertyPath, element, properties); + var allOfSchemas = ParseAllOfConstraints(tableName, schemaPath, propertyPath, element, properties); + + if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) + { + var targetDescription = DescribeObjectSchemaTarget(propertyPath); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{targetDescription} in schema file '{schemaPath}' declares 'minProperties' greater than 'maxProperties'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null && + allOfSchemas is null + ? null + : new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas, allOfSchemas); + } + + /// + /// 解析对象节点声明的 dependentRequired 依赖关系。 + /// 该关键字只表达“当触发字段出现时,还必须同时声明哪些同级字段”, + /// 因此这里会把触发字段与依赖字段都限制在当前对象已声明的属性集合内, + /// 避免运行时与工具链对无效键名各自做隐式容错。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 当前对象已声明的属性集合。 + /// 归一化后的依赖关系表;未声明或只有空依赖时返回空。 + private static IReadOnlyDictionary>? ParseDependentRequiredConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + IReadOnlyDictionary properties) + { + if (!element.TryGetProperty("dependentRequired", out var dependentRequiredElement)) + { + return null; + } + + if (dependentRequiredElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentRequired' as an object.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependentRequired = new Dictionary>(StringComparer.Ordinal); + foreach (var dependency in dependentRequiredElement.EnumerateObject()) + { + if (!properties.ContainsKey(dependency.Name)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentRequired' for undeclared property '{dependency.Name}'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (dependency.Value.ValueKind != JsonValueKind.Array) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' as an array of sibling property names.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependencyTargets = new List(); + var seenDependencyTargets = new HashSet(StringComparer.Ordinal); + foreach (var dependencyTarget in dependency.Value.EnumerateArray()) + { + if (dependencyTarget.ValueKind != JsonValueKind.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' entries as strings.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependencyTargetName = dependencyTarget.GetString(); + if (string.IsNullOrWhiteSpace(dependencyTargetName)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank 'dependentRequired' entries.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (!properties.ContainsKey(dependencyTargetName)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentRequired' target '{dependencyTargetName}' that is not declared in the same object schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (seenDependencyTargets.Add(dependencyTargetName)) + { + dependencyTargets.Add(dependencyTargetName); + } + } + + if (dependencyTargets.Count > 0) + { + dependentRequired[dependency.Name] = dependencyTargets; + } + } + + return dependentRequired.Count == 0 + ? null + : dependentRequired; + } + + /// + /// 解析对象节点声明的 dependentSchemas 条件 schema。 + /// 当前实现把它作为“当触发字段出现时,当前对象还必须额外满足一段内联 schema”来解释, + /// 因此触发字段仍限制在当前对象已声明的属性内,而具体约束则继续复用现有递归节点解析逻辑。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 当前对象已声明的属性集合。 + /// 归一化后的触发字段到条件 schema 的映射;未声明时返回空。 + private static IReadOnlyDictionary? ParseDependentSchemasConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + IReadOnlyDictionary properties) + { + if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement)) + { + return null; + } + + if (dependentSchemasElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentSchemas' as an object.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependentSchemas = new Dictionary(StringComparer.Ordinal); + foreach (var dependency in dependentSchemasElement.EnumerateObject()) + { + if (!properties.ContainsKey(dependency.Name)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentSchemas' for undeclared property '{dependency.Name}'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (dependency.Value.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentSchemas' as an object-valued schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependencySchemaPath = BuildNestedSchemaPath(propertyPath, $"dependentSchemas:{dependency.Name}"); + var dependencySchemaNode = ParseNode( + tableName, + schemaPath, + dependencySchemaPath, + dependency.Value); + if (dependencySchemaNode.NodeType != YamlConfigSchemaPropertyType.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare an object-typed 'dependentSchemas' schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(dependencySchemaPath)); + } + + dependentSchemas[dependency.Name] = dependencySchemaNode; + } + + return dependentSchemas.Count == 0 + ? null + : dependentSchemas; + } + + /// + /// 解析对象节点声明的 allOf 组合约束。 + /// 当前实现仅接受 object-typed 内联 schema,并把每个条目当成 focused constraint block + /// 叠加到当前对象上,而不是参与属性合并或改变生成类型形状。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 父对象已声明的属性集合。 + /// 归一化后的 allOf schema 列表;未声明或为空时返回空。 + private static IReadOnlyList? ParseAllOfConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + IReadOnlyDictionary properties) + { + if (!element.TryGetProperty("allOf", out var allOfElement)) + { + return null; + } + + if (allOfElement.ValueKind != JsonValueKind.Array) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'allOf' as an array of object-valued schemas.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var allOfSchemas = new List(); + var allOfIndex = 0; + foreach (var allOfSchemaElement in allOfElement.EnumerateArray()) + { + if (allOfSchemaElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'allOf' entries as object-valued schemas.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var allOfSchemaPath = BuildNestedSchemaPath(propertyPath, $"allOf[{allOfIndex.ToString(CultureInfo.InvariantCulture)}]"); + ValidateAllOfSchemaTargetsAgainstParentObject( + tableName, + schemaPath, + propertyPath, + allOfSchemaPath, + allOfIndex + 1, + allOfSchemaElement, + properties); + var allOfSchemaNode = ParseNode( + tableName, + schemaPath, + allOfSchemaPath, + allOfSchemaElement); + if (allOfSchemaNode.NodeType != YamlConfigSchemaPropertyType.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{(allOfIndex + 1).ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare an object-typed schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + allOfSchemas.Add(allOfSchemaNode); + allOfIndex++; + } + + return allOfSchemas.Count == 0 + ? null + : allOfSchemas; + } + + /// + /// 验证 allOf 条目只约束父对象已经声明过的同级字段。 + /// 当前 object-focused 语义不会把条目里的属性并回父对象形状,因此这里要提前拒绝 + /// “在 focused block 里引入父对象未声明字段”的不可满足 schema。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 父对象路径。 + /// 当前 allOf 条目路径。 + /// 从 1 开始的 allOf 条目编号。 + /// 当前 allOf 条目。 + /// 父对象已声明的属性集合。 + private static void ValidateAllOfSchemaTargetsAgainstParentObject( + string tableName, + string schemaPath, + string propertyPath, + string allOfSchemaPath, + int allOfEntryNumber, + JsonElement allOfSchemaElement, + IReadOnlyDictionary properties) + { + if (allOfSchemaElement.TryGetProperty("properties", out var allOfPropertiesElement)) + { + if (allOfPropertiesElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'properties' as an object-valued map.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + foreach (var property in allOfPropertiesElement.EnumerateObject()) + { + if (properties.ContainsKey(property.Name)) + { + continue; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' declares property '{property.Name}', but that property is not declared in the parent object schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + } + + if (!allOfSchemaElement.TryGetProperty("required", out var allOfRequiredElement)) + { + return; + } + + if (allOfRequiredElement.ValueKind != JsonValueKind.Array) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' as an array of property names.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + foreach (var requiredProperty in allOfRequiredElement.EnumerateArray()) + { + if (requiredProperty.ValueKind != JsonValueKind.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' entries as property-name strings.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + var requiredPropertyName = requiredProperty.GetString(); + if (string.IsNullOrWhiteSpace(requiredPropertyName)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank property names in 'required'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + if (properties.ContainsKey(requiredPropertyName)) + { + continue; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' requires property '{requiredPropertyName}', but that property is not declared in the parent object schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + } + + /// + /// 读取对象属性数量约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 关键字名称。 + /// 属性数量约束;未声明时返回空。 + private static int? TryParseObjectPropertyCountConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + string keywordName) + { + if (!element.TryGetProperty(keywordName, out var constraintElement)) + { + return null; + } + + if (constraintElement.ValueKind != JsonValueKind.Number || + !constraintElement.TryGetInt32(out var constraintValue) || + constraintValue < 0) + { + var targetDescription = DescribeObjectSchemaTarget(propertyPath); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{targetDescription} in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return constraintValue; + } + + /// + /// 为对象级 schema 关键字构造稳定的诊断主体。 + /// 根对象不会再显示为空字符串属性名,避免坏 schema 诊断出现 Property '' 之类的文本。 + /// + /// 对象字段路径。 + /// 用于错误消息的对象主体描述。 + private static string DescribeObjectSchemaTarget(string propertyPath) + { + return string.IsNullOrWhiteSpace(propertyPath) + ? "Root object" + : $"Property '{propertyPath}'"; + } + + /// + /// 为插入句中位置的对象级 schema 关键字构造稳定描述。 + /// 这里只调整语法前缀大小写,不改变真实字段路径,避免诊断消息把 schema 作者声明的大小写一起改写。 + /// + /// 对象字段路径。 + /// 可直接拼接到句中介词后的对象主体描述。 + private static string DescribeObjectSchemaTargetInClause(string propertyPath) + { + return string.IsNullOrWhiteSpace(propertyPath) + ? "root object" + : $"property '{propertyPath}'"; + } +} diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 08edbeb4..dabff389 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -15,7 +15,7 @@ namespace GFramework.Game.Config; /// 与稳定字符串 format 子集,让数值步进、数组去重、数组匹配计数、 /// 对象属性数量、对象内字段依赖、条件对象子 schema 与对象组合约束在运行时与生成器 / 工具侧保持一致。 /// -internal static class YamlConfigSchemaValidator +internal static partial class YamlConfigSchemaValidator { // The runtime intentionally uses the same culture-invariant regex semantics as the // JS tooling so grouping and backreferences behave consistently across environments. @@ -1636,411 +1636,6 @@ internal static class YamlConfigSchemaValidator return new YamlConfigArrayContainsConstraints(containsNode, minContains, maxContains); } - /// - /// 解析对象节点支持的属性数量约束。 - /// - /// 所属配置表名称。 - /// Schema 文件路径。 - /// 对象字段路径。 - /// Schema 节点。 - /// 当前对象已声明的属性集合。 - /// 对象约束模型;未声明时返回空。 - private static YamlConfigObjectConstraints? ParseObjectConstraints( - string tableName, - string schemaPath, - string propertyPath, - JsonElement element, - IReadOnlyDictionary properties) - { - var minProperties = TryParseObjectPropertyCountConstraint( - tableName, - schemaPath, - propertyPath, - element, - "minProperties"); - var maxProperties = TryParseObjectPropertyCountConstraint( - tableName, - schemaPath, - propertyPath, - element, - "maxProperties"); - var dependentRequired = ParseDependentRequiredConstraints(tableName, schemaPath, propertyPath, element, properties); - var dependentSchemas = ParseDependentSchemasConstraints(tableName, schemaPath, propertyPath, element, properties); - var allOfSchemas = ParseAllOfConstraints(tableName, schemaPath, propertyPath, element, properties); - - if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) - { - var targetDescription = DescribeObjectSchemaTarget(propertyPath); - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{targetDescription} in schema file '{schemaPath}' declares 'minProperties' greater than 'maxProperties'.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null && - allOfSchemas is null - ? null - : new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas, allOfSchemas); - } - - /// - /// 解析对象节点声明的 dependentRequired 依赖关系。 - /// 该关键字只表达“当触发字段出现时,还必须同时声明哪些同级字段”, - /// 因此这里会把触发字段与依赖字段都限制在当前对象已声明的属性集合内, - /// 避免运行时与工具链对无效键名各自做隐式容错。 - /// - /// 所属配置表名称。 - /// Schema 文件路径。 - /// 对象字段路径。 - /// Schema 节点。 - /// 当前对象已声明的属性集合。 - /// 归一化后的依赖关系表;未声明或只有空依赖时返回空。 - private static IReadOnlyDictionary>? ParseDependentRequiredConstraints( - string tableName, - string schemaPath, - string propertyPath, - JsonElement element, - IReadOnlyDictionary properties) - { - if (!element.TryGetProperty("dependentRequired", out var dependentRequiredElement)) - { - return null; - } - - if (dependentRequiredElement.ValueKind != JsonValueKind.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentRequired' as an object.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var dependentRequired = new Dictionary>(StringComparer.Ordinal); - foreach (var dependency in dependentRequiredElement.EnumerateObject()) - { - if (!properties.ContainsKey(dependency.Name)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentRequired' for undeclared property '{dependency.Name}'.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - if (dependency.Value.ValueKind != JsonValueKind.Array) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' as an array of sibling property names.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var dependencyTargets = new List(); - var seenDependencyTargets = new HashSet(StringComparer.Ordinal); - foreach (var dependencyTarget in dependency.Value.EnumerateArray()) - { - if (dependencyTarget.ValueKind != JsonValueKind.String) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' entries as strings.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var dependencyTargetName = dependencyTarget.GetString(); - if (string.IsNullOrWhiteSpace(dependencyTargetName)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank 'dependentRequired' entries.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - if (!properties.ContainsKey(dependencyTargetName)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentRequired' target '{dependencyTargetName}' that is not declared in the same object schema.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - if (seenDependencyTargets.Add(dependencyTargetName)) - { - dependencyTargets.Add(dependencyTargetName); - } - } - - if (dependencyTargets.Count > 0) - { - dependentRequired[dependency.Name] = dependencyTargets; - } - } - - return dependentRequired.Count == 0 - ? null - : dependentRequired; - } - - /// - /// 解析对象节点声明的 dependentSchemas 条件 schema。 - /// 当前实现把它作为“当触发字段出现时,当前对象还必须额外满足一段内联 schema”来解释, - /// 因此触发字段仍限制在当前对象已声明的属性内,而具体约束则继续复用现有递归节点解析逻辑。 - /// - /// 所属配置表名称。 - /// Schema 文件路径。 - /// 对象字段路径。 - /// Schema 节点。 - /// 当前对象已声明的属性集合。 - /// 归一化后的触发字段到条件 schema 的映射;未声明时返回空。 - private static IReadOnlyDictionary? ParseDependentSchemasConstraints( - string tableName, - string schemaPath, - string propertyPath, - JsonElement element, - IReadOnlyDictionary properties) - { - if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement)) - { - return null; - } - - if (dependentSchemasElement.ValueKind != JsonValueKind.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentSchemas' as an object.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var dependentSchemas = new Dictionary(StringComparer.Ordinal); - foreach (var dependency in dependentSchemasElement.EnumerateObject()) - { - if (!properties.ContainsKey(dependency.Name)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentSchemas' for undeclared property '{dependency.Name}'.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - if (dependency.Value.ValueKind != JsonValueKind.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentSchemas' as an object-valued schema.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var dependencySchemaPath = BuildNestedSchemaPath(propertyPath, $"dependentSchemas:{dependency.Name}"); - var dependencySchemaNode = ParseNode( - tableName, - schemaPath, - dependencySchemaPath, - dependency.Value); - if (dependencySchemaNode.NodeType != YamlConfigSchemaPropertyType.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare an object-typed 'dependentSchemas' schema.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(dependencySchemaPath)); - } - - dependentSchemas[dependency.Name] = dependencySchemaNode; - } - - return dependentSchemas.Count == 0 - ? null - : dependentSchemas; - } - - /// - /// 解析对象节点声明的 allOf 组合约束。 - /// 当前实现仅接受 object-typed 内联 schema,并把每个条目当成 focused constraint block - /// 叠加到当前对象上,而不是参与属性合并或改变生成类型形状。 - /// - /// 所属配置表名称。 - /// Schema 文件路径。 - /// 对象字段路径。 - /// Schema 节点。 - /// 父对象已声明的属性集合。 - /// 归一化后的 allOf schema 列表;未声明或为空时返回空。 - private static IReadOnlyList? ParseAllOfConstraints( - string tableName, - string schemaPath, - string propertyPath, - JsonElement element, - IReadOnlyDictionary properties) - { - if (!element.TryGetProperty("allOf", out var allOfElement)) - { - return null; - } - - if (allOfElement.ValueKind != JsonValueKind.Array) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'allOf' as an array of object-valued schemas.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var allOfSchemas = new List(); - var allOfIndex = 0; - foreach (var allOfSchemaElement in allOfElement.EnumerateArray()) - { - if (allOfSchemaElement.ValueKind != JsonValueKind.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'allOf' entries as object-valued schemas.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var allOfSchemaPath = BuildNestedSchemaPath(propertyPath, $"allOf[{allOfIndex.ToString(CultureInfo.InvariantCulture)}]"); - ValidateAllOfSchemaTargetsAgainstParentObject( - tableName, - schemaPath, - propertyPath, - allOfSchemaPath, - allOfIndex + 1, - allOfSchemaElement, - properties); - var allOfSchemaNode = ParseNode( - tableName, - schemaPath, - allOfSchemaPath, - allOfSchemaElement); - if (allOfSchemaNode.NodeType != YamlConfigSchemaPropertyType.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Entry #{(allOfIndex + 1).ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare an object-typed schema.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); - } - - allOfSchemas.Add(allOfSchemaNode); - allOfIndex++; - } - - return allOfSchemas.Count == 0 - ? null - : allOfSchemas; - } - - /// - /// 验证 allOf 条目只约束父对象已经声明过的同级字段。 - /// 当前 object-focused 语义不会把条目里的属性并回父对象形状,因此这里要提前拒绝 - /// “在 focused block 里引入父对象未声明字段”的不可满足 schema。 - /// - /// 所属配置表名称。 - /// Schema 文件路径。 - /// 父对象路径。 - /// 当前 allOf 条目路径。 - /// 从 1 开始的 allOf 条目编号。 - /// 当前 allOf 条目。 - /// 父对象已声明的属性集合。 - private static void ValidateAllOfSchemaTargetsAgainstParentObject( - string tableName, - string schemaPath, - string propertyPath, - string allOfSchemaPath, - int allOfEntryNumber, - JsonElement allOfSchemaElement, - IReadOnlyDictionary properties) - { - if (allOfSchemaElement.TryGetProperty("properties", out var allOfPropertiesElement)) - { - if (allOfPropertiesElement.ValueKind != JsonValueKind.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'properties' as an object-valued map.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); - } - - foreach (var property in allOfPropertiesElement.EnumerateObject()) - { - if (properties.ContainsKey(property.Name)) - { - continue; - } - - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' declares property '{property.Name}', but that property is not declared in the parent object schema.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); - } - } - - if (!allOfSchemaElement.TryGetProperty("required", out var allOfRequiredElement)) - { - return; - } - - if (allOfRequiredElement.ValueKind != JsonValueKind.Array) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' as an array of property names.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); - } - - foreach (var requiredProperty in allOfRequiredElement.EnumerateArray()) - { - if (requiredProperty.ValueKind != JsonValueKind.String) - { - continue; - } - - var requiredPropertyName = requiredProperty.GetString(); - if (string.IsNullOrWhiteSpace(requiredPropertyName) || - properties.ContainsKey(requiredPropertyName)) - { - continue; - } - - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' requires property '{requiredPropertyName}', but that property is not declared in the parent object schema.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); - } - } - /// /// 读取数值区间约束。 /// @@ -2376,69 +1971,6 @@ internal static class YamlConfigSchemaValidator return constraintValue; } - /// - /// 读取对象属性数量约束。 - /// - /// 所属配置表名称。 - /// Schema 文件路径。 - /// 对象字段路径。 - /// Schema 节点。 - /// 关键字名称。 - /// 属性数量约束;未声明时返回空。 - private static int? TryParseObjectPropertyCountConstraint( - string tableName, - string schemaPath, - string propertyPath, - JsonElement element, - string keywordName) - { - if (!element.TryGetProperty(keywordName, out var constraintElement)) - { - return null; - } - - if (constraintElement.ValueKind != JsonValueKind.Number || - !constraintElement.TryGetInt32(out var constraintValue) || - constraintValue < 0) - { - var targetDescription = DescribeObjectSchemaTarget(propertyPath); - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{targetDescription} in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - return constraintValue; - } - - /// - /// 为对象级 schema 关键字构造稳定的诊断主体。 - /// 根对象不会再显示为空字符串属性名,避免坏 schema 诊断出现 Property '' 之类的文本。 - /// - /// 对象字段路径。 - /// 用于错误消息的对象主体描述。 - private static string DescribeObjectSchemaTarget(string propertyPath) - { - return string.IsNullOrWhiteSpace(propertyPath) - ? "Root object" - : $"Property '{propertyPath}'"; - } - - /// - /// 为插入句中位置的对象级 schema 关键字构造稳定描述。 - /// 这里只调整语法前缀大小写,不改变真实字段路径,避免诊断消息把 schema 作者声明的大小写一起改写。 - /// - /// 对象字段路径。 - /// 可直接拼接到句中介词后的对象主体描述。 - private static string DescribeObjectSchemaTargetInClause(string propertyPath) - { - return string.IsNullOrWhiteSpace(propertyPath) - ? "root object" - : $"property '{propertyPath}'"; - } - /// /// 读取数组去重约束。 /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 030d12f1..88442e3c 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -1053,6 +1053,110 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证生成器会拒绝把 allOf.required 条目声明为非字符串。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Required_Item_Is_Not_A_String() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": [1] + } + ] + } + } + } + """; + + 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_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("must declare 'required' entries as parent property-name strings")); + }); + } + + /// + /// 验证生成器会拒绝把 allOf.required 条目声明为空白字段名。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Required_Item_Is_Blank() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": [""] + } + ] + } + } + } + """; + + 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_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("cannot declare blank property names in 'required'")); + }); + } + /// /// 验证生成器会拒绝在 allOf 中引入父对象未声明的字段。 ///