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;
}
}