feat(config): 添加配置 schema 诊断和验证功能

- 新增 ConfigSchemaDiagnostics 类提供配置 schema 代码生成相关诊断
- 添加 JSON 解析错误、根对象检查、ID字段要求等12种诊断规则
- 实现配置验证逻辑,支持整数、数字、布尔值、邮箱等格式校验
- 添加日期时间、持续时间、URI、UUID 等字符串格式验证
- 实现 YAML 解析和注释提取功能
- 提供配置样本生成和批量编辑更新功能
- 添加模式匹配和格式验证的正则表达式支持
This commit is contained in:
GeWuYou 2026-04-17 14:17:12 +08:00
parent 59dfb68add
commit 6ed4d8da1a
12 changed files with 1417 additions and 31 deletions

View File

@ -9,8 +9,8 @@ namespace GFramework.Game.SourceGenerators.Config;
/// 当前共享子集也会把 <c>multipleOf</c>、<c>uniqueItems</c>、
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
/// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>、
/// <c>dependentSchemas</c> 与稳定字符串 <c>format</c> 子集写入生成代码文档,
/// 让消费者能直接在强类型 API 上看到运行时生效的约束。
/// <c>dependentSchemas</c>、<c>allOf</c> 与稳定字符串 <c>format</c> 子集写入生成代码文档,
/// 让消费者能直接在强类型 API 上看到运行时生效且不改变生成类型形状的约束。
/// </summary>
[Generator]
public sealed class SchemaConfigGenerator : IIncrementalGenerator
@ -160,6 +160,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return SchemaParseResult.FromDiagnostic(dependentSchemasDiagnostic!);
}
if (!TryValidateAllOfMetadataRecursively(
file.Path,
"<root>",
root,
out var allOfDiagnostic))
{
return SchemaParseResult.FromDiagnostic(allOfDiagnostic!);
}
var entityName = ToPascalCase(GetSchemaBaseName(file.Path));
var rootObject = ParseObjectSpec(
file.Path,
@ -681,8 +690,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// <summary>
/// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。
/// 该遍历覆盖对象属性、<c>dependentSchemas</c> / <c>not</c> 子 schema、
/// 数组 <c>items</c> 与 <c>contains</c>
/// 该遍历覆盖对象属性、<c>dependentSchemas</c> / <c>allOf</c> / <c>not</c> 子 schema、
/// 数组 <c>items</c> 与 <c>contains</c>
/// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
@ -759,6 +768,33 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
}
if (string.Equals(schemaType, "object", StringComparison.Ordinal) &&
element.TryGetProperty("allOf", out var allOfElement) &&
allOfElement.ValueKind == JsonValueKind.Array)
{
var allOfIndex = 0;
foreach (var allOfSchema in allOfElement.EnumerateArray())
{
if (allOfSchema.ValueKind != JsonValueKind.Object)
{
allOfIndex++;
continue;
}
if (!TryTraverseSchemaRecursively(
filePath,
$"{displayPath}[allOf:{allOfIndex}]",
allOfSchema,
nodeValidator,
out diagnostic))
{
return false;
}
allOfIndex++;
}
}
if (element.TryGetProperty("not", out var notElement) &&
notElement.ValueKind == JsonValueKind.Object &&
!TryTraverseSchemaRecursively(
@ -1122,6 +1158,144 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return true;
}
/// <summary>
/// 验证当前 schema 节点是否以运行时支持的方式声明了 <c>allOf</c>。
/// 当前共享子集只接受 object 节点上的 object-typed inline schema 数组,
/// 以便把它们解释成 focused constraint block而不引入额外的类型合并语义。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前 schema 节点。</param>
/// <param name="schemaType">当前节点声明的 schema 类型。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前节点上的 allOf 声明是否有效。</returns>
private static bool TryValidateAllOfDeclaration(
string filePath,
string displayPath,
JsonElement element,
string? schemaType,
out Diagnostic? diagnostic)
{
diagnostic = null;
if (!element.TryGetProperty("allOf", out _))
{
return true;
}
if (!string.Equals(schemaType, "object", StringComparison.Ordinal))
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidAllOfMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
"Only object schemas can declare 'allOf'.");
return false;
}
return TryValidateAllOfMetadata(filePath, displayPath, element, out diagnostic);
}
/// <summary>
/// 递归验证 schema 树中的对象级 <c>allOf</c> 元数据。
/// 该遍历会覆盖根节点、<c>not</c>、数组元素、<c>contains</c>、<c>dependentSchemas</c>
/// 与嵌套 <c>allOf</c>,确保生成器对组合约束的接受范围与运行时保持一致。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前 schema 节点。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前节点树的 allOf 元数据是否有效。</returns>
private static bool TryValidateAllOfMetadataRecursively(
string filePath,
string displayPath,
JsonElement element,
out Diagnostic? diagnostic)
{
return TryTraverseSchemaRecursively(
filePath,
displayPath,
element,
static (currentFilePath, currentDisplayPath, currentElement, schemaType) =>
{
return TryValidateAllOfDeclaration(
currentFilePath,
currentDisplayPath,
currentElement,
schemaType,
out var currentDiagnostic)
? (true, (Diagnostic?)null)
: (false, currentDiagnostic);
},
out diagnostic);
}
/// <summary>
/// 验证单个对象 schema 节点上的 <c>allOf</c> 元数据。
/// 生成器当前只接受 object-typed inline schema 数组,
/// 避免 XML 文档描述出运行时不会按 focused constraint block 解释的组合形状。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前对象 schema 节点。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前对象上的 allOf 元数据是否有效。</returns>
private static bool TryValidateAllOfMetadata(
string filePath,
string displayPath,
JsonElement element,
out Diagnostic? diagnostic)
{
diagnostic = null;
if (!element.TryGetProperty("allOf", out var allOfElement))
{
return true;
}
if (allOfElement.ValueKind != JsonValueKind.Array)
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidAllOfMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
"The 'allOf' value must be an array.");
return false;
}
var allOfIndex = 0;
foreach (var allOfSchema in allOfElement.EnumerateArray())
{
if (allOfSchema.ValueKind != JsonValueKind.Object)
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidAllOfMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
$"Entry #{allOfIndex + 1} in 'allOf' must be an object-valued schema.");
return false;
}
if (!allOfSchema.TryGetProperty("type", out var allOfTypeElement) ||
allOfTypeElement.ValueKind != JsonValueKind.String ||
!string.Equals(allOfTypeElement.GetString(), "object", StringComparison.Ordinal))
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidAllOfMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
$"Entry #{allOfIndex + 1} in 'allOf' must declare an object-typed schema.");
return false;
}
allOfIndex++;
}
return true;
}
/// <summary>
/// 判断给定 format 名称是否属于当前共享支持子集。
/// </summary>
@ -3215,7 +3389,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
/// <summary>
/// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains 与对象属性数量约束整理成 XML 文档可读字符串。
/// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains、
/// 对象属性数量 / dependent* / allOf 约束整理成 XML 文档可读字符串。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="schemaType">标量类型。</param>
@ -3362,6 +3537,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
{
parts.Add($"dependentSchemas = {dependentSchemasDocumentation}");
}
var allOfDocumentation = TryBuildAllOfDocumentation(element);
if (allOfDocumentation is not null)
{
parts.Add($"allOf = {allOfDocumentation}");
}
}
return parts.Count > 0 ? string.Join(", ", parts) : null;
@ -3443,6 +3624,39 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
: null;
}
/// <summary>
/// 将对象 <c>allOf</c> 组合约束整理成 XML 文档可读字符串。
/// </summary>
/// <param name="element">对象 schema 节点。</param>
/// <returns>格式化后的 allOf 说明。</returns>
private static string? TryBuildAllOfDocumentation(JsonElement element)
{
if (!element.TryGetProperty("allOf", out var allOfElement) ||
allOfElement.ValueKind != JsonValueKind.Array)
{
return null;
}
var parts = new List<string>();
foreach (var allOfSchema in allOfElement.EnumerateArray())
{
if (allOfSchema.ValueKind != JsonValueKind.Object)
{
continue;
}
var summary = TryBuildInlineSchemaSummary(allOfSchema, includeRequiredProperties: true);
if (summary is not null)
{
parts.Add(summary);
}
}
return parts.Count > 0
? $"[ {string.Join("; ", parts)} ]"
: null;
}
/// <summary>
/// 将数组 <c>contains</c> 子 schema 整理成 XML 文档可读字符串。
/// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。

