diff --git a/GFramework.Core.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Core.SourceGenerators/AnalyzerReleases.Unshipped.md index dbcd3b60..9e0a6cb0 100644 --- a/GFramework.Core.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Core.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -18,17 +18,6 @@ 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_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics - GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics - GF_ConfigSchema_010 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics - GF_ConfigSchema_011 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_AutoModule_001 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics GF_AutoModule_002 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics GF_AutoModule_003 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics diff --git a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md index 3a2574f9..4d7f7481 100644 --- a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -15,3 +15,4 @@ GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_010 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_011 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index 7e041180..45180c9c 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -958,6 +958,44 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return true; } + /// + /// 验证当前 schema 节点是否以运行时支持的方式声明了 dependentSchemas。 + /// 只有 object 节点允许挂载该关键字;一旦关键字出现,就继续复用对象节点的形状校验, + /// 保证发布到 XML 文档和运行时的约束解释范围保持一致。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 当前节点声明的 schema 类型。 + /// 失败时返回的诊断。 + /// 当前节点上的 dependentSchemas 声明是否有效。 + private static bool TryValidateDependentSchemasDeclaration( + string filePath, + string displayPath, + JsonElement element, + string? schemaType, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("dependentSchemas", out _)) + { + return true; + } + + if (!string.Equals(schemaType, "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Only object schemas can declare 'dependentSchemas'."); + return false; + } + + return TryValidateDependentSchemasMetadata(filePath, displayPath, element, out diagnostic); + } + /// /// 递归验证 schema 树中的对象级 dependentSchemas 元数据。 /// 该遍历会覆盖根节点、not、数组元素、contains 与嵌套 dependentSchemas, @@ -980,15 +1018,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator element, static (currentFilePath, currentDisplayPath, currentElement, schemaType) => { - if (!string.Equals(schemaType, "object", StringComparison.Ordinal)) - { - return (true, (Diagnostic?)null); - } - - return TryValidateDependentSchemasMetadata( + return TryValidateDependentSchemasDeclaration( currentFilePath, currentDisplayPath, currentElement, + schemaType, out var currentDiagnostic) ? (true, (Diagnostic?)null) : (false, currentDiagnostic); @@ -3447,6 +3481,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains / not 与主属性文档逐渐漂移。 /// /// 内联子 schema。 + /// + /// 为对象摘要额外输出 required 信息时返回 。 + /// /// 格式化后的摘要字符串。 private static string? TryBuildInlineSchemaSummary( JsonElement schemaElement, diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderDependentSchemasTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderDependentSchemasTests.cs index 4fc18c19..19245013 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderDependentSchemasTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderDependentSchemasTests.cs @@ -10,7 +10,7 @@ namespace GFramework.Game.Tests.Config; [TestFixture] public sealed class YamlConfigLoaderDependentSchemasTests { - private string _rootPath = null!; + private string? _rootPath; /// /// 为每个用例创建隔离的临时目录,避免不同 dependentSchemas 场景互相污染。 @@ -28,7 +28,8 @@ public sealed class YamlConfigLoaderDependentSchemasTests [TearDown] public void TearDown() { - if (Directory.Exists(_rootPath)) + if (!string.IsNullOrEmpty(_rootPath) && + Directory.Exists(_rootPath)) { Directory.Delete(_rootPath, true); } @@ -310,6 +311,8 @@ public sealed class YamlConfigLoaderDependentSchemasTests /// 要写入的 YAML 或 schema 内容。 private void CreateConfigFile(string relativePath, string content) { + ArgumentNullException.ThrowIfNull(_rootPath); + var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); var directoryPath = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directoryPath)) @@ -336,6 +339,8 @@ public sealed class YamlConfigLoaderDependentSchemasTests /// 已注册测试表与 schema 路径的加载器。 private YamlConfigLoader CreateMonsterRewardLoader() { + ArgumentNullException.ThrowIfNull(_rootPath); + return new YamlConfigLoader(_rootPath) .RegisterTable( "monster", diff --git a/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs index 11b26894..26acb434 100644 --- a/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs @@ -9,7 +9,7 @@ namespace GFramework.Game.Tests.Config; [TestFixture] public sealed class YamlConfigSchemaValidatorTests { - private string _rootPath = null!; + private string? _rootPath; /// /// 为每个测试准备独立临时目录。 @@ -27,7 +27,8 @@ public sealed class YamlConfigSchemaValidatorTests [TearDown] public void TearDown() { - if (Directory.Exists(_rootPath)) + if (!string.IsNullOrEmpty(_rootPath) && + Directory.Exists(_rootPath)) { Directory.Delete(_rootPath, true); } @@ -70,6 +71,61 @@ public sealed class YamlConfigSchemaValidatorTests Assert.That(schema.ReferencedTableNames, Is.EqualTo(new[] { "ally", "item", "weapon" })); } + /// + /// 验证条件子 schema 复用同一条 ref-table 字段时,不会把同一引用重复写入结果。 + /// + [Test] + public void ValidateAndCollectReferences_Should_Not_Duplicate_Reference_Usages_From_DependentSchemas() + { + var schemaPath = CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + }, + "dependentSchemas": { + "itemId": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + } + } + } + } + } + } + """); + var schema = YamlConfigSchemaValidator.Load("monster", schemaPath); + + var references = YamlConfigSchemaValidator.ValidateAndCollectReferences( + "monster", + schema, + "monster/slime.yaml", + """ + reward: + itemId: potion + """); + + Assert.That(references, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(references[0].DisplayPath, Is.EqualTo("reward.itemId")); + Assert.That(references[0].ReferencedTableName, Is.EqualTo("item")); + Assert.That(references[0].RawValue, Is.EqualTo("potion")); + }); + } + /// /// 在临时目录中创建 schema 文件。 /// @@ -80,6 +136,8 @@ public sealed class YamlConfigSchemaValidatorTests string relativePath, string content) { + ArgumentNullException.ThrowIfNull(_rootPath); + var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); var directoryPath = Path.GetDirectoryName(fullPath); if (!string.IsNullOrWhiteSpace(directoryPath)) diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 7f851183..cb527344 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -813,15 +813,20 @@ internal static class YamlConfigSchemaValidator } /// - /// 校验对象节点声明的属性数量约束。 + /// 校验对象节点声明的数量约束与条件对象约束。 + /// 该阶段除了检查 minProperties / maxProperties,还会复用同一份 sibling 集合处理 + /// dependentRequired,并在 dependentSchemas 命中时以 focused constraint block 语义 + /// 对整个 做额外试匹配。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 对象字段路径;根对象时为空。 - /// 当前 YAML 对象节点。 + /// 当前 YAML 对象节点;用于让条件子 schema 在完整对象视图上做匹配。 /// 当前对象已出现的属性集合。 /// 对象 schema 节点。 - /// 可选的跨表引用收集器。 + /// + /// 可选的跨表引用收集器;当 dependentSchemas 命中且匹配成功时,只会回写该条件分支新增的引用。 + /// private static void ValidateObjectConstraints( string tableName, string yamlPath, @@ -924,6 +929,9 @@ internal static class YamlConfigSchemaValidator // dependentSchemas acts as an additional conditional constraint block on the // current object. Keep undeclared sibling fields outside the dependent sub-schema // from blocking the match so schema authors can express focused follow-up rules. + // The trial matcher merges only new reference usages back into the outer collector, + // so re-checking the same scalar via a conditional sub-schema does not duplicate + // cross-table validation work later in the loader pipeline. if (TryMatchSchemaNode( tableName, yamlPath, @@ -3138,10 +3146,7 @@ internal static class YamlConfigSchemaValidator if (references is not null && matchedReferences is not null) { - foreach (var referenceUsage in matchedReferences) - { - references.Add(referenceUsage); - } + AddUniqueReferenceUsages(references, matchedReferences); } return true; @@ -3153,6 +3158,50 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 将试匹配分支采集到的引用回写到外层集合,并按结构化标识去重。 + /// + /// 外层引用集合。 + /// 当前成功匹配分支采集到的引用。 + private static void AddUniqueReferenceUsages( + ICollection references, + IEnumerable matchedReferences) + { + foreach (var referenceUsage in matchedReferences) + { + if (!ContainsReferenceUsage(references, referenceUsage)) + { + references.Add(referenceUsage); + } + } + } + + /// + /// 判断外层引用集合中是否已经存在同一条引用使用记录。 + /// + /// 要检查的引用集合。 + /// 当前待合并的引用记录。 + /// 当集合中已存在语义相同的记录时返回 + private static bool ContainsReferenceUsage( + IEnumerable references, + YamlConfigReferenceUsage candidate) + { + foreach (var referenceUsage in references) + { + if (string.Equals(referenceUsage.YamlPath, candidate.YamlPath, StringComparison.Ordinal) && + string.Equals(referenceUsage.SchemaPath, candidate.SchemaPath, StringComparison.Ordinal) && + string.Equals(referenceUsage.PropertyPath, candidate.PropertyPath, StringComparison.Ordinal) && + string.Equals(referenceUsage.RawValue, candidate.RawValue, StringComparison.Ordinal) && + string.Equals(referenceUsage.ReferencedTableName, candidate.ReferencedTableName, StringComparison.Ordinal) && + referenceUsage.ValueType == candidate.ValueType) + { + return true; + } + } + + return false; + } + /// /// 校验节点是否命中了 not 声明的禁用 schema。 /// 与 contains 不同,not 会沿用主校验链的严格对象语义,避免把“声明属性子集”误当成完整命中。 diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index a11d9f76..5229394e 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -584,6 +584,55 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证只有 object 节点允许声明 dependentSchemas。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_NonObject_Schema_Declares_DependentSchemas() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "tag"], + "properties": { + "id": { "type": "integer" }, + "tag": { + "type": "string", + "dependentSchemas": { + "itemId": { + "type": "object", + "properties": {} + } + } + } + } + } + """; + + 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_011")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("tag")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only object schemas can declare 'dependentSchemas'.")); + }); + } + /// /// 验证 dependentSchemas 子 schema 内的非法 format 也会在生成阶段直接给出诊断。 /// diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index da4fbaa8..fcbe8e9e 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1742,9 +1742,8 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca } if (schemaNode.dependentSchemas && typeof schemaNode.dependentSchemas === "object") { - for (const [triggerProperty, dependentSchema] of Object.entries(schemaNode.dependentSchemas)) { - if (!yamlNode.map.has(triggerProperty) || - matchesSchemaNode(dependentSchema, yamlNode, true)) { + for (const [triggerProperty, dependentSchema] of getTriggeredDependentSchemas(schemaNode, yamlNode)) { + if (matchesSchemaNode(dependentSchema, yamlNode, true)) { continue; } @@ -1795,6 +1794,32 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer); } +/** + * Enumerate object-level `dependentSchemas` entries whose trigger property is + * present on the current YAML object. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {YamlNode} yamlNode YAML node. + * @returns {Array<[string, SchemaNode]>} Triggered dependent schema entries. + */ +function getTriggeredDependentSchemas(schemaNode, yamlNode) { + if (!schemaNode.dependentSchemas || + typeof schemaNode.dependentSchemas !== "object" || + !yamlNode || + yamlNode.kind !== "object") { + return []; + } + + const triggeredSchemas = []; + for (const [triggerProperty, dependentSchema] of Object.entries(schemaNode.dependentSchemas)) { + if (yamlNode.map.has(triggerProperty)) { + triggeredSchemas.push([triggerProperty, dependentSchema]); + } + } + + return triggeredSchemas; +} + /** * Test whether one YAML node satisfies one schema node without emitting user-facing diagnostics. * This is used by array `contains`, where object sub-schemas must behave like @@ -1869,12 +1894,9 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope } } - if (schemaNode.dependentSchemas && typeof schemaNode.dependentSchemas === "object") { - for (const [triggerProperty, dependentSchema] of Object.entries(schemaNode.dependentSchemas)) { - if (yamlNode.map.has(triggerProperty) && - !matchesSchemaNodeInternal(dependentSchema, yamlNode, true)) { - return false; - } + for (const [, dependentSchema] of getTriggeredDependentSchemas(schemaNode, yamlNode)) { + if (!matchesSchemaNodeInternal(dependentSchema, yamlNode, true)) { + return false; } }