feat(config): 添加配置验证模块

- 实现配置架构解析功能,支持对象、数组和标量类型的递归解析
- 添加YAML文件解析和注释提取功能
- 实现配置值的类型验证,包括整数、数字、布尔值和字符串格式验证
- 添加对日期、时间、持续时间、邮箱、URI和UUID等特殊格式的支持
- 实现表单更新应用功能,支持标量值和数组的批量编辑
- 添加配置架构枚举和常量值的处理逻辑
- 实现多语言本地化支持的验证消息系统
- 添加精确十进制计算功能,用于数值倍数约束验证
- 实现YAML标量格式化和反引用功能
- 添加配置架构模式规范化处理,包括正则表达式和格式验证
This commit is contained in:
GeWuYou 2026-04-17 15:01:04 +08:00
parent 160842d093
commit 3f1a1957b2
6 changed files with 510 additions and 7 deletions

View File

@ -691,7 +691,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// <summary>
/// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。
/// 该遍历覆盖对象属性、<c>dependentSchemas</c> / <c>allOf</c> / <c>not</c> 子 schema、
/// 数组 <c>items</c> 与 <c>contains</c>
/// 数组 <c>items</c> 与 <c>contains</c>
/// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
@ -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;
}
/// <summary>
/// 为对象级 <c>allOf</c> 条目生成与运行时一致的逻辑路径。
/// </summary>
/// <param name="displayPath">父对象路径。</param>
/// <param name="allOfIndex">从 0 开始的条目索引。</param>
/// <returns>格式化后的 allOf 条目路径。</returns>
private static string BuildAllOfEntryPath(string displayPath, int allOfIndex)
{
return $"{displayPath}[allOf[{allOfIndex}]]";
}
/// <summary>
/// 递归验证 schema 树中的对象级 <c>dependentRequired</c> 元数据。
/// 该遍历会覆盖根节点、<c>dependentSchemas</c> / <c>not</c> 子 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<string>(
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;
}
/// <summary>
/// 验证单个 <c>allOf</c> 条目只约束父对象已声明的同级字段。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">父对象逻辑路径。</param>
/// <param name="allOfSchema">当前 allOf 条目。</param>
/// <param name="allOfIndex">从 0 开始的条目索引。</param>
/// <param name="declaredProperties">父对象已声明属性集合。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前 allOf 条目是否只引用父对象已声明字段。</returns>
private static bool TryValidateAllOfEntryTargets(
string filePath,
string displayPath,
JsonElement allOfSchema,
int allOfIndex,
ISet<string> 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;
}
/// <summary>
/// 判断给定 format 名称是否属于当前共享支持子集。
/// </summary>

View File

@ -300,6 +300,51 @@ public sealed class YamlConfigLoaderAllOfTests
});
}
/// <summary>
/// 验证 allOf 条目不能要求父对象未声明的字段。
/// </summary>
[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<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("requires property 'bonus'"));
Assert.That(exception.Message, Does.Contain("parent object schema"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 在测试目录下写入配置文件,并自动创建缺失目录。
/// </summary>

View File

@ -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
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">对象字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="properties">父对象已声明的属性集合。</param>
/// <returns>归一化后的 allOf schema 列表;未声明或为空时返回空。</returns>
private static IReadOnlyList<YamlConfigSchemaNode>? ParseAllOfConstraints(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
JsonElement element,
IReadOnlyDictionary<string, YamlConfigSchemaNode> 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;
}
/// <summary>
/// 验证 <c>allOf</c> 条目只约束父对象已经声明过的同级字段。
/// 当前 object-focused 语义不会把条目里的属性并回父对象形状,因此这里要提前拒绝
/// “在 focused block 里引入父对象未声明字段”的不可满足 schema。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">父对象路径。</param>
/// <param name="allOfSchemaPath">当前 allOf 条目路径。</param>
/// <param name="allOfEntryNumber">从 1 开始的 allOf 条目编号。</param>
/// <param name="allOfSchemaElement">当前 allOf 条目。</param>
/// <param name="properties">父对象已声明的属性集合。</param>
private static void ValidateAllOfSchemaTargetsAgainstParentObject(
string tableName,
string schemaPath,
string propertyPath,
string allOfSchemaPath,
int allOfEntryNumber,
JsonElement allOfSchemaElement,
IReadOnlyDictionary<string, YamlConfigSchemaNode> 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));
}
}
/// <summary>
/// 读取数值区间约束。
/// </summary>

View File

@ -850,6 +850,53 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 验证生成器会拒绝非对象值的 <c>allOf</c> 条目。
/// </summary>
[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."));
});
}
/// <summary>
/// 验证生成器会拒绝非 object-typed 的 <c>allOf</c> 条目。
/// </summary>
@ -902,6 +949,116 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 验证生成器会拒绝在 <c>allOf</c> 中引入父对象未声明的字段。
/// </summary>
[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"));
});
}
/// <summary>
/// 验证 allOf 内层递归诊断路径会与运行时保持一致。
/// </summary>
[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'."));
});
}
/// <summary>
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
/// </summary>

View File

@ -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<string, SchemaNode>} 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<string, SchemaNode>} 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.
*

View File

@ -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(`
{