View File

@ -129,4 +129,15 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
/// <summary>
/// schema 对象节点的 allOf 元数据无效。
/// </summary>
public static readonly DiagnosticDescriptor InvalidAllOfMetadata = new(
"GF_ConfigSchema_012",
"Config schema uses invalid allOf metadata",
"Property '{1}' in schema file '{0}' uses invalid 'allOf' metadata: {2}",
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
}

View File

@ -0,0 +1,451 @@
using System.IO;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证 YAML 配置加载器对对象级 <c>allOf</c> 组合约束的运行时行为。
/// </summary>
[TestFixture]
public sealed class YamlConfigLoaderAllOfTests
{
private const string DefaultRewardPropertiesJson = """
{
"itemId": { "type": "string" },
"itemCount": { "type": "integer" },
"bonus": { "type": "integer" }
}
""";
private const string DefaultAllOfJson = """
[
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "type": "integer" }
}
},
{
"type": "object",
"properties": {
"itemCount": {
"type": "integer",
"minimum": 2
}
}
}
]
""";
private string? _rootPath;
/// <summary>
/// 为每个用例创建隔离的临时目录,避免不同 allOf 场景互相污染。
/// </summary>
[SetUp]
public void SetUp()
{
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_rootPath);
}
/// <summary>
/// 清理当前测试创建的目录,避免本地临时文件堆积。
/// </summary>
[TearDown]
public void TearDown()
{
if (!string.IsNullOrEmpty(_rootPath) &&
Directory.Exists(_rootPath))
{
Directory.Delete(_rootPath, true);
}
}
/// <summary>
/// 验证当前对象未满足任一 allOf 条目时,运行时会拒绝加载。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_AllOf_Entry_Is_Not_Satisfied()
{
CreateConfigFile(
"monster/slime.yaml",
BuildMonsterConfigYaml(
"""
bonus: 1
"""));
CreateSchemaFile(
"schemas/monster.schema.json",
BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultAllOfJson));
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation));
Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward"));
Assert.That(exception.Message, Does.Contain("allOf"));
Assert.That(exception.Message, Does.Contain("entry #1"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证对象满足全部 allOf 条目时,可以保留未在 focused block 中重复声明的同级字段。
/// </summary>
[Test]
public async Task LoadAsync_Should_Accept_When_All_AllOf_Entries_Are_Satisfied()
{
CreateConfigFile(
"monster/slime.yaml",
BuildMonsterConfigYaml(
"""
itemId: potion
itemCount: 3
bonus: 1
"""));
CreateSchemaFile(
"schemas/monster.schema.json",
BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultAllOfJson));
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
await loader.LoadAsync(registry);
var table = registry.GetTable<int, MonsterAllOfConfigStub>("monster");
var reward = table.Get(1).Reward;
Assert.Multiple(() =>
{
Assert.That(table.Count, Is.EqualTo(1));
Assert.That(reward.ItemId, Is.EqualTo("potion"));
Assert.That(reward.ItemCount, Is.EqualTo(3));
Assert.That(reward.Bonus, Is.EqualTo(1));
});
}
/// <summary>
/// 验证非数组 allOf 声明会在 schema 解析阶段被拒绝。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_AllOf_Is_Not_An_Array()
{
CreateConfigFile(
"monster/slime.yaml",
BuildMonsterConfigYaml(
"""
itemCount: 3
"""));
CreateSchemaFile(
"schemas/monster.schema.json",
BuildMonsterSchema(
DefaultRewardPropertiesJson,
"""
{
"type": "object",
"properties": {
"itemCount": { "type": "integer" }
}
}
"""));
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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"));
Assert.That(exception.Message, Does.Contain("must declare 'allOf' as an array"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证只有对象字段允许声明 allOf。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_NonObject_Schema_Declares_AllOf()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
tag: elite
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "tag"],
"properties": {
"id": { "type": "integer" },
"tag": {
"type": "string",
"allOf": [
{
"type": "object",
"properties": {}
}
]
}
}
}
""");
ArgumentNullException.ThrowIfNull(_rootPath);
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterTagConfigStub>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
var registry = CreateRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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("tag"));
Assert.That(exception.Message, Does.Contain("can only declare 'allOf' on object schemas"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证 allOf 条目必须是对象值 schema。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_AllOf_Entry_Is_Not_Object_Valued()
{
CreateConfigFile(
"monster/slime.yaml",
BuildMonsterConfigYaml(
"""
itemCount: 3
"""));
CreateSchemaFile(
"schemas/monster.schema.json",
BuildMonsterSchema(
DefaultRewardPropertiesJson,
"""
[123]
"""));
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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"));
Assert.That(exception.Message, Does.Contain("allOf' entries as object-valued schemas"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证 allOf 条目只接受 object-typed schema。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_AllOf_Entry_Is_Not_Object_Typed()
{
CreateConfigFile(
"monster/slime.yaml",
BuildMonsterConfigYaml(
"""
itemCount: 3
"""));
CreateSchemaFile(
"schemas/monster.schema.json",
BuildMonsterSchema(
DefaultRewardPropertiesJson,
"""
[
{
"type": "string",
"const": "potion"
}
]
"""));
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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("object-typed schema"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 在测试目录下写入配置文件,并自动创建缺失目录。
/// </summary>
/// <param name="relativePath">相对根目录的配置文件路径。</param>
/// <param name="content">要写入的 YAML 或 schema 内容。</param>
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))
{
Directory.CreateDirectory(directoryPath);
}
File.WriteAllText(filePath, content);
}
/// <summary>
/// 写入测试 schema 文件,复用统一的测试文件创建逻辑。
/// </summary>
/// <param name="relativePath">schema 相对路径。</param>
/// <param name="content">schema JSON 内容。</param>
private void CreateSchemaFile(string relativePath, string content)
{
CreateConfigFile(relativePath, content);
}
private static string BuildMonsterConfigYaml(string rewardYaml)
{
return $$"""
id: 1
reward:
{{IndentLines(rewardYaml, 2)}}
""";
}
private static string BuildMonsterSchema(
string rewardPropertiesJson,
string allOfJson)
{
return $$"""
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {{rewardPropertiesJson}},
"allOf": {{allOfJson}}
}
}
}
""";
}
private static string IndentLines(string text, int indentLevel)
{
var indentation = new string(' ', indentLevel);
var lines = text
.Trim()
.Split('\n', StringSplitOptions.None)
.Select(static line => line.TrimEnd('\r'));
return string.Join(
Environment.NewLine,
lines.Select(line => $"{indentation}{line}"));
}
/// <summary>
/// 创建用于对象 allOf 场景的加载器。
/// </summary>
/// <returns>已注册测试表与 schema 路径的加载器。</returns>
private YamlConfigLoader CreateMonsterRewardLoader()
{
ArgumentNullException.ThrowIfNull(_rootPath);
return new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterAllOfConfigStub>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
}
/// <summary>
/// 创建新的配置注册表,确保每个用例从干净状态开始。
/// </summary>
/// <returns>空的配置注册表。</returns>
private static ConfigRegistry CreateRegistry()
{
return new ConfigRegistry();
}
/// <summary>
/// 用于对象 allOf 回归测试的最小配置类型。
/// </summary>
private sealed class MonsterAllOfConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置奖励对象。
/// </summary>
public AllOfRewardConfigStub Reward { get; set; } = new();
}
/// <summary>
/// 表示对象 allOf 回归测试中的奖励节点。
/// </summary>
private sealed class AllOfRewardConfigStub
{
/// <summary>
/// 获取或设置掉落物 ID。
/// </summary>
public string ItemId { get; set; } = string.Empty;
/// <summary>
/// 获取或设置掉落物数量。
/// </summary>
public int ItemCount { get; set; }
/// <summary>
/// 获取或设置额外奖励值。
/// </summary>
public int Bonus { get; set; }
}
/// <summary>
/// 用于非对象 allOf 场景回归测试的最小配置类型。
/// </summary>
private sealed class MonsterTagConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置标签。
/// </summary>
public string Tag { get; set; } = string.Empty;
}
}

View File

@ -126,6 +126,61 @@ public sealed class YamlConfigSchemaValidatorTests
});
}
/// <summary>
/// 验证 <c>allOf</c> focused block 复用同一条 ref-table 字段时,不会把同一引用重复写入结果。
/// </summary>
[Test]
public void ValidateAndCollectReferences_Should_Not_Duplicate_Reference_Usages_From_AllOf()
{
var schemaPath = CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
},
"allOf": [
{
"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"));
});
}
/// <summary>
/// 在临时目录中创建 schema 文件。
/// </summary>

View File

@ -11,9 +11,9 @@ namespace GFramework.Game.Config;
/// 当前共享子集额外支持 <c>multipleOf</c>、<c>uniqueItems</c>、
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
/// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>、
/// <c>dependentSchemas</c>
/// <c>dependentSchemas</c>、<c>allOf</c>
/// 与稳定字符串 <c>format</c> 子集,让数值步进、数组去重、数组匹配计数、
/// 对象属性数量、对象内字段依赖以及条件对象子 schema 在运行时与生成器 / 工具侧保持一致。
/// 对象属性数量、对象内字段依赖、条件对象子 schema 与对象组合约束在运行时与生成器 / 工具侧保持一致。
/// </summary>
internal static class YamlConfigSchemaValidator
{
@ -325,6 +325,16 @@ internal static class YamlConfigSchemaValidator
var typeName = typeElement.GetString() ?? string.Empty;
var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element);
if (!string.Equals(typeName, "object", StringComparison.Ordinal) &&
element.TryGetProperty("allOf", out _))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' can only declare 'allOf' on object schemas.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var parsedNode = typeName switch
{
@ -825,7 +835,8 @@ internal static class YamlConfigSchemaValidator
/// <param name="seenProperties">当前对象已出现的属性集合。</param>
/// <param name="schemaNode">对象 schema 节点。</param>
/// <param name="references">
/// 可选的跨表引用收集器;当 <c>dependentSchemas</c> 命中且匹配成功时,只会回写该条件分支新增的引用。
/// 可选的跨表引用收集器;当 <c>dependentSchemas</c> 或 <c>allOf</c> 命中且匹配成功时,
/// 只会回写对应内联分支新增的引用。
/// </param>
private static void ValidateObjectConstraints(
string tableName,
@ -912,47 +923,81 @@ internal static class YamlConfigSchemaValidator
}
}
if (constraints.DependentSchemas is null ||
constraints.DependentSchemas.Count == 0)
if (constraints.DependentSchemas is not null &&
constraints.DependentSchemas.Count > 0)
{
foreach (var dependency in constraints.DependentSchemas)
{
if (!seenProperties.Contains(dependency.Key))
{
continue;
}
var triggerPath = CombineDisplayPath(displayPath, dependency.Key);
// 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,
displayPath,
mappingNode,
dependency.Value,
references,
allowUnknownObjectProperties: true))
{
continue;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must satisfy the 'dependentSchemas' schema triggered by sibling property '{triggerPath}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
detail:
$"Dependent schema: when '{triggerPath}' exists, the current object must satisfy the corresponding inline schema.");
}
}
if (constraints.AllOfSchemas is null ||
constraints.AllOfSchemas.Count == 0)
{
return;
}
foreach (var dependency in constraints.DependentSchemas)
for (var index = 0; index < constraints.AllOfSchemas.Count; index++)
{
if (!seenProperties.Contains(dependency.Key))
{
continue;
}
var triggerPath = CombineDisplayPath(displayPath, dependency.Key);
// 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.
var allOfSchema = constraints.AllOfSchemas[index];
// allOf follows the same focused constraint block semantics as dependentSchemas:
// the inline schema may validate a subset of the current object without forcing
// unrelated sibling fields to be restated.
if (TryMatchSchemaNode(
tableName,
yamlPath,
displayPath,
mappingNode,
dependency.Value,
allOfSchema,
references,
allowUnknownObjectProperties: true))
{
continue;
}
var allOfEntryNumber = index + 1;
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must satisfy the 'dependentSchemas' schema triggered by sibling property '{triggerPath}'.",
$"{subject} in config file '{yamlPath}' must satisfy all 'allOf' schemas, but entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} did not match.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
detail:
$"Dependent schema: when '{triggerPath}' exists, the current object must satisfy the corresponding inline schema.");
$"allOf entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} must match the current object.");
}
}
@ -1621,6 +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);
if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value)
{
@ -1633,9 +1679,10 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(propertyPath));
}
return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null
return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null &&
allOfSchemas is null
? null
: new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas);
: new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas, allOfSchemas);
}
/// <summary>
@ -1827,6 +1874,76 @@ internal static class YamlConfigSchemaValidator
: dependentSchemas;
}
/// <summary>
/// 解析对象节点声明的 <c>allOf</c> 组合约束。
/// 当前实现仅接受 object-typed 内联 schema并把每个条目当成 focused constraint block
/// 叠加到当前对象上,而不是参与属性合并或改变生成类型形状。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">对象字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <returns>归一化后的 allOf schema 列表;未声明或为空时返回空。</returns>
private static IReadOnlyList<YamlConfigSchemaNode>? ParseAllOfConstraints(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
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<YamlConfigSchemaNode>();
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)}]");
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;
}
/// <summary>
/// 读取数值区间约束。
/// </summary>
@ -3624,6 +3741,15 @@ internal static class YamlConfigSchemaValidator
CollectReferencedTableNames(dependentSchemaNode, referencedTableNames);
}
}
var allOfSchemas = node.ObjectConstraints?.AllOfSchemas;
if (allOfSchemas is not null)
{
foreach (var allOfSchemaNode in allOfSchemas)
{
CollectReferencedTableNames(allOfSchemaNode, referencedTableNames);
}
}
}
/// <summary>
@ -4217,7 +4343,7 @@ internal sealed class YamlConfigAllowedValue
}
/// <summary>
/// 表示一个对象节点上声明的属性数量约束、字段依赖约束与条件子 schema
/// 表示一个对象节点上声明的属性数量约束、字段依赖约束、条件子 schema 与组合约束
/// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。
/// </summary>
internal sealed class YamlConfigObjectConstraints
@ -4229,16 +4355,19 @@ internal sealed class YamlConfigObjectConstraints
/// <param name="maxProperties">最大属性数量约束。</param>
/// <param name="dependentRequired">对象内字段依赖约束。</param>
/// <param name="dependentSchemas">对象内条件 schema 约束。</param>
/// <param name="allOfSchemas">对象内组合 schema 约束。</param>
public YamlConfigObjectConstraints(
int? minProperties,
int? maxProperties,
IReadOnlyDictionary<string, IReadOnlyList<string>>? dependentRequired,
IReadOnlyDictionary<string, YamlConfigSchemaNode>? dependentSchemas)
IReadOnlyDictionary<string, YamlConfigSchemaNode>? dependentSchemas,
IReadOnlyList<YamlConfigSchemaNode>? allOfSchemas)
{
MinProperties = minProperties;
MaxProperties = maxProperties;
DependentRequired = dependentRequired;
DependentSchemas = dependentSchemas;
AllOfSchemas = allOfSchemas;
}
/// <summary>
@ -4262,6 +4391,12 @@ internal sealed class YamlConfigObjectConstraints
/// 键表示“触发字段”,值表示“触发字段出现后当前对象还必须满足的额外 schema 子树”。
/// </summary>
public IReadOnlyDictionary<string, YamlConfigSchemaNode>? DependentSchemas { get; }
/// <summary>
/// 获取对象内 <c>allOf</c> 组合约束。
/// 每个条目都表示“当前对象还必须额外满足的 focused constraint block”。
/// </summary>
public IReadOnlyList<YamlConfigSchemaNode>? AllOfSchemas { get; }
}
/// <summary>

View File

@ -690,6 +690,218 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 验证对象 <c>allOf</c> 会写入生成 XML 文档。
/// </summary>
[Test]
public void Run_Should_Write_AllOf_Constraint_Into_Generated_Documentation()
{
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": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" }
},
"allOf": [
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "type": "integer" }
}
}
]
}
}
}
""";
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["MonsterConfig.g.cs"],
Does.Contain("Constraints: allOf = [ object (required = [itemCount]) ]."));
}
/// <summary>
/// 验证只有 object 节点允许声明 <c>allOf</c>。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_NonObject_Schema_Declares_AllOf()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id", "tag"],
"properties": {
"id": { "type": "integer" },
"tag": {
"type": "string",
"allOf": [
{
"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_012"));
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 'allOf'."));
});
}
/// <summary>
/// 验证生成器会拒绝非数组的 <c>allOf</c> 声明。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_AllOf_Is_Not_An_Array()
{
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" }
}
}
}
}
}
""";
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("The 'allOf' value must be an array."));
});
}
/// <summary>
/// 验证生成器会拒绝非 object-typed 的 <c>allOf</c> 条目。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Is_Not_Object_Typed()
{
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": "string",
"const": "potion"
}
]
}
}
}
""";
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 declare an object-typed schema."));
});
}
/// <summary>
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
/// </summary>

