diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index 20947d00..2f9f3fdd 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -691,7 +691,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。 /// 该遍历覆盖对象属性、dependentSchemas / allOf / not 子 schema、 -/// 数组 itemscontains, + /// 数组 itemscontains, /// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。 /// /// Schema 文件路径。 @@ -783,7 +783,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator if (!TryTraverseSchemaRecursively( filePath, - $"{displayPath}[allOf:{allOfIndex}]", + BuildAllOfEntryPath(displayPath, allOfIndex), allOfSchema, nodeValidator, out diagnostic)) @@ -839,6 +839,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return true; } + /// + /// 为对象级 allOf 条目生成与运行时一致的逻辑路径。 + /// + /// 父对象路径。 + /// 从 0 开始的条目索引。 + /// 格式化后的 allOf 条目路径。 + private static string BuildAllOfEntryPath(string displayPath, int allOfIndex) + { + return $"{displayPath}[allOf[{allOfIndex}]]"; + } + /// /// 递归验证 schema 树中的对象级 dependentRequired 元数据。 /// 该遍历会覆盖根节点、dependentSchemas / not 子 schema、 @@ -1263,6 +1274,24 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return false; } + if (!element.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Object schemas using 'allOf' must also declare an object-valued 'properties' map."); + return false; + } + + var declaredProperties = new HashSet( + propertiesElement + .EnumerateObject() + .Select(static property => property.Name), + StringComparer.Ordinal); + var allOfIndex = 0; foreach (var allOfSchema in allOfElement.EnumerateArray()) { @@ -1290,12 +1319,101 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return false; } + if (!TryValidateAllOfEntryTargets( + filePath, + displayPath, + allOfSchema, + allOfIndex, + declaredProperties, + out diagnostic)) + { + return false; + } + allOfIndex++; } return true; } + /// + /// 验证单个 allOf 条目只约束父对象已声明的同级字段。 + /// + /// Schema 文件路径。 + /// 父对象逻辑路径。 + /// 当前 allOf 条目。 + /// 从 0 开始的条目索引。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前 allOf 条目是否只引用父对象已声明字段。 + private static bool TryValidateAllOfEntryTargets( + string filePath, + string displayPath, + JsonElement allOfSchema, + int allOfIndex, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; + var allOfEntryPath = BuildAllOfEntryPath(displayPath, allOfIndex); + + if (allOfSchema.TryGetProperty("properties", out var allOfPropertiesElement) && + allOfPropertiesElement.ValueKind == JsonValueKind.Object) + { + foreach (var property in allOfPropertiesElement.EnumerateObject()) + { + if (declaredProperties.Contains(property.Name)) + { + continue; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' declares property '{property.Name}', but that property is not declared in the parent object schema."); + return false; + } + } + + if (!allOfSchema.TryGetProperty("required", out var requiredElement) || + requiredElement.ValueKind != JsonValueKind.Array) + { + return true; + } + + foreach (var requiredProperty in requiredElement.EnumerateArray()) + { + if (requiredProperty.ValueKind != JsonValueKind.String) + { + continue; + } + + var requiredPropertyName = requiredProperty.GetString(); + if (string.IsNullOrWhiteSpace(requiredPropertyName)) + { + continue; + } + + var normalizedRequiredPropertyName = requiredPropertyName!; + if (declaredProperties.Contains(normalizedRequiredPropertyName)) + { + continue; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' requires property '{normalizedRequiredPropertyName}', but that property is not declared in the parent object schema."); + return false; + } + + return true; + } + /// /// 判断给定 format 名称是否属于当前共享支持子集。 /// diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs index e92ae060..ed2c5e79 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs @@ -300,6 +300,51 @@ public sealed class YamlConfigLoaderAllOfTests }); } + /// + /// 验证 allOf 条目不能要求父对象未声明的字段。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Requires_Undeclared_Parent_Property() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + """ + { + "itemCount": { "type": "integer" } + } + """, + """ + [ + { + "type": "object", + "required": ["bonus"] + } + ] + """)); + + 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("requires property 'bonus'")); + Assert.That(exception.Message, Does.Contain("parent object schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 在测试目录下写入配置文件,并自动创建缺失目录。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index e6307401..2f642d6d 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -1666,7 +1666,7 @@ internal static class YamlConfigSchemaValidator "maxProperties"); var dependentRequired = ParseDependentRequiredConstraints(tableName, schemaPath, propertyPath, element, properties); var dependentSchemas = ParseDependentSchemasConstraints(tableName, schemaPath, propertyPath, element, properties); - var allOfSchemas = ParseAllOfConstraints(tableName, schemaPath, propertyPath, element); + var allOfSchemas = ParseAllOfConstraints(tableName, schemaPath, propertyPath, element, properties); if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) { @@ -1883,12 +1883,14 @@ internal static class YamlConfigSchemaValidator /// Schema 文件路径。 /// 对象字段路径。 /// Schema 节点。 + /// 父对象已声明的属性集合。 /// 归一化后的 allOf schema 列表;未声明或为空时返回空。 private static IReadOnlyList? ParseAllOfConstraints( string tableName, string schemaPath, string propertyPath, - JsonElement element) + JsonElement element, + IReadOnlyDictionary properties) { if (!element.TryGetProperty("allOf", out var allOfElement)) { @@ -1920,6 +1922,14 @@ internal static class YamlConfigSchemaValidator } var allOfSchemaPath = BuildNestedSchemaPath(propertyPath, $"allOf[{allOfIndex.ToString(CultureInfo.InvariantCulture)}]"); + ValidateAllOfSchemaTargetsAgainstParentObject( + tableName, + schemaPath, + propertyPath, + allOfSchemaPath, + allOfIndex + 1, + allOfSchemaElement, + properties); var allOfSchemaNode = ParseNode( tableName, schemaPath, @@ -1944,6 +1954,75 @@ internal static class YamlConfigSchemaValidator : 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) && + allOfPropertiesElement.ValueKind == JsonValueKind.Object) + { + 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) || + allOfRequiredElement.ValueKind != JsonValueKind.Array) + { + return; + } + + 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)); + } + } + /// /// 读取数值区间约束。 /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 7e3d2d45..314df325 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -850,6 +850,53 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证生成器会拒绝非对象值的 allOf 条目。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Is_Not_Object_Valued() + { + 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": [123] + } + } + } + """; + + 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")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Entry #1 in 'allOf' must be an object-valued schema.")); + }); + } + /// /// 验证生成器会拒绝非 object-typed 的 allOf 条目。 /// @@ -902,6 +949,116 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证生成器会拒绝在 allOf 中引入父对象未声明的字段。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Targets_Undeclared_Parent_Property() + { + 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": ["bonus"] + } + ] + } + } + } + """; + + 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("requires property 'bonus'")); + Assert.That(diagnostic.GetMessage(), Does.Contain("parent object schema")); + }); + } + + /// + /// 验证 allOf 内层递归诊断路径会与运行时保持一致。 + /// + [Test] + public void Run_Should_Report_Diagnostic_With_Runtime_Aligned_Path_When_AllOf_Inner_Schema_Is_Invalid() + { + 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", + "properties": { + "itemCount": { + "type": "integer", + "format": "uuid" + } + } + } + ] + } + } + } + """; + + 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_009")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]].itemCount")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only 'string' properties can declare 'format'.")); + }); + } + /// /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 /// diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 92458dad..ce6426e2 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1139,7 +1139,7 @@ function parseSchemaNode(rawNode, displayPath) { } const dependentRequired = parseDependentRequiredMetadata(value.dependentRequired, displayPath, properties); const dependentSchemas = parseDependentSchemasMetadata(value.dependentSchemas, displayPath, properties); - const allOf = parseAllOfSchemaNodes(value.allOf, displayPath); + const allOf = parseAllOfSchemaNodes(value.allOf, displayPath, properties); return applyEnumMetadata(applyConstMetadata({ type: "object", @@ -1386,9 +1386,10 @@ function parseDependentSchemasMetadata(rawDependentSchemas, displayPath, propert * * @param {unknown} rawAllOf Raw `allOf` node. * @param {string} displayPath Parent schema path. + * @param {Record} properties Declared object properties. * @returns {SchemaNode[] | undefined} Normalized allOf schema list. */ -function parseAllOfSchemaNodes(rawAllOf, displayPath) { +function parseAllOfSchemaNodes(rawAllOf, displayPath, properties) { if (rawAllOf === undefined) { return undefined; } @@ -1410,7 +1411,8 @@ function parseAllOfSchemaNodes(rawAllOf, displayPath) { `Schema property '${displayPath}' must declare object-typed schemas in 'allOf' entry #${index + 1}.`); } - const allOfSchema = parseSchemaNode(rawAllOfSchema, `${displayPath}[allOf:${index}]`); + validateAllOfEntryTargets(rawAllOfSchema, displayPath, index, properties); + const allOfSchema = parseSchemaNode(rawAllOfSchema, `${displayPath}[allOf[${index}]]`); normalized.push(allOfSchema); } @@ -1419,6 +1421,53 @@ function parseAllOfSchemaNodes(rawAllOf, displayPath) { : undefined; } +/** + * Ensure one object-focused `allOf` entry only constrains properties that the + * parent object schema already declared. + * + * @param {unknown} rawAllOfSchema Raw allOf entry. + * @param {string} displayPath Parent schema path. + * @param {number} index Zero-based allOf entry index. + * @param {Record} properties Declared parent properties. + */ +function validateAllOfEntryTargets(rawAllOfSchema, displayPath, index, properties) { + if (!rawAllOfSchema || typeof rawAllOfSchema !== "object" || Array.isArray(rawAllOfSchema)) { + return; + } + + if (rawAllOfSchema.properties && + typeof rawAllOfSchema.properties === "object" && + !Array.isArray(rawAllOfSchema.properties)) { + for (const propertyName of Object.keys(rawAllOfSchema.properties)) { + if (Object.prototype.hasOwnProperty.call(properties, propertyName)) { + continue; + } + + throw new Error( + `Schema property '${displayPath}' declares property '${propertyName}' in 'allOf' entry #${index + 1}, ` + + "but that property is not declared in the parent object schema."); + } + } + + if (!Array.isArray(rawAllOfSchema.required)) { + return; + } + + for (const requiredProperty of rawAllOfSchema.required) { + if (typeof requiredProperty !== "string" || requiredProperty.trim().length === 0) { + continue; + } + + if (Object.prototype.hasOwnProperty.call(properties, requiredProperty)) { + continue; + } + + throw new Error( + `Schema property '${displayPath}' requires property '${requiredProperty}' in 'allOf' entry #${index + 1}, ` + + "but that property is not declared in the parent object schema."); + } +} + /** * Validate one schema node against one YAML node. * diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 8b6289ec..95623d68 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -1893,6 +1893,61 @@ test("parseSchemaContent should reject non-object-typed allOf sub-schemas", () = ); }); +test("parseSchemaContent should reject allOf entries that introduce undeclared parent properties", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["bonus"] + } + ] + } + } + } + `), + /requires property 'bonus' in 'allOf' entry #1/u + ); +}); + +test("parseSchemaContent should use runtime-aligned allOf paths for nested schema errors", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "properties": { + "itemCount": { + "type": "integer", + "format": "uuid" + } + } + } + ] + } + } + } + `), + /reward\[allOf\[0\]\]\.itemCount/u + ); +}); + test("parseSchemaContent should capture not sub-schema metadata", () => { const schema = parseSchemaContent(` {