View File

@ -1125,6 +1125,10 @@ function parseSchemaNode(rawNode, displayPath) {
: undefined
};
if (value.allOf !== undefined && type !== "object") {
throw new Error(`Only object schemas can declare 'allOf' at '${displayPath}'.`);
}
if (type === "object") {
const required = Array.isArray(value.required)
? value.required.filter((item) => typeof item === "string")
@ -1135,6 +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);
return applyEnumMetadata(applyConstMetadata({
type: "object",
@ -1145,6 +1150,7 @@ function parseSchemaNode(rawNode, displayPath) {
maxProperties: metadata.maxProperties,
dependentRequired,
dependentSchemas,
allOf,
title: metadata.title,
description: metadata.description,
defaultValue: metadata.defaultValue,
@ -1374,6 +1380,45 @@ function parseDependentSchemasMetadata(rawDependentSchemas, displayPath, propert
: undefined;
}
/**
* Parse one object-level `allOf` list and keep it aligned with the runtime's
* focused-constraint-block contract.
*
* @param {unknown} rawAllOf Raw `allOf` node.
* @param {string} displayPath Parent schema path.
* @returns {SchemaNode[] | undefined} Normalized allOf schema list.
*/
function parseAllOfSchemaNodes(rawAllOf, displayPath) {
if (rawAllOf === undefined) {
return undefined;
}
if (!Array.isArray(rawAllOf)) {
throw new Error(`Schema property '${displayPath}' must declare 'allOf' as an array.`);
}
const normalized = [];
for (let index = 0; index < rawAllOf.length; index += 1) {
const rawAllOfSchema = rawAllOf[index];
if (!rawAllOfSchema || typeof rawAllOfSchema !== "object" || Array.isArray(rawAllOfSchema)) {
throw new Error(
`Schema property '${displayPath}' must declare 'allOf' entry #${index + 1} as an object-valued schema.`);
}
if (rawAllOfSchema.type !== "object") {
throw new Error(
`Schema property '${displayPath}' must declare object-typed schemas in 'allOf' entry #${index + 1}.`);
}
const allOfSchema = parseSchemaNode(rawAllOfSchema, `${displayPath}[allOf:${index}]`);
normalized.push(allOfSchema);
}
return normalized.length > 0
? normalized
: undefined;
}
/**
* Validate one schema node against one YAML node.
*
@ -1767,6 +1812,32 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
}
}
if (Array.isArray(schemaNode.allOf)) {
for (let index = 0; index < schemaNode.allOf.length; index += 1) {
if (matchesSchemaNode(schemaNode.allOf[index], yamlNode, true)) {
continue;
}
const localizedMessage = localizeValidationMessage(
ValidationMessageKeys.allOfViolation,
localizer,
{
displayPath: displayPath || "<root>",
index: String(index + 1)
});
if (reportedMessages.has(localizedMessage)) {
continue;
}
diagnostics.push({
severity: "error",
message: localizedMessage
});
reportedMessages.add(localizedMessage);
}
}
if (typeof schemaNode.minProperties === "number" &&
propertyCount < schemaNode.minProperties) {
diagnostics.push({
@ -1900,6 +1971,14 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope
}
}
if (Array.isArray(schemaNode.allOf)) {
for (const allOfSchema of schemaNode.allOf) {
if (!matchesSchemaNodeInternal(allOfSchema, yamlNode, true)) {
return false;
}
}
}
if (typeof schemaNode.minProperties === "number" &&
propertyCount < schemaNode.minProperties) {
return false;
@ -2309,6 +2388,8 @@ function localizeValidationMessage(key, localizer, params) {
if (localizer && localizer.isChinese) {
switch (key) {
case ValidationMessageKeys.allOfViolation:
return `对象“${params.displayPath}”必须满足全部 \`allOf\` schema${params.index} 项未匹配。`;
case ValidationMessageKeys.constMismatch:
return `属性“${params.displayPath}”必须匹配固定值 ${params.value}`;
case ValidationMessageKeys.dependentRequiredViolation:
@ -2363,6 +2444,8 @@ function localizeValidationMessage(key, localizer, params) {
}
switch (key) {
case ValidationMessageKeys.allOfViolation:
return `Object '${params.displayPath}' must satisfy all 'allOf' schemas; entry #${params.index} did not match.`;
case ValidationMessageKeys.constMismatch:
return `Property '${params.displayPath}' must match constant value ${params.value}.`;
case ValidationMessageKeys.dependentRequiredViolation:
@ -3119,6 +3202,7 @@ module.exports = {
* maxProperties?: number,
* dependentRequired?: Record<string, string[]>,
* dependentSchemas?: Record<string, SchemaNode>,
* allOf?: SchemaNode[],
* title?: string,
* description?: string,
* defaultValue?: string,

View File

@ -1622,7 +1622,7 @@ function describeInlineSchemaForHint(schema, includeRequiredProperties = false)
/**
* Render human-facing metadata hints for one schema field.
*
* @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, required?: string[], dependentRequired?: Record<string, string[]>, dependentSchemas?: Record<string, {type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}>, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, format?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string}, refTable?: string}} propertySchema Property schema metadata.
* @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, required?: string[], dependentRequired?: Record<string, string[]>, dependentSchemas?: Record<string, {type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}>, allOf?: Array<{type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}>, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, format?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string}, refTable?: string}} propertySchema Property schema metadata.
* @param {boolean} isArrayField Whether the field is an array.
* @param {boolean} includeDescription Whether description text should be included in the hint output.
* @returns {string} HTML fragment.
@ -1723,6 +1723,15 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true
}
}
if (propertySchema.type === "object" &&
Array.isArray(propertySchema.allOf)) {
for (const allOfSchema of propertySchema.allOf) {
hints.push(escapeHtml(localizer.t("webview.hint.allOf", {
schema: describeInlineSchemaForHint(allOfSchema, true)
})));
}
}
if (isArrayField && typeof propertySchema.minItems === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems})));
}

View File

@ -136,11 +136,13 @@ const enMessages = {
"webview.hint.maxProperties": "Max properties: {value}",
"webview.hint.dependentRequired": "When {trigger} is set: require {dependencies}",
"webview.hint.dependentSchemas": "When {trigger} is set: satisfy {schema}",
"webview.hint.allOf": "Also satisfy: {schema}",
"webview.hint.refTable": "Ref table: {refTable}",
"webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.",
"webview.unsupported.type": "{type} fields are currently raw-YAML-only.",
"webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.",
"webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.",
[ValidationMessageKeys.allOfViolation]: "Object '{displayPath}' must satisfy all 'allOf' schemas; entry #{index} did not match.",
[ValidationMessageKeys.constMismatch]: "Property '{displayPath}' must match constant value {value}.",
[ValidationMessageKeys.dependentRequiredViolation]: "Property '{displayPath}' is required when sibling property '{triggerProperty}' is present.",
[ValidationMessageKeys.dependentSchemasViolation]: "Object '{displayPath}' must satisfy the dependent schema triggered by sibling property '{triggerProperty}'.",
@ -260,11 +262,13 @@ const zhCnMessages = {
"webview.hint.maxProperties": "最多属性数:{value}",
"webview.hint.dependentRequired": "当 {trigger} 出现时:还必须声明 {dependencies}",
"webview.hint.dependentSchemas": "当 {trigger} 出现时:还必须满足 {schema}",
"webview.hint.allOf": "还必须满足:{schema}",
"webview.hint.refTable": "引用表:{refTable}",
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",
"webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。",
"webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。",
[ValidationMessageKeys.allOfViolation]: "对象“{displayPath}”必须满足全部 `allOf` schema第 {index} 项未匹配。",
[ValidationMessageKeys.constMismatch]: "属性“{displayPath}”必须匹配固定值 {value}。",
[ValidationMessageKeys.dependentRequiredViolation]: "属性“{triggerProperty}”存在时,必须同时声明属性“{displayPath}”。",
[ValidationMessageKeys.dependentSchemasViolation]: "对象“{displayPath}”在属性“{triggerProperty}”存在时,必须满足对应的 dependent schema。",

View File

@ -1,4 +1,5 @@
const ValidationMessageKeys = Object.freeze({
allOfViolation: "validation.allOfViolation",
constMismatch: "validation.constMismatch",
dependentSchemasViolation: "validation.dependentSchemasViolation",
enumMismatch: "validation.enumMismatch",

View File

@ -1792,6 +1792,107 @@ test("parseSchemaContent should reject non-object-typed dependentSchemas sub-sch
);
});
test("parseSchemaContent should capture allOf metadata", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" }
},
"allOf": [
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "type": "integer" }
}
}
]
}
}
}
`);
assert.equal(schema.properties.reward.allOf[0].type, "object");
assert.deepEqual(schema.properties.reward.allOf[0].required, ["itemCount"]);
});
test("parseSchemaContent should reject allOf declarations on non-object schemas", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"tag": {
"type": "string",
"allOf": [
{
"type": "object",
"properties": {}
}
]
}
}
}
`),
/Only object schemas can declare 'allOf'/u
);
});
test("parseSchemaContent should reject non-array allOf declarations", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemCount": { "type": "integer" }
},
"allOf": {
"type": "object",
"properties": {
"itemCount": { "type": "integer" }
}
}
}
}
}
`),
/must declare 'allOf' as an array/u
);
});
test("parseSchemaContent should reject non-object-typed allOf sub-schemas", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemCount": { "type": "integer" }
},
"allOf": [
{
"type": "string",
"const": "potion"
}
]
}
}
}
`),
/object-typed schemas in 'allOf' entry #1/u
);
});
test("parseSchemaContent should capture not sub-schema metadata", () => {
const schema = parseSchemaContent(`
{
@ -1942,6 +2043,87 @@ reward:
assert.deepEqual(validateParsedConfig(schema, yaml), []);
});
test("validateParsedConfig should report allOf violations", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" }
},
"allOf": [
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "type": "integer" }
}
}
]
}
}
}
`);
const yaml = parseTopLevelYaml(`
reward:
itemId: potion
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].severity, "error");
assert.match(diagnostics[0].message, /allOf/u);
assert.match(diagnostics[0].message, /#1|第 1 项/u);
});
test("validateParsedConfig should accept satisfied allOf constraints", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" },
"bonus": { "type": "integer" }
},
"allOf": [
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "type": "integer" }
}
},
{
"type": "object",
"properties": {
"itemCount": {
"type": "integer",
"minimum": 2
}
}
}
]
}
}
}
`);
const yaml = parseTopLevelYaml(`
reward:
itemId: potion
itemCount: 3
bonus: 1
`);
assert.deepEqual(validateParsedConfig(schema, yaml), []);
});
test("parseSchemaContent should reject non-object not declarations", () => {
assert.throws(
() => parseSchemaContent(`

View File

@ -146,3 +146,31 @@ test("createLocalizer should expose dependentSchemas validation keys", () => {
}),
"对象“reward”在属性“reward.itemId”存在时必须满足对应的 dependent schema。");
});
test("createLocalizer should expose allOf validation keys", () => {
const englishLocalizer = createLocalizer("en");
const chineseLocalizer = createLocalizer("zh-cn");
assert.equal(
englishLocalizer.t("webview.hint.allOf", {
schema: "object, Required: itemCount"
}),
"Also satisfy: object, Required: itemCount");
assert.equal(
chineseLocalizer.t("webview.hint.allOf", {
schema: "object, 必填字段itemCount"
}),
"还必须满足object, 必填字段itemCount");
assert.equal(
englishLocalizer.t(ValidationMessageKeys.allOfViolation, {
displayPath: "reward",
index: "1"
}),
"Object 'reward' must satisfy all 'allOf' schemas; entry #1 did not match.");
assert.equal(
chineseLocalizer.t(ValidationMessageKeys.allOfViolation, {
displayPath: "reward",
index: "1"
}),
"对象“reward”必须满足全部 `allOf` schema第 1 项未匹配。");